一次对话的解剖:Turn、Run、Inbox 与压缩

系列第 4 篇,共 8 篇。

对外看,对话就是「你说一句,它答一句」。对内,它是一条不断分叉又汇合的任务流水线:输入落盘、模型请求、工具任务、第二次模型请求、答案结算。中间还夹着排队、打断、交接和上下文压缩。

本文按时间顺序拆开这条流水线,重点解释三个时间尺度的区别,以及对话「忙碌」时一条新输入会去哪里。

一、三个时间尺度:Turn、Run、Submission

这三个词经常被混用,但在 Pi Durable 里各有明确边界:

概念范围结束标志
Turn一次模型响应及其工具调用工具结果全部写回,或模型不再调用工具
Run从一条输入被放置,到它最终被回答产生最终答案,或失败 / 被 abort
Submission调用方交进来的一件事被回答(done)或失败(unanswered)

关系可以用一张图表示:

flowchart LR
    SUB["Submission(输入)"] --> RUN["Run"]
    RUN --> T1["Turn 1<br/>模型 + 工具"]
    T1 --> T2["Turn 2<br/>模型 + 工具"]
    T2 --> T3["Turn 3<br/>最终答案"]
    T3 --> DONE["Submission done"]

一个 Run 通常包含多个 Turn,一个 Submission 对应一个 Run。工具可以在 Turn 之间插话,也可以让 Run 提前结束。

二、正常路径的完整时序

把上一篇文章的流水线展开到消息层面:

sequenceDiagram
    autonumber
    participant U as 用户
    participant H as Harness
    participant G as Generation 任务
    participant T as Tool 任务
    participant S as Storage

    U->>H: submit(input)
    H->>S: commit(pi.user)
    H->>S: commit(pi.generation, pi.system)
    G->>S: commit(pi.assistant:工具调用)
    G->>S: commit(pi.tool 任务 × n)
    T->>S: commit(pi.tool-result × n)
    G->>S: commit(pi.generation:第二次)
    G->>S: commit(pi.assistant:最终答案)
    H-->>U: submission done

这里有两个内置任务在协作:

  • pi.generation 负责调用模型、解析工具调用、拥有工具任务并等待它们;
  • pi.tool 负责执行单个工具,拥有自己的输出与重放策略。

工具结果不会被直接塞回模型内存,而是先写成 pi.tool-result entry。第二次 generation 从 transcript 重新构造请求,因此「模型看到什么」完全由已提交的记录决定。

2.1 为什么工具是独立任务

如果工具只是 await 里的一个函数调用,进程死亡后无法判断它执行到哪一步。把它做成任务后,它拥有:

  • 自己的 checkpoint 与 output;
  • 自己的 replay 策略;
  • 自己的 abort 边界;
  • 自己的计费记录。

这也是 subagent、后台任务、Code Mode 能复用同一套机制的原因。

三、忙碌时:一条输入会去哪里

当对话正在跑一个 Run 时,新输入不会打断它。submit() 提供四种行为:

whenBusy行为适用场景
"follow-up"(默认)排队,等当前 Run 回答后启动下一个 Run用户补充需求
"steer"在当前工具轮之后插入,加入正在运行的工作用户纠偏、补充约束
"reject"立即抛 ConversationBusy调用方只想在空闲时提交
type: "write"只追加 entry,不请求模型记录 UI 事件、外部通知

决策过程:

flowchart TD
    A["submit(request)"] --> B{"对话 busy?"}
    B -- 否 --> C["放置输入,启动 Run"]
    B -- 是 --> D{"whenBusy"}
    D -- follow-up --> E["进入 pi.inbox 队列<br/>当前 Run 结束后放置"]
    D -- steer --> F["进入队列<br/>当前工具轮边界插入"]
    D -- reject --> G["抛 ConversationBusy"]
    D -- write --> H["立即追加 entry<br/>不请求模型"]
    E --> I["下一个 Run"]
    F --> J["当前 Run 继续"]

几个细节值得注意:

  • write 是特例。 只含 kind 与 data 的写入不改变模型看到的东西,所以立即追加,即使 Run 中途也是,且永不排队。因此它可能出现在「更早提交、仍在队列中」的条目之前。
  • 带 model、head 或 edits 的写入则像 steer 一样等下一个边界。
  • Run 失败时,排队项留在 inbox 里,直到下一次输入或被排队的写入把它们(按最旧优先)放置出去。

settings 可以改变放置策略:

settings: {
    steeringMode: "one-at-a-time", // 或 "all"
    followUpMode: "one-at-a-time", // 或 "all"
}

