はじめに
LangGraph でチェックポイントを有効にしたまま長いスレッドを回すと、保存されるデータ量がターン数の二乗で増えていきます。会話履歴を state に貯めるエージェントなら、100 ターン目のチェックポイントは 100 ターン分の履歴を丸ごと書き直しているからです。
LangGraph 1.2 で入った DeltaChannel は、この書き直しをやめて差分だけを残します。対象読者は、LangGraph のチェックポイントを永続ストアに置いていて保存量が気になっている Python エンジニアです。
公式ブログは、複数ファイルを編集するコーディング用途で「200 ターンで 5.3GB が 129MB になった」「40 倍超の削減」と報告しています(Delta Channels: How We're Evolving our Runtime for Long-Running Agents)。筆者の手元で同じ性質の計測をしたところ、削減率はターン数に比例して伸び、400 ターンでは 77 倍まで開きました。倍率が一つの数字に収束しないのは条件が違うからで、そこが DeltaChannel の効き方を理解する手がかりになります。
計測結果(ターン数ごとの保存量)
InMemorySaver に保存されたバイト数(blobs + checkpoints + writes の合計)を、既定の operator.add チャンネルと DeltaChannel で比べた結果です。1 ターンあたり 400 バイトの文字列を 1 件ずつ list に足していく、同じグラフを使っています。
| ターン数 |
operator.add(既定) |
DeltaChannel |
倍率 | 削減率 |
|---|---|---|---|---|
| 50 | 1,106,315 B | 104,521 B | 10.6 倍 | 90.6% |
| 100 | 4,228,184 B | 209,827 B | 20.2 倍 | 95.0% |
| 200 | 16,517,437 B | 421,074 B | 39.2 倍 | 97.5% |
| 400 | 65,275,682 B | 844,027 B | 77.3 倍 | 98.7% |
ターン数を倍にすると既定側は約 4 倍、DeltaChannel 側は約 2 倍になっています。前者が O(N²)、後者が O(N) という説明そのままの形です。倍率が一定の値に落ち着かないので、「何倍軽くなるか」はスレッドの長さを言わないと意味を持ちません。公式ブログの 40 倍超も 200 ターン時点の値で、筆者の 200 ターン計測(39.2 倍)とほぼ重なりました。ワークロードが違っても倍率が近いのは、効き方がターン数だけで決まる性質だからだと読めます。
検証環境は Python 3.11.15 / langgraph 1.2.11 / langgraph-checkpoint 4.2.0 です。
内訳を見ると差が出ている場所が分かる
200 ターン時点の内訳を分解すると、増えているのが 1 箇所だけだと分かります。
| 内訳 | operator.add |
DeltaChannel |
|---|---|---|
| blobs(チャンネル値の本体) | 16,122,538 B | 1,400 B |
| checkpoints(メタデータ) | 313,899 B | 338,674 B |
| writes(各ステップの書き込み) | 81,000 B | 81,000 B |
blobs が 16,122,538 B から 1,400 B に落ちる一方、writes は完全に同じ、checkpoints は約 8% 増えています。DeltaChannel はチェックポイント本体に値を書かず、番兵(sentinel)だけを置いて、読み出し時に祖先の書き込みを reducer で畳み直して復元します。InMemorySaver の内部辞書を数えると blobs のエントリ数は両者とも 1,200 件で変わらず、1 件あたりの中身が空に近くなっているという構造です。checkpoints がわずかに増えるのは、差分の遡り位置を管理するメタデータが乗るためです。
writes が変わらないのは見落としやすい点です。各スーパーステップの書き込みログは DeltaChannel でも従来どおり残ります。DeltaChannel はそのログを復元の材料として使う側なので、writes を減らす仕組みではありません。保存量の見積もりを立てるときは、削減対象が blobs だけだと考えておくと外しません。
snapshot_frequency は保存量と復元時間のつまみ
DeltaChannel は差分だけを書き続けるわけではなく、K ステップごとに全体スナップショットを挟みます。この K が snapshot_frequency で、LangGraph の既定値は 1000 です(langgraph/channels/delta.py)。公式ブログによると Deep Agents 側の既定は 50 で、製品によって値が違います。
スナップショットの発火条件は OR で 2 つあります。このチャンネルへの更新回数が snapshot_frequency に達したときと、システム全体の DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT(既定 5000)にスーパーステップ数が達したときです。後者があるおかげで、書き込みが止まったチャンネルでも遡る距離が青天井にはなりません。以下の計測は 400 ターンなので、実際に効いているのは前者だけです。
400 ターン時点で K を変えた結果です。
snapshot_frequency |
blobs | 状態復元 |
|---|---|---|
| 10 | 6,532,050 B | 0.25 ms |
| 50 | 1,373,132 B | 0.17 ms |
| 200 | 405,834 B | 0.22 ms |
| 1000(既定) | 2,800 B | 3.84 ms |
K を小さくするほどスナップショットが頻繁に落ちるので保存量は増え、その代わり復元時に遡る距離が短くなります。ただし 400 ターン程度では復元時間の差はミリ秒単位で、保存量の差(約 2,333 倍)に比べて誤差のように見えます。K=10 は K=1000 の約 2,333 倍のディスクを使って 3.6 ミリ秒を買っている計算になり、この規模では割に合いません。
K が 10・50・200 の 3 水準で復元時間が単調に並んでいないのは、いずれもサブミリ秒でノイズに埋もれているためです。差として読めるのは K=1000 の 3.84 ミリ秒だけで、残り 3 水準は「どれも速い」と括るのが実態に近いです。
筆者が試した範囲では、既定の 1000 のまま置いておき、復元レイテンシが体感できるようになってから下げるので十分でした。逆に Deep Agents が 50 を選んでいるのは、ファイル本体まで delta 化していて 1 件あたりの差分が大きく、遡りコストが本文より重くなるためだと読めます。自分の state に何が入っているかで最適値が変わるつまみです。
reducer がバッチ不変でないと、復元値が実行中の値とズレる
DeltaChannel の reducer は (state, [write1, write2, ...]) -> new_state という形で、書き込みを「まとめて」受け取ります。ドキュメント文字列は、この reducer が決定的かつバッチ不変(batching-invariant)でなければならないと明記しています。
reducer(reducer(state, xs), ys) == reducer(state, xs + ys)
復元のときに、LangGraph は元の実行より大きなバッチで書き込みを畳み直します。上の等式が成り立たない reducer を渡すと、実行中に見えていた値とチェックポイントから復元した値が食い違います。
バッチ境界を記録してしまう reducer で試すと、実際にズレました。
# バッチ境界を記録する reducer(= バッチ不変ではない)
def batch_marker(state, writes):
base = list(state) if state else []
base.append(f"[batch:{len(writes)}]")
for w in writes:
base.extend(w)
return base
class S(TypedDict):
msgs: Annotated[list, DeltaChannel(batch_marker)]
5 ターン回した結果です。
実行中に見えた値 : ['[batch:8]', 'm', 'm', 'm', 'm', '[batch:1]', '[batch:1]', 'm']
チェックポイント復元: ['[batch:10]', 'm', 'm', 'm', 'm', 'm']
一致: False
例外は出ません。エラーにならず静かに値が変わるので、テストで get_state() の戻り値を突き合わせない限り気づけない種類の壊れ方です。list への追加や dict のマージのように「順序さえ保てば分割位置に依存しない」操作なら安全ですが、直近 N 件だけ残す、重複を除く、件数で分岐する、といった処理を reducer に入れるときは要注意です。
DeltaChannel のドキュメント文字列には Beta 表記があり、BaseCheckpointSaver.get_delta_channel_history や _DeltaSnapshot の blob 形状、counters_since_delta_snapshot メタデータフィールドといった周辺契約はまだ安定ではないと書かれています。書いたスレッドが読めなくなることは想定していないとしつつ、カスタムの checkpointer を自前実装している場合は追随が必要になる可能性があります。
使いどころの整理
計測を通して見えた判断材料をまとめます。
- 効くのは長いスレッドだけです。50 ターンでも 10 倍差は出ますが、絶対量は 1MB と 0.1MB の差で、運用コストとして問題になる水準ではありません。効果が跳ねるのは数百ターンからです。
- 削減されるのは blobs のみです。writes とメタデータは減らないので、チェックポイントテーブル全体が同じ比率で縮むわけではありません。
- reducer の書き方に制約が付きます。既存の reducer をそのまま
DeltaChannelに差し替えられるとは限らず、バッチ不変かどうかの確認が先です。 - 復元時間は伸びます。今回の規模ではミリ秒単位でしたが、K を既定のまま数千ターン回した場合の挙動は別途測る必要があります。
LangGraph のチェックポイント周りは、durability の指定と再開の挙動やノード単位の error_handler と timeoutと組み合わせて設計する部分です。DeltaChannel は保存量の軸だけを担当するので、落ちたときに再開できるかどうかは別の設定で担保します。
まとめ
DeltaChannel の削減率は固定値ではなく、スレッドが長いほど伸びます。筆者の計測では 50 ターンで 10.6 倍、400 ターンで 77.3 倍でした。導入判断は「何倍軽くなるか」ではなく「自分のスレッドが何ターン続くか」から入るのが正しい順序です。
そのうえで、reducer がバッチ不変であることの確認だけは省けません。ここを外すと例外なしに復元値が変わるので、切り替えのときは実行中の値と get_state() の戻り値を突き合わせるテストを 1 本足しておくと安全です。
関連記事
- LangGraphのdurability、exitで落ちると再開すらできなかった
- AIエージェントフレームワーク選定2026 — LangGraph・CrewAI・Microsoft Agent比較
- CrewAI vs LangGraph 2026 本番選定ガイド — PoC・本番別の最適解