返回首页
09
Compaction上下文压缩切割点算法结构化摘要turnPrefix

上下文压缩:当对话太长怎么办

阈值触发、切割点算法、结构化摘要——把 50 轮对话压成一段笔记

带着问题读

读完本章,你应该能回答这三个问题:

  1. 1Pi 用 chars/4 估算 token,这个估算在中文场景下会严重低估——为什么还能用?出了偏差靠什么兜底?答案见正文中对应的「面试题 1」气泡
  2. 2压缩历史时切割点为什么不能落在 toolResult 消息上?Pi 允许切在 assistant 上,切断 Turn 的代价用什么机制弥补?答案见正文中对应的「面试题 2」气泡
  3. 3长对话被压缩多次后,Pi 怎么避免摘要'每次漂移一点'的累积误差?答案见正文中对应的「面试题 3」气泡

第 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,完整保留)──┐

压缩前后 Token 占用对比

记忆从"原始录像"变成"摘要笔记"。先把一条核心时序刻在脑子里:压缩不是对话进行中触发的,而是在两轮对话之间发生的——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 可订阅显示"正在压缩上下文..."。

压缩的完整链路

设计精华

  1. 向后遍历 + 合法切点:findCutPoint 不是"找哪里能切",而是"找哪里值得保留"——从最新消息往回走;排除 toolResult 是协议硬约束,不可妥协。
  2. 结构化摘要对抗自由发挥:固定模板 + 增量更新,用 prompt 设计约束 LLM 的组织能力,防止遗漏和漂移。
  3. 文件跟踪累积:把"改过哪些文件"这个领域知识嵌入通用压缩机制,跨多次压缩持续累积。

这一章反复提到却没展开的概念是 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 许可的开源教程。