以下四个场景,各自该用回退、恢复、分叉、还是 Git?请逐个作答并说明理由。
Level 1:四个场景,选对工具- 一个跑了 20 多轮的任务里,你发现模型在第 12 轮选错了修改方案,后面的轮次都建立在这个错误方案之上,但进程本身一直好好地在跑,没有崩溃。
- 同一个任务跑到第 18 轮,宿主机被重启了,进程整个断掉,什么都没跑完。
- 你不确定该把一个模块拆成两个服务还是拆成三个,想让 Agent 各按一种方案跑一遍,再比较结果。
- 你想知道三天前这份代码是什么样子、是谁在什么时候改的。
学习目标:
- 说清检查点除了灾后恢复,还有两种主动用法——回退到早先的现场重来、分叉出另一条时间轴去试——并理解它们和恢复(resume)同样建立在一份检查点序列上
- 把检查点从「只留最新一份」改造成按轮次留存的序列,实现
rewindTo(turn),并说清回退拨回的是决策现场,不是已经发生的外部副作用- 实现
forkFrom(turn, branchName)从同一现场复制出独立时间轴,并说清检查点、Git、效果台账三者各自的分工边界前置要求:完成第 1-4 课,熟悉
checkpoint.json的字段结构(version、task、turns、tokensUsed、messages、pendingToolUse)与原子写、断点恢复怎么处理悬空调用、以及第 4 课的效果台账与幂等键 | 上一课 第 4 课 << | 下一课 第 6 课 >>
前三课一路把检查点讲成救灾用的保险——进程崩了,从最近一份检查点把循环接着跑起来。这么用没问题,但如果只在崩溃之后才想起打开它,等于让它大部分时间都闲置着:没崩,是不是就白存了?
不是。一串检查点攒起来,其实是这个任务的一条时间轴——每一轮它在想什么、准备做什么、已经落了哪些账,全都留了痕迹。除了灾后恢复,这条时间轴还能派上另外两种主动用场:回退到早先的某一轮重新来过;从某一轮分叉出去,同时跑另一条路线看结果。一份以 harness 工程为主线的社区路线图,给持久化这个组件的概括正是把这三件事连在一起说的:每一步都要落检查点,为的是能恢复、能回退、能分叉1。这是路线图给出的一个框架性说法,没有规定具体怎么实现,但它点出了一件事:resume 只是检查点用法的三分之一,后面两种才是这一课的正题。
这两种都不是「灾后处理」——任务顺顺当当往下跑的时候,都可能用得上。
设想一个要跑 20 多轮工具调用的任务:模型在第 15 轮做了一个错误决策——选错了要改的文件,或者对一条含糊的需求做了错误的假设。接下来的 10 轮,它都在这个错误的基础上继续往下建。本系列第 8 门课讲过,上下文越堆越长、越堆越杂,模型准确召回其中信息的能力会随之下降;何况这段历史里还带着一个错误决策,与其让模型在这段又长又跑偏的上下文里继续挣扎,不如把现场拨回第 14 轮——那个还没做错决策的时间点——从那里重新开始。
要做到这一点,检查点不能再只留「最新一份」。前几课的 saveCheckpoint 每次都覆盖同一份 checkpoint.json,恢复时只能拿到最后一次写入的状态——这对灾后恢复够用,但没法回退,因为第 14 轮那份现场早被第 15 轮盖掉了。要支持回退,检查点得按轮次留存成一个序列,文件名里带上轮次号和存档点:checkpoints/turn-014-A.json、checkpoints/turn-014-B.json 这样——每一轮里,模型给出方案、工具还没执行时落一份存档点 A;这一轮的工具结果写回 messages、这一轮真正跑完时再落一份存档点 B。默认「回到第 N 轮」指这一轮跑完后的现场,也就是取该轮里最后落盘的那个存档点:
拿到 rewindTo(14) 返回的现场,接下来的流程和断点恢复一样:用这份 messages 重建历史,从这个 turns 数字继续循环,只是这一次,模型面对的不再是被第 15 轮污染过的上下文,而是那个决策发生之前的干净现场。
但有一件事得主动点破:回退拨回的是决策现场,不是外部世界。如果第 16 轮那个错误决策已经调用了某个高影响工具——比如真的发出了一封邮件——回退到第 14 轮并不会把那封邮件收回来。检查点存的是 messages、turns、pendingToolUse 这些你自己定义进快照的状态字段,从来没打算,也做不到去撤销一次已经落地的外部动作。第 4 课的效果台账(effects.json)继续遵守只增不减的规则:回退之后从第 15 轮重新跑,哪怕模型这次选了完全不同的动作,台账里也只会多一条新记录,不会把旧的那条抹掉——被丢弃的十轮里到底发生过什么,账上依然留着痕迹,这正是第 4 课那份幂等视角在回退场景里的延续。
回退解决的是「这条路走错了,退回去重来」;但有时候问题不是「错没错」,而是「不确定哪条更好」——两种重构方案都说得通,想各跑一遍看效果再挑。这种时候不该覆盖式地二选一,而是从同一个检查点复制出两条独立时间轴,分别跑:
forkFrom(14, "plan-b") 之后,checkpoints-plan-b/ 里有了它自己的检查点序列和一份空白的效果台账,从第 14 轮往后,这条时间轴要往哪走、跑几轮、落多少检查点,都跟主线互不干扰。
分叉出来的两条时间轴各自独立,这件事对高影响工具是个提醒:如果两条时间轴都会调用同一个真正对外的动作——比如都要发同一封邮件——让它们各自无审批地跑下去,就是两条时间轴各发一遍,变成双份副作用。给这类工具接上审批闸,或者分叉期间先切成干跑模式,是分叉之前值得做的准备;这和回退时台账不会跟着回滚是同一个道理:检查点可以复制成两份,但已经落地的外部效果没法跟着复制成「平行世界各一份」。
前面这套回退与分叉,Claude Code 已经把它做成了产品级功能——这里只作对照,不是要教的工具。它的检查点机制会在每条用户提示之前自动捕获代码状态2:每条用户提示都会新建一个检查点2,而且检查点跟着会话一起保存,就算你退出会话再回来,依然能用 /rewind2。
它的 /rewind 菜单把「恢复什么」拆成了三档:只恢复对话(代码保持当前状态)、只恢复代码(对话保留当前状态)、或者代码和对话一起恢复到那个时间点2——这恰好对应本课「回退拨回的是决策现场」这句话的产品化版本:可以选择只拨回决策现场(对话),也可以连同代码一起拨回。官方文档也列了检查点的几个常见用例,比如探索替代方案、尝试不同实现路径而不丢掉起点,以及从错误中恢复、快速撤销引入 bug 的改动2。要说明的是,这些用例文档是笼统挂在检查点(/rewind)名下讲的,并没有按「回退」「分叉」分开归类;但拿来对照本课「走错了退回去重来」和「不确定就分叉去试」这两种用法,方向是一致的。
分叉这一侧,Claude Code 提供了 /branch 或者 claude --continue --fork-session:在保留原会话完整不动的前提下,另起一条时间轴去试别的路子2。
Claude Code 的文档也主动划了一条边界:它的检查点不追踪 bash 命令改动的文件2,只追踪 Claude 自己那几个文件编辑工具做出的修改2。同理,你自己 harness 里的检查点,覆盖的也只是你显式定义进快照的那几个状态字段——messages、turns、tokensUsed、pendingToolUse;工具在外部世界造成的改动,无论是写数据库、调用别的服务、还是发一封邮件,统统不在检查点的管辖范围内,那是效果台账的活。
官方文档把这套机制的定位说得很直接:检查点是为快速的会话级恢复而设计的,长期历史和协作,还是要继续用 Git 这样的版本控制2。三者各管一段,摆在一起看更清楚:
按轮次留存整条检查点序列,不是没有成本的——每一轮两份存档点,跑得越久,磁盘上堆的文件就越多。Anthropic 那条「只在复杂度能明确改善结果时才考虑添加」的原则3,放到这里同样适用:如果任务本来就跑不了几轮,也不太需要回退重来,按轮次留存整条序列这份开销,可以考虑省下来——像前几课那样只留最新一份,也够用。反过来,任务动辄几十轮、又常常需要回退或者分叉着试几条方案,序列化留存换来的是明确的收益:出了岔子不必从头再来,试错的成本也压低了。这终归是个按任务规模取舍的判断,不是哪种做法天生更对。
turn-014-A/B.json),而不是覆盖式地只留最新一份;rewindTo(turn) 默认取该轮里最后落盘的存档点/rewind 可分别恢复对话或代码、/branch 与 --fork-session 用于分叉2;但它自己划了边界——只追踪 Claude 自己文件编辑工具的修改、不追踪 bash 命令改动的文件2,定位是快速的会话级恢复,长期历史与协作仍归 Git2>> 第 6 课:实战:给 harness 装上检查点与恢复
The 2026 Agent Engineering Roadmap — GitHub (codejunkie99/agent-roadmap-2026) — https://github.com/codejunkie99/agent-roadmap-2026 ↩ ↩2
Checkpointing — Claude Code Docs — https://code.claude.com/docs/en/checkpointing ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12
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.
import fs from "node:fs/promises";
import path from "node:path";
function turnFileName(turn, point) {
return `turn-${String(turn).padStart(3, "0")}-${point}.json`;
}
// 取某一轮里最后落盘的存档点:A、B 按字典序排列,B 排在 A 后面,
// 正好对应「工具执行前」到「工具结果落账后」的先后顺序
async function pickLatestPoint(turn, dir) {
const files = await fs.readdir(dir);
const prefix = `turn-${String(turn).padStart(3, "0")}-`;
const points = files
.filter((f) => f.startsWith(prefix))
.map((f) => f.slice(prefix.length, prefix.length + 1))
.sort();
if (points.length === 0) throw new Error(`没有第 ${turn} 轮的检查点`);
return points[points.length - 1];
}
async function rewindTo(turn, { point, dir = "checkpoints" } = {}) {
const wanted = point ?? (await pickLatestPoint(turn, dir));
const raw = await fs.readFile(path.join(dir, turnFileName(turn, wanted)), "utf8");
return JSON.parse(raw); // { version, task, turns, tokensUsed, messages, pendingToolUse }
}
async function forkFrom(turn, branchName, { point, baseDir = "checkpoints" } = {}) {
const startPoint = point ?? (await pickLatestPoint(turn, baseDir));
const state = await rewindTo(turn, { point: startPoint, dir: baseDir });
const branchDir = `${baseDir}-${branchName}`;
await fs.mkdir(branchDir, { recursive: true });
await fs.writeFile(
path.join(branchDir, turnFileName(turn, startPoint)),
JSON.stringify(state, null, 2)
);
// 独立的效果台账:这条时间轴自己往下跑,谁的效果记谁自己的账,不沿用主线的记录
await fs.writeFile(path.join(branchDir, "effects.json"), "[]\n");
return branchDir;
}
// 旧版本:每次都覆盖同一份文件,只能取到「最新」,取不到「第 N 轮」
import fs from "node:fs/promises";
async function saveCheckpoint(state) {
const tmp = "checkpoint.json.tmp";
await fs.writeFile(tmp, JSON.stringify(state, null, 2));
await fs.rename(tmp, "checkpoint.json");
}
async function loadCheckpoint() {
const raw = await fs.readFile("checkpoint.json", "utf8");
return JSON.parse(raw);
}