崩溃了怎么办
这是Pi《源码精读》的第四集,崩溃了怎么办?模型每要一次工具,就开一个工具任务去做。第二集讲过,工具任务在动手之前,会先记下要做的事。当时留了一个问题:这一笔到底有什么用?今天就来回答。先看一个更小的任务,它是包里的示例 12(12-recovery.ts)。
示例 12 现在是一个从 1 数到 5 的任务,里面没有模型,也没有工具;它用的是 openNodeSqliteStorage,不是 JSONL。每数完一个数,没到 5 就把下一个数存进 checkpoint,睡 50 毫秒再继续。第一次运行,打印 tick 1、tick 2。就在 tick 2 打印完、下一步还没存下的时候,示例把 Harness 关掉。用同一个 SQLite 文件再打开,它没有从 tick 1 重来,而是接着打印 tick 3、tick 4、tick 5,最后得到 counted to 5。tick 2 没有再打印一遍。这就像玩游戏读档,从最后一个存档点接着玩;Durable 的存档点就是 checkpoint。
图 4-1 · 崩溃后从 tick 2 的入口重跑,打印从 tick 3 继续
这里的关键不是“没重来”,而是“重跑了,但没有重复做”。tick 2 那一步其实又执行了一次,只是它先查了一个 memo:printed-2 已经写过,就不会打印。memo 的规则是同一个名字只认第一次写入,所以同一个 tick 不会被打印两次。它保证的是不重复;memo 写在打印之前,如果正好崩在两者之间,这一次打印会丢。示例里用的是 close;真正把进程杀掉,普通打开也会走到同一个状态:open 把当时 running 的任务改回 pending,checkpoint 原样保留。状态上只差一个计数:close 不算一次崩溃,真被切断才算。只想看存了什么,用 readOnly: true 打开:open 不会把 running 改回 pending,带写入的提交和 resume() 都会报 ReadOnlySession。
如果崩的时候模型请求正在飞,恢复时不会把这次请求再发一遍:这次尝试算一次失败,按 settings.retry 退避,半截回答写成一条 aborted 记录。重试用完,这一轮以 pi.live.runFailure 的 reason interrupted 结束,输入变成 unanswered,onReport(report) 收到 task_interrupted(report.kind,reason interrupted)。close 之后重开仍会重发。
源码对照 · packages/durable/src/harness/scheduler.ts:453
if (record.state.status === "running") { const restarts = (record.restarts ?? 0) + 1; const pending = withState(record, { status: "pending", checkpoint: record.state.checkpoint }); tx.setTask({ ...pending, restarts } as AnyTaskRecord);}withState 只换状态,checkpoint 还是原来那一份;restarts 比原来多一。只有 running 会这样改,waiting、sleeping、pending 都保持原样。这个计数是为了防止崩溃循环:同一个任务连续 5 次被切断,中间没有取得进展,就不会自动重跑;取得进展后,计数清零。到了上限,任务直接结束:示例 12 这种扩展任务以 faulted 结束,detail 是 { reason: "restart_limit" },onReport(report) 收到 task_faulted(report.kind,reason restart_limit);内置的工具、生成、压缩任务以各自的可见结果结束,并报 task_interrupted——工具写一条 restart_limit 错误结果,生成写 pi.live.runFailure,压缩写 pi.live.compactionFailure。要再跑,重新提交一次(新输入、compact() 或新任务)。5 是默认值,settings.restartLimit 可以改,Infinity 表示不设上限。
checkpoint 记的是这一步的入口,不是这一步做到哪里。所以重新启动后,任务从这一步的开头再执行一遍。每一步都要经得起重跑。示例 12 的 tick 就是这样:先看 memo,再决定要不要打印;打完,用 sleepUntil 把下一个 checkpoint 和“什么时候再跑”一起提交。
源码对照 · packages/durable/test/examples/12-recovery.ts:26
tick: async (task, runtime, taskContext) => { const n = task.state.checkpoint.n; // … if ((await runtime.memo(`printed-${n}`, taskContext)) === undefined) { await runtime.memo(`printed-${n}`, true, taskContext); console.log(`tick ${n}`); } // … if (n === task.input.to) { await runtime.commit( () => ({ status: "terminal", outcome: { status: "completed", result: `counted to ${n}` } }), taskContext, ); return; } await runtime.sleepUntil(runtime.now() + 50, { phase: "tick", n: n + 1 }, taskContext);},memo 把“这个 tick 是否已经开工”变成了一个可重跑的判断;sleepUntil 则把“什么时候再跑”写进状态,任务睡着的时候不占着一次执行,到点再继续。重新打开后,它从存着的 checkpoint 接着跑,不会把前面的 tick 全部重来。
图 4-2 · checkpoint 只记入口,memo 让重跑的 tick 2 不会重复打印
存储实况 · SQLite(示例 12 关闭后,重开前;保留 DB)
SELECT id, status, record ->> '$.state.checkpoint.n' AS n, record ->> '$.memos' AS memos, record ->> '$.restarts' AS restartsFROM tasks;| id | status | n | memos | restarts |
|---|---|---|---|---|
| 7 | pending | 2 | {“printed-1”:true,“printed-2”:true} | — |
这是本次运行的任务 id。重开后跑完,同一行变成 terminal,结果是 counted to 5,memos 和 restarts 都会被清掉。示例用的是 close,所以 restarts 是空的;同一个位置换成真正杀进程,restarts 会是 1。示例默认会删掉临时目录;想自己查这张表,把末尾的 rm(...) 去掉,或者把 DB 路径固定下来。
图 4-3 · close 与真被杀都回到 pending,只有后者让 restarts 加一
游戏读档时,整个世界跟着倒回去;现实里已经写进文件的那一行不会。读文件、算行数这种只看不改的事,重做没坏处;加一行字、发一封邮件,做过再做就重复了。任务可以这样重跑,工具不能一律这样重跑。模型要的工具是一个 pi.tool 任务。它在真正调用工具之前,会把最终参数和 replay 策略写进 checkpoint:{ phase: "execute", arguments, replay }。重启后跑到的是 execute 这个恢复阶段,不是原来的 call。工具自己没写 replay 的,默认是 unsafe;当前注册表里找不到这个工具,也按不安全处理。
图 4-4 · 工具调用的四道 checkpoint,崩在 execute 就从 execute 重来
源码对照 · packages/durable/src/harness/tool.ts:202
execute: async (task, runtime, context) => { const { arguments: args, replay } = task.state.checkpoint; const call = await readCall(runtime, task.input, context); if (cutOffTooOften(task, runtime)) return endCutOff(runtime, task, call, context); const tool = await resolveTool(runtime, task.input, call.name, context); if (replay === "safe" && tool?.replay === "safe") { // … return run(runtime, task.input, call, tool, args, context); } const message = `Tool ${call.name} was interrupted and may have partially run`; const ending = { status: "failed", message } as const; await settleFromSlot(runtime, task.input, call, { tool }, ending, "interrupted", message, context);},只有存着的 replay 和当前工具的 replay 都是 safe,才重新执行。存着 safe、现在改成 unsafe,不跑;存着 unsafe、现在改成 safe,也不跑。没到 restartLimit 时,其余情况都写一条 interrupted 结果;到了上限,写一条 restart_limit 错误结果,任务仍以 failed 结束,但工具不会重跑。以这次探针为例,内容是“Tool append was interrupted and may have partially run”,再带上崩溃前已经落盘的部分输出。任务本身以 failed 结束,但这条结果和普通工具结果一样追加进对话,模型下一轮能看到。
图 4-5 · 存着的 replay 和当前 tool.replay 都是 safe,工具才会重跑
模型这一轮的 generation 任务会停在 tools 这个 checkpoint,等 tool 任务结束;所以下面表里还有一条 waiting 的 pi.generation。
存储实况 · SQLite(工具崩溃探针,重启前后)
SELECT id, kind, status, record ->> '$.restarts' AS restarts, record ->> '$.state.checkpoint.phase' AS phase, record ->> '$.state.checkpoint.replay' AS replayFROM tasks ORDER BY id;| 时刻 | id | kind | status | restarts | phase | replay |
|---|---|---|---|---|---|---|
| 崩溃后 | 9 | pi.generation | waiting | — | tools | — |
| 崩溃后 | 12 | pi.tool | running | — | execute | unsafe |
| open 后 | 9 | pi.generation | waiting | — | tools | — |
| open 后 | 12 | pi.tool | pending | 1 | execute | unsafe |
重开后工具没有重跑。它写下的结果里,content 先是崩溃前已经落盘的 working\n,后面是换行的 <harness>、[error] Tool append was interrupted and may have partially run 和 </harness>,isError 是 true。工具运行中的输出是延迟落盘的:要复现这段 working\n,探针得等过 settings.progress.persistWithinMs(默认 1000 毫秒)再切断,或者把它设成 0;真实崩溃可能丢掉这段输出。
图 4-6 · 工具任务失败后,interrupted 结果仍会进入对话供模型读取
这个探针用的是 SQLite;仓库里对应的自动化测试是 packages/durable/test/harness-tools-recovery.test.ts,同样走 SQLite。表里的 id 是这次探针的编号;换一种跑法,编号会变。
把同一个探针的 replay 改成 safe 再走一遍,工具会重新执行:它往 notes.txt 追加的那行 hello 会再写一次,文件从一行变成两行,结果变成 appended。这也说明为什么加行不能标 safe:标了,它就会真的再写一遍。
现在可以回答第二集的问题了。要是没有 execute 这一笔,重启后只能把整个 call 阶段重来:重新解析工具、重新校验参数、重新跑 beforeTool,然后再次执行。工具要是已经动过手,就会做第二遍。有了这一笔,至少知道“可能已经开始了”,能重做的才重做,不能重做的就如实告诉模型。要是崩在 execute 之前,工具本身还没开始,call 阶段会整段重跑;但 beforeTool 钩子可能已经跑过,所以钩子也要经得起重跑。
图 4-7 · 有 execute 存档才知道工具可能已开始,避免盲目做第二遍
这一集记住一句话:崩了以后,从存着的 checkpoint 接着跑;做到一半的工具,存着的和当前的 replay 都是 safe 才重做。任务可以用 memo 把重复的效果挡住,工具则靠 replay 策略把选择说清楚。本集用到的几处代码都列在这里,读源码的话,可以照着找。下一集,我们来看这些存档到底存在哪里。前四集一直在说存档。
当前源码里,恢复在 scheduler.ts:453,按 checkpoint 执行在 scheduler.ts:2208,默认 replay 在 tool.ts:196,execute 在 tool.ts:202。
图 4-8 · 本集恢复逻辑的四个源码入口
Agent每做一步就存一次,只要存在磁盘上,程序崩了也还在。可这些存档到底存在哪里?要是存到一半,程序突然崩了,会不会只存下一半?