0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LangGraphのdurability、exitで落ちると再開すらできなかった

0
Posted at

はじめに

LangGraph のグラフを checkpointer 付きでコンパイルすると、途中でプロセスが落ちても続きから再開できます。ただし「どこまで書き込まれているか」は durability という引数で変わります。対象読者は、LangGraph でエージェントを組んで本番運用や長時間バッチを考えている Python 開発者です。

durability"sync" / "async" / "exit" の 3 択で、既定値は "async" です。ドキュメントには「exit は性能が良いがプロセスクラッシュから復旧できない」と書かれていますが、では実際にプロセスを殺したとき、何がどこまで残り、再開したときに何が起きるのか。SQLite の checkpointer を使って 3 モードを同じ条件で落としてみました。結果は次のとおりです。

durability クラッシュ時に残った状態 再開したときの挙動 50 ノード実行の所要時間(中央値)
"sync" n1〜n3(完了済みノードすべて) n4 から再開。再実行は 2 ノード 0.0953 秒
"async" ノードが速いと空、遅いと n1〜n3 空なら 5 ノード全部やり直し 0.0845 秒
"exit" 何も残らない(チェックポイント 0 件) EmptyInputError再開できない 0.0223 秒

意外だったのは 3 行目です。"exit" は「再開が遅くなる」のではなく、そもそも再開の入口に入れませんでした。

検証環境と測り方

Python 3.11.15
langgraph                   1.2.11
langgraph-checkpoint        4.2.0
langgraph-checkpoint-sqlite 3.1.1
langchain-core              1.6.1

n1 から n5 まで一直線に繋いだ 5 ノードのグラフを用意し、各ノードが自分の名前を state に追記します。n4 に入った瞬間に os._exit(9) でプロセスを即死させ、atexit もフラッシュも走らせません。そのあと別プロセスで SQLite ファイルを開き、残っているチェックポイントと state を読み出してから、同じ thread_id で再開します。

ノード本体は次のような形です。NODE_SLEEP でノード 1 個あたりの処理時間を変えられるようにしています。

def make_node(name: str):
    def node(state: State) -> State:
        executed.append(name)
        time.sleep(float(os.environ.get("NODE_SLEEP", "0.05")))
        if PHASE == "crash" and name == "n4":
            os._exit(9)          # プロセスごと即死
        return {"log": [name]}
    return node

with SqliteSaver.from_conn_string(DB_PATH) as saver:
    graph = builder.compile(checkpointer=saver)
    config = {"configurable": {"thread_id": "t1"}}
    graph.invoke({"log": []}, config, durability=MODE)

再開側は inputNone を渡すだけで、LangGraph が最新チェックポイントの続きから流してくれます。再開したプロセスで実際に走ったノード名を記録しておけば、「何ノード分の仕事が失われたか」がそのまま数えられます。

ノードが 50 ミリ秒かかると sync と async は同じ結果になった

まず NODE_SLEEP=0.05、つまり 1 ノード 50 ミリ秒の設定で 3 回ずつ流しました。3 回とも結果は完全に一致しています。

durability 残った state チェックポイント件数 再開時に実行されたノード
"sync" ['n1', 'n2', 'n3'] 5 ['n4', 'n5']
"async" ['n1', 'n2', 'n3'] 5 ['n4', 'n5']
"exit" [] 0 なし(エラー終了)

"async" は「次のステップを実行しながら非同期に書く」モードなので、理屈のうえでは書き込み前に落ちれば取りこぼします。ところが 1 ノード 50 ミリ秒という、LLM 呼び出しどころかローカル関数としても遅めの処理を挟むだけで、書き込みは毎回間に合っていました。筆者はここで一度「async でも実質 sync と同じでは」と判断しかけました。この誤解は次の実験で崩れます。

ノードを一瞬で終わらせると async だけ取りこぼした

同じスクリプトを NODE_SLEEP=0、つまりスリープなしで 5 回まわしました。ノードは state に 1 要素追記するだけなので、1 ステップはマイクロ秒のオーダーで終わります。

試行 "sync" の残り state "async" の残り state "async" の再開時実行ノード数
1 回目 n1, n2, n3 4
2 回目 n1, n2, n3 5
3 回目 n1, n2, n3 5
4 回目 n1, n2, n3 5
5 回目 n1, n2, n3 5

"sync" は 5 回とも n1, n2, n3 を保持し、再開時の実行は n4, n5 の 2 ノードだけでした。一方 "async" は 5 回とも state が空で、チェックポイント自体は 1〜2 件書かれているのに、そこに完了ノードの結果が乗っていません。再開すると 5 回中 4 回はグラフ全体を最初からやり直しています。

