Pi Durable 全景:把「不丢工作」做成运行时的不变量
系列第 1 篇,共 8 篇。基于
@earendil-works/pi-durable@1.1.0,该包仍属实验性,API 可能随版本变化。
大多数 Agent 框架默认「进程会一直活着」:消息放在内存里,工具调用挂在 await 上,任务状态藏在闭包里。一旦容器被重新部署、笔记本休眠、机器 OOM,正在跑的工作就一起消失。更麻烦的是,恢复时你无法判断某一步到底执行过没有——因为框架从来没有把「意图」和「结果」分开保存。
Pi Durable 把这件事反过来:状态先落盘,工作才允许发生;客户端看到的每一帧,都是已经提交的事实。 这不是一个功能,而是整个运行时的组织原则。理解这一点,后面所有机制——提交线、checkpoint、所有权树、观察接口——都只是它的推论。
一、问题的根源:工作状态散落在三个地方
一个正在运行的 Agent,至少有三种状态:
| 状态 | 典型位置 | 进程死亡后的命运 |
|---|---|---|
| 对话消息 | 内存数组、SDK 会话对象 | 丢失,或只剩 provider 侧残留 |
| 正在执行的步骤 | await 的 Promise、函数调用栈 | 无法判断执行到哪一步 |
| 应用状态(计划、待办、沙箱) | 闭包、全局变量、外部数据库 | 与对话记录脱节,无法一起回滚 |
传统做法是「定期存盘」:每隔几秒把消息序列化一次。但存盘只能恢复数据,不能恢复正在做的事。如果进程死在「工具已经产生副作用、结果还没写回」之间,下一次启动只能靠猜。
Pi Durable 的答案是两条:
- 所有写操作走同一条原子提交线,不存在「先改内存、再异步落盘」的旁路;
- 所有长步骤都是显式状态机,每一步先保存意图,再执行,再保存结果。
二、Harness 是什么
官方博文给了一个很克制的定义:
harness 就是「存储」加上「并行运行一个或多个大模型对话所需的机器」。
这个定义里没有「Agent 框架」常见的编排 DSL、节点图或可视化画布。Pi Durable 只提供四样东西,其余全部交给扩展:
flowchart LR
C["客户端 / UI"] -->|"submit / watch"| H["Harness"]
H --> S[("Storage<br/>Memory / SQLite / JSONL / Durable Object")]
H --> M["Models<br/>pi-ai"]
H --> E["ExecutionEnv<br/>Node / 远程 / 沙箱"]
H --> R["Registry<br/>扩展、工具、hooks"]
正在渲染图表…
- Storage:唯一的真相来源。提交是原子的,重启后从同一个存储继续。
- Models:模型访问层,负责流式请求、重试、缓存与用量统计。
- ExecutionEnv:工具接触文件与进程的通道。它可以是本机、远程 VM 或内存沙箱,按对话构建。
- Registry:本进程安装的扩展集合。它不落存储,对话只保存扩展与工具的名字。
存储接口和执行环境接口都很小,因此可以移植到 Bun、Cloudflare Durable Object,或你自己的键值存储、Postgres 之上。同一时刻一个存储只能被一个进程打开,其他客户端通过 watch 附着——这是理解多人协作的前提。
三、五个核心概念
Pi Durable 的概念不多,但每一个都有明确的不变量。先把它们放在一张表里:
| 概念 | 一句话定义 | 关键不变量 |
|---|---|---|
| Commit | 一次原子写入 | 要么全部落盘,要么全不落盘;落盘之前什么都不展示 |
| Entry | 不可变的 transcript 记录 | 模型只能看到最近一次 pi.reset 之后的条目 |
| Document | 与 transcript 并存的带类型 JSON 状态 | 与 entry 在同一次提交中修改,永不与对话记录错位 |
| Task | 持久状态机,每步保存 checkpoint | 崩溃后重跑的那一步必须幂等 |
| Ownership | 任务与对话构成一棵树 | abort 自底向上,每个任务只撤销自己的副作用 |
这五个概念对应五种失效场景:半成品可见、历史被篡改、状态与消息不一致、崩溃后重复执行、取消时留下孤儿。后七篇文章会逐一展开。
3.1 Entry 与 Document 的分工
Entry 是「发生过什么」,只追加、不修改;Document 是「现在是什么」,可以反复覆盖。两者必须一起提交,否则会出现「消息说已经付款,订单状态却还是待支付」这种裂缝。
一个对话至少拥有五个内置文档:
| 文档 | 内容 |
|---|---|
pi.agent | 该对话使用的模型、思考等级、扩展、工具、指令、工作目录 |
pi.provider | provider 会话身份(UUIDv7),用于提示缓存与会话亲和 |
pi.live | 运行中的 generation、工具调用、压缩 |
pi.inbox | 排队中的 steer / follow-up / write |
pi.usage | 按模型与工具名累计的 token 与费用 |
业务状态(待办、计划、工单、沙箱句柄)用 defineDoc() 定义自己的文档,同样参与原子提交。
四、一次回答的完整生命周期
对外看,对话是「你说一句,它答一句」。对内,它是一条任务流水线:
flowchart TD
I["submit(input)"] --> U["pi.user"]
U --> G1["pi.generation(第一次)"]
G1 --> SYS["pi.system(仅当 prompt 或工具变化时)"]
G1 --> A1["pi.assistant(工具调用)"]
A1 --> T["pi.tool × n"]
T --> TR["pi.tool-result × n"]
TR --> G2["pi.generation(第二次)"]
G2 --> A2["pi.assistant(最终答案)"]
A2 --> DONE["submission done"]
正在渲染图表…
关键点在于:一次「回答」通常是两次 generation。 第一次模型决定调用哪些工具;工具作为独立任务执行并把结果写回 transcript;第二次模型读取工具结果,产出最终答案。中间那层 pi.tool 不是函数调用,而是可恢复的持久任务。
把它展开成时序:
sequenceDiagram
autonumber
participant C as 客户端
participant H as Harness
participant S as Storage
participant M as Model
C->>H: submit({ type: "input", content, requestId })
H->>S: commit(pi.user)
S-->>H: 已落盘
H->>S: commit(pi.generation 任务 + pi.system)
H->>M: 请求(messages + tools)
M-->>H: 流式输出 / 工具调用
H->>S: commit(partial, pi.live)
H->>S: commit(pi.assistant + pi.tool 任务)
H->>S: commit(pi.tool-result × n)
H->>M: 第二次请求
M-->>H: 最终答案
H->>S: commit(pi.assistant)
H-->>C: submission done
正在渲染图表…
注意两条被刻意分开的通道:
- 工作(generation、tool)有自己的生命周期,进程重启后继续;
- 等待(
submission.wait()、watch)只是观察者。取消一次等待不会取消工作本身。
这条分离是「UI 断开、超时、重启都不会杀死任务」的根本原因。
五、五条不变量
如果只记五句话,记这五句:
- 没有提交的东西不存在。 所有可见状态都必须先经过
commit()。 - 消息与状态同一次落盘。 Entry 与 Document 不能分两次写。
- 崩溃只可能发生在两个 checkpoint 之间。 恢复时从最后一个 checkpoint 继续。
- 重跑的那一步必须幂等。 框架不替业务代码判断副作用是否安全。
- abort 自底向上。 先撤销子任务,再撤销父任务,每层只清理自己。
这五条会在后续每一篇里反复出现。它们不是建议,而是框架用来保证正确性的边界条件。
六、系列阅读地图
| 篇目 | 主题 | 适合什么时候读 |
|---|---|---|
| 01 | 全景与五条不变量 | 第一次接触 Pi Durable |
| 02 | 提交线:Entry、Document、Fork | 想理解「为什么不会出现半成品」 |
| 03 | 崩溃恢复:checkpoint、memo、requestId | 要接生产环境的故障恢复 |
| 04 | 对话解剖:Turn、Run、Inbox、压缩 | 要实现聊天产品或多轮交互 |
| 05 | 扩展系统:工具、hooks、热重载 | 要给 Agent 增加能力 |
| 06 | 所有权树:subagent、abort、后台任务 | 要做多 Agent 或长任务 |
| 07 | 观察与协作:View、Watch、多人接入 | 要做 UI、断线重连、多人协作 |
| 08 | 落地手册:配置、选型、示例地图 | 准备把框架接进真实项目 |
建议按顺序读前四篇;第五、六、七篇可以按需跳读;第八篇是工具书,随时查阅。
小结
Pi Durable 的核心不是「更多功能」,而是把持久化从事后补丁变成运行时前提:
- 存储是唯一的真相来源;
- 提交是唯一的写路径;
- 任务是唯一的长步骤单位;
- 所有权是唯一的取消边界。
下一篇从最小的写路径开始:一次 commit() 到底提交了什么,以及 Entry、Document 与 Fork 为什么必须共享同一条原子线。
