一次对话的解剖: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 取消
三条路径:
- 后台压缩:上下文接近上限时提前开始,对话继续工作;摘要生成完,在下一个轮次边界放置。
- 阻塞压缩:下一次请求否则放不下,对话等待摘要完成。
- 手动压缩:
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 里的扩展决定。
