提交线:为什么「没落盘的东西不存在」

系列第 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

正在渲染图表…

这里有三个容易忽略的细节:

  1. 回调里不能有不可回滚的外部副作用。 发邮件、扣款、调用第三方 API 都应该放在任务里,而不是 commit 回调里。
  2. 提交失败时什么都看不到。 不存在「部分成功」的中间态,因此观察者不需要处理半成品。
  3. 提交顺序即事实顺序。 多个客户端看到的是同一条线性历史,不需要自己排序。

最小示例:

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.systemsystem 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 有三个明确语义:

  1. 可见范围是前缀。 子对话看不到 fork 点之后的父级 entries。
  2. agent 继承 fork 点的配置。 子对话保留父级在那一刻的模型、工具、扩展与指令。
  3. provider 身份是新的。 子对话获得新的 pi.provider UUID,因此 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 如何判断该重跑、该中断,还是该交给模型决定。