昨日のあの地点に戻れるか
一言でいうと
チェックポインターを付けると、グラフはステップごとに状態をまるごと残します。そのため、続きから動かせ、過去に巻き戻せ、そこからブランチを切れます。そして、同じ理由で、状態のサイズがそのまま保存コストになります。
なぜ必要なのか
エージェントが10ステップを回る途中、8番目で失敗したとします。チェックポインターがなければ、方法は1つです。最初からやり直しです。前の7ステップがファイルを書いてメールを送っていたなら、それももう一度起きます。
もっとよくあるのは、こちらです。ユーザーが答えを受け取って、「3番目のステップで別の資料を使っていたらどうなっただろう」と尋ねます。状態が残っていなければ、答える方法がありません。入力を直して最初から動かし直すしかなく、そうすると、前の2ステップが作った結果まで新しく作られます。同じ条件で比べる必要があるのに、条件が変わってしまいます。
チェックポインターは、この2つの問題を1つの方法で解決します。ステップが終わるたびに、その時点の状態全体を1セット保存しておきます。保存された時点の1つ1つにラベル(checkpoint_id)が付いていて、そのラベルを持っていけば、いつでもその場所に戻れます。
どう動くのか
使い方は2行です。コンパイルするときにチェックポインターを渡し、動かすときにthread_idを渡します。
from langgraph.checkpoint.memory import MemorySaver
app = graph.compile(checkpointer=MemorySaver())
config = {"configurable": {"thread_id": "user-42"}}
app.invoke({"topic": "가을"}, config)
thread_idは、会話1セットを指す名前です。同じ値を渡すと、保存された状態の上で続きを動かし、違う値を渡すと、何もないところから新しく始まります。Persistenceのドキュメントは、これを短期記憶と呼びます。会話1セットの中でだけつながる記憶という意味です。
ここで重要なのは、保存される時点がいくつあるかです。ノード3つを一列につないだグラフを、このラボの環境(langgraph 0.2.60)で1回動かしてget_state_history(config)を数えると、5個でした。入力を受け取った場所が1つ、ノードが終わるたびに1つずつで3つ、そして、すべて終わった場所が1つです。ノードが1つ増えれば、チェックポイントも1つ増えます。
リストは新しいものから出てきます。時間順に読みたいなら、逆にする必要があります。スナップショット1つには、その時点の値(values)、次に動くノード(next)、そしてその場所を指すconfigが入っています。
巻き戻し: 保存された場所からもう一度動く
過去のスナップショットのconfigをそのまま使い、入力をNoneにすると、その場所からもう一度動きます。
snapshot = ... # next 가 ("write",) 인 스냅샷
app.invoke(None, snapshot.config)
入力をNoneにするのが核心です。新しい入力を渡すと、新しく始まり、Noneは「保存されたその状態から続きを動かす」という意味になります。実際に測ると、writeの前に巻き戻すと、チェックポイントが2つ増えました(writeとreview)。planの前なら3つ、reviewの前なら1つです。もう一度動いた分だけ増えます。
フォーク: 値を直して別の道へ行く
同じ座標で値を1つ書き換えて入れると、そこから別のブランチができます。
forked = app.update_state(snapshot.config, {"angle": "비교"})
app.invoke(None, forked)
update_stateは、その場所にチェックポイントをもう1つ載せて、新しいcheckpoint_idが入ったconfigを返します。それで続きを動かすと、新しいブランチができます。
ここでよく誤解する点が1つあります。元のブランチは消えません。チェックポイントがそのまま残っているので、その時点のconfigでいつでも読めます。しかし、thread_idだけを渡してget_state(config)を呼ぶと、返ってくるのは、新しいブランチの終端です。スレッドの「現在」が移ったからです。元の結果と新しい結果を並べて比べるには、フォークする前に、その時点のconfigを手に持っておく必要があります。このラボで測ると、1回動かしたあとでwriteの前でフォークすると、チェックポイントは5個から8個になりました。ブランチを作った場所が1つと、もう一度動いたノードが2つです。Use time-travelが、この2つを、再生とフォークという名前で分けて説明しています。
保存されるのは状態全体
チェックポイントに何が入るかで、迷う必要はありません。状態全体です。一部ではありません。
そのため、状態に大きな値を入れると、チェックポイントも一緒に大きくなります。時間で話すと、マシンと負荷によって変わりますが、サイズは同じ状態ならいつも同じなので、測ればよいのです。このラボのグラフで、状態をシリアライズしてバイト数を数えてみました。何も入れていないとき、チェックポイント5個の合計は399バイトでした。状態に1,500バイトの値を1つ入れて、同じグラフを動かしたら、合計は6,447バイトになりました。その値を実際に持っているチェックポイントは、5個のうち4個でした。一度入れた値が、4回保存されたのです。
この比率は、ノード数が増えるほど大きくなります。ノードが30個なら、一度入れた値が30回近く保存されます。そのため、大きな原文や表は、状態の外(ファイル・ストレージ)に置き、状態には指し示す値だけを入れるほうがよいです。
現場での姿
1つ目: 会話がつながらない。チェックポインターを付けていないか、付けておいてthread_idを毎回新しく作っているのです。エラーは出ません。ただ毎回、最初のように振る舞います。実際に測ると、チェックポインターのないグラフに同じconfigで2回動かしても、2回とも新しく始まりました。
2つ目: フォークしたら元の結果が見えない。消えたのではなく、スレッドの「現在」が移ったのです。元の時点のconfigを持っていれば、そのまま読めます。
3つ目: 保存量が目立って増える。状態に原文をまるごと入れて、ノードをたくさん回るグラフが、そうなります。原因を探すには、時間ではなくサイズを測る必要があります。
4つ目: Podが死ぬとすべて消える。MemorySaverは、名前のとおり、プロセスの中のメモリに入れます。新しいプロセスが立ち上がると、同じthread_idで尋ねても、残っているものが1つもありません。ラボやテストには向いていますが、本番には合いません。長く残す必要があるなら、外部のストレージに書くチェックポインターを使います。どんなものがあるかは、PersistenceとグラフAPIリファレンスに書かれています。
実務で本当に大切なこと
thread_idを何にするかを先に決めます。ユーザーごとか、案件ごとか、セッションごとか。この選択が、「何がつながるのか」を丸ごと決めます。- 巻き戻しとフォークを、言葉で区別します。巻き戻しは同じ値でもう一度、フォークは値を直して別の道へ。コードでは、
update_state1つがその2つを分けます。 - 元のブランチの座標を手に持ってフォークします。あとで比べるには、その
configが必要です。 - 状態に入れる前に、「これを毎ステップ保存してもよいか」を問います。答えにためらうなら、外に置いて、指し示す値だけを入れます。
- 測るのは時間ではなくサイズです。シリアライズしてバイトを数えれば、同じ状態でいつも同じ答えが出ます。
次のラボですること
/root/work/agckpt/ckpt.pyを、1ステップずつ育てます。まず、MemorySaverとthread_idで、同じ会話がつながり、違う会話が別々に残ることを確認し、チェックポイントを数えてリストとして読みます。続いて、巻き戻す座標を探す関数を作り、その座標で巻き戻し、値を直してブランチを切ったあとで、元のブランチをもう一度読み出します。次に、状態をシリアライズしてバイトを数え、大きな値1つが何か所に載るかを数値で確認し、最後に、帳簿を新しく作ると何が消えるかを、自分の手で再現します。採点ツールは、書かれたモジュールを実際に読み込んで実行し、毎回異なるトピックと異なるサイズの値で叩いてみます。