TT Lab
はじめる
学ぶ 学習パス コース

AIエージェント — モデルではなくグラフ

昨日のあの地点に戻れるか

TT Labで続きを見る

一言でいうと

チェックポインターを付けると、グラフはステップごとに状態をまるごと残します。そのため、続きから動かせ、過去に巻き戻せ、そこからブランチを切れます。そして、同じ理由で、状態のサイズがそのまま保存コストになります。

なぜ必要なのか

エージェントが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リファレンスに書かれています。

実務で本当に大切なこと

次のラボですること

/root/work/agckpt/ckpt.pyを、1ステップずつ育てます。まず、MemorySaverとthread_idで、同じ会話がつながり、違う会話が別々に残ることを確認し、チェックポイントを数えてリストとして読みます。続いて、巻き戻す座標を探す関数を作り、その座標で巻き戻し、値を直してブランチを切ったあとで、元のブランチをもう一度読み出します。次に、状態をシリアライズしてバイトを数え、大きな値1つが何か所に載るかを数値で確認し、最後に、帳簿を新しく作ると何が消えるかを、自分の手で再現します。採点ツールは、書かれたモジュールを実際に読み込んで実行し、毎回異なるトピックと異なるサイズの値で叩いてみます。