"one-at-a-time" 每轮放一条,"all" 一次性全部放入。

四、Inbox 里到底有什么

pi.inbox 是一个文档,记录所有尚未放置的提交:

flowchart LR
    I["pi.inbox"] --> S["steer 队列"]
    I --> F["follow-up 队列"]
    I --> W["write 队列(仅带模型变更的写入)"]
    S --> B["下一工具轮边界"]
    F --> N["下一 Run 开始"]
    W --> B

每条排队项都保留原始 requestId。因此重启后:

  • 排队项仍在原队列;
  • 客户端用同一 requestId 重提,不会产生重复项;
  • submission.abort() 可以撤回排队中的提交。

五、Reset 与 Handoff

reset() 开启一个新的上下文。模型不再看到更老的 entries,但它们留在存储里:

await root.reset(undefined, context);
await root.reset("We were fixing the flaky login test. Continue.", context);

忙碌时,reset 像 write 一样排队。当它在一个工具轮次中被放置时,当前 Run 结束。工具也可以通过返回 control: { handoff: "..." } 请求同样的事。

Reset 与删除的区别:

flowchart LR
    H["完整历史(存储中)"] --> R["pi.reset 标记"]
    R --> V["模型可见窗口"]
    H --> Q["search_history 等工具仍可读取"]

因为什么都没被删除,第二个工具仍能搜索交接之前的一切。这就是「把工作交给自己、之后还能回查」的完整机制。

六、Compaction:压缩而不是删除

长对话迟早会超出模型上下文。Compaction 的做法是总结较老的 entries,并追加一条 pi.compaction entry,它带着摘要、并指向它保留的第一条 entry。更老的 entries 仍留在存储里。

stateDiagram-v2
    [*] --> Normal
    Normal --> Background: 距离阻塞阈值不足 backgroundTokens
    Background --> Normal: 摘要完成,等待放置
    Background --> Blocking: 下一次请求放不下
    Blocking --> Placed: 摘要生成完成
    Placed --> Normal: 下一个轮次边界
    Normal --> Manual: compact() 手动触发
    Manual --> Placed: 摘要完成
    Manual --> Normal: abort 取消

三条路径:

  1. 后台压缩:上下文接近上限时提前开始,对话继续工作;摘要生成完,在下一个轮次边界放置。
  2. 阻塞压缩:下一次请求否则放不下,对话等待摘要完成。
  3. 手动压缩:root.compact("保留失败的测试名"),可以带自己的指令;Esc 会取消它。

如果 provider 仍以「太长」拒绝请求,generation 会自动压缩并重试一次。如果摘要的切点落在当前上下文起点之前,它会在放置时以 stale 结算——所以多个压缩同时进行时,切点最靠后的那个生效。

关键配置:

settings: {
    compaction: {
        enabled: true,
        reserveTokens: 16384,      // 超过 contextWindow - reserveTokens 时阻塞等待
        keepRecentTokens: 20000,   // 摘要后仍逐字保留的近期上下文
        backgroundTokens: 32768,   // 距阻塞阈值还有这么远时提前后台压缩
        modelOverrides: {
            "local/small-32k": { reserveTokens: 4096, keepRecentTokens: 8000 },
        },
    },
}

modelOverrides 解决的是一个实际问题:固定 token 数很难同时适配 32k 和 1M 的上下文窗口。当前模型会在三个时机挑选对应条目:阈值检查、上下文溢出后、以及决定总结哪些内容时。

七、失败与排队的关系

一个 Run 失败时,inbox 不会被清空。这条设计避免了「一次模型错误吃掉用户三条消息」:

sequenceDiagram
    participant U as 用户
    participant Q as pi.inbox
    participant R as Run

    U->>Q: 消息 A(steer)
    U->>Q: 消息 B(follow-up)
    R->>R: 模型请求失败
    Note over Q: A、B 仍在队列
    U->>Q: 消息 C
    Q->>R: 下一次输入时按最旧优先放置

如果用户不再输入,排队项会一直保留。这既保证不丢消息,也把「是否继续」的决定权留给用户或上层产品。

小结

对话的正常路径可以概括为:

  • Submission 是入口,requestId 保证恰好一次;
  • Run 是一次完整回答,包含多个 Turn;
  • Turn 是一次模型响应及其工具调用;
  • Inbox 管理忙碌时的输入,四种策略对应四种产品语义;
  • Reset 移动可见起点,Compaction 缩小可见体积,两者都不删除历史。

下一篇离开单个对话,进入扩展系统:Agent 会什么,几乎全部由 registry 里的扩展决定。