つまり "async" の「小さなリスク」は確率的なゆらぎではなく、ノード 1 個の実行時間と書き込みレイテンシの競争 でした。ノードが書き込みより速く終わる限り、落ちたときに失うのは「直前 1 ステップ」ではなくグラフ全体になり得ます。LLM 呼び出しのように 1 ノードが秒単位かかるエージェントでは "async" はほぼ "sync" と同じ耐性に見えますが、ツール呼び出しの結果を整形するだけの軽いノードが連続する区間では話が変わります。

exit は再開できず EmptyInputError で止まった

"exit" はどちらのスリープ設定でも同じ挙動でした。クラッシュ後に SQLite を開くとチェックポイントは 0 件、当然 state も空です。そのうえで同じ thread_idinput=None で再開すると、こうなります。

error: EmptyInputError: Received no input for __start__

再開が「最初からやり直しになる」のではありません。再開という操作自体が失敗します。LangGraph は最新チェックポイントを起点に続きを流す設計なので、起点が 1 件も無いスレッドには流し込む入力が存在せず、__start__ チャンネルが空だと言って止まります。復旧するには、アプリ側が元の入力を別の場所に持っていて、input=None ではなく最初の入力を渡し直す必要があります。

ここが "exit" の実務上の落とし穴だと感じました。「クラッシュしたら最初からやり直せばいい」と設計していると、その"やり直し"のための入力を誰が保持しているのかという問題が残ります。checkpointer を付けているのに、クラッシュ復旧の責任はアプリ側に戻ってきているわけです。

書き込みコストは 50 ノードで 0.07 秒差だった

では "exit" が速いぶんはどれくらいなのか。スリープなしのノードを 50 個直列に並べたグラフを、モードごとに 3 回ずつ実行しました。

durability 1 回目 2 回目 3 回目 中央値 1 ステップあたりの上乗せ
"sync" 0.1002 秒 0.0941 秒 0.0953 秒 0.0953 秒 約 1.46 ミリ秒
"async" 0.0819 秒 0.0845 秒 0.0854 秒 0.0845 秒 約 1.24 ミリ秒
"exit" 0.0213 秒 0.0223 秒 0.0227 秒 0.0223 秒 基準

"sync""exit" の約 4.3 倍、"async" は約 3.8 倍の時間がかかりました。倍率だけ見ると大きいものの、絶対値は 50 ステップで 0.07 秒です。1 ノードが LLM 呼び出しなら 1 回あたり数百ミリ秒から数秒はかかるので、チェックポイント書き込みの 1.5 ミリ秒は誤差に埋もれます。逆に、LLM を呼ばずにデータを整形するだけのノードを数千ステップ回すバッチなら、この差は数秒単位で効いてきます。

"async""sync" の差が 0.2 ミリ秒しかなかったのも実感と違いました。SQLite ローカルファイル相手だと非同期化の旨みはほとんど出ず、耐性だけが下がっています。ネットワーク越しの Postgres なら差は開くはずですが、少なくとも「ローカル SQLite で開発中に async を選ぶ理由」は今回の数字からは見つかりませんでした。

3 モードの選び分け

実測を踏まえると、選択の軸は「速度」ではなく「クラッシュしたときに誰が入力を持っているか」でした。

  • "sync": 完了ノードの結果が必ず残ります。1 ステップ約 1.5 ミリ秒の上乗せを払えるなら既定でこれを選んで良い、というのが今回の結論です。とくに人手の承認待ち(interrupt)を挟むエージェントは、待っている間にプロセスが死ぬ前提で組むべきなので相性が良いです。
  • "async": ノード 1 個が数百ミリ秒以上かかるワークロード限定です。軽いノードが連続する区間では state が丸ごと空になり得るため、「1 ステップ分だけ失う」という見積もりは危険です。
  • "exit": チェックポイントを再開に使わないと決めた場合だけです。使うなら、元の入力をアプリ側で永続化し、input=None ではなく初回入力で流し直す復旧経路を必ず用意します。

なお durabilityinvoke / stream の引数なので、同じグラフでもフェーズごとに切り替えられます。長い前処理は "exit" で速く流し、承認待ちを含む本体だけ "sync" にする、といった使い分けが構造的には可能です。

おわりに

durability の 3 モードは「速度と安全のトレードオフ」として説明されがちですが、実際に落としてみると、"exit" だけは連続した軸の端ではなく別物でした。再開が遅くなるのではなく、再開できません。そして "async" の危険度はノードの実行時間に依存し、軽いノードほど失う範囲が広がります。

手元の環境で試すなら、5 ノードのグラフに os._exit(9) を仕込んで SqliteSaver.list() を覗くだけで再現できます。既定値のまま本番に出す前に、自分のワークロードのノード 1 個が何ミリ秒なのかを測っておくと判断が変わるかもしれません。

参考

関連記事

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?