提交线:为什么「没落盘的东西不存在」
系列第 2 篇,共 8 篇。
Pi Durable 只有一条写路径:commit()。用户输入、助手回答、工具结果、文档修改、任务创建,全部经过同一条原子提交线。没有旁路,没有「先更新内存、再异步落盘」,也没有「UI 先乐观渲染、失败再回滚」。
这条规则听起来朴素,但它决定了后面所有能力:崩溃恢复不需要猜、UI 不需要合并半成品、多个客户端不需要互相同步草稿。本文拆开一次提交,讲清 Entry、Document 与 Fork 的边界。
一、一次提交的时序
conversation.commit((tx) => ...) 接收一个回调,回调里能做三类事情:
- 追加 entries(
tx.entry(...)); - 读写 documents(
tx.doc(...)); - 创建任务与对话(
tx.createTask(...)、tx.createConversation(...))。
回调返回后,这些变更作为一个整体写入存储:
sequenceDiagram
autonumber
participant Caller as 调用方
participant Tx as 事务 tx
participant Store as Storage
Caller->>Tx: commit(callback)
Tx->>Tx: 追加 entries / 修改 documents / 创建 tasks
Tx->>Store: 一次原子写入
alt 成功
Store-->>Caller: 新状态可见
else 失败
Store-->>Caller: 回滚,任何变更都不可见
end
正在渲染图表…
这里有三个容易忽略的细节:
- 回调里不能有不可回滚的外部副作用。 发邮件、扣款、调用第三方 API 都应该放在任务里,而不是 commit 回调里。
- 提交失败时什么都看不到。 不存在「部分成功」的中间态,因此观察者不需要处理半成品。
- 提交顺序即事实顺序。 多个客户端看到的是同一条线性历史,不需要自己排序。
最小示例:
await root.commit(async (tx) => {
await tx.entry(UserEntry, { content: "hello" });
const todos = await tx.doc(Todos, root.id);
todos.items.push("read the docs");
}, context);
二、Entry:只追加的 transcript
Entry 是一条不可变记录。内置 kind 包括:
| kind | 含义 |
|---|---|
pi.user | 用户输入 |
pi.assistant | 模型响应 |
pi.tool-result | 工具结果 |
pi.system | system prompt 或工具集变更 |
pi.reset | 一次上下文重开 |
pi.compaction | 一次压缩,携带摘要与保留起点 |
模型在构造请求时,只会看到最近一次 pi.reset 之后的 entries。更早的记录仍在存储里,只是不进入模型上下文。这个区分很重要:reset 不是删除,compact 也不是删除。
Entry 的不可变性带来两个好处:
- 回放是确定的。 同样的 entry 序列,永远能重建同样的视图;
- fork 不需要复制历史。 新对话只需要引用父级到某个 entry 为止的前缀。
2.1 自定义 entry
业务事件可以有自己的 kind:
await root.submit({
type: "input",
content: "ann: can you check the build?",
entry: {
kind: "chat.mention",
data: { author: "ann", text: "can you check the build?" },
},
}, context);
它会落成 chat.mention,但模型仍然把 content 当作用户消息。注意 kind 不能以 pi. 开头,那是内置命名空间。
三、Document:与 transcript 同一次落盘
Document 是带类型的 JSON 状态,与 entries 存在同一个对话里。它解决的是「应用状态和对话记录对不上」的问题。
假设一个工具先写 entry「已创建订单」,再把订单 ID 写进 document。如果这是两次独立提交,进程可能死在两者之间:transcript 说订单存在,document 里却没有。Pi Durable 要求两者在同一个 commit 回调里完成,于是不存在这个窗口。
const Todos = defineDoc<{ items: string[] }>({
kind: "app.todos",
version: 1,
scope: "conversation",
history: "rewindable",
fork: "asOf",
initial: () => ({ items: [] }),
});
await root.commit(async (tx) => {
const todos = await tx.doc(Todos, root.id);
todos.items.push("ship the release");
}, context);
defineDoc() 的四个选项决定了文档的生命周期:
| 选项 | 取值 | 作用 |
|---|---|---|
history | "latest" / "rewindable" | 是否保留历史值,能否用 snapshotAsOf() 回看 |
fork | "initial" / "current" / "asOf" | 子对话从什么值开始 |
version | 数字 | 迁移用;升级文档结构时递增 |
scope | "conversation" 等 | 文档的归属范围 |
3.1 观察文档
const state = await harness.documentState(Todos, root.id, context);
state?.subscribe((value) => render(value?.items ?? []));
documentState() 给最新值,watchDoc() 给变更流。两者都只返回已提交的值,不会把回调里的中间状态漏出去。
四、Fork:引用历史,而不是复制历史
Fork 在某个 entry 处创建一个新对话。新对话能看到父级到该 entry 为止的全部 entries,但底层不复制这些记录。
flowchart LR
E1["entry #1"] --> E2["entry #2"] --> E3["entry #3"] --> E4["entry #4"]
E3 -.->|"fork 于第 3 条"| F0["fork 起点"]
F0 --> F1["fork 后续 entry"]
E3 --> E4
正在渲染图表…
Fork 有三个明确语义:
- 可见范围是前缀。 子对话看不到 fork 点之后的父级 entries。
- agent 继承 fork 点的配置。 子对话保留父级在那一刻的模型、工具、扩展与指令。
- provider 身份是新的。 子对话获得新的
pi.providerUUID,因此 prompt cache 与会话亲和不会串线。
const thread = await channel.fork(answered.answer!, {
ownership: { kind: "ownerless" },
}, context);
ownership 决定这个对话归谁所有:ownerless 表示独立存活;{ kind: "task", taskId } 表示随某个任务中止。这个选择直接影响 abort 的传播范围,第 6 篇会展开。
五、三种观察方式
提交线不只服务写入,也决定了读取接口的形状。Pi Durable 提供三种观察方式,分别面向不同场景:
| 接口 | 给什么 | 适合谁 |
|---|---|---|
viewState() | 当前完整视图 | 本地 UI、初次渲染 |
watch() | 每次提交的精确操作流 | 远程客户端、增量同步 |
watchEvents() | 编码 Agent 风格的事件流 | 已有事件驱动架构的宿主 |
viewState() 返回的 value 包含:
entries:当前活跃 transcript;docs["pi.live"]:运行中的 generation、工具输出、压缩;docs["pi.inbox"]、docs["pi.usage"]、docs["pi.agent"]、docs["pi.provider"];- 对话元信息与所有权。
const view = await root.viewState(context);
view.subscribe((value) => render(value));
// 之后:view.dispose();
watch() 则给出 ops:
const watch = await root.watch(context);
render(watch.value);
watch.start(async (value, ops) => {
await send(ops); // 发给远程客户端,由它应用
});
// 之后:await watch.stop();
慢速消费者不会无限积压:最多保留 100 个未投递帧,超过之后用一个携带最新完整视图的帧替换。晚加入或重连的客户端从当前视图开始,不重放历史操作。
六、常见误区
误区一:把 commit 当成「保存按钮」。 Commit 不是定期快照,而是每一次状态变更的必经之路。任何绕过它的写入都会破坏恢复语义。
误区二:在 commit 回调里做外部副作用。 回调可能重试或回滚,外部副作用必须放进任务,并配合幂等键。
误区三:认为 reset 会删除历史。 Reset 只是移动模型上下文的起点。旧 entries 仍然可查,这正是「交接后还能回查」的基础。
误区四:fork 会复制一份完整历史。 Fork 引用前缀,不复制。因此 fork 很便宜,但父子对话之后的写入互不影响。
小结
提交线是 Pi Durable 的地基:
- 所有写入经过
commit(),原子、无旁路; - Entry 记录「发生过什么」,Document 记录「现在是什么」,两者同一次落盘;
- Fork 引用历史前缀,不复制、不共享后续状态;
- 观察接口只暴露已提交的值,晚加入者从当前视图开始。
下一篇进入故障场景:当进程死在两个 checkpoint 之间,Pi Durable 如何判断该重跑、该中断,还是该交给模型决定。
