跳转到内容

崩溃了怎么办

这是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 3 继续

图 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 入口重跑,memo 挡住重复

图 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 restarts
FROM 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

图 4-3 · close 与真被杀都回到 pending,只有后者让 restarts 加一

游戏读档时,整个世界跟着倒回去;现实里已经写进文件的那一行不会。读文件、算行数这种只看不改的事,重做没坏处;加一行字、发一封邮件,做过再做就重复了。任务可以这样重跑,工具不能一律这样重跑。模型要的工具是一个 pi.tool 任务。它在真正调用工具之前,会把最终参数和 replay 策略写进 checkpoint:{ phase: "execute", arguments, replay }。重启后跑到的是 execute 这个恢复阶段,不是原来的 call。工具自己没写 replay 的,默认是 unsafe;当前注册表里找不到这个工具,也按不安全处理。

图 4-4 工具崩在 execute,就从 execute 重来

图 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 两个 safe 才重跑

图 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 replay
FROM 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 工具 failed,结果仍回到模型

图 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 存档,才不盲做第二遍

图 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 恢复与重跑的四个源码入口

图 4-8 · 本集恢复逻辑的四个源码入口

Agent每做一步就存一次,只要存在磁盘上,程序崩了也还在。可这些存档到底存在哪里?要是存到一半,程序突然崩了,会不会只存下一半?

正在加载交互演示…