第 9 章:上下文压缩 —— 当对话太长怎么办
Agent 对话是有状态的——每轮都把完整历史发给模型。50 轮 × 平均 3000 token/轮 = 150K token,Claude 的 200K 窗口快满了。满了会怎样?API 报错 "prompt is too long",对话中断。最直觉的解法是删旧消息,但那样 Agent 就"失忆"了——不记得最初的目标、做过的决策、改过的文件。
Pi 的解法是压缩(Compaction):把旧消息变成一段结构化摘要,用摘要替代原始消息。
压缩前(约 185K token):
┌── 第1-30轮(135K)──┬── 第31-50轮(50K)──┐
│ 原始消息 │ 原始消息(近期) │
压缩后(约 60K token):
┌── 结构化摘要(约 10K)──┬── 第31-50轮(50K,完整保留)──┐
记忆从"原始录像"变成"摘要笔记"。先把一条核心时序刻在脑子里:压缩不是对话进行中触发的,而是在两轮对话之间发生的——agent_end 事件后检查 token,超阈值就立刻压缩,把 CompactionEntry 写进 Session Tree;下一轮 buildSessionContext() 重建上下文时,摘要替代旧消息。全章所有细节都围绕这个"两轮之间"展开。
什么时候压缩:红灯亮起
function shouldCompact(contextTokens, contextWindow, settings): boolean {
if (!settings.enabled) return false;
return contextTokens > contextWindow - settings.reserveTokens;
}
代入数字:contextWindow = 200,000、reserveTokens = 16,384(为 LLM 回复预留的空间)——当 contextTokens > 183,616 时触发。
Token 估算:chars/4 的精度问题
精确计算要跑 tokenizer,开销大且各家不同。Pi 用了极简启发式(compaction.ts:256-296):
function estimateTokens(message: AgentMessage): number {
let chars = 0;
// ...按 role 累加 text/thinking/toolCall/command/output/summary 的字符数
return Math.ceil(chars / 4);
}
这个估算的精度口径要说清楚:英文场景下接近准确(4 字符 ≈ 1 token,与实际偏差不大);中文场景下会严重低估——1 个汉字实际约 1–2 token,chars/4 只算成 0.25 token。这意味着纯中文对话里,Pi 以为"还没到阈值",实际 token 可能已逼近上限。这是已知缺陷,兜底靠应急性压缩:万一估算偏了、API 真返回上下文溢出错误,系统会先执行压缩再重试——估算漏掉的安全边际由这道补救防线补上。
由此对应两种触发场景:预防性压缩(token 超阈值但还没报错,常态)和应急性压缩(API 报溢出错误后的补救重试)。
在哪切:切割点算法
配图说明:消息条带 entries 0–8。从最新往旧累积 token,toolResult 后不能切(✗),user / assistant 后可以(✓)。最终选定 entry 7(assistant)为切割点——左侧 0–6 压缩成摘要,右侧 7–8 保留。切在 assistant 上会把 entry 4(user)所在的 Turn 切断,触发 turnPrefix 处理(见后文)。
LLM 对话历史有严格结构约束:ToolResult 必须紧跟触发它的 AssistantMessage。把 ToolCall 留在保留区、ToolResult 切进压缩区,模型就会"调了工具但找不到结果"。所以切割点必须是有效切割点(findValidCutPoints):user 和 assistant 都是合法切点,toolResult 不是——因为切在 assistant 上时,它的 toolResult 会跟着进保留区,消息对仍然完整。
切点的语义是"保留区的第一条",不是"被切掉的最后一条"。 切点 = user 最安全:user 后面的 assistant 和 toolResult 都跟着进保留区,整个 Turn 天然完整。
确定切点位置的策略是从后往前累积(findCutPoint):
// 伪代码(含中文说明),实际实现见 compaction.ts:392-454
function findCutPoint(entries, keepRecentTokens) {
const cutPoints = findValidCutPoints(entries); // 排除 toolResult
let accumulated = 0;
for (let i = entries.length - 1; i >= 0; i--) {
accumulated += estimateTokens(entries[i]);
if (accumulated >= keepRecentTokens) {
return 第一个 >= i 的合法切点; // 伪代码:取停止位置之后最近的切点
}
}
return 最早的切点; // 伪代码:全部都需要压缩
}
为什么从后往前?最近的上下文最重要——"刚才读了什么文件""用户最新说了什么"远比"10 轮前讨论了什么"关键。往回累积够 keepRecentTokens(默认 20K),保证留足近期上下文。切点之前进 messagesToSummarize(被压缩),之后进 kept(保留)。
切掉的部分:结构化摘要
被压缩的几十轮不是直接扔掉,而是让 LLM 填一张固定格式的表——6 个 section:
## Goal ← 用户最初要做什么
## Constraints & Preferences
## Progress ← Done / In Progress / Blocked
## Key Decisions
## Next Steps
## Critical Context ← 不能忘记的关键信息
为什么不让 LLM 自由写总结?自由文本有个失败模式:LLM 容易被"有趣的内容"吸引,花大篇幅描述技术细节,忘了记录用户的核心需求。固定 section 强制每个维度至少扫一遍,把"易遗漏"变成"必须填"。Progress 里的 Blocked 子项专门记录"卡住的事",新一轮里可以优先尝试解锁。
增量更新:长对话压缩多次时,第二次压缩会传入上一次的摘要作为 previousSummary(用 UPDATE_SUMMARIZATION_PROMPT)——LLM 做的是更新而非重写:已有的 Goal/Constraints 保留,新增的 Progress 追加。这避免了"每次压缩摘要漂移一点"的累积误差。
文件跟踪:摘要从上次压缩的 details 和本轮被压缩消息里的工具调用两个来源,累积维护 <read-files> 和 <modified-files> 列表。对编码 Agent 来说"改过哪些文件"比"讨论过什么"更精确、更可验证——压缩算法是通用的,通过 details 字段承载领域特定信息。
极端情况:Turn 分割与 turnPrefix
user 切点天然保证 Turn 完整,但 assistant 切点会切断 Turn——这个 assistant 对应的 user 在压缩区,自己却在保留区。为什么还允许?看一个场景:向后累积到 entry 6 时预算刚好用完,如果只允许 user 切点,最近的 user 可能是 entry 1——保留区要装 8 个 entry,远超 20K 预算,压缩直接失败。允许 assistant 切点则能精确控制 token 量。这是权衡:优先保证压缩能生效,Turn 完整性的损失用 turnPrefix 机制弥补。
entry: 0 1 2 3 4 5 6 7 8
hdr usr ass tool usr ass tool ass tool
└──── Turn A ────┘ └────── Turn B ──────────┘
↑ └─ kept ─┘
turnStart=4 切点 = 7
切点在 entry 7(assistant),它的 Turn 起点是 entry 4(user)
→ entry 4 进主摘要压缩
→ entry 5–6 是"被切断的 Turn 前缀"(turnPrefixMessages),单独生成前缀摘要
→ entry 7–8 完整保留
turnPrefix 摘要和主摘要并行生成(Promise.all)后合并进同一个 CompactionEntry。两者分工不同:主摘要覆盖多个完整 Turn,用 6 section 格式;turnPrefix 摘要覆盖"半个 Turn",用更轻量的 3 段格式(Original Request / Early Progress / Context for Suffix)。LLM 下一轮看到的是完整的"压缩前发生了什么 + 半个 Turn 的前缀"。
压缩结果如何生效
每次压缩产生一个 CompactionEntry,存到 Session Tree 上(第 10 章详讲):
{
type: "compaction",
summary: "## Goal\nFix auth.ts...\n## Progress\n...", // 摘要文本
tokensBefore: 185000, // 压缩前 token 数(诊断/审计)
firstKeptEntryId: "e30", // 保留区起始 entry(重建时从这开始)
details: { // 文件操作跟踪
readFiles: ["src/auth.ts", "src/utils/hash.ts"],
modifiedFiles: ["src/auth.ts"],
},
}
下次运行时 buildSessionContext() 重建上下文:CompactionSummaryMessage(第 6 章的自定义消息,convertToLlm 翻译成 <summary> 标签包裹的 UserMessage)+ 保留的原始消息。LLM 看到的提示是"The conversation history before this point was compacted into the following summary: ..."——几十轮对话变成一段摘要,目标、进度、决策、文件记录都在,继续工作通常够用。
自动压缩嵌入在 AgentSession 的 agent_end 事件处理里,过程中发出 compaction_start(reason: "manual" | "threshold" | "overflow")和 compaction_end 两种 Session 层事件,UI 可订阅显示"正在压缩上下文..."。
设计精华
- 向后遍历 + 合法切点:findCutPoint 不是"找哪里能切",而是"找哪里值得保留"——从最新消息往回走;排除 toolResult 是协议硬约束,不可妥协。
- 结构化摘要对抗自由发挥:固定模板 + 增量更新,用 prompt 设计约束 LLM 的组织能力,防止遗漏和漂移。
- 文件跟踪累积:把"改过哪些文件"这个领域知识嵌入通用压缩机制,跨多次压缩持续累积。
这一章反复提到却没展开的概念是 Session Tree——CompactionEntry 存在树上,buildSessionContext 从树重建上下文,分支摘要依赖树的分叉结构。对话历史为什么不是线性数组而是一棵树?下一章——会话管理——回答这个问题。
本章关键源码索引:
coding-agent/src/core/compaction/compaction.ts(findCutPoint / shouldCompact / 摘要 prompt)、:256-296(estimateTokens)、session-manager.ts(CompactionEntry + buildSessionContext)、agent-session.ts(自动压缩集成)。本文改编自 CC-BY-SA-4.0 许可的开源教程。