レッスン3: チェックポイントからの再開: ループを再起動する
学習目標:
- クラッシュ復旧の場面で「宙ぶらりんの呼び出し」が必ず現れる理由と、それが通常のツール実行失敗とどう別物なのかを言う
loadCheckpoint()からループに戻るまでの再開パスを、バージョンチェックと状態の再構築を含めて書く- 宙ぶらりんの呼び出しを、闇雲に再実行したり闇雲に削除したりするのではなく、ツールの性質——読み取り専用ツールはそのまま再実行、影響の大きいツールはまずフォールバック——に応じて突き合わせる
前提: レッスン2を終え、
checkpoint.jsonのフィールドと2つの保存地点を理解していること | 前: レッスン2 << | 次: レッスン4 >>
再起動ではなく、再開する
エージェントが途中でクラッシュしたとき、最初に浮かぶのはたいてい「もう一度走らせる」です。しかし、すでに十数ターン進み、ツールを何度も呼んだ長時間タスクにとって、再起動は割に合わない取引です。"restarts are expensive and frustrating for users"1(再実行はコストが高く、ユーザーにとって苛立たしい)。レッスン2では実行中の場面をcheckpoint.jsonに書き出しました——version、task、turns、tokensUsed、messages、pendingToolUse——モデルが応答した後(保存地点A)に一度、ツール結果が記録された後(保存地点B)に一度、保存します。本レッスンがやるのは、保存されたその場面を、前へ進めるループへと戻すことです。毎回最初からやり直すのではなく、"resume from where the agent was when the errors occurred"1(エラーが起きた地点のエージェントの状態から再開する)ことのできるシステムを作ります。
再開の背骨: 半分は簡単
簡単なほうの半分から始めましょう。再開の背骨は4つのステップです。ファイルを読む、JSON.parseする、versionを確認する、フィールドを実行時のstateへ展開し直す。この4つが済めば、runAgentは初期のmessages配列を組み立て直す必要がありません。チェックポイントがすでに完全なものを持っているので、初期化を飛ばして、そのままループに落ちていきます。
この2つの関数が揃えば、runAgentの冒頭はシンプルな分岐になります。
再開後、ループが最初にやることは、いつもとまったく同じです。state.messagesを取って、次のclient.messages.create()を撃つ。モデルが見るmessagesは、クラッシュ前に見ていたものと同一であり、その間にプロセスの再起動があったことをモデルは知りようがありません。レッスン2がmessagesを手を加えずにチェックポイントへ入れることにこだわったのは、このためです。その配列さえ忠実に復元されていれば、再開はモデルから見えません。
難しい半分: 宙ぶらりんの呼び出しを突き合わせる
本当に厄介なのは、state.pendingToolUseがnullでないチェックポイントです。2つの保存地点の位置を思い出してください。地点Aはモデルの応答の後にあり、その瞬間pendingToolUseはこの応答の{id, name, input}を持っています。地点Bはツール結果が記録された後にあり、pendingToolUseはnullへ戻されています。プロセスがAとBのちょうど間で死ぬと——ツールがまだ実行されていないか、完了したのに結果がmessagesに入らなかったか——チェックポイントが保持するのは、nullでないpendingToolUseです。
このときmessagesの末尾は、tool_useブロックを載せたassistantメッセージであり、対応するtool_resultがありません。これは引きずったまま進める状態ではありません。プロトコルはこう要求しています。"return one tool_result for each tool_use block, all together in the next user message"2(各tool_useブロックにつき1つのtool_resultを返し、それらをまとめて次のユーザーメッセージに入れること)。その結果が1つ欠けているだけで、再開は次の呼び出しをそもそも行えません。モデルから見えるのは、自分がツール呼び出しを起こしたのに答えが永遠に返ってこない、中途半端なやり取りです。この宙ぶらりんの呼び出しは、ループに再突入する前に片づけなければなりません。
3つの対処法、成り立つのは1つだけ
この宙ぶらりんのassistantメッセージを前にすると、思いつく手は3つありますが、実際に成り立つのは1つだけです。
手その1: messagesからassistantメッセージを削除し、なかったことにする。 いちばんきれいに見えます。再開後の会話にはもう欠けがありません。しかし代償は2層に及びます。第一に、モデルは自分がすでに下した判断を忘れるので、同じ探索をもう一度たどり、余分なターンを無駄に燃やしかねません。第二に、そしてより危険なことに、そのツール呼び出しが実際にはすでに実行されていて、プロセスが結果を記録する前に死んだだけだった場合、メッセージを削除してもすでに起きた副作用は取り消されません。モデルも、以降のあらゆるログも、それが起きたことを知らなくなるだけです。削除が隠すのは事実であって、リスクではありません。
手その2: ツールを再実行して、その結果をtool_resultに埋める。 読み取り専用のツール(read_file、grepなど)にはこれがまさに正解です。2回読むことは1回読むことと変わらず、副作用はゼロです。影響の大きいツール(メール送信、データベースへの書き込み)には危険です。そのツールはすでに一度実行されている可能性が高く、無条件に再実行することは二度目の実行を意味します。これはまさにレッスン4が本格的に取り上げる冪等性の問題です。ここでは、いま行動に移せるルールを1つ立てておきます。読み取り専用ツールはそのまま再実行する。影響の大きいツールは、再実行するかどうかを決める前に、まずすでに実行済みかどうかを確認しなければならない。
手その3: 「実行状況が不明です、状況を評価し直してください」と伝えるis_error: trueのtool_resultを追加し、判断をモデルに返す。 これは実行されたかどうかを判別できないときの保守的なフォールバックです。is_errorフィールドはまさに "Set to true if the tool execution resulted in an error"2(ツール実行がエラーになった場合にtrueを設定する)ために存在します。そして "letting the agent know when a tool is failing and letting it adapt works surprisingly well"1(ツールが失敗していることをエージェントに知らせ、適応させることは驚くほどうまく機能する)ことが分かっています。モデルはコンテキストを読み直し、黙って繰り返されたかもしれない行動に焼かれる代わりに、別の方法で結果を確認するかどうかを自分で決めます。
3つを並べると、手その1は脱落します。手その2と手その3は、それぞれ「判別できる」場合と「判別できない」場合をカバーしており、両方そろってはじめて完全な突き合わせルールになります。
reconcile(cp): 突き合わせをコードにする
このルールを関数にします。ツール名から読み取り専用かどうかを判定し、そうであれば再実行する。そうでなければ「副作用台帳」を見に行き、この呼び出しがすでに実行されたかを確認する——本レッスンにはまだ副作用台帳がないので、コメントで代役を立てておき、実装はレッスン4で与えます。判別できないときは、is_errorのフォールバックに落とします。
reconcile()が終わると、cp.messagesの末尾には対応するtool_resultが埋まり、cp.pendingToolUseはnullに戻っています。このcpはもはや、保存地点Bで正常に着地したチェックポイントと見分けがつきません。そのままwhileループへ渡して続行できます。
再開後: turnsとtokensUsedの数え方
再開パスが狂わせやすいカウンタが2つあり、それぞれ独立して明示しておく価値があります。
turnsは再開でリセットされません。これは「このプロセスインスタンスが何ターン走ったか」ではなく、タスク開始から現在までの総ターン数を数えるものです。チェックポイントのturnsは中断した地点から加算を続けるべきであり、それだけがレッスン2で設けたMAX_TURNSの上限を機能させ続ける唯一の道です。再開時にturnsをゼロにすると、クラッシュと復旧を繰り返すタスクはターンの天井をすり抜け、永遠に走り続けられてしまいます。
tokensUsedも同じです。再計算するのではなく、チェックポイントから引き継ぎます。"Context Engineering: Spending Finite Attention Where It Counts" がコンテキストの圧縮を扱うとき、tokensUsedは「現在のウィンドウの使用量」を意味しますが、チェックポイントが保存したのはまさにクラッシュの瞬間のそのウィンドウの使用量です。両者は同じ意味を担っているので、再開時はそれを受け取ってそのまま続ければよく、余分な変換は要りません。
まとめ
再開の背骨は難しくありません。チェックポイントを読み、バージョンを確認し、フィールドを実行時のstateへ展開し直し、初期化を飛ばしてそのままループへ落ちる——その間にクラッシュがあったことをモデルは感じ取ることすらできません。実際に設計を要するのは、宙ぶらりんの呼び出しの突き合わせです。削除は判断を失わせ、すでに起きた副作用を覆い隠します。読み取り専用ツールなら気兼ねなく再実行できます。そして実行済みかを判別できない影響の大きいツールには、闇雲な再実行よりもis_errorのフォールバックのほうが安全な選択です。しかしこのルールは、まだ1つの問題を解いていません。影響の大きいツールがすでに実行されたかどうかを、実際にはどうやって確認するのか? 本レッスンは「判別できない」にフォールバックしただけでした。本当に判別できるようになるには影響台帳が要ります——そしてそれこそが、次のレッスンが解く問題です。
>> レッスン4: 副作用と冪等性: 再開時に再実行しても安全なツールはどれか
Footnotes
-
How we built our multi-agent research system — Anthropic Engineering — https://www.anthropic.com/engineering/multi-agent-research-system ↩ ↩2 ↩3
-
Handle tool calls — Claude API — https://platform.claude.com/docs/en/agents-and-tools/tool-use/handle-tool-calls ↩ ↩2
練習
運用インシデントの報告です。「send_emailツール呼び出しの後、結果が記録される前に、プロセスがOOM killerに落とされて再起動された。再起動時にハーネスが自動で--resumeし、数分後にユーザーから同一のメールを2通受け取ったと報告があった。」
レベル2: 重複メールの根本原因を突き止める当時本番で動いていたreconcile()はこうでした。
根本原因を特定し、そのうえでこのreconcile()を、ツールの性質で振り分ける版に書き直してください(ヒント: 本レッスンが立てたルールは「読み取り専用はそのまま再実行、台帳のない影響の大きいツールはis_errorへフォールバック」です)。書き直したらnodeで走らせ、send_emailのような影響の大きいツールがもうexecuteTool()を起動しないことを確認してください。