powered by TechFeed
表示モード
ハウツー

LangGraphのAIエージェントをCLIからチャットUIへ — エージェントとUIを完全分離したままStreamlitを被せる実装パターン

8月8日、Soner Yıldırımが「Building a Streamlit UI for My LangGraph AI Agent」と題した記事を公開した。LangGraphのグラフ本体を一切変更せずにStreamlitのチャットUIを被せる——この「エージェントとUIの完全分離」という設計方針こそが本記事の核心であり、同じグラフをCLI・API・WhatsAppといった複数のチャネルから呼び出せる拡張性を生む。

8月8日、Soner Yıldırımが「Building a Streamlit UI for My LangGraph AI Agent」と題した記事を公開した。LangGraphのグラフ本体を一切変更せずにStreamlitのチャットUIを被せる——この「エージェントとUIの完全分離」という設計方針こそが本記事の核心であり、同じグラフをCLI・API・WhatsAppといった複数のチャネルから呼び出せる拡張性を生む。


設計の核心:エージェントとUIを完全に分離する

この実装で最も重要なポイントは、LangGraphのグラフ本体にStreamlit固有のロジックを一切持ち込まないという設計方針だ。StreamlitはLangGraphエージェントのラッパーとして機能し、状態を画面に表示しユーザー操作をエージェントに渡すだけに徹する。

この分離により、グラフ側のコードに手を入れることなく、CLI・API・WhatsApp・別フロントエンドといった任意のインターフェースから同一のエージェントを呼び出せる構造になっている。UIをStreamlitに依存させず、エージェントをUIに依存させない——この双方向の独立性が、本実装を単なる「画面追加」ではなく再利用可能な設計パターンたらしめている。


背景:CLIで動いていた予約エージェントをUI化する

Soner Yıldırımは以前の記事で、15分かかる顧客サービスの予約プロセスを自動化するLangGraphベースのAIエージェントを構築した。このエージェントは、顧客の要望把握・料金計算・日程提案・予約確定まで一連の予約フローを処理する。

ただし初版はPythonのCLIで動作するだけで、UIは最低限だった。本記事ではそのエージェントにStreamlit製のインタラクティブなUIを被せる実装を解説している。ソースコードはGitHubのcustomer-service-agentで公開されている。


実装の要点

セッション状態の管理

Streamlitの重要な特性として、ウィジェットへの操作があるたびにPythonスクリプト全体が再実行されるという点がある。通常のローカル変数は再実行のたびに消えるため、会話履歴やグラフ状態を保持するにはst.session_stateを使う必要がある。

def initialize_session() -> None:
    if "graph" in st.session_state:
        return
    llm = ChatOpenAI(
        model=os.getenv("OPENAI_MODEL", "gpt-4o-mini"),
        temperature=0,
    )
    handler = create_langfuse_handler()
    st.session_state.graph = build_graph(llm)
    st.session_state.handler = handler
    st.session_state.config = graph_config(
        str(uuid4()),
        handler,
    )
    st.session_state.agent_state = INITIAL_STATE.copy()
    st.session_state.started = False

コード中のcreate_langfuse_handler()およびflush_langfuse()は、LLMの入出力・レイテンシ・コストをトレースするオブザーバビリティツールLangfuseのハンドラを生成・フラッシュするためのヘルパーだ。エージェントの挙動を可視化・デバッグするために組み込まれている。

関数冒頭のif "graph" in st.session_stateチェックが肝で、これがないと再実行のたびにグラフが初期化されてしまう。同様の理由でthread_id(LangGraphが会話を識別するためのID)も固定する必要があり、毎回新しいUUIDを生成すると全メッセージが別会話として扱われる。

グラフの呼び出しと状態の保存

ユーザーからの入力(チャットメッセージやボタンクリック)は_invoke関数がまとめて処理する。

def _invoke(customer_text: str) -> None:
    graph_input: dict[str, Any] = {"messages": [HumanMessage(content=customer_text)]}
    if not st.session_state.started:
        graph_input.update(INITIAL_STATE)
        graph_input["messages"] = [HumanMessage(content=customer_text)]
        st.session_state.started = True
    try:
        result = st.session_state.graph.invoke(graph_input, config=st.session_state.config)
        st.session_state.agent_state = result
        flush_langfuse(st.session_state.handler)
    except Exception:
        st.session_state.started = bool(st.session_state.agent_state.get("messages"))
        st.error("The assistant could not process that request. Please try again.")

初回メッセージのみINITIAL_STATE(空の予約詳細・日程オプション・料金・ステータス)でグラフを初期化し、以降は返ってきたグラフ状態をそのままセッションに保存する。LangGraphのadd_messagesリデューサーにより、新メッセージは既存の会話履歴に追記される形になる。これはLangGraph固有の概念で、グラフの状態スキーマにアノテーションとして指定することで、invokeのたびにメッセージリストを手動で連結する実装を不要にする仕組みだ。

画面の描画

現在のグラフ状態を画面要素に変換するrender関数群(_render_messagesなど)もstreamlit_app.py内に定義する。たとえば_render_messagesstate["messages"]から会話履歴を取得し、StreamlitのチャットバブルUIとして表示する。メッセージがない初期状態では定型の挨拶文を表示する。

def _render_messages(state: AgentState) -> None:
    if not state.get("messages"):
        with st.chat_message("assistant"):
            st.write(
                "Hi! I can help you book house or couch cleaning. "
                "Tell me what you need, including the size and service address."
            )
        return
    for message in state["messages"]:
        if isinstance(message, HumanMessage):
            role = "user"
        elif isinstance(message, AIMessage):
            role = "assistant"
        else:
            continue
        with st.chat_message(role):
            st.write(str(message.content))

実際の動作

ローカルでの起動は以下のコマンドで行う(パッケージ管理にはPoetryを使用)。

poetry run streamlit run customer_service_agent/streamlit_app.py

http://localhost:8501/でページが開く。動作確認にはOpenAI APIキーが必要で、数セント程度のコストがかかる。

実際の画面例:

住所を伝えずにメッセージを送ると、エージェントが住所を追加で聞いてくる。

最初のメッセージに住所を含めると、追加質問なしに料金を提示。承認すると3つの日程候補を提示し、選択後に予約が完了する。


今後の展開

著者はWhatsApp連携などの機能追加を計画しており、地元ビジネスへの販売も視野に入れているとのことだ。WhatsApp対応が実現した場合、エージェント本体のグラフは変更不要で、WhatsApp向けのI/Oアダプターを新たに実装するだけで済む。これはまさに本記事で示した「エージェントとUIの完全分離」設計の恩恵であり、チャネルが増えるほどこのアーキテクチャの価値が際立つ構造になっている。

詳細はBuilding a Streamlit UI for My LangGraph AI Agentを参照していただきたい。