第九章:上下文压缩 -- 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 压缩的触发条件
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 数:
// 方式 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: 找到切割点
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: 生成摘要
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 引导):
## 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 会分别生成两个摘要:
async function compact(preparation: CompactionPreparation, ...): Promise<CompactionResult> {
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 还会追踪哪些文件被读取和修改过:
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 标签:
<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 会为被放弃的分支生成摘要:
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 追加到会话文件中:
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] ← 再次接近限制
│
▼ 再次压缩(摘要也会被更新)关键设计决策:
- 自动触发:不需要用户手动干预
- 保留最近消息:
keepRecentTokens确保最近的上下文不丢失 - 结构化摘要:Goal/Progress/Decisions 格式确保关键信息不丢失
- 文件索引:即使内容被压缩,文件列表仍然保留
- 切割安全:不在工具调用-结果对中间断开