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 的答案是两条:

  1. 所有写操作走同一条原子提交线,不存在「先改内存、再异步落盘」的旁路;
  2. 所有长步骤都是显式状态机,每一步先保存意图,再执行,再保存结果。

二、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.providerprovider 会话身份(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 断开、超时、重启都不会杀死任务」的根本原因。

五、五条不变量

如果只记五句话,记这五句:

  1. 没有提交的东西不存在。 所有可见状态都必须先经过 commit()。
  2. 消息与状态同一次落盘。 Entry 与 Document 不能分两次写。
  3. 崩溃只可能发生在两个 checkpoint 之间。 恢复时从最后一个 checkpoint 继续。
  4. 重跑的那一步必须幂等。 框架不替业务代码判断副作用是否安全。
  5. 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 为什么必须共享同一条原子线。