前面的压缩页把主线讲成“摘要旧消息 + 保留最近消息”。这个模型对教学版足够好,因为它能让你先抓住两个核心不变量:
但真实 Pi 面对的是更麻烦的工程问题:模型上下文窗口有限,工具输出可能很长,用户可能在会话树里跳分支,一个巨大 turn 可能自己就超过保留预算,扩展还可能接管压缩。官方 Compaction 文档和 packages/coding-agent/src/core/compaction/ 源码把这些边界都显式处理了。
| 结论 | 官方文档 / 源码位置 | 教学时怎么理解 |
|---|---|---|
自动压缩触发条件是 contextTokens > contextWindow - reserveTokens |
Compaction 文档 与 compaction.ts |
压缩不是按消息条数触发,而是给下一次模型回复预留空间 |
当前默认 reserveTokens 为 16384,keepRecentTokens 为 20000 |
Compaction Settings | 一个控制“要留多少输出空间”,一个控制“最近原文保留多少” |
CompactionEntry 记录 summary、firstKeptEntryId、tokensBefore 和可选 details |
Session Format | summary 不是孤立文本,它带着恢复上下文所需的边界指针 |
Pi 通常在 turn 边界切分,且不会在 toolResult 处切 |
Cut Point Rules | 工具调用和工具结果必须保持语义连续 |
| split turn 会额外摘要这个 turn 的前半段 | Split Turns | 一个超长 turn 不能简单整段保留或整段丢给历史摘要 |
branch summary 发生在 /tree 切换分支时,解决的问题不同于 compaction |
Branch Summarization | compaction 是同一路径减重,branch summary 是换路径时带走经验 |
| 默认摘要会累计文件读写信息 | Cumulative File Tracking | 代码 Agent 需要知道哪些文件被读过、改过,而不只是聊天摘要 |
这些细节不是为了炫技。它们共同解决一个问题:压缩后,模型看到的上下文必须仍然像“连续工作现场”,而不是一段抽象回忆录。
教学版可以用“上下文字符串长度超过阈值”模拟压缩。真实 Pi 更接近下面这个流程:
flowchart TD
A["最近 assistant usage"] --> B["从模型 usage 读取 token"]
C["usage 之后的新消息"] --> D["估算追加 token"]
B --> E["contextTokens"]
D --> E
E --> F{"是否超过可用窗口"}
F -->|"否"| G["继续请求模型"]
F -->|"是"| H["准备 compaction"]
H --> I["选择 firstKeptEntryId"]
I --> J["生成 summary 并追加 CompactionEntry"]公式是:
contextTokens > contextWindow - reserveTokens
这里的 reserveTokens 很关键。Agent 不是只要“输入塞得下”就行,还要给模型回复、工具调用参数和后续事件留空间。否则模型刚开始输出就可能撞上下文限制。
keepRecentTokens 解决另一个问题:哪些内容必须保留原文。最近的报错、工具输出、用户补充约束常常非常具体,过早摘要会损失细节。
很多人第一次实现压缩,会只保存一个 summary 字段。这样页面能展示,但恢复上下文时会立刻遇到问题:summary 后面到底从哪条原文消息接上?
Pi 用 firstKeptEntryId 明确这个边界。
| 字段 | 作用 | 没有它会怎样 |
|---|---|---|
summary |
旧上下文摘要 | 只能知道大概发生过什么 |
firstKeptEntryId |
后续原文从哪个 entry 开始保留 | 重建上下文时可能重复消息,也可能漏掉消息 |
tokensBefore |
压缩前上下文 token 规模 | 无法在 UI、日志和扩展里解释这次压缩替换了多少上下文 |
details |
默认可保存读写文件等实现细节 | 摘要只剩自然语言,丢掉代码工作现场的结构化线索 |
可以把压缩后的上下文想成:
system prompt
compaction summary
messages from firstKeptEntryId to current leaf
注意:JSONL 文件里旧消息仍然存在。firstKeptEntryId 只是告诉运行时“下次喂给模型时从哪里接回原文”。
工具调用让切分变复杂了。一次工具调用不是单条消息,而是至少包含:
toolCall。toolResult。如果压缩刚好切在 toolResult 前后,模型可能看到一个没有来源的工具结果,或者看到一个没有结果的工具调用。这会破坏对话协议。
flowchart LR U["user turn start"] --> A["assistant toolCall"] A --> T["toolResult"] T --> R["assistant response"] R --> N["next user turn"]
官方规则里,合法 cut point 可以是用户消息、assistant 消息、bashExecution、自定义消息或 branch summary,但不会切在 tool result 上。直觉上讲:可以从一轮的起点接上,也可以从 assistant 的一个稳定输出点接上,但不能把“工具调用和结果”拆成孤儿。
通常一个 turn 从用户消息开始,到下一条用户消息前结束。理想情况下,压缩在 turn 边界上切:旧 turn 摘要掉,新 turn 保留原文。
但代码 Agent 经常遇到一个超长 turn:
flowchart TD U["user: 修复失败测试"] --> A1["assistant: 分析并调用 read"] A1 --> T1["toolResult: 大文件内容"] T1 --> A2["assistant: 调用 test"] A2 --> T2["toolResult: 长日志"] T2 --> A3["assistant: 调用 edit"] A3 --> T3["toolResult: diff"]
如果这个 turn 已经超过 keepRecentTokens,Pi 不能简单说“整个 turn 都保留”。这时 cut point 会落在 turn 内部,形成 split turn。真实实现会把 turn 前半段作为 turnPrefixMessages 单独摘要,再把后半段原文保留下来。
这一步保护的是连续性:模型仍能知道这个巨大 turn 前面做了什么,同时不会把整个巨大工具输出都塞进下一轮请求。
长会话不会只压缩一次。第二次、第三次压缩时,Pi 需要处理之前已经存在的 compaction entry。
核心原则是:下一次摘要的范围从上一次保留边界附近继续算,而不是简单从上一个 compaction entry 后面开始。官方文档说明,重复压缩会从前一次 firstKeptEntryId 的保留边界开始处理;如果找不到该 entry,则退回到前一个 compaction 后的 entry。
这样做的原因很实在:上一次被“保留原文”的消息,下一次可能已经变旧了,也应该有机会进入新的 summary。否则最近上下文会越滚越大,压缩效果越来越差。
branch_summary 和 compaction 都是摘要,但触发场景完全不同。
| 维度 | compaction | branch summary |
|---|---|---|
| 触发 | 上下文超过阈值,或用户运行 /compact |
用户在 /tree 中切换到另一条分支 |
| 摘要对象 | 当前路径上的旧上下文 | 正在离开的分支,从旧 leaf 到共同祖先 |
| 目标 | 减少 token 占用 | 把离开分支的重要发现带到新位置 |
| 写入 entry | type: "compaction" |
type: "branch_summary" |
| 恢复方式 | summary + firstKeptEntryId 后的原文 |
作为 branch summary message 注入上下文 |
一个常见误区是:切分支时直接把旧分支全丢掉。对探索式编程来说,这很可惜。你可能在 A 分支里读过关键文件、验证过失败假设,后来切到 B 分支继续实现。branch summary 的价值就是把这些“走过的弯路”变成可携带经验。
Pi 官方摘要格式里包含 <read-files> 和 <modified-files>。默认实现会从被摘要消息里的工具调用、之前的 compaction details、嵌套 branch summary details 中累计文件操作。
这对代码 Agent 很重要:
| 信息 | 为什么不能只写进普通 summary |
|---|---|
| 读过哪些文件 | 后续模型可以避免重复探索,或知道证据来自哪里 |
| 改过哪些文件 | 恢复任务时能快速聚焦风险区域 |
| 工具结果是否很长 | 摘要时可以截断展示,但保留结构化线索 |
| previous summary 的 details | 多次压缩后仍能累计历史工作现场 |
从工程角度看,details 是“给程序读的摘要”,自然语言 summary 是“给模型读的摘要”。两者配合,恢复质量会比只存一段文本稳定得多。
教学版 JsonlSessionStore.compactIfNeeded() 故意只实现最小闭环:
| 真实 Pi 能力 | 教学版处理 | 为什么这样安排 |
|---|---|---|
| 根据真实 token usage 判断 | 用近似字符长度模拟 | 本科生先理解触发点,不被 provider usage 细节打断 |
reserveTokens / keepRecentTokens |
固定阈值和保留条数 | 保持 Demo 可预测,方便观察 JSONL |
| turn boundary / split turn | 不做复杂切分 | 工具调用链路已经在 loop 章节单独学习 |
| 文件操作追踪 | 不实现 details | 避免把工具语义、摘要 prompt 和 session store 混在第一版里 |
| branch summary | 放到扩展方向 | 需要会话树 UI 支撑,适合作为二阶段练习 |
这不是偷懒,而是教学顺序。你先做出一个能跑、能保存、能压缩的最小 Agent,再回头把真实边界逐个补进去,会比一开始复刻 Pi 的完整压缩系统更稳。
在教学版基础上做一个“轻量真实化”的压缩练习:
compactIfNeeded() 增加 reserveApproxChars 和 keepRecentApproxChars 两个参数。firstKeptEntryId 时,不再按消息条数保留,而是从最新消息向前累加近似字符数。toolResult,保证它前面的 assistant tool call 也被保留,避免出现孤立工具结果。tokensBefore、firstKeptEntryId 和 keptMessageCount。完成后,你会更直观地理解真实 Pi 为什么要把压缩做成一个独立模块:它不是字符串裁剪,而是在保护协议边界、上下文预算和工程恢复能力。