第 8 章我们看了上下文工程的全景——其中 transformContext 只是扩展点,真正"动手压缩"的核心机制是 Compaction。当对话越来越长,消息越来越多,最终会超出模型的上下文窗口(Claude 200K、GPT 128K)。这时候需要一件更激进的事——压缩对话历史。
这一章就看 Pi 怎么在上下文窗口快满时,把 50 轮对话压缩成一段摘要,让 Agent 继续"记住"之前发生了什么。
Agent 和 LLM 的对话是"有状态的"——每轮都把之前的完整历史发给模型。你和 Agent 聊了 50 轮,每轮可能有几千 token 的工具结果。算一算:50 轮 × 平均 3000 token/轮 = 150,000 token。Claude Sonnet 的上下文窗口是 200,000 token。快满了。

配图说明:左侧红色条带——185K token 几乎塞满 200K 窗口。右侧绿色——压缩后只剩 60K(10K 摘要 + 50K 近期消息),腾出 140K 可继续聊。底部说明压缩是有损的但保留了目标/约束/决策等结构化信息。
满了会怎样?API 会报错:"prompt is too long"。对话被迫中断。
最直觉的解决方案是删旧消息——把前 30 轮扔掉,只保留最近 20 轮。但这样 Agent 就"失忆"了——它不记得你最初让它做什么、不记得之前做了什么决策、不记得改了哪些文件。
Pi 的解法是压缩(Compaction):把旧消息变成一段结构化摘要,用摘要替代原始消息。这样既腾出了空间,又保留了关键信息。
Agent 仍然"记得"前 30 轮做了什么——只是记忆从"原始录像"变成了"摘要笔记"。
在读后面所有细节之前,先把一条核心时序刻在脑子里——压缩不是在对话进行中触发的,而是在两轮对话之间发生的:
这是理解整章的钥匙——所有细节(什么时候触发、在哪切、怎么生成摘要、结果怎么生效)都围绕这个"两轮之间"的时序展开。后续每一节都是这条主线的具体环节。
压缩不是随便触发的,它有一个明确的"红线":
代入具体数字:contextWindow = 200,000,reserveTokens = 16,384。
当 contextTokens > 200,000 - 16,384 = 183,616 时,触发压缩。
reserveTokens 是为 LLM 回复预留的空间——你不能把上下文窗口塞满到 200,000,否则模型连回复的空间都没有了。
一个关键问题:怎么知道当前有多少 token?精确计算需要用 tokenizer,但不同模型的 tokenizer 不同,而且计算开销大。Pi 用了一个简单粗暴的方法:
一个英文字符大约 0.25 token(4 个字符 ≈ 1 token,估算与实际接近)。中文是反向偏差:1 个汉字实际大约 1-2 token,但 chars/4 只算成 0.25 token——严重低估中文占比高的对话。这意味着纯中文场景下,Pi 觉得"还没到压缩阈值",实际 token 已经接近上限。这是一个已知的精度问题,但 chars/4 在英文为主时足够准,且实现极简。
为什么用不精确的估算? 因为宁可高估、不可低估。高估了最多多压缩一次(无害);低估了 API 会报错(有害)。这是一种"保守策略"——用精度换安全。
| 场景 | 什么时候触发 | 意味着什么 |
|---|---|---|
| 预防性压缩 | token 超过阈值(183,616)但还没报错 | 提前压缩,避免 API 报错 |
| 应急性压缩 | API 返回上下文溢出错误 | 补救措施,先压缩再重试 |
预防性压缩是常态——在问题发生之前就处理掉。应急性压缩是兜底——万一估算偏了,API 真报错了,还有一道防线。
知道该压缩了,但从哪里"下刀"?不能随便切——有些位置切了会破坏数据完整性。

