Skip to content

第九章:上下文压缩 -- Compaction

"遗忘不是缺陷,而是特性。"


9.1 问题:上下文窗口是有限的

LLM 有一个硬性限制——上下文窗口。Claude 的窗口是 200K token,GPT-4 是 128K token。当你的对话历史超过这个限制时,LLM 就无法处理了。

一个典型的编码会话可能包含:

  • 20 轮对话
  • 多次文件读取(每次可能几千行)
  • 多次命令执行(输出可能很长)

这些加起来很容易超过 100K token。

解决方案:自动压缩——当上下文接近窗口限制时,用 LLM 生成一个摘要来替代旧消息。

📌 源码定位 ─────────────────────────────────────

  • packages/coding-agent/src/core/compaction/compaction.ts -- 压缩核心逻辑
  • packages/coding-agent/src/core/compaction/branch-summarization.ts -- 分支摘要
  • packages/coding-agent/src/core/compaction/utils.ts -- 工具函数 ─────────────────────────────────────────────────

9.2 压缩的触发条件

typescript
interface CompactionSettings {
    enabled: boolean;
    reserveTokens: number;      // 默认 16384,预留空间
    keepRecentTokens: number;   // 默认 20000,保留最近的消息
}

function shouldCompact(
    contextTokens: number,
    contextWindow: number,
    settings: CompactionSettings,
): boolean {
    return contextTokens > contextWindow - settings.reserveTokens;
}

举例

  • 模型上下文窗口:200,000 token
  • 预留空间:16,384 token
  • 触发阈值:200,000 - 16,384 = 183,616 token
  • 当上下文超过 183,616 token 时,自动触发压缩

★ Insight ─────────────────────────────────────为什么要预留空间? 因为压缩本身也需要 LLM 调用来生成摘要。如果上下文已经满了,连摘要都生成不了。预留空间确保压缩操作本身有足够的上下文空间。这和操作系统保留一部分内存给内核是同一个道理。 ─────────────────────────────────────────────────

9.3 Token 估算

Pi 使用两种方式估算 token 数:

typescript
// 方式 1:使用 LLM 报告的 usage(精确)
function calculateContextTokens(usage: Usage): number {
    return usage.totalTokens || (usage.input + usage.output + usage.cacheRead + usage.cacheWrite);
}

// 方式 2:字符数 / 4 的启发式估算(快速但不精确)
function estimateTokens(message: AgentMessage): number {
    const contentLength = getContentLength(message);
    return Math.ceil(contentLength / 4);
}

★ Insight ─────────────────────────────────────"字符数 / 4" 是一个经验法则。 英文中,1 个 token 约等于 4 个字符("hello" = 1 token,"indistinguishable" = 4 tokens)。中文大约是 1-2 个字符 = 1 个 token。这个估算不精确,但足够快速,不需要调用 tokenizer 库。 ─────────────────────────────────────────────────

9.4 压缩的核心算法

压缩分为三个步骤:

Step 1: 找到切割点

typescript
function findCutPoint(
    entries: SessionEntry[],
    startIndex: number,
    endIndex: number,
    keepRecentTokens: number,
): CutPointResult {
    // 从最新的消息向旧消息回溯
    let tokenBudget = keepRecentTokens;

    for (let i = endIndex; i >= startIndex; i--) {
        const entry = entries[i];
        if (entry.type === "message") {
            tokenBudget -= estimateTokens(entry.message);
            if (tokenBudget <= 0) {
                return {
                    firstKeptEntryIndex: i,      // 从此处开始保留
                    turnStartIndex: findTurnStart(entries, i),
                    isSplitTurn: ...,
                };
            }
        }
    }

    return { firstKeptEntryIndex: startIndex, ... };
}

切割策略

原始消息:  [msg1] [msg2] [msg3] [msg4] [msg5] [msg6] [msg7] [msg8]

                              切割点 (keepRecentTokens 边界)

