有人把 saveCheckpoint 写成了这样:
Level 1:补全一份残缺的检查点按本课定的协议,这份检查点还少哪些字段?对每一个缺的字段,说清楚:如果拿着这份残缺的检查点去恢复,会具体在哪个环节出问题?
学习目标:
- 说出 checkpoint.json 该装哪六个字段,并对每个字段说清楚「恢复时缺了它会撞上什么问题」
- 区分一轮循环里的两个存档点(模型点名之后、工具结果落账之后),说清楚只存其中一个会埋下什么隐患
- 写出一个不会把检查点文件本身写坏的 saveCheckpoint 函数,用「先写临时文件、再原子改名」而不是直接覆盖写
前置要求:读过第 1 课,能区分「记忆」与「执行状态」;熟悉本系列第 7 门课里 messages 数组和 stop_reason 驱动的循环骨架 | 上一课 第 1 课 << | 下一课 第 3 课 >>
第 1 课把记忆和执行状态分开:记忆是喂给模型看的东西,执行状态是宿主自己攥着的运行现场——messages 数组、轮次计数、还没落账的工具调用。这份现场默认只活在进程的内存里,进程一死,它跟着一起消失,哪怕磁盘上所有其他文件都完好无损,任务也只能从头再来。
把这份现场写到磁盘上、变成进程重启后还能读到的东西,这个动作就叫检查点(checkpoint)。工程实践里能撑住长任务可靠性的,往往不是让模型自己想办法扛住一切,而是给它配上「重试逻辑 + 定期检查点」这类确定性护栏,跟模型本身的适应力搭配着用1。这一课就讲检查点这半:一份检查点里该装什么、循环跑到哪一步该落盘、以及落盘这个动作本身要写对——不然存下来的东西可能比没存还糟。
一份检查点不是「把内存里所有东西倒出来」,而是「把恢复循环需要的东西,一个不多一个不少地记下来」。本课程后面几课都基于同一份协议:
messages 加压缩、给 pendingToolUse 换形状),version 让恢复逻辑先问一句「这份检查点我认识吗」——版本不对就该拒绝装载、报错走人,而不是硬着头皮往下解析。task,宿主连「这个检查点对应哪个任务」都说不清,更别提向用户报告恢复进度。messages 里每条消息重新估算一遍用量(多数场景根本拿不到历史 usage)。null,或者形如 { id, name, input } 的一条记录——模型已经点名要用的工具,结果还没落账。这个字段的用法本课先按下不表,第 3 课恢复循环时要靠它做对账;这里只需要知道,它是检查点里专门用来标记「半吊子状态」的地方。为了让这个字段的形状保持简单,本课的例子都假设每轮只有一个 tool_use 块;一轮里有多个并发工具调用时,把它换成数组,道理一样。把上一节的字段套进循环,落盘的时机不是「每轮结束存一次」这么简单,而是有两个存档点:
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 是一步原子替换,操作系统要么把目录项完整地指向新文件,要么保持指向旧文件,不存在「改了一半」的中间状态。
本课教的这套协议是给无人值守的长任务用的,粒度是每轮循环两个存档点。作为对照,看一眼一个真实产品——Claude Code——把「检查点」这个词用在哪:它的检查点在每条用户消息之前自动捕获代码状态2,每一条用户提示都会新建一条检查点2,检查点跟着会话一起保存,即便退出后又恢复了会话,也还能继续 /rewind2。
它服务的场景和本课不一样:Claude Code 的检查点面向的是人机协作会话——用户随时可能喊停、试错、想回到某条消息之前重新来过,粒度天然按「用户说了一句话」切分。本课要造的是无人值守的长任务:没有人在旁边随时喊停,粒度按「循环转了一圈」切分,一圈里还要再细分出 A、B 两个存档点,因为「模型点名」和「工具结果落账」之间可能隔着一次崩溃。两者不是在解决同一个问题,摆在一起看,主要是想说清楚:「检查点」该切多细、多久存一次,答案要看它服务的是什么场景,并不只有一种。
检查点不是免费的:按本课的协议,一轮循环要写两次磁盘。对一个三五轮就能跑完的短任务,这纯粹是开销——进程正常跑完,那些检查点文件从没被读起来过。要不要在自己的 harness 里加这套机制,值得用「加了它是不是真的让结果更好」这条尺子去衡量,而不是默认「有检查点总比没有强」3。任务越长、崩溃代价越高,这笔开销就越划算;一个几秒钟能跑完的任务,多半用不上。
version、task、turns、tokensUsed、messages、pendingToolUse——messages 是最大的一块,没有它模型就没有任何「记得之前发生过什么」的依据;pendingToolUse 是留给第 3 课对账用的悬空标记messages 之后是 B 点;只存 B 点会在「模型点了名、工具还没跑完」这段区间留下盲区.tmp 再用 fs.renameSync 原子改名,才能保证磁盘上任何时刻看到的都是完整的一份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
按本课定的协议,这份检查点还少哪些字段?对每一个缺的字段,说清楚:如果拿着这份残缺的检查点去恢复,会具体在哪个环节出问题?
Jot down thoughts, sticking points, things you didn't get. Written to this course's appendix only — the lesson file is never touched.
const checkpoint = {
version: 1,
task: "把上季度的支持工单按问题类型分类汇总成一张表",
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;
}