配图说明:消息序列条带(entries 0-9),每条标注类型。从最新往旧累积 token,toolResult 后不能切(红 ✗),user / assistant 后可以(绿 ✓)。最终选定 entry 7 (assistant) 为切割点——左侧 0-6 被压缩成 CompactionSummaryMessage,右侧 7-9 保留。注意:切在 assistant 上会把 entry 4 (user) 和 entry 5-7 (assistant+toolResult) 这个 Turn 切断,触发 turnPrefix 处理(§五详讲)。
LLM 的对话历史有严格的结构约束。比如 ToolResult 消息必须紧跟在触发它的 AssistantMessage(含 ToolCall)之后。如果你把 ToolCall 留在"保留区"、把 ToolResult 切到"压缩区",模型会看到"我调用了 read 工具,但结果在哪?"——上下文断裂。
所以切割点必须是有效切割点——不会破坏消息对的位置。
源码 findValidCutPoints(repo/packages/coding-agent/src/core/compaction/compaction.ts#L305) 的规则很清楚:user 和 assistant 都是合法切点,toolResult 不是。注释里的关键说明是:
When we cut at an assistant message with tool calls, its tool results follow it and will be kept.
理解切割点要抓住一个关键——切点不是"被切掉的最后一条",而是"保留区的第一条"。这个语义非常重要,会澄清你接下来的所有疑问。
切点 = user,意味着什么?user 自己进保留区,它后面的 assistant 和 toolResult 也都跟着进保留区——这个 user 开头的完整 Turn 全部保留。被压缩的是 user 之前的消息。
所以在 user 后切是最安全的选择——天然保证 Turn 完整,因为 user 后面跟着的 assistant 和 toolResult 都会跟着进保留区。
确定有效切割点后,从哪里切?Pi 的策略是从后往前累积(findCutPoint L392-454(repo/packages/coding-agent/src/core/compaction/compaction.ts#L392)):
为什么从后往前?因为最近的上下文最重要。模型需要知道"刚才做了什么""刚才读了什么文件""用户最新说了什么"。往回走直到累积够 20,000 token,确保保留足够的近期上下文。
切割结果把消息分成两组:
被压缩的那几十轮对话,不是直接扔掉,而是变成一段结构化摘要。
Pi 不是让 LLM "随便写一段总结",而是要求它填一个固定格式的表格——6 个 section:
为什么用结构化格式?因为自由文本容易遗漏信息——LLM 可能花大篇幅描述某个有趣的技术细节,却忘了记录用户的核心需求。固定 section 强制 LLM 覆盖每个维度,减少遗漏。
生成摘要的过程是:先把消息序列化成文本,然后调用 LLM 生成摘要。
如果一个长对话被压缩了多次(第一次压缩第 1-30 轮,第二次压缩第 31-50 轮),第二次压缩时会传入上一次的摘要作为 previousSummary:
这让 LLM 做的是更新而非重写——已有的 Goal/Constraints 保留,新增的 Progress 追加。比每次从零开始写摘要更稳定。
对于编码 Agent 来说,"改了哪些文件"是至关重要的信息。Pi 的摘要里还维护一个文件跟踪列表:
这些列表跨压缩累积——第二次压缩时,previousSummary 里的文件列表会被合并进新的摘要。这样即使经过了多轮压缩,Agent 仍然知道整个会话过程中读过哪些文件、改过哪些文件。
§三 讲了 user 切点和 assistant 切点都合法。但两者性质不同——
源码 findCutPoint L444-453(repo/packages/coding-agent/src/core/compaction/compaction.ts#L444) 的判断逻辑:
切点是 user → 一定不是 split turn。切点是 assistant(或 bashExecution/custom 等)→ 可能是 split turn——向前找到这个 Turn 的 user 起点,把 user 起点到切点之间的消息(assistant + toolResult 序列)单独处理。
最直觉的疑问是:既然 assistant 切点会切断 Turn,为什么不允许只切 user?这样不就完全避免 split turn 了吗?
答案藏在 token 控制的精度上。看这个场景:
假设向后累积到 entry 6 时累积量刚好达到 keepRecentTokens(20K)。这时要找一个"在 6 之后或等于 6"的合法切点:
这是个权衡:
Pi 选择了后者——优先保证压缩能生效,然后用 turnPrefix 摘要弥补 Turn 完整性的损失。
源码里 Pi 把这部分叫做 turnPrefixMessages(compaction.ts:698-705(repo/packages/coding-agent/src/core/compaction/compaction.ts#L698))——用专门的 TURN_PREFIX_SUMMARIZATION_PROMPT(repo/packages/coding-agent/src/core/compaction/compaction.ts#L737) 单独生成一份前缀摘要,和主摘要并行生成(L784-813(repo/packages/coding-agent/src/core/compaction/compaction.ts#L784) 用 Promise.all),最后合并成一个摘要文本。
注意主摘要和 turnPrefix 摘要的分工不同:
两份摘要合并后存储在同一个 CompactionEntry 里,下一轮 buildSessionContext 时一起注入。LLM 看到的是一份完整的"压缩前发生了什么 + 半个 Turn 的前缀"。
§一末尾已经讲了核心时序——压缩在两轮之间发生。这一节展开"压缩结果如何影响下一次运行"。
压缩完成后,结果怎么影响后续的 Agent 运行?
每次压缩产生一个 CompactionEntry,它存储在 Session Tree 上(第 10 章详讲):
下次 Agent 运行时,buildSessionContext() 会根据 CompactionEntry 重建上下文:
回忆第 6 章的消息系统:CompactionSummaryMessage 是 coding-agent 的自定义消息类型,convertToLlm 会把它翻译成 <summary> 标签包裹的 UserMessage。LLM 看到的是:"The conversation history before this point was compacted into the following summary: ..."
对 LLM 来说,之前的几十轮对话变成了一个摘要。 它不知道原始消息的细节,但它知道目标、进度、决策和文件操作记录——这对继续工作来说通常够用了。
自动压缩嵌入在 AgentSession 的 agent_end 事件处理中(第 7 章讲过事件系统):
压缩过程会发出两种事件(第 7 章讲过的 Session 层扩展事件):
UI 可以订阅这些事件来显示"正在压缩上下文..."的进度提示。
把全章串起来,一次压缩的完整旅程:

配图说明:6 步横向流程——触发判断→找切割点→分割→生成摘要(红色焦点)→存 CompactionEntry→下次运行时重建 context。Step 5 和 Step 6 之间是跨运行的虚线箭头,强调 CompactionEntry 是连接两次运行的桥梁。
回顾整章,Pi 的压缩算法有三个值得带走的设计思路。每个都不是"为了巧而巧",而是为了回应一个具体的工程张力。
findCutPoint 不是"找哪里能切",而是"找哪里值得保留"——从最新消息往回走,直到累积够 keepRecentTokens(默认 20K)。这种"逆向"思路背后的判断是:最近的上下文最重要——模型需要"刚才读了什么""用户最新说了什么",比"10 轮前讨论了什么"关键得多。
切点排除 toolResult 是因为协议约束——toolResult 必须紧跟 toolCall,否则模型会"调了工具但找不到结果"。这是个不可妥协的硬约束。
实现:packages/coding-agent/src/core/compaction/compaction.ts 的 findCutPoint(约 L392)
SUMMARIZATION_PROMPT 强制 LLM 填 6 个固定 section:Goal / Constraints & Preferences / Progress(含三个子项:Done / In Progress / Blocked)/ Key Decisions / Next Steps / Critical Context。其中 Progress 的 Blocked 子项专门记录"卡住的事"——LLM 在新一轮里看到这条,可以优先尝试解锁。
为什么不写"请总结对话"?因为自由文本摘要有一个失败模式:LLM 容易被"有趣的内容"吸引,花大篇幅描述某个技术细节,忘了记录用户的核心需求。固定 section 强制 LLM 在每个维度上至少扫一遍,把"易遗漏"变成"必须填"。
加上增量更新(UPDATE_SUMMARIZATION_PROMPT)——多次压缩时新摘要是在旧摘要基础上更新,不是从零重写。这避免了"每次压缩摘要漂移一点"的累积误差。
这是用 prompt 设计对抗 LLM 认知偏差的范例——固定模板 + 增量更新 = 让 LLM 一次性"想起"和"组织"信息的能力受到结构约束。
实现:packages/coding-agent/src/core/compaction/compaction.ts 的 SUMMARIZATION_PROMPT(约 L460)与 UPDATE_SUMMARIZATION_PROMPT(约 L493)
extractFileOperations 从两个来源累积文件列表:
最终用 formatFileOperations 把这两个列表以 <read-files>...</read-files> 和 <modified-files>...</modified-files> 标签附加到摘要末尾。
为什么单独跟踪文件?因为对编码 Agent 来说,"改过哪些文件"是至关重要的元信息——比"对话里讨论过什么"更精确、更可验证。LLM 看到这个列表,知道哪些文件已经被项目触碰过,避免重复读、避免覆盖他人改动。这是一种"领域知识嵌入到通用机制"的做法——压缩算法本身是通用的,但通过 details 字段承载领域特定信息。
实现:packages/coding-agent/src/core/compaction/compaction.ts 的 extractFileOperations(约 L41);标签格式化在 utils.ts 的 formatFileOperations
这一章我们看到了压缩算法怎么工作——从触发判断到切割点计算到摘要生成。但有一个概念我们反复提到却没展开:Session Tree。压缩结果(CompactionEntry)存在 Session Tree 上,buildSessionContext() 从 Session Tree 构建 LLM 需要的上下文。
Session Tree 是什么?为什么对话历史不是线性数组而是一棵树?分支是怎么回事?
下一章——会话管理——回答这些问题。
本章关键源码索引:
- packages/coding-agent/src/core/compaction/compaction.ts — 核心算法(findCutPoint、prepareCompaction、shouldCompact)
- packages/coding-agent/src/core/compaction/compaction.ts:256-296 — estimateTokens(chars/4 启发式估算)
- packages/coding-agent/src/core/compaction/utils.ts — 其他工具函数(消息序列化等)
- packages/coding-agent/src/core/session-manager.ts — CompactionEntry 定义 + buildSessionContext
- packages/coding-agent/src/core/messages.ts — CompactionSummaryMessage
- packages/coding-agent/src/core/agent-session.ts — 自动压缩集成(agent_end 后触发)