压缩后:    [摘要] ... [msg6] [msg7] [msg8]
            ↑                ↑
         摘要替代旧消息    保留最近的消息

★ Insight ─────────────────────────────────────切割点不会在工具调用的中间断开。 如果 msg4 是 AssistantMessage(包含 ToolCall),msg5 是 ToolResultMessage,切割点不会放在它们之间——因为 LLM 需要看到工具调用和结果的完整配对。这就像 Git 的 rebase 不会在一个 commit 的中间断开一样。 ─────────────────────────────────────────────────

Step 2: 生成摘要

typescript
async function generateSummary(
    messages: AgentMessage[],
    model: Model<any>,
    reserveTokens: number,
    apiKey?: string,
    previousSummary?: string,
): Promise<string> {

    // 构建提示词
    const prompt = previousSummary
        ? `Update the existing summary with new conversation context.
           Existing summary: ${previousSummary}
           New messages: ${serializeConversation(messages)}`
        : `Summarize the conversation so far.
           Focus on: goals, progress, key decisions, next steps.
           Messages: ${serializeConversation(messages)}`;

    // 调用 LLM 生成摘要
    const result = await completeSimple(model, {
        systemPrompt: COMPACTION_SYSTEM_PROMPT,
        messages: [{ role: "user", content: prompt }],
    }, { apiKey });

    return result.content[0].text;
}

摘要格式(由 system prompt 引导):

markdown
## Goal
[用户的原始目标]

## Constraints
[已知的约束条件]

## Progress
[已完成的工作]

## Key Decisions
[重要的设计决策]

## Next Steps
[接下来要做的事]

## Critical Context
[LLM 需要知道的关键上下文]

★ Insight ─────────────────────────────────────摘要的结构化格式是精心设计的。 "Goal" 确保 LLM 不会忘记用户的原始意图;"Progress" 记录了已完成的工作,避免重复;"Next Steps" 给 LLM 明确的方向;"Critical Context" 保留了可能被压缩掉但仍然重要的信息。这就像一个项目的 sprint 回顾文档。 ─────────────────────────────────────────────────

Step 3: 处理分割的 Turn

如果切割点恰好在一个 turn 的中间(比如 Assistant 发了 ToolCall 但 ToolResult 还没到),Pi 会分别生成两个摘要:

typescript
async function compact(preparation: CompactionPreparation, ...): Promise&lt;CompactionResult&gt; {
    if (preparation.isSplitTurn) {
        // 并行生成两个摘要
        const [historySummary, turnPrefixSummary] = await Promise.all([
            generateSummary(preparation.messagesToSummarize, model, ...),
            generateSummary(preparation.turnPrefixMessages, model, ...),
        ]);
        // 合并摘要
        return { summary: `${historySummary}\n\n${turnPrefixSummary}`, ... };
    } else {
        const summary = await generateSummary(preparation.messagesToSummarize, model, ...);
        return { summary, ... };
    }
}

★ Insight ─────────────────────────────────────并行生成摘要是一个性能优化。 如果你有一个很长的历史和一个很长的当前 turn,串行生成两个摘要可能需要 10 秒以上。并行生成可以将时间减半。这和 Java 的 CompletableFuture.allOf() 是同一个模式。 ─────────────────────────────────────────────────

9.5 文件操作追踪

压缩时,Pi 还会追踪哪些文件被读取和修改过:

typescript
interface FileOperations {
    read: Set<string>;      // 被读取的文件
    written: Set<string>;   // 被写入的文件
    edited: Set<string>;    // 被编辑的文件
}

// 从工具调用中提取文件操作
function extractFileOpsFromMessage(message: AssistantMessage, fileOps: FileOperations): void {
    for (const content of message.content) {
        if (content.type === "toolCall") {
            switch (content.name) {
                case "read":
                    fileOps.read.add(content.arguments.path);
                    break;
                case "write":
                    fileOps.written.add(content.arguments.path);
                    break;
                case "edit":
                    fileOps.edited.add(content.arguments.path);
                    break;
            }
        }
    }
}

