第 11 章:持久化执行 —— 会崩溃恢复的 Agent 运行时
进阶篇 · 实验性内容。 本章讲的是随 Pi v1.0.0 首次公开的独立包
@earendil-works/pi-durable(npm 已发布,本章基于 1.0.x)。官方明确定位为实验品:The API changes without notice between releases——1.0.3、1.0.4 每个补丁都有 breaking change。读本章是为了吃透「持久化执行」这套架构思想,具体 API 请以你安装版本的 README 为准。
前 10 章拆解的是 pi 本体:一个跑在终端里的编码 agent。但有一条线索一直没展开——如果进程死在半路,会怎样?
第 10 章的 JSONL 会话文件能保住「已经完成的对话历史」,第 9 章的压缩结果也挂在树上。可一个正在进行中的轮次——模型流式输出到一半、bash 工具刚执行 30 秒——它的状态全在内存里。进程一死,这些就都没了。重启之后,agent 只能从头再来。
pi-durable 就是 Pi 团队对这个问题的正式回答:一个把「正在进行中」也全部落盘的 agent 运行时。它不是 pi 的补丁,而是在同一套 pi-ai 模型层之上,把 Agent Loop 重写成了一台带事务的持久状态机。
问题:杀死一个正在调工具的 Agent
先把这个问题的尖锐性具体化。假设 agent 正在执行你的指令「跑一下测试套件」,bash 工具已经跑了 2 分钟。此时 kill -9:
| 状态 | 普通 agent 运行时 | 持久化运行时 |
|---|---|---|
| 已完成的对话历史 | 在(JSONL 文件) | 在(存储后端) |
| 模型流式输出的半截回答 | 丢 | 在(每 100ms 已提交) |
| 跑到一半的工具调用 | 丢——模型重问会再跑一遍 | 在——重开后收到 interrupted 结果 |
| 排队的 follow-up | 丢 | 在(pi.inbox 文档) |
| 已花掉的 token 和成本 | 丢 | 在(pi.usage 文档) |
右边这一列就是 pi-durable 的目标。官方示例(packages/coding-agent/src/experimental/durable/)演示得最直白:工具调用跑到一半 kill 掉进程,--continue 重启,被中断的调用收到一个「中断」结果,轮次正常收尾——TUI 里没有任何恢复代码,它只是渲染存储里的状态。
核心答案:先提交,再显示
pi-durable 的全部设计可以从一句话推出来:
任何东西在被你看到之前,先被原子地提交到存储。
规范文档(packages/durable/docs/spec.md §1)把它展开成一组硬不变式,最关键的三条:
- 一次提交全有或全无:条目追加、文档变更、任务创建在同一个原子写里完成,不存在「条目存了但任务没建」的中间态。
- 提交成功之前不发布:文档更新只有在存储提交成功后才对订阅者可见。
- 没有易失发布路径:所有可见进度都必须持久化——连流式 partial 和工具输出也不例外(默认每 100ms 提交一次,崩溃最多丢这 100ms)。
第 4 条不变式同样关键:外部副作用不在 Session 的变更事务里执行。事务里只写「意图」和「结果」,真正调模型、跑 bash 发生在事务之外。这就是后面 effect sandwich 的来源。
四个核心概念
pi-durable 的数据模型只有四种记录,前 10 章的概念都能对号入座:
| 概念 | 是什么 | 对应主线概念 |
|---|---|---|
| Entry(条目) | 不可变的转录记录:pi.user、pi.assistant、pi.tool-result、pi.system、pi.reset、pi.compaction,可自定义 |
第 6 章消息系统 + 第 10 章树节点 |
| Commit(提交) | 一次原子写:条目 + 文档 + 任务,要么全存要么都不存 | (主线没有对应物——JSONL 是裸追加) |
| Document(文档) | 转录旁边的类型化 JSON 状态,随提交变更:pi.agent(模型/工具选择)、pi.live(进行中的生成与工具)、pi.inbox(排队输入)、pi.usage(花费) |
第 10 章状态节点的「物化视图」版 |
| Task(任务) | 每步存档的持久状态机:pi.generation 调模型,拥有并等待它的 pi.tool 子任务 |
第 3 章 Agent Loop 的可恢复版 |
条目定义在 packages/durable/src/entries.ts,每种条目就是一个字符串 kind 加类型守卫,比如:
export const UserEntry = defineEntry("pi.user");
export const AssistantEntry = defineEntry("pi.assistant");
export const ToolResultEntry = defineEntry<{ diagnostics: ToolDiagnostic[] }>("pi.tool-result");
Document 解决的是第 10 章遗留的一个张力:Session Tree 把所有状态变更都节点化(切模型是 model_change 节点),这对「历史决策」很优雅,但「流式输出到了第几段」「队列里还有几条」这种每秒变十次的状态也追加节点就太沉了。Document 是只留最新值的可变状态,但变更依然走 commit——历史不可变,现状可改,两者同一事务。
一次提问的完整生命周期
把 Quick Start 里那句 "What is the capital of France?" 跟踪到底(packages/durable/src/harness/generation.ts 是主战场):
submit(input)
→ commit#1:pi.user 条目落盘,创建 pi.generation 任务
→ generation 流式调模型:partial 每 100ms 提交到 pi.live
→ commit#2:pi.assistant(含工具调用)落盘 + 为每个调用创建 pi.tool 任务
→ 每个 tool 任务:意图先提交,再执行 execute()
→ commit#3:pi.tool-result 落盘(generation 在等待中)
→ commit#4:pi.assistant(最终回答)落盘,submission 置为 done
每一步都是「先 commit,再做下一步」。submit() 返回的 Submission 可以 wait()——等的是存储里的状态变化,不是内存里的 Promise 回调。所以等待者崩溃重开后,可以用 harness.submission(id) 重新拿到同一个提交继续等。
Effect Sandwich:副作用的三明治
工具调用是最危险的部分——它有真实世界的副作用。spec §5.2 给所有带副作用的任务规定了固定节奏,叫 effect sandwich:
commit intent phase ← 先把「要做什么」落盘
perform external effect ← 再在事务外执行副作用
commit outcome or next phase ← 最后把「结果如何」落盘
崩溃可能发生在中间那片「效果」里:bash 写了一半文件,进程没了。重开后运行时面对的是一个意图已提交、效果不确定的任务。pi-durable 的选择是:
- 工具声明了
replay: "safe"(幂等,重跑无害)→ 自动重跑; - 否则 → 不重跑,给模型一个
interrupted错误结果,已提交的输出保留。让模型决定下一步——它知道自己在干什么,运行时不知道。
这和第 5 章讲的工具设计一脉相承:工具是 agent 系统里唯一「不纯粹」的部分,对它的崩溃语义必须显式声明,不能靠运气。
崩溃恢复实战
把存储从内存换成 SQLite,恢复能力就齐了:
import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
const harness = await Harness.open(
await openNodeSqliteStorage("./session.sqlite"),
{ models, registry },
context,
);
const root = await harness.root(context); // 还是上次那个 root
harness.resume(); // 启动调度器,续跑上个进程没完成的 run
三个细节值得记住:
- 幂等提交:
submit()带requestId时,重试同一个 ID 返回同一个 Submission,不会重复入队——网络重试、进程重启都不会把一句话问两遍。 - Provider 会话亲和:每个对话持久化一个 UUIDv7(存在
pi.provider文档),作为sessionId转发给 pi-ai,重开、重试、压缩、换模型之后,prompt 缓存亲和还在——这正是第 8 章「缓存经济学」在崩溃场景下的延续。 - SQLite 的诚实声明:WAL 模式 +
synchronous = NORMAL——提交能扛进程崩溃,但断电/宿主机故障可能丢最新一条。文档把这句话写在明处,这是工程诚实。
存储后端是可换的:Memory(不持久)、SQLite(单文件)、JSONL(追加文件),还有不带 Node API 的可移植核心,理论上能跑在 Cloudflare Durable Objects 上。自带一套存储一致性测试套件(registerStorageConformance()),自定义后端可以验证自己是否满足全部不变式。
观察即渲染:UI 不需要恢复逻辑
传统 agent 的 TUI 要自己维护一堆内存状态:流式光标、重试计时器、工具进度条——崩溃后这些全靠猜。pi-durable 里 UI 只有一件事可做:订阅 viewState()。
const view = await root.viewState(context);
view.subscribe((value) => {
// value.entries:当前转录
// value.docs["pi.live"]:进行中的生成(流式 partial、重试、排队)和工具调用
// value.docs["pi.inbox"] / docs["pi.usage"] / docs["pi.agent"]
render(value);
});
每次提交后视图自动更新;慢消费者积压超过 100 帧就直接给最新全量,不补回放。这意味着任何一个进程、在任何时刻 attach 上去,看到的都是完整正确的当前状态——远程客户端、迟到加入的旁观者、崩溃后重启的 TUI,走的是同一条路径。第 7 章的事件驱动在这里升级成了「提交驱动」:事件不再是一过性的通知,而是持久状态变化的投影。
子代理: owned 关系决定的生死
第 5 章讲过主线 pi 的子代理。pi-durable 把子代理做成了所有关系:子代理是一个被子任务拥有(ownership: { kind: "task", taskId })的子对话。
- 父调用被中止 → 子对话一起中止;父调用崩溃且不可重放 → 子对话同样中止;
- 父任务要等子对话闲下来才算完成;
- 想要「父任务死了子代理还活着」的后台代理?创建
{ background: true }的边界任务,中止和闲等都不会越界。
官方示例里那个 subagent 工具(packages/coding-agent/src/experimental/durable/subagent.ts)把这套机制用得很漂亮:replay: "safe" + 固定的 requestId,崩溃重跑时在同一个事务里先查「我是不是已经创建过子对话了」,有就复用——重放不产生第二个子代理。中止主轮次会级联中止所有子代理,Esc 一键全停。
和前 10 章的对照
| 主线 pi(前 10 章) | pi-durable(本章) |
|---|---|
| Agent Loop 跑在内存里,停止条件当轮判定(第 3 章) | Agent Loop 是 pi.generation 持久任务,每步 checkpoint |
| 事件流:一过性通知,错过即失(第 7 章) | 提交流:事件是持久状态的投影,随时重放 |
| 会话存 JSONL,恢复=重读历史(第 10 章) | 会话存事务型存储,恢复=从最后一个 checkpoint 续跑 |
| 工具崩溃语义未定义(第 5 章) | 工具必须声明 replay 安全性,崩溃默认 interrupted |
| 上下文溢出靠压缩兜底(第 9 章) | 压缩同样是持久任务,pi.compaction 条目落盘 |
现状与边界
- 实验性:API 无预告变更,1.0.3/1.0.4 连续两个补丁带 breaking(环境接口、watch 行为都在改)。生产采用需谨慎,学习研究正当时。
- 单进程占有:一个存储同一时间只能一个进程打开,没有跨进程锁——它是「单写者」架构,不是分布式方案。
- 体验入口:
npm install @earendil-works/pi-durable @earendil-works/pi-ai @earendil-works/chord,test/examples/目录从 00 到 31 编号递进,14-chat 是最小起点,26-coding-agent 是一个完整迷你编码 agent。
小结
pi-durable 用一句话概括:把 agent 运行时从「内存里的循环」变成「存储上的状态机」。先提交再显示换来崩溃恢复,effect sandwich 换来副作用的诚实语义,Document + Entry 分离换来历史与现状的各得其所,ownership 换来子代理生死的清晰边界。它回答的正是读完前 10 章后自然会问的那个问题——这套架构,怎么扛住进程崩溃?