LangGraphでState Machineを名乗るなら、ブラウザを閉じても復帰できるべきだった
LangGraph で State Machine を作っている。
そう言いながら、ブラウザを閉じたら状態が消える。
次に開いたときには、また最初から。
これは本当に State Machine なのか。
実装を進めていく中で、OpenRouter 側の API 呼び出しが詰まることがありました。
そのたびに、途中まで進んでいた workflow を最初からやり直す。
検索も、整理も、中間結果も、もう一度やる。
何度かそれを繰り返しているうちに、ふと思いました。
State Machine と言っているのに、なぜ途中から再開できないのか。
そこで、ここが気になり始めました。
RAG や Agentic RAG の workflow は、意外と長いです。
検索する。
複数の document を読む。
必要な chunk を選ぶ。
矛盾や不足を整理する。
中間結果を作る。
最後に回答を生成する。
ここまで進んだあとに、ブラウザを間違って閉じる。
すると、単に UI が閉じただけでは済みません。
それまでに使った検索時間、model call、token、途中の判断、選ばれた context がまとめて失われます。
もう一度最初から実行すればよい、と言うこともできます。
でも、それは人間の待ち時間も、LLM の token も、workflow の中間判断も、全部もう一度払うということです。
必要だったのは、チャット履歴だけではありませんでした。
どこまで進み、どの中間判断を作り、次にどこから再開できるのか。
つまり、workflow の復帰点でした。
さらに、LLM を含む workflow では、再実行しても同じ中間結果になるとは限りません。
通常のプログラムなら、最初から実行し直せば同じ状態に戻れる、と考えやすい。
でも、LLM の model call が途中に入ると、再実行は完全な replay ではなくなります。
ここで、temperature を 0 にして seed を固定すれば再現できる、と思うかもしれません。
実際、自分も最初はそれである程度 replay できるのではないかと考えていました。
でも、thinking model を使うと、どうもそう単純ではなさそうでした。
reasoning 過程そのものが非決定的で、外から完全に固定する手段が見えにくい。
このシステムも thinking model を使っているので、少なくともこの workflow では、「再実行すれば同じところに戻れる」とは置けませんでした。
同じ入力を渡したつもりでも、抽出された claim、整理された tradeoff、生成された common rule が変わることがある。
つまり、途中状態を失うことは、時間や token を失うだけではありません。
その run で作られた中間判断そのものを失う、ということでもあります。
PoC なら、それでも動きます。
画面を開く。
LLM に投げる。
結果を表示する。
ブラウザを閉じる。
状態は消える。
それでも、デモとしては成立します。
でも、Agent Runtime や State Machine を名乗るなら、話は少し変わります。
状態遷移できるだけでは足りない。
途中まで進んだ状態に戻れること。
少なくとも、どこまで進んで、何が残っているのかを復元できること。
状態復帰できて初めて、Runtime に近づくのではないか。
ブラウザを閉じたら、Agentの状態が消える
LLM アプリの初期実装では、状態が UI に寄りがちです。
チャット履歴が画面にある。
途中の結果が画面に出ている。
ユーザーはそれを見ている。
だから、一見状態があるように見えます。
LLM の conversation history は、みんな気にします。
前の発言を覚えているか。
文脈を引き継いでいるか。
同じことを何度も聞き返していないか。
でも、そのわりに system の execution history は忘れられがちです。
どの node まで進んだのか。
どの検索結果を選んだのか。
どの中間判断を作ったのか。
どこで失敗したのか。
LLM の履歴を気にするのに、システムの履歴を気にしないのは少しおかしい。
でも、ブラウザを閉じると消える。
それは Runtime の状態ではなく、UI の都合だったのかもしれません。
State Machine として見たいのは、画面に何が表示されているかだけではありません。
今どの状態にいるのか
どこまで終わったのか
何を待っているのか
どこで止まったのか
次にどこから再開できるのか
ここが残っていないと、Agent は毎回ゼロから考え直すことになります。
それは賢いというより、記憶喪失に近い。
うおw、これじゃただの Workflow じゃん。
Checkpointerは保存機能ではなく復帰点だった
実装では、LangGraph の Checkpointer を入れています。
これによって、ブラウザを途中で閉じても、次に同じチャットを開いたときに resume できるようにしています。
ここで重要なのは、Checkpointer を単なる保存機能として見ていないことです。
Checkpointer = log storage
ではなく、
Checkpointer = resume point
として扱っています。
これは中間出力の cache とも違います。
cache は「過去の出力を再利用して replay する」設計です。
checkpoint は「止まった state から続きを走る」設計です。
過去をやり直すのか、次に進むのか。
同じ「保存」でも、向いている方向が逆です。
本来、こういう checkpoint は Postgres に置いて、transaction も含めて管理するのが筋だと思います。
特に複数ユーザーで使う、並列実行する、途中状態を業務データとして扱う、という方向に進むなら、SQLite で済ませるより Postgres の方が自然です。
ただ、今回はそこまでやりませんでした。
この実験では、Docker を外して身軽に動かすことを優先しました。
Postgres を立てるために Docker Compose を戻すより、まずは SQLite で checkpoint を残し、ブラウザを閉じても resume できるところまで持っていく。
SQLite に倒したことで、身軽なまま poor e2e を通せるようにしました。
ここでは、理想的な本番構成よりも、実験を軽く回せることを選びました。
これは「SQLite が最強」という話ではありません。
State Machine としての復帰点を持つことと、開発体験を重くしすぎないことの間で、今回は SQLite に倒した、というだけです。
どこまで処理が進んでいたのか。
どの state にいたのか。
次に何を実行すべきなのか。
そこに戻れることが、State Machine としての振る舞いを支えます。
State Machineは、状態遷移だけでは足りない
State Machine というと、状態と遷移を定義する話になりがちです。
state A
→ state B
→ state C
もちろん、それは大事です。
でも、実際のアプリケーションでは、もう一つ必要になります。
途中で止まっても、そこから戻れること
状態遷移が定義されていても、実行途中の状態が消えるなら、Runtime としては弱い。
ブラウザを閉じる。
ネットワークが切れる。
ユーザーがあとで戻ってくる。
そのときに、State Machine が前回の状態から再開できるか。
ここまで含めて、状態設計なのだと思います。
失敗もresumeできるべきだった
resume したいのは、成功した状態だけではありません。
むしろ、失敗した状態こそ残っていてほしい。
どこで止まったのか。
何が足りなかったのか。
どの node で失敗したのか。
次に何を直せばいいのか。
ここが残っていれば、失敗はただの失敗ではなく、修正可能な signal になります。
これは、これまで書いてきた Agent Runtime / Boundary State の話ともつながります。
失敗を LLM の中に溶かさない。
失敗を vague failure にしない。
状態として残す。
そして、必要ならそこから戻れるようにする。
Checkpointer は、そのためのかなり地味だけど大事な部品でした。
実装について
実装では、FastAPI、LangGraph、Streamlit、Langfuse の責務を分けています。
まず、LangGraph の checkpointer は SQLite に置いています。
実装の要点だけを抜くと、このような形です。
import sqlite3
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
conn = sqlite3.connect(
config.langgraph_sqlite_path,
check_same_thread=False,
)
checkpointer = SqliteSaver(
conn,
serde=JsonPlusSerializer(
allowed_msgpack_modules=CHECKPOINT_ALLOWED_MSGPACK_MODULES,
),
)
checkpointer.setup()
graph = graph_builder.compile(checkpointer=checkpointer)
通常実行では、chat id と turn number から deterministic な thread_id を作り、その thread_id に checkpoint を残します。
thread_id = f"{chat_id}:{chat_turn}"
initial_state = AssistantResponseState(
user_prompt=user_prompt,
config=config,
last_request_goal=last_request_goal,
history=history,
)
graph.stream(
initial_state,
{"configurable": {"thread_id": thread_id}},
)
resume では、新しい initial state を渡しません。
保存済みの thread_id だけを渡して、checkpoint から再開します。
thread_id = stored_latest_thread_id
graph.stream(
None,
{"configurable": {"thread_id": thread_id}},
)
重要なのは、SQLite を使っていることそのものではありません。
同じ入力をもう一度投げているのではなく、保存された workflow state に戻っていることです。
FastAPI は、chat id や turn number、実行中・完了・失敗といった run metadata を持つ。
LangGraph は、同じ thread_id で node-level checkpoint を SQLite に保存する。
Streamlit は、再度開かれたときに unfinished run を見つけて resume する UI として振る舞う。
Langfuse は、実行を再開するためではなく、あとから何が起きたかを確認するために使う。
つまり、resume は trace inspection ではなく execution control の話です。
これは単なる履歴保存ではなく、State Machine の復帰点を残すための設計です。
まとめ
State Machine を作っていると言いながら、ブラウザを閉じたら状態が消える。
それは少し変でした。
状態遷移できるだけでは、State Machine としては足りない。
状態復帰できて初めて、Runtime に近づく。
Checkpointer は保存機能ではなく、次にどこから始めるかを決める復帰点でした。
State Machineを名乗るなら、ブラウザを閉じても復帰できるべきだった。
この記事を含む一連の設計メモを、Zenn本としてまとめました。
『LLMに溶かさないAgent設計』
LLM Agent に溶けがちな責務を、Runtime / State / Checkpoint / MCP / Output Boundary として外に出す、という観点で再構成しています。
Discussion