这些文件列表会附加到摘要中,格式化为 XML 标签:

xml
<read-files>
  src/config.ts
  src/utils.ts
</read-files>
<modified-files>
  src/main.ts
  src/types.ts
</modified-files>

★ Insight ─────────────────────────────────────文件列表让 LLM 知道"上下文中提到了哪些文件"。 即使原始的文件内容被压缩掉了,LLM 仍然知道这些文件的存在和角色。如果需要,它可以重新读取这些文件。这就像一个索引——你不需要记住整本书的内容,只需要知道去哪一章找。 ─────────────────────────────────────────────────

9.6 分支摘要

当你从一个分支切换到另一个分支时,Pi 会为被放弃的分支生成摘要:

typescript
async function generateBranchSummary(
    entries: SessionEntry[],
    options: { model: Model<any>; apiKey?: string },
): Promise<string> {
    // 1. 准备消息(保留最近的,限制 token 预算)
    const prepared = prepareBranchEntries(entries, TOKEN_BUDGET);

    // 2. 生成摘要(带序言说明这是分支探索)
    const preamble = "The following is a summary of an alternative branch " +
                     "that was explored but not continued.";

    const summary = await generateSummary(prepared.messages, options.model, ...);

    return `${preamble}\n\n${summary}`;
}

★ Insight ─────────────────────────────────────分支摘要是"探索性工作"的保险。 假设你在分支 A 中花了一小时调试一个 bug,然后决定切换到分支 B 试试另一种方法。分支 A 的工作不是白费的——摘要保留了关键发现,分支 B 可以从中受益。这和 Git 的 git stash 类似,但更智能——不是保存原始代码,而是保存"学到了什么"。 ─────────────────────────────────────────────────

9.7 压缩的存储

压缩结果作为 CompactionEntry 追加到会话文件中:

typescript
interface CompactionEntry {
    type: "compaction";
    id: string;
    parentId: string;
    timestamp: number;
    summary: string;            // 摘要文本
    firstKeptEntryId: string;   // 保留的第一条消息 ID
    tokensBefore: number;       // 压缩前的 token 数
    details?: {
        readFiles: string[];    // 被读取的文件
        modifiedFiles: string[]; // 被修改的文件
    };
}

buildSessionContext() 中处理压缩:

完整路径: [msg1, msg2, compaction, msg5, msg6, msg7]

构建上下文:
1. 发现 compaction 条目
2. 创建 CompactionSummary 消息(包含摘要 + 文件列表)
3. 跳过 msg1, msg2(已被压缩)
4. 添加 msg5, msg6, msg7

最终上下文: [CompactionSummary, msg5, msg6, msg7]

9.8 本章小结

上下文增长过程:

[msg1][msg2][msg3][msg4][msg5][msg6]  ← 接近窗口限制

                                      ▼ 触发 shouldCompact()

                               ┌──────┴──────┐
                               │  找切割点    │
                               │  生成摘要    │
                               │  追加条目    │
                               └──────┬──────┘


[CompactionSummary][msg5][msg6]  ← 大幅减少 token

压缩后继续增长:

[CompactionSummary][msg5][msg6][msg7][msg8][msg9]  ← 再次接近限制

                                      ▼ 再次压缩(摘要也会被更新)

关键设计决策

  1. 自动触发:不需要用户手动干预
  2. 保留最近消息keepRecentTokens 确保最近的上下文不丢失
  3. 结构化摘要:Goal/Progress/Decisions 格式确保关键信息不丢失
  4. 文件索引:即使内容被压缩,文件列表仍然保留
  5. 切割安全:不在工具调用-结果对中间断开

第八章:会话管理 | 第十章:扩展系统 -- Extension API →

基于 MIT 许可证发布