开源编程 Agent pi 的可持久化运行:中断后能续上需要哪些前提
本文基于 pi v0.82.1(仓库 commit 027a584,2026-07-28)梳理,该项目仍在快速迭代,具体行为以官方文档 https://pi.dev/docs/latest 与仓库最新代码为准。
一个 Agent 运行能不能被中断后恢复,跟它写没写检查点关系不大,真正的分水岭是:这次运行里所有被”接受”的变更,是不是在对外承认之前就已经落到了持久介质上。 pi 的 packages/agent/docs/durable-harness.md 开头就把这件事挑明了 —— 一个完全可持久化的 harness 根本不现实,因为它依赖的一大半东西压根不是数据。
pi 是 earendil-works 开源的编程 Agent 项目,MIT 许可证,仓库在 https://github.com/earendil-works/pi ,截至 2026 年 7 月在 GitHub 上约 8 万 star。它把”可持久化运行”当成一个专门的设计议题写了文档,而不是塞在某个类的注释里,这份文档本身就值得拆开看。
站内已经有两篇讲通用方法论的文章:Agent 检查点怎么设计 谈的是在什么粒度上存档,Agent 失败重试的边界 谈的是失败之后该不该重来。这篇不重复那些原则,它只回答一个更窄的问题:当一个真实的开源项目要把这些原则落到自己的代码结构里时,它被迫做了哪些让步、又划了哪些必须守住的线。
一、跑一次和能续上,差的不是存档而是承诺
普通的一次性运行有个隐含前提:进程活着的这段时间就是全部世界。上下文、工具实例、队列都在内存里,进程一死全部消失,也没人会追问它们本该是什么样子。可恢复的运行把这个前提拿掉了:进程可以在任意两条指令之间死掉,重启后必须有人回答”崩之前那一刻系统处在什么状态”,而这个问题只有一种答案方式 —— 从持久化的记录里重新推导。
pi 文档里那句约束写得很硬:每一个被接受的变更,必须在公开 API 兑现之前就是耐久的。翻译成工程语言就是,如果一个入队方法已经返回、调用方已经认为消息进了队列,那这条消息就必须已经在磁盘上;否则重启之后它凭空消失,而调用方毫不知情。
这条约束一旦成立,整个 harness 的写入路径就得重排:不是”先改内存再顺手落盘”,而是”先落盘再改内存”。在 packages/agent/src/harness/agent-harness.ts 里能看到这个顺序已经落地,setActiveTools() 的实现是先做校验、再决定写入方式,最后才更新 this.activeToolNames 并发事件。空闲时直接写会话,忙碌时压进待写队列:
if (this.phase === "idle") {
await this.session.appendActiveToolsChange(toolNames);
} else {
this.pendingSessionWrites.push({ type: "active_tools_change", activeToolNames: [...toolNames] });
}
this.activeToolNames = [...toolNames];
顺序颠倒一下,代码看起来一模一样,可恢复性就没了。
二、半可持久化:把不可序列化的东西诚实地推给宿主
文档给出的判断是:一个完全耐久的 AgentHarness 不现实,因为下面这些依赖是宿主应用提供的运行时 JS,不是数据 —— 工具实现、模型与鉴权提供方、扩展与 hook 处理器、资源加载器、系统提示回调与修饰器。
工具注册表就是典型例子。harness 应该持久化可序列化的工具配置,例如当前活跃的工具名,但不该持久化具体的工具实现。函数没法写进 JSONL,硬要序列化只会得到一个假的安全感。
所以 pi 定的实际目标是”半可持久化”,四句话:
- 会话是耐久的、仅追加的状态树;
- harness 把自己拥有的状态写进会话条目;
- 宿主应用负责在恢复时重建兼容的、不可持久化的那部分依赖;
- 恢复从耐久边界重启,而不是从进行中的 provider 流里接上。
这个分工的好处是责任清晰。harness 不假装自己能存下整个世界,它只保证”我记下来的部分是完整且有序的”;宿主应用则要明确知道自己欠了哪些东西。文档把这份清单列了出来:恢复时应用必须重建模型注册表或模型对象、工具注册表、扩展集合及其版本与顺序、资源加载器、系统提示 provider 与 hook、鉴权 provider、应用特定的 hook。harness 在有稳定 ID、版本或哈希可用时可以做校验,但没办法替你把这些序列化。
关于模型服务商补一句:pi 通过 provider 抽象接入模型,海外主流模型服务商官方对中国大陆存在区域限制、不支持直连,市面上存在第三方中转但本文不作任何背书;各家接入规则不同且会调整,以官方最新说明为准。这一层不影响上面的持久化结论,但会影响你恢复之后能不能真的重连上去。
三、只留一条日志:会话就是那棵耐久状态树
要不要给 harness 单独开一个 sidecar 文件存自己的状态?pi 的结论是不要,理由很实在 —— 现有的会话状态里本来就装着 harness 的状态:模型变更、思考等级变更、活跃工具变更、leaf 条目、标签、压缩与分支摘要、自定义消息与自定义条目。这些在 packages/agent/src/harness/types.ts 里都是实打实的条目类型,ModelChangeEntry、ThinkingLevelChangeEntry、ActiveToolsChangeEntry、LeafEntry、CompactionEntry、BranchSummaryEntry 各自带着完整字段。既然一半状态已经在会话日志里,再开一条日志只会制造两个真相来源。所以继续用一条耐久会话日志;大 blob 用 sidecar 仍可能有价值,但会话条目要保持为真相来源的引用。
有个容易被当成小事的实现细节值得单独说:setLeafId() 不是内存里的游标更新,它会追加一条真正的 leaf 条目,targetId 指向当前树叶或者在根节点时为 null;存储重新打开时必须从最近一条影响叶子的持久条目里重建当前叶子。这条规则写在 agent-harness.md 里,SessionStorage 接口上 setLeafId() 那行也带了一句”持久化一条记录当前会话树叶的 leaf 条目”的注释与之呼应。如果叶子只活在内存里,“我在树的哪个分支上”一崩就没了,恢复出来的历史会接到错误的位置上 —— 比丢失更糟。
下面这张表把这套东西的落点整理了一下,路径都是仓库里实际存在的文件:
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 会话条目类型定义 | 定义 SessionTreeEntry 的各个变体,以及 SessionStorage 接口约定 | packages/agent/src/harness/types.ts | 你要新增一种需要被恢复的状态时 |
| JSONL 追加式存储 | 把每条条目序列化成一行追加进文件,并从首行读会话头 | packages/agent/src/harness/session/jsonl-storage.ts | 你要换存储后端或排查日志损坏时 |
| Session 上下文构建 | buildContextEntries() 给出压缩感知的条目序列,buildContext() 投影成消息 | packages/agent/src/harness/session/session.ts | 你要控制哪些条目进模型上下文时 |
| AgentHarness 阶段与待写队列 | 维护 phase、待写会话写入的入队与冲刷 | packages/agent/src/harness/agent-harness.ts | 你在 hook 或监听器里写会话时 |
| 耐久设计文档 | 半可持久化目标、恢复模型、崩溃场景清单 | packages/agent/docs/durable-harness.md | 你要判断某个崩溃点该怎么处理时 |
| 生命周期文档与实施清单 | 状态四分法、保存点、阶段语义与待办项状态 | packages/agent/docs/agent-harness.md | 你要确认某个能力是已实现还是仅设计时 |
最后一行不是凑数的。这两份文档里有大量内容是设计草案而非现状,混淆这一点会让你写出跑不起来的代码,下一节就是。
四、恢复怎么走:从耐久边界重启,而不是从断掉的流
pi 设计的启动流程是五步:宿主应用先注册工具、模型、扩展、资源、鉴权与 hook;harness 打开会话;harness 把会话条目归约成当前叶子、会话分支、harness 配置(含活跃工具名)、队列、待写、以及活动中的操作/turn/工具状态;然后校验必需的运行时依赖,包括拿恢复出来的活跃工具名去比对应用提供的工具注册表;最后协调那些没跑完的操作状态。
第三步的”归约”是整个方案的技术核心:状态不是直接存出来的,是从仅追加日志里重放推导出来的。它跟做复现回放用的是同一套底子,只是目的不同 —— 复现是为了重看一遍,这里是为了接着往下跑。
关于恢复的入口,文档明确表态:构造函数选项保持为显式的运行时配置,不去读会话状态。理由是隐藏在构造函数里的异步恢复会让失败处理变得含糊 —— 构造失败了,对象是半成品还是不该存在?未来由一个异步的 builder 或 factory 拥有耐久恢复,文档里给的形态是这样:
const harness = await AgentHarness.builder()
.env(env)
.session(session)
.model(defaultModel)
.tools(runtimeTools)
.defaultActiveTools(["read", "edit"])
.restore({ missingActiveTools: "fail" });
注意这段是设计文档里的目标形态,不是当前可调用的 API。 agent-harness.md 的实施清单里,“半可持久化 harness/会话恢复”这一项的状态标的是 Planned,已完成的只有”写了这份耐久性设计文档”这一条。你现在照抄上面这段代码是跑不通的。
restore() 被设想为承担这些职责:读取活跃分支、把耐久的 harness 配置归约出来、对缺失的条目套用默认值、对照应用提供的运行时依赖做校验、构造 harness,并可选地在构造完成后发出带 source: "restore" 的更新事件。
活跃工具的恢复规则最能体现这套设计的谨慎:active_tools_change 是分支作用域的耐久配置;分支上没有这类条目时,恢复退回 builder 默认值,连默认值都没给就用全部已注册工具;活跃工具名必须唯一,工具注册表里的名字也必须唯一;恢复出来的活跃工具名如果在注册表里找不到,默认应当让恢复失败,宽松的丢弃或禁用策略以后再显式加;具体的工具对象永远不从会话里恢复,必须由宿主应用提供兼容实现。
至于 provider 流 —— 文档里那句话没有余地:provider 流不可恢复。恢复只能从某个耐久边界重试,或者把这次操作标记为已中断。
默认的保守策略是这样分的:没跑完的 agent turn 标记为中断,保留耐久的队列与待写,返回空闲;没跑完的 provider 请求标记为中断,不自动重试;没跑完的工具调用追加一条中断或错误的工具结果,只有当工具自己声明了可重试或幂等时才重试;没跑完的压缩,如果还没有压缩条目就重跑;没跑完的分支摘要或树导航,在安全的前提下补上缺失的摘要或叶子条目。文档同时留了一个可选开关:
recovery: "mark_interrupted" | "retry_unfinished"
并且写明 retry_unfinished 必须在非幂等工具调用周围加保护。这跟 重试与幂等性设计 讲的是一回事,区别在于这里的幂等信息必须由工具自己声明出来 —— 文档说得很直白:工具调用需要稳定 ID 和可重试性元数据,自动恢复才有依据。
五、边界与代价:这套设计明确不管什么
一个设计的价值往往在它拒绝了什么。这套方案放弃的东西并不少,值得逐条摆出来。
它放弃了完全的可持久化。 代价直接转移给宿主应用:恢复时你得自己把模型、工具、扩展、资源加载器、鉴权、hook 全部重建成兼容的样子。harness 能帮你校验的仅限于那些有稳定 ID、版本或哈希的部分,其余靠你自己保证。文档把”是否要求恢复时严格匹配依赖的 ID 与版本”列成了一个尚未定论的开放问题,也就是说这道防线现在是空的。
它不管 provider 流的续传。 流断了就是断了。这意味着一次很长的生成,只要在流中途崩掉,那部分产出默认就没了。文档还点出一个更细的窟窿:如果崩在 provider 响应已经回来、但助手消息还没持久化之间,这个响应就丢失了,除非 provider 结果被单独记账。“要记多少 provider 请求数据”同样是开放问题。
它不管外部副作用的回滚。 工具调用开始之后、结果落盘之前崩掉,外部副作用可能已经真实发生了 —— 文件已经写了、命令已经执行了。恢复能做的只是不重跑,没法撤销。想要更强的保证,得靠 Agent 工作区隔离 那一层的手段,而不是指望持久化层。
它不解决队列在两个耐久点之间的窗口。 文档自己列了这个风险:队列已经排空、但耐久的 turn 记录还没写下来时崩掉,会有丢失或重复的风险。给出的不变量是,被消费的队列 ID 必须先记录进 turn_started 或等价条目才算已消费 —— 这是靠约定守住的,不是靠机制自动成立的。存储层同理:“恢复时是否支持截断 JSONL 文件末尾那条残缺的行”也被列为开放问题,而追加写在崩溃瞬间留下半行 JSON 相当常见。
扩展与 hook 这一侧还有个已知的死角。 agent-harness.md 里写着,监听器和 hook 目前拿不到任何 facade;如果它们闭包捕获了原始的 harness 实例、并在运行过程中调用 waitForIdle() 这类等待结算的 API,就会死锁。文档给出的方向是未来暴露一个 runWhenIdle() 替代,但那是计划。
六、上手与避坑清单
别指望把工具实现存进会话。 会踩是因为”活跃工具”听起来像配置,让人以为整套工具都能被序列化恢复。实际上工具注册表是运行时依赖,能进日志的只有名字。做法是把工具名当成跨进程的契约来管:名字稳定、唯一、由宿主在启动时无条件重建。
改工具名要当破坏性变更处理。 会踩是因为改名在单次运行里毫无痛感,跑得好好的。但恢复时会拿会话里记下的活跃工具名去比对注册表,默认策略是找不到就让恢复失败。做法是给工具名建立跟数据库列名同级的变更纪律,真要改就同时准备迁移或显式的降级策略。
别把耐久写入放在内存更新之后。 会踩是因为先改内存跑起来更”顺”,异常路径也少写几行。但公开 API 一旦返回,调用方就当这件事成了。做法是照 setActiveTools() 的顺序来:校验 → 落盘或入待写队列 → 更新内存 → 发事件。
队列消费必须先记账再排空。 会踩是因为先把队列取出来处理、事后再记录看起来更自然。崩在中间就会出现消息既没被处理也没在队列里,或者被处理了两遍。做法是守住那条不变量:消费的队列 ID 先进 turn 起始记录,才算消费。
待写条目要有确定性的目标 ID。 会踩是因为 ID 通常是生成时才有的,恢复时无从判断这条写入到底应用过没有。文档给的办法是让目标条目 ID 确定化,恢复时可以直接检测到条目已存在并标记为已应用。这也是 PendingSessionWrite 这个类型要把 id、parentId、timestamp 三个生成字段 Omit 掉的原因 —— 待写描述的是意图,不是已成事实的条目。
别在 hook 或监听器里绕过 harness 直接写会话。 会踩是因为原始 session 对象就在手边,直接写最省事。但 harness 靠待写队列保证顺序,绕过去写会破坏转录的次序。文档规划了一个 HarnessSession facade 来强制这套语义,但明确写着尚未实现。在它出现之前,这条得靠自觉。
分清哪些是已实现、哪些是设计草案。 会踩是因为两份文档的行文风格接近,builder()、DurableHarnessEntry、recovery 开关读起来都像现成 API。做法是每次动手前先翻 agent-harness.md 末尾的实施清单,那里每一项都标了 Done / In progress / Planned,以及具体哪些子项还没做。
收束:拿这套东西怎么用
如果你正在给自己的 Agent 加”能续上”的能力,可以拿这几条当自检:
- 你的公开 API 返回时,被承诺的那件事是不是已经落盘了?
- 你的状态是从日志重放推导出来的,还是散在几个地方各存一份?
- 树的位置(当前分支、当前叶子)有没有持久记录?
- 哪些依赖是重启后必须由宿主重建的,这份清单写下来了吗?
- 每个工具有没有声明自己是否可安全重试?没声明的默认按不可重试处理了吗?
- 崩在两个耐久点之间的那些窗口,你逐个列过一遍吗?
pi 那份 durable-harness.md 最值钱的部分是”关键场景”一节 —— 队列、待写、turn 循环、工具调用、压缩、分支摘要,每一类都把崩溃点按时间轴切开,逐个说明恢复该怎么办。这种把失败点穷举出来的写法,比任何架构图都更接近工程现实。想接着看,就从 packages/agent/src/harness/types.ts 的条目类型定义读起,再回那份文档对照,哪些状态已经有条目、哪些还只是候选,一眼就能看清楚。
至于框架层面该怎么选,可以参考 Agent 框架横向对比 里的评估维度;持久化能力只是其中一项,但它是那种平时看不出差别、出事时决定你能不能接着干的一项。
本篇属于一个把开源编程 Agent 项目 pi 逐层拆开讲的系列,整体地图见开源编程 Agent pi 是什么;沿着这条线往下,还可以看 开源编程 Agent pi 的模型解析链路 和 开源编程 Agent pi 的服务端包。