第 8 章我们看了上下文工程的全景——其中 transformContext 只是扩展点,真正“动手压缩”的核心机制是 Compaction。当对话越来越长,消息最终会逼近当前模型声明的 contextWindow,这时需要一件更激进的事——压缩对话历史。
这一章就看 Pi 怎么在上下文窗口快满时,把一段很长的对话压缩成摘要,让 Agent 继续”记住”之前发生了什么。
校对口径:本章对应 Pi v0.80.2 的 compaction 实现(发布提交
0201806)。文中的 token 数是演算示例,不代表所有模型;estimateTokens()的chars / 4是低成本启发式,在中文等 CJK 文本上会明显低估。
一、问题:对话越来越长,窗口装不下了
Agent 和 LLM 的对话是“有状态的”——每轮都要发送由当前会话路径重建出的历史。假设 50 轮平均各增加 3000 token,就是 150,000 token;是否接近上限取决于当前 Model.contextWindow。
配图说明:左侧红色条带——185K token 几乎塞满 200K 窗口。右侧绿色——压缩后只剩 60K(10K 摘要 + 50K 近期消息),腾出 140K 可继续聊。这里为便于演算,假设 keepRecentTokens = 50K;v0.80.2 默认值是 20K,后文切点示例使用默认值。底部说明压缩是有损的但保留了目标/约束/决策等结构化信息。
满了会怎样?不同 Provider 的表现并不统一:可能返回类似 "prompt is too long" 的错误,也可能成功结束但报告的输入用量超过窗口,或以 stopReason: "length"、零输出和接近满窗的输入用量暴露。Pi 把这些信号都纳入溢出检测。
最直接的解决方案是删旧消息——把前 30 轮扔掉,只保留最近 20 轮。但这样 Agent 就”失忆”了——它不记得你最初让它做什么、不记得之前做了什么决策、不记得改了哪些文件。
Pi 的解法是压缩(Compaction):把旧消息变成一段结构化摘要,用摘要替代原始消息。这样既腾出了空间,又保留了关键信息。
压缩前(185,000 token):
├─ 第 1–30 轮:135,000 token
│ 原始消息,含大量工具结果
└─ 第 31–50 轮:50,000 token
最近的原始上下文
压缩后(约 60,000 token):
├─ 结构化摘要:约 10,000 token
│ 目标、进度、决策、文件跟踪……
└─ 第 31–50 轮:50,000 token
最近的原始消息完整保留
Agent 仍然”记得”前 30 轮做了什么——只是记忆从”原始录像”变成了”摘要笔记”。
一个关键时序:自动压缩发生在 Agent 运行边界
在读后面所有细节之前,先把一条核心时序刻在脑子里:阈值与溢出自动压缩由 AgentSession 在一次 agent.prompt() / agent.continue() 完成后检查;提交下一条用户提示前还会补做一次检查。它不是在某个工具执行到一半时直接改写当前 Loop 的 context:
Agent 运行结束
└─ 已发 agent_end,agent.prompt() 返回
│
▼
_handlePostAgentRun() 检查 usage / overflow
├─ 不需要压缩 → 返回
└─ 需要压缩
├─ 找切割点
├─ 生成摘要
├─ 把 CompactionEntry 写进 Session Tree
└─ 立即 buildSessionContext()
替换 agent.state.messages
├─ 阈值压缩 → 等待下一次输入
└─ 需要重试的溢出 → continue()
这是理解整章的钥匙:压缩属于 Session 编排边界;Entry 写入后,内存中的 Agent context 也会立刻按摘要重建。下一次模型调用——可能是用户的下一条提示,也可能是需要重试的溢出响应之后的自动 continue()——看到“摘要 + 近期消息”。
二、什么时候压缩:红灯亮起
触发条件
压缩不是随便触发的,它有一个明确的”红线”:
function shouldCompact(contextTokens, contextWindow, settings): boolean {
if (!settings.enabled) return false;
return contextTokens > contextWindow - settings.reserveTokens;
}
代入具体数字:contextWindow = 200,000,reserveTokens = 16,384。
当 contextTokens > 200,000 - 16,384 = 183,616 时,触发压缩。
reserveTokens 是为 LLM 回复预留的空间——你不能把上下文窗口塞满到 200,000,否则模型连回复的空间都没有了。
Token 计数:Provider usage 为主,启发式为辅
一个关键问题:怎么知道当前有多少 token?在正常成功响应后,自动压缩的阈值判断直接使用最新 AssistantMessage 的 Provider usage:calculateContextTokens() 优先取 usage.totalTokens,没有时再把 input / output / cacheRead / cacheWrite 相加。
chars / 4 启发式并没有消失,但它主要用于两处:(1)findCutPoint() 估算每条 message 的大小,以决定保留多少近期内容;(2)最新响应是 error 或 usage 全零时,estimateContextTokens() 从上一条有效 usage 出发,再估算其后的消息。只有完全没有有效 usage 时,通用估算函数才会估算全部消息。
// 实际签名(compaction.ts:256-296):estimateTokens(message: AgentMessage): number
// 对每个 message 取其文本字符数 chars,然后 return Math.ceil(chars / 4)
function estimateTokens(message: AgentMessage): number {
let chars = 0;
// ...按 message.role 分别累加 text/thinking/toolCall/command/output/summary 的字符数
return Math.ceil(chars / 4); // chars / 4
}
一个英文字符大约 0.25 token(4 个字符 ≈ 1 token,估算与实际往往接近)。中文是反向偏差:1 个汉字实际可能占 1 个或更多 token,但 chars/4 只算成 0.25 token,因此会明显低估中文占比高的消息。
为什么仍使用不精确的估算? 它便宜、与 tokenizer 无关,适合切点预算和 usage 缺失后的补估;但它并不总是保守。CJK 偏差会影响“保留近期多少内容”的精度,也会影响 error / 零 usage 等回退路径。正常成功响应后的阈值判断并不是只靠 chars/4。
两种触发场景
| 场景 | 什么时候触发 | 意味着什么 |
|---|---|---|
| 预防性压缩 | 通常是 Provider usage 超过 contextWindow - reserveTokens;error / 零 usage 时用上一条有效 usage + 尾部估算 | 尝试在请求真正溢出前压缩 |
| 溢出压缩 | 错误文本命中溢出模式,或 usage / length-stop 呈现静默溢出信号 | error / length overflow 在成功压缩后请求自动 continue();已成功完成的超窗响应只压缩,不重试 |
预防性压缩是常态——在问题发生之前就处理掉。溢出压缩是兜底,但它不只识别显式 API 错误:isContextOverflow() 还检查“成功响应却报告超窗输入用量”,以及“length 停止、零输出、输入用量达到窗口约 99%”两类信号。
三、在哪切:切割点算法
知道该压缩了,但从哪里”下刀”?不能随便切——有些位置切了会破坏数据完整性。
配图说明:消息序列条带(entries 0-9),每条标注类型。从最新往旧累积 token;在图示的三种标准消息里,toolResult 不能作为保留起点(红 ✗),user / assistant 可以(绿 ✓)。最终选定 entry 7 (assistant):entries 0-3 进入主摘要,entries 4-6 进入 turnPrefix 摘要,两份结果合并进一个 CompactionEntry;entries 7-9 原样保留。
不是哪里都能切
LLM 的对话历史有严格的结构约束。比如 ToolResult 消息必须紧跟在触发它的 AssistantMessage(含 ToolCall)之后。如果你把 ToolCall 留在”保留区”、把 ToolResult 切到”压缩区”,模型会看到”我调用了 read 工具,但结果在哪?“——上下文断裂。
所以切割点必须是有效切割点:至少不能让保留区从孤立的 toolResult 开始;从含 ToolCall 的 assistant 开始时,其后对应的结果仍留在保留区。
| Entry | 类型 | 可作为切割点 |
|---|---|---|
| 0 | hdr | — |
| 1 | usr | ✓ |
| 2 | ass | ✓ |
| 3 | tool | ✗ |
| 4 | usr | ✓ |
| 5 | ass | ✓ |
| 6 | tool | ✗ |
| 7 | ass | ✓ |
| 8 | tool | ✗ |
源码 findValidCutPoints 的完整规则比图更广:message entry 中的 user、assistant、bashExecution、custom、branchSummary、compactionSummary 都能成为候选;session entry 形式的 branch_summary 与 custom_message 也可以,toolResult 则不可以。图只画出最常见的 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 之前的消息。
例子:切点选 entry 4 (usr)
压缩区
├─ entry 0 · hdr
├─ entry 1 · usr
├─ entry 2 · ass
└─ entry 3 · tool
保留区(切点从这里开始)
├─ entry 4 · usr ← 切点
├─ entry 5 · ass
├─ entry 6 · tool
├─ entry 7 · ass
└─ entry 8 · tool
entry 4 开始的 Turn 完整保留。
所以以 user 作为保留起点最容易保持 Turn 完整:user 及其后的 assistant、toolResult 都在保留区。
向后遍历:保护最重要的东西
确定有效切割点后,从哪里切?Pi 的策略是从后往前累积(findCutPoint L392-454):
从最新消息往回走,累积 token 数。
当累积量 >= keepRecentTokens(20,000)时,停止。
在停止位置之后找最近的有效切割点——那里就是切刀。
为什么从后往前?因为最近的上下文最重要。模型需要知道”刚才做了什么""刚才读了什么文件""用户最新说了什么”。往回走直到累积够 20,000 token,确保保留足够的近期上下文。
function findCutPoint(entries, startIndex, endIndex, keepRecentTokens) {
const cutPoints = findValidCutPoints(entries, startIndex, endIndex);
if (cutPoints.length === 0) return startIndex;
let cutIndex = cutPoints[0]; // 默认从最早候选开始保留
let accumulated = 0;
for (let i = endIndex - 1; i >= startIndex; i--) {
const entry = entries[i];
if (entry.type !== "message") continue;
// toolResult 也会计入 token;它只是不允许作为切点
accumulated += estimateTokens(entry.message);
if (accumulated >= keepRecentTokens) {
const candidate = 第一个大于等于 i 的 cutPoint;
if (candidate !== undefined) cutIndex = candidate;
break;
}
}
// 把紧挨切点之前的非 message entry 一并纳入保留区,
// 直到遇到前一条 message 或 compaction 边界
return 向前回扫后的 cutIndex;
}
如果遍历完仍未达到 keepRecentTokens,cutIndex 保持在最早候选,语义是“相关消息都放进保留区”,不是“全部需要压缩”;后续 prepareCompaction() 通常会因为没有可总结的消息而不执行压缩。
切割结果把消息分成两组:
切割点之前的消息 → messagesToSummarize(被压缩)
切割点及之后的消息 → kept(保留)
四、切掉的部分怎么处理:结构化摘要
被压缩的那几十轮对话,不是直接扔掉,而是变成一段结构化摘要。
摘要的格式:不是自由文本,是填表
Pi 不是让 LLM “随便写一段总结”,而是要求它填一个包含 6 个部分的固定格式:
## Goal ← 用户最初要做什么
## Constraints & Preferences ← 有什么约束
## Progress ← 做了什么(Done / In Progress / Blocked)
## Key Decisions ← 关键决策
## Next Steps ← 下一步做什么
## Critical Context ← 不能忘记的关键信息
为什么用结构化格式?因为自由文本容易遗漏信息——LLM 可能花大篇幅描述某个有趣的技术细节,却忘了记录用户的核心需求。固定格式要求 LLM 覆盖每个维度,减少遗漏。
摘要生成:普通路径一次 LLM 调用
未切断 Turn 的普通路径会先把消息序列化成文本,再调用 LLM 一次生成主摘要。若切点落在 Turn 中间,§五会看到另一条路径:主历史摘要与 turnPrefix 摘要最多各调用一次 LLM,并在两边都有内容时并行执行。
原始消息(AgentMessage[])
│
▼ 序列化
"[User]: 帮我修 auth.ts
[Assistant tool calls]: read(path=\"auth.ts\")
[Tool result]: export function authenticate() {...}
[Assistant]: 找到问题了,缺少 salt..."
│
▼ LLM 调用(用摘要 prompt)
│
结构化摘要
## Goal
Fix authentication bug in auth.ts
## Progress
### Done
- [x] Read auth.ts, identified missing salt
...
增量更新:不是每次从零开始
如果一个长对话被压缩了多次(第一次压缩第 1-30 轮,第二次压缩第 31-50 轮),第二次压缩时会传入上一次的摘要作为 previousSummary:
第一次压缩:
输入:第1-30轮原始消息
输出:摘要 A
第二次压缩:
输入:摘要 A + 第31-50轮原始消息
输出:摘要 B(在 A 的基础上合并新信息)
这让 LLM 做的是更新而非重写——已有的 Goal/Constraints 保留,新增的 Progress 追加。比每次从零开始写摘要更稳定。
文件跟踪:压缩不只是摘要文字
对于编码 Agent 来说,“工具调用涉及哪些文件”是重要信息。Pi 的摘要里还维护一个文件路径跟踪列表:
<read-files>
src/utils/hash.ts
</read-files>
<modified-files>
src/auth.ts
</modified-files>
read-files 的准确语义是“被 read 工具调用引用、且没有同时被 edit/write 调用引用的路径”,所以同一路径不会同时出现在两个列表里。提取器只扫描 assistant 消息中的工具名和 args.path,不检查对应 ToolResult 是否成功;这两组路径表示尝试过的操作,不证明磁盘上的最终状态。列表会跨压缩累积:第二次压缩时,代码从上一条 Pi 生成的 CompactionEntry details.readFiles / details.modifiedFiles 恢复集合,再合并本次工具调用;previousSummary 则单独作为文本交给摘要模型。
五、极端情况:Turn 分割
先沿着图中的主线看 user 与 assistant 两种切点。它们的性质不同——
- user 切点:保留区从 Turn 的 user 开始,后续 assistant + toolResult 一并保留
- assistant 切点:如果向前能找到该 Turn 的起点,就会被标记为 split turn——assistant 自己在保留区,而此前的 Turn 前缀需要另行总结
源码 findCutPoint L444-453 的判断逻辑:
const isUserMessage = cutEntry.message.role === "user";
const turnStartIndex = isUserMessage ? -1 : findTurnStartIndex(entries, cutIndex, startIndex);
isSplitTurn: !isUserMessage && turnStartIndex !== -1,
切点是 message role user → 一定不是 split turn。其他候选会调用 findTurnStartIndex() 向前寻找 user / bashExecution,或 session entry 形式的 branch_summary / custom_message。找到起点时,起点到切点之前的消息会进入 turnPrefixMessages。因此,下面的 assistant 案例是最容易理解的代表路径,不是候选类型的完整枚举。
为什么允许 assistant 切点?
一个自然的疑问是:既然 assistant 切点会切断 Turn,为什么不允许只切 user?这样不就完全避免 split turn 了吗?
答案藏在 token 控制的精度上。看这个场景:
entry: 1 2 3 4 5 6 7 8
usr ass tool ass tool ass tool ass
↑
向后累积到这里 token 预算用完
假设向后累积到 entry 6 时累积量刚好达到 keepRecentTokens(20K)。这时要找一个”在 6 之后或等于 6”的合法切点:
- 如果只允许 user 切点:在 entry 6 之后没有 user 候选,算法会退回较早的 entry 1,意味着要保留 entry 1-8。压缩仍可执行,但释放的上下文可能远少于目标。
- 如果允许 assistant 切点:entry 6 是 toolResult,不能选;算法可选择它后面的 entry 8 (assistant),只原样保留 entry 8,从而更接近近期 token 预算。
这是个权衡:
- 只允许 user 切点 → 某些超长 Turn 会让保留区过大,压缩释放的空间不足
- 允许 assistant 切点 → 更接近 token 目标,但可能切断 Turn,需要 turnPrefix 摘要弥补
Pi 选择允许更多候选,使保留区更接近期望的 token 预算;遇到 split turn 时,再用 turnPrefix 摘要补充被切开的前缀。
turnPrefix 机制:被切断的 Turn 怎么办?
turnPrefixMessages(Turn 起点到切点之前)
├─ entry 1 · usr ← turnStart
├─ entry 2 · ass
├─ entry 3 · tool
├─ entry 4 · ass
├─ entry 5 · tool
└─ entry 6 · tool
kept(从切点开始原样保留)
├─ entry 7 · ass ← 切点
├─ entry 8 · tool
└─ entry 9 · ass
entry 0 及更早历史进入主摘要;
entries 1–6 单独生成 turnPrefix 摘要。
源码里 Pi 把这部分叫做 turnPrefixMessages(compaction.ts:698-705)——用专门的 TURN_PREFIX_SUMMARIZATION_PROMPT 单独生成一份前缀摘要,和主摘要并行生成(L784-813 用 Promise.all),最后合并成一个摘要文本。
注意主摘要和 turnPrefix 摘要的分工不同:
- 主摘要覆盖 Turn 起点之前的历史;有内容时调用 LLM 生成 6 个部分的结构化摘要;若这里为空,代码不会再调一次空摘要,而是直接使用字面量
No prior history. - turnPrefix 摘要覆盖当前 Turn 从起点(含 user)到切点之前的前缀;切点及后续消息仍原样保留 → 用更轻量的 3 段格式(Original Request / Early Progress / Context for Suffix)
因此,普通路径通常是一笔摘要 LLM 调用;split turn 且主历史非空时是两笔并行调用;split turn 但主历史为空时只调用 turnPrefix 摘要,并把 No prior history. 与其合并。最终只保存一段合并后的 summary 到 CompactionEntry;原始保留消息不复制进该 Entry,而是由 firstKeptEntryId 在重建时接回。保存 Entry 后,AgentSession 立即调用 buildSessionContext(),注入合并后的摘要并接上从切点开始保留的原始消息。
六、压缩结果如何生效
§一末尾已经讲了核心时序——压缩在一次 Agent 运行结束后或新提示提交前检查,并在成功后立即重建内存上下文。这一节展开结果如何生效。
压缩完成后,结果怎么影响后续的 Agent 运行?
CompactionEntry:压缩结果的物理形态
每次压缩产生一个 CompactionEntry,它存储在 Session Tree 上(第 10 章详讲):
{
type: "compaction",
summary: "## Goal\nFix auth.ts...\n## Progress\n...", // 摘要文本
tokensBefore: 185000, // 压缩前 token 数(用于诊断和审计)
firstKeptEntryId: "e30", // 保留的起始 entry id(重建上下文时从这开始)
details: { // 文件操作跟踪(来自 extractFileOperations)
readFiles: ["src/utils/hash.ts"], // read 调用引用,且未被 edit/write 引用
modifiedFiles: ["src/auth.ts"], // edit/write 调用引用
},
// ... 含 id/parentId/timestamp 等 SessionEntryBase 字段
}
上下文重建
自动压缩保存 CompactionEntry 后会立刻调用 buildSessionContext();以后从 Session Tree 恢复时也使用同一规则:
重建后的上下文:
├── CompactionSummaryMessage(role: "compactionSummary")
│ summary = 摘要文本
│ (第6章讲过:convertToLlm 把它翻译成 UserMessage)
│
├── 保留的原始消息(从 entry 30 开始,包含 entry 30)
│ ├── UserMessage: "继续修复"
│ ├── AssistantMessage: ...
│ └── ...
│
└── (新的消息会在运行中追加)
回忆第 6 章的消息系统:CompactionSummaryMessage 是 coding-agent 的自定义消息类型,convertToLlm 会把它翻译成 <summary> 标签包裹的 UserMessage。LLM 看到的是:“The conversation history before this point was compacted into the following summary: …”
对 LLM 来说,之前的几十轮对话变成了一个摘要。 它不知道原始消息的细节,但它知道目标、进度、决策和文件操作记录——这对继续工作来说通常够用了。
自动压缩的集成
自动压缩不是 _handleAgentEvent(agent_end) 监听器中的一步。AgentSession._runAgentPrompt() 先等待 agent.prompt() 完成,再由 _handlePostAgentRun() 检查最后一条 assistant message;新提示提交前还会检查一次,以覆盖此前被中止的响应:
agent.prompt() / continue() 完成
│
▼
_handlePostAgentRun() → _checkCompaction()
│
├── 不需要 → 结束
│
└── 需要 → 执行压缩
├── findCutPoint → 找切割点
├── 序列化 + LLM 调用 → 生成摘要
├── 追加 CompactionEntry 到 Session Tree
├── 立即 buildSessionContext 并更新 agent.state.messages
└── overflow 且 willRetry=true,或队列仍有消息时,调用 continue()
两套事件
压缩过程会发出两种事件(第 7 章讲过的 Session 层扩展事件):
compaction_start(reason: “manual” | “threshold” | “overflow”)compaction_end(携带压缩结果或错误信息)
UI 可以订阅这些事件来显示”正在压缩上下文…”的进度提示。
七、完整链路回顾
把全章串起来,一次压缩的完整旅程:
配图说明:6 步横向流程——触发判断→找切割点→分割→生成摘要(红色焦点)→存 CompactionEntry→立即重建 Agent context。Entry 同时提供持久化恢复边界,下一次模型请求直接消费已重建的“摘要 + 保留消息”。
① 触发判断
shouldCompact() → contextTokens(185K) > contextWindow(200K) - reserve(16K)
红灯亮起,开始压缩
② 找切割点
向后遍历 → 累积 token 到 keepRecent(20K) → 找最近的有效切割点
排除 ToolResult 起点 → 避免保留区从孤立结果开始
③ 分割消息
切割点之前 → messagesToSummarize(被压缩)
切割点及之后 → kept(保留)
④ 生成摘要
普通路径:调一次 LLM 填写 6 个部分的结构化摘要
split turn:主历史非空时与 turnPrefix 摘要并行生成
主历史为空则使用 "No prior history."
传入 previousSummary 做增量更新 → 合并文件跟踪列表
⑤ 存储结果
CompactionEntry 追加到 Session Tree
⑥ 压缩成功后立即
buildSessionContext() → 用 CompactionSummaryMessage 替换旧消息
convertToLlm → 摘要翻译成 UserMessage 发给 LLM
八、设计精华
回顾整章,Pi 的压缩算法有三个值得带走的设计思路。每个都不是”为了巧而巧”,而是为了回应一个具体的工程张力。
1. 向后遍历 + 合法切点:保护最重要的东西
findCutPoint 不是”找哪里能切”,而是”找哪里值得保留”——从最新消息往回走,直到累积够 keepRecentTokens(默认 20K)。这种”逆向”思路背后的判断是:最近的上下文最重要——模型需要”刚才读了什么""用户最新说了什么”,比”10 轮前讨论了什么”关键得多。
切点排除 toolResult 是因为协议约束——toolResult 必须紧跟 toolCall,否则模型会”调了工具但找不到结果”。这是个不可妥协的硬约束。
实现:
packages/coding-agent/src/core/compaction/compaction.ts的findCutPoint(约 L392)
2. 结构化摘要:用固定模板对抗 LLM 的”自由发挥”
SUMMARIZATION_PROMPT 强制 LLM 填写 6 个固定部分:Goal / Constraints & Preferences / Progress(含三个子项:Done / In Progress / Blocked)/ Key Decisions / Next Steps / Critical Context。其中 Progress 的 Blocked 子项专门记录”卡住的事”——LLM 在新一轮里看到这条,可以优先尝试解锁。
为什么不写”请总结对话”?因为自由文本摘要有一个失败模式:LLM 容易被”有趣的内容”吸引,花大篇幅描述某个技术细节,忘了记录用户的核心需求。固定格式要求 LLM 在每个维度上至少扫一遍,把”易遗漏”变成”必须填”。
加上增量更新(UPDATE_SUMMARIZATION_PROMPT)——多次压缩时新摘要是在旧摘要基础上更新,不是从零重写。这能降低每次都从头总结造成的信息漂移风险,但 LLM 摘要仍然是有损的,不能保证完全没有累积误差。
这是用 prompt 设计对抗 LLM 认知偏差的范例——固定模板 + 增量更新 = 让 LLM 一次性”想起”和”组织”信息的能力受到结构约束。
实现:
packages/coding-agent/src/core/compaction/compaction.ts的SUMMARIZATION_PROMPT(约 L460)与UPDATE_SUMMARIZATION_PROMPT(约 L493)
3. 文件跟踪累积:编码 Agent 的领域特定知识
extractFileOperations 从两个来源累积文件列表:
- 上一次压缩的
details.readFiles/modifiedFiles - 这次被压缩的消息里所有工具调用涉及的文件(read 工具 → readFiles,edit/write 工具 → modifiedFiles)
最终用 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— Provider usage 计数、estimateContextTokens回退,以及estimateTokens(chars/4;用于切点与补估,CJK 会低估)packages/coding-agent/src/core/compaction/utils.ts— 其他工具函数(消息序列化等)packages/coding-agent/src/core/session-manager.ts— CompactionEntry 定义 + buildSessionContextpackages/coding-agent/src/core/messages.ts— CompactionSummaryMessagepackages/coding-agent/src/core/agent-session.ts— 自动压缩集成(Agent 运行后处理与新提示前检查)