Pi Agent · Book
M09

第9章:上下文压缩 —— 当对话太长怎么办

4921字 · 含 182 行代码 · 约 25 分钟

第 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

压缩前后 Token 占用对比
压缩前后 Token 占用对比

配图说明:左侧红色条带——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,000reserveTokens = 16,384

contextTokens > 200,000 - 16,384 = 183,616 时,触发压缩。

reserveTokens 是为 LLM 回复预留的空间——你不能把上下文窗口塞满到 200,000,否则模型连回复的空间都没有了。

Token 计数:Provider usage 为主,启发式为辅

一个关键问题:怎么知道当前有多少 token?在正常成功响应后,自动压缩的阈值判断直接使用最新 AssistantMessage 的 Provider usagecalculateContextTokens() 优先取 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类型可作为切割点
0hdr
1usr
2ass
3tool
4usr
5ass
6tool
7ass
8tool

源码 findValidCutPoints 的完整规则比图更广:message entry 中的 userassistantbashExecutioncustombranchSummarycompactionSummary 都能成为候选;session entry 形式的 branch_summarycustom_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;
}

如果遍历完仍未达到 keepRecentTokenscutIndex 保持在最早候选,语义是“相关消息都放进保留区”,不是“全部需要压缩”;后续 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 把这部分叫做 turnPrefixMessagescompaction.ts:698-705)——用专门的 TURN_PREFIX_SUMMARIZATION_PROMPT 单独生成一份前缀摘要,和主摘要并行生成L784-813Promise.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.tsfindCutPoint(约 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.tsSUMMARIZATION_PROMPT(约 L460)与 UPDATE_SUMMARIZATION_PROMPT(约 L493)

3. 文件跟踪累积:编码 Agent 的领域特定知识

extractFileOperations 从两个来源累积文件列表:

  1. 上一次压缩的 details.readFiles / modifiedFiles
  2. 这次被压缩的消息里所有工具调用涉及的文件(read 工具 → readFiles,edit/write 工具 → modifiedFiles)

最终用 formatFileOperations 把这两个列表以 <read-files>...</read-files><modified-files>...</modified-files> 标签附加到摘要末尾。

为什么单独跟踪文件?因为对编码 Agent 来说,“曾尝试读写哪些路径”是有用的领域信息,比只保留自由文本摘要更容易检索。LLM 可以把这个列表当作后续操作的线索,但不能据此断言调用成功或文件当前已经修改;必要时仍应重新读取或检查结果。这是一种”领域知识嵌入到通用机制”的做法——压缩算法本身是通用的,但通过 details 字段承载领域特定信息。

实现:packages/coding-agent/src/core/compaction/compaction.tsextractFileOperations(约 L41);标签格式化在 utils.tsformatFileOperations


九、下一站

这一章我们看到了压缩算法怎么工作——从触发判断到切割点计算到摘要生成。但有一个概念我们反复提到却没展开: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 定义 + buildSessionContext
  • packages/coding-agent/src/core/messages.ts — CompactionSummaryMessage
  • packages/coding-agent/src/core/agent-session.ts — 自动压缩集成(Agent 运行后处理与新提示前检查)