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_messagesはstate["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を参照していただきたい。