ある人がsaveCheckpointをこう書きました。
レベル1: 不完全なチェックポイントを埋める本レッスンが定めたプロトコルに照らして、このチェックポイントにはまだどのフィールドが欠けていますか? 欠けているフィールドそれぞれについて、この不完全なチェックポイントから再開しようとしたとき、正確にどこで破綻するかを具体的に述べてください。
学習目標:
- checkpoint.jsonに入るべき6つのフィールドを挙げ、それぞれが欠けたときに再開が何にぶつかるかを言う
- ループの1ターンの中にある2つの保存地点(モデルがツールを指名した直後、ツール結果が記録された直後)を区別し、片方しか書かないと何を招くかを説明する
- チェックポイントファイル自体を壊しえない
saveCheckpointを書く — その場で上書きするのではなく、一時ファイルを書いてからアトミックにリネームする前提: レッスン1を読み、メモリと実行状態を区別できること。"Agent Harness Fundamentals: Loops and Control" の
messages配列とstop_reason駆動のループ骨格に慣れていること | 前: レッスン1 << | 次: レッスン3 >>
レッスン1ではメモリと実行状態を切り分けました。メモリはモデルに与えるもの、実行状態はハーネス自身が抱えている実行中の場面——messages配列、ターンカウンタ、結果がまだ記録されていないツール呼び出し——です。デフォルトでは、その場面はプロセスのメモリ上にしか存在しません。プロセスが死ねば一緒に消え、ディスク上の他のファイルがすべて無傷であっても、タスクはゼロからやり直すことしかできません。
その場面をディスクに書き出し、再起動したプロセスが読み戻せるものに変えること——それがチェックポイントです。実務で長時間タスクの信頼性を実際に支えているのは、たいていの場合、あらゆる失敗をモデル自身に吸収させることではありません。"the adaptability of AI agents built on Claude with deterministic safeguards like retry logic and regular checkpoints"1(Claude上に構築されたAIエージェントの適応力を、リトライロジックや定期的なチェックポイントといった決定論的な安全装置と組み合わせること)です。本レッスンはそのチェックポイント側を扱います。何を入れるのか、ループのどこで書くのか、そして書き込みそのものをどう行うのか。間違ったやり方で書かれたチェックポイントは、チェックポイントが無い場合よりも状況を悪くしうるからです。
チェックポイントは「メモリ上のものを全部ファイルに吐き出す」ことではありません。「ループの再開に必要なものを記録する——それ以上でも以下でもなく」です。本コースの以降のレッスンはすべて、同じプロトコルの上で動きます。
messagesの圧縮、pendingToolUseの新しい形など)。versionがあることで、再開パスは何よりも先に「このチェックポイントを自分は認識できるか?」を問えます。知らないバージョンに対しては、歯を食いしばって解析を続けるのではなく、読み込みを拒否して大きな声で失敗すべきです。taskがなければ、ハーネスはそのチェックポイントがどのタスクのものかすら言えず、再開の進捗をユーザーに報告することなど到底できません。messagesの全メッセージについて使用量を推定し直すはめになります。そして多くの構成では、過去の使用量の数字はもう手に入りません。nullか、{ id, name, input }の形をしたレコードです。モデルが指名した、結果がまだ記録されていないツールを指します。このフィールドをどう使うかはレッスン3の担当で、そこで再開が突き合わせを行います。ここでは、これが中途半端な状態を印づけるためにチェックポイントが用意した専用の枠だと分かっていれば十分です。形をシンプルに保つため、本レッスンの例はすべて1ターンにつきtool_useブロックは1つと仮定します。1ターンで複数のツール呼び出しを並行して出す場合は配列にしてください。考え方は同じです。これらのフィールドをループに流し込むと、タイミングは「毎ターンの最後に1回書く」ほど単純ではないことが分かります。保存地点は2つあります。
地点Aはモデルの応答が届いた後、ツールが走る前に置きます。応答のtool_useブロックをpendingToolUseに記録してから書きます。地点Bはツール結果がmessagesに追加された後に置きます。pendingToolUseをnullに戻してから、もう一度書きます。
Bだけで十分でしょうか。露出しているのはAとBの間の窓です。モデルがツールを指名し、ツールが実行中であるか、完了はしたもののその結果がmessagesに入っておらず、ディスクにも書かれていない、という窓です。この窓でプロセスが死ぬと、ディスク上の最後のチェックポイントは前のターンでBが書いたものであり、このターンの呼び出しについて何も知りません。細部が少し失われたのではなく、このツール呼び出しがディスク上に痕跡を一切残していない、ということです。レッスン3は再開時に突き合わせを行います——そのツールは本当に完了したのか、再実行が必要か——そしてその突き合わせの相手こそが、Aが書いたpendingToolUseです。本レッスンは穴を掘るところまでです。レッスン6の演習では、Bだけのチェックポイントを目の前に置き、再開で何が壊れるかを診断してもらいます。
真っ先に思いつくやり方は、stateオブジェクトをJSON.stringifyして、fs.writeFileSyncで古いcheckpoint.jsonにそのまま上書きすることです。プロセスが正常終了する場合はそれで問題ありません。しかし「正常終了」こそ、チェックポイントが用意されていない場合です。チェックポイントは、プロセスがいつ落とされるか分からないこと、電源が落ちること、コンテナが追い出されることのために存在します。ファイルの書き込みはアトミックな操作ではありません。書き込みの途中でプロセスが中断されると、ディスク上に残るcheckpoint.jsonは書きかけかもしれません。古いバージョンでもなく、新しいバージョンでもなく、ただ途中で切れたJSONです。次の再開はJSON.parseで例外を投げますし、そのファイルはタスクにとって場面の唯一のコピーでした。戻れる古いバージョンは存在しません。
取るべき手は「一時ファイルを書いてから、アトミックにリネームする」です。完全な内容をcheckpoint.json.tmpに書き込みます。その途中でクラッシュしても、犠牲になるのは一時ファイルだけで、本物のcheckpoint.jsonはクラッシュ前の無傷な古いバージョンのままであり、再開は問題なく読めます。.tmpファイルが完成したら、fs.renameSyncで本物のファイル名に付け替えます。同一ファイルシステム上では、renameは一段階のアトミックな置換です。OSはディレクトリエントリを新しいファイルに丸ごと向けるか、古いファイルに向けたままにするかのどちらかであり、途中まで名前が変わった状態というものは存在しません。
本レッスンが教えるプロトコルは無人の長時間タスク向けで、粒度はループ1ターンにつき2つの保存地点です。対比として、実在のプロダクト——Claude Code——が「チェックポイント」という語をどこに置いているかを見てみましょう。"checkpointing automatically captures the state of your code before each user prompt."2(チェックポイント機能は、ユーザープロンプトのたびにコードの状態を自動的に捕捉する)。そして "Every user prompt creates a new checkpoint"2(ユーザープロンプトのたびに新しいチェックポイントが作られる)であり、"Claude Code saves checkpoints with the conversation, so you can still run /rewind after you resume a session"2(Claude Codeはチェックポイントを会話とともに保存するため、セッションを再開したあとでも/rewindを実行できる)です。
それが仕えている場面は、ここでの場面とは違います。Claude Codeのチェックポイントは人間がループに入っているセッションのために作られています。ユーザーはいつでも手を止め、あるやり方を試し、どこかのメッセージより前に戻ってもう一度やり直そうと決めることができる——だから自然な単位は「ユーザーが何かを言った」になります。あなたがここで作っているものは無人の長時間タスク向けです。止めてくれる人は誰も控えておらず、単位は「ループが1周した」であり、1ターンの中でさらに保存地点AとBに分かれます。クラッシュは「モデルがツールを指名した」と「結果が記録された」の間に落ちうるからです。この2つは同じ問題を解いているのではありません。並べて置いたのは、主に1つのことをはっきりさせるためです。チェックポイントをどれだけ細かく刻むか、どれだけ頻繁に書くかは、そのチェックポイントが何に仕えているかで決まります。唯一の答えがあるわけではありません。
チェックポイントにはコストがかかります。本レッスンのプロトコルでは、ループ1ターンにつきディスクへの書き込みが2回発生します。3ターンや5ターンで終わる短いタスクにとって、それは純粋なオーバーヘッドです。プロセスは最後まで走り切り、それらのチェックポイントファイルは一度も読まれません。この仕組みを自分のハーネスに入れるかどうかは、"you should consider adding complexity only when it demonstrably improves outcomes."3(複雑さを加えるのは、それが結果を明確に改善すると示せるときだけにすべきである)という原則に照らして測る価値があります。タスクが長く、クラッシュのコストが高いほど、この取引は割に合うようになります。数秒で終わるものなら、おそらく必要ありません。
version、task、turns、tokensUsed、messages、pendingToolUse。messagesが最大の塊であり、これがなければモデルは以前に何が起きたかを知る手がかりを一切持たない。pendingToolUseはレッスン3が突き合わせる、宙ぶらりんの呼び出しの印であるmessagesに完全に記録された後のB。Bだけで保存すると、モデルが指名したツールがまだ完了していない窓に死角ができる.tmpファイルを書き、fs.renameSyncで置き換えること——それが、どの瞬間にディスク上にあるものも1つの完全なバージョンであることを保証する>> レッスン3: チェックポイントからの再開: ループを再起動する
How we built our multi-agent research system — Anthropic Engineering — https://www.anthropic.com/engineering/multi-agent-research-system ↩ ↩2
Checkpointing — Claude Code Docs — https://code.claude.com/docs/en/checkpointing ↩ ↩2 ↩3 ↩4
Building Effective AI Agents — Anthropic Engineering — https://www.anthropic.com/engineering/building-effective-agents ↩ ↩2
本レッスンが定めたプロトコルに照らして、このチェックポイントにはまだどのフィールドが欠けていますか? 欠けているフィールドそれぞれについて、この不完全なチェックポイントから再開しようとしたとき、正確にどこで破綻するかを具体的に述べてください。
const checkpoint = {
version: 1,
task: "前四半期のサポートチケットを問題種別ごとに整理し、1つの表にまとめる",
turns: 3,
tokensUsed: 14208,
messages: [/* 会話履歴の全体 */],
pendingToolUse: null, // または { id, name, input }
};
while (response.stop_reason === "tool_use") {
state.messages.push({ role: "assistant", content: response.content });
const block = response.content.find((b) => b.type === "tool_use");
// 保存地点A: モデルがツールを指名した。まだ実行されていない
state.pendingToolUse = { id: block.id, name: block.name, input: block.input };
saveCheckpoint(state);
const result = await executeTool(block.name, block.input);
state.messages.push({
role: "user",
content: [{ type: "tool_result", tool_use_id: block.id, content: result }],
});
state.turns += 1;
state.pendingToolUse = null;
// 保存地点B: このターンのツール結果はmessagesに完全に記録された
saveCheckpoint(state);
response = await callModel({ tools, messages: state.messages });
state.tokensUsed += response.usage?.output_tokens ?? 0;
}
import fs from "node:fs";
import path from "node:path";
function saveCheckpoint(state, dir = "./checkpoints") {
fs.mkdirSync(dir, { recursive: true });
const finalPath = path.join(dir, "checkpoint.json");
const tmpPath = `${finalPath}.tmp`;
fs.writeFileSync(tmpPath, JSON.stringify(state, null, 2));
fs.renameSync(tmpPath, finalPath); // 同一ファイルシステム上では、renameはアトミックな置換
}
function saveCheckpoint(state) {
fs.writeFileSync("checkpoint.json", JSON.stringify({ messages: state.messages }));
}
import fs from "node:fs";
function saveCheckpoint(state) {
fs.writeFileSync("checkpoint.json", JSON.stringify(state, null, 2));
}
async function runAgent(task, tools, callModel, executeTool) {
const state = {
version: 1,
task,
turns: 0,
tokensUsed: 0,
messages: [{ role: "user", content: task }],
pendingToolUse: null,
};
let response = await callModel({ tools, messages: state.messages });
while (response.stop_reason === "tool_use") {
state.messages.push({ role: "assistant", content: response.content });
const block = response.content.find((b) => b.type === "tool_use");
const result = await executeTool(block.name, block.input);
state.messages.push({
role: "user",
content: [{ type: "tool_result", tool_use_id: block.id, content: result }],
});
state.turns += 1;
saveCheckpoint(state);
response = await callModel({ tools, messages: state.messages });
}
return state;
}