12 上下文压缩
12.1 概述
LLM 的上下文窗口是有限的。当对话变得过长时,Pi 使用压缩(Compaction)机制来摘要旧内容,同时保留最近的工作。本章涵盖自动压缩和分支摘要两种机制。
12.1.1 压缩 vs 分支摘要
Pi 有两种摘要机制:
| 机制 | 触发条件 | 目的 |
|---|---|---|
| 压缩(Compaction) | 上下文超过阈值,或 /compact 命令 |
摘要旧消息以释放上下文 |
| 分支摘要(Branch Summarization) | /tree 导航 |
切换分支时保留上下文 |
两种机制使用相同的结构化摘要格式,并累积跟踪文件操作。压缩和分支摘要请求使用全新的路由会话 ID,并在提供商支持时禁用 Prompt 缓存写入——因为这些一次性提示不太可能被重用。
12.2 自动压缩
12.2.1 触发条件
当满足以下条件时触发自动压缩:
contextTokens > contextWindow - reserveTokens
默认情况下,reserveTokens 为 16384 Token(可在 ~/.pi/agent/settings.json 或 .pi/settings.json 中配置)。这为 LLM 的响应预留了空间。
你也可以通过 /compact [instructions] 手动触发压缩,其中可选的指令用于聚焦摘要内容。
/compact 命令的 [instructions] 参数让你可以自定义压缩的重点。例如:/compact Focus on the database schema decisions 会生成一个侧重于数据库模式的摘要。
12.2.2 压缩原因类型
自动压缩的触发原因分为三种:
"manual":通过/compact命令手动触发"threshold":上下文 Token 超过阈值"overflow":上下文溢出,需要压缩后重试
12.3 工作原理
压缩过程分为五个步骤:
12.3.1 步骤一:寻找切割点
从最新消息开始向前回溯,累积 Token 估计直到达到 keepRecentTokens(默认 20k Token)。
压缩前:
entry: 0 1 2 3 4 5 6 7 8 9
┌─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬─────┐
│ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│
└─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┘
└────────┬───────┘ └──────────────────┬──────────────────┘
待摘要消息 保留消息
↑
firstKeptEntryId (entry 4)
12.3.2 步骤二:提取消息
从前一个保留边界(或会话开始处)收集到切割点之间的消息。
12.3.3 步骤三:生成摘要
调用 LLM 使用结构化格式摘要提取的消息。如果存在前一次的摘要,会作为迭代上下文传入。
12.3.4 步骤四:保存条目
将带有摘要和 firstKeptEntryId 的 CompactionEntry 追加到会话中。
压缩后(追加新条目):
entry: 0 1 2 3 4 5 6 7 8 9 10
┌─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬─────┬─────┐
│ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│ cmp │
└─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┴─────┘
└──────────┬──────────┘ └──────────────────────┬───────────────────┘
不发送给 LLM 发送给 LLM
↑
从 firstKeptEntryId 开始
12.3.5 步骤五:重新加载会话
会话重新加载,使用摘要 + 从 firstKeptEntryId 开始的消息。
LLM 实际看到的:
┌────────┬─────────┬─────┬─────┬──────┬──────┬─────┬──────┐
│ system │ summary │ usr │ ass │ tool │ tool │ ass │ tool │
└────────┴─────────┴─────┴─────┴──────┴──────┴─────┴──────┘
↑ ↑ └──────────────────┬──────────────────┘
系统提示 来自 cmp 从 firstKeptEntryId 开始的消息
在重复压缩时,摘要范围从前一次压缩的保留边界(firstKeptEntryId)开始,而不是从压缩条目本身开始。如果找不到保留的条目,则从前一次压缩之后的条目开始。这确保了在前一次压缩中存活的消息也会被包含在下一次摘要中。
12.4 分割轮次(Split Turns)
一个”轮次”从用户消息开始,包含直到下一个用户消息的所有助手响应和工具调用。通常,压缩在轮次边界切割。
当单个轮次超过 keepRecentTokens 时,切割点会落在轮次中间的助手消息上。这被称为分割轮次:
分割轮次(一个巨大轮次超出预算):
entry: 0 1 2 3 4 5 6 7 8
┌─────┬─────┬─────┬──────┬─────┬──────┬──────┬─────┬──────┐
│ hdr │ usr │ ass │ tool │ ass │ tool │ tool │ ass │ tool │
└─────┴─────┴─────┴──────┴─────┴──────┴──────┴─────┴──────┘
↑ ↑
turnStartIndex = 1 firstKeptEntryId = 7
│ │
└──── turnPrefixMessages (1-6) ──────┘
└── 保留 (7-8)
isSplitTurn = true
messagesToSummarize = [] (前面没有完整轮次)
turnPrefixMessages = [usr, ass, tool, ass, tool, tool]
对于分割轮次,Pi 生成两个摘要并合并它们:
- 历史摘要:之前的上下文(如果有)
- 轮次前缀摘要:分割轮次的前半部分
12.5 切割点规则
有效的切割点包括:
- ✅ 用户消息
- ✅ 助手消息
- ✅ Bash 执行消息
- ✅ 自定义消息(
custom_message、branch_summary)
永远不能在以下位置切割:
- ❌ 工具结果(它们必须与对应的工具调用在一起)
工具结果必须与生成它们的工具调用保持在同一切割段内,否则模型会看到没有对应工具调用的工具结果,导致上下文断裂。
12.6 CompactionEntry 结构
CompactionEntry 定义在 session-manager.ts 中:
interface CompactionEntry<T = unknown> {
type: "compaction";
id: string;
parentId: string;
timestamp: number;
summary: string;
firstKeptEntryId: string;
tokensBefore: number;
usage?: Usage; // 生成摘要的 LLM 使用量
fromHook?: boolean; // 如果由扩展提供则为 true
details?: T; // 实现特定的数据
}
// 默认压缩的 details 结构(来自 compaction.ts):
interface CompactionDetails {
readFiles: string[];
modifiedFiles: string[];
}扩展可以在 details 中存储任何可 JSON 序列化的数据。默认压缩跟踪文件操作,但自定义扩展实现可以使用自己的结构。生成的和扩展提供的摘要在可用时存储其 LLM 使用量,以便会话总计包含摘要工作。
12.7 分支摘要机制
12.7.1 触发条件
当你使用 /tree 导航到不同分支时,Pi 会提供对你正在离开的工作进行摘要的选项。这将被离开分支的上下文注入到新分支中。
12.7.2 工作原理
- 寻找共同祖先:旧位置和新位置共享的最深节点
- 收集条目:从旧叶子回溯到共同祖先
- 预算准备:从最新开始,在 Token 预算内包含消息
- 生成摘要:使用结构化格式调用 LLM
- 保存条目:在导航点保存
BranchSummaryEntry
导航前的树:
┌─ B ─ C ─ D (旧叶子,将被放弃)
A ───┤
└─ E ─ F (目标)
共同祖先:A
待摘要条目:B, C, D
导航后(带摘要):
┌─ B ─ C ─ D ─ [B,C,D 的摘要]
A ───┤
└─ E ─ F (新叶子)
12.8 累积文件跟踪
压缩和分支摘要都以累积方式跟踪文件操作。生成摘要时,Pi 从以下来源提取文件操作:
- 被摘要消息中的工具调用
- 之前的压缩或分支摘要详情(如果有)
这意味着文件跟踪在多次压缩或嵌套分支摘要之间累积,保留已读取和已修改文件的完整历史。
12.8.1 BranchSummaryEntry 结构
interface BranchSummaryEntry<T = unknown> {
type: "branch_summary";
id: string;
parentId: string;
timestamp: number;
summary: string;
fromId: string; // 我们导航来源的条目
usage?: Usage; // 生成摘要的 LLM 使用量
fromHook?: boolean; // 如果由扩展提供则为 true
details?: T; // 实现特定的数据
}
// 默认分支摘要的 details 结构:
interface BranchSummaryDetails {
readFiles: string[];
modifiedFiles: string[];
}12.9 摘要格式模板
压缩和分支摘要使用相同的结构化格式:
## Goal
[用户试图实现的目标]
## Constraints & Preferences
- [用户提到的要求]
## Progress
### Done
- [x] [已完成的任务]
### In Progress
- [ ] [当前进行的工作]
### Blocked
- [问题(如果有)]
## Key Decisions
- **[决策]**: [理由]
## Next Steps
1. [下一步应该做什么]
## Critical Context
- [继续工作所需的数据]
<read-files>
path/to/file1.ts
path/to/file2.ts
</read-files>
<modified-files>
path/to/changed.ts
</modified-files>这个结构化格式确保了摘要的一致性和可读性。模型在后续工作中可以参考这个摘要来了解之前的进展和决策。
12.10 消息序列化
在摘要之前,消息通过 serializeConversation() 函数序列化为文本:
[User]: 用户说了什么
[Assistant thinking]: 内部推理
[Assistant]: 响应文本
[Assistant tool calls]: read(path="foo.ts"); edit(path="bar.ts", ...)
[Tool result]: 工具输出
这种格式防止模型将其视为要继续的对话。序列化为只读的历史记录,而不是活跃的对话。
工具结果在序列化时被截断为 2000 个字符。超出限制的内容被替换为指示截断字符数的标记。这使摘要请求保持在合理的 Token 预算内,因为工具结果(尤其是来自 read 和 bash 的)通常是上下文大小的最大贡献者。
12.11 通过扩展自定义摘要
扩展可以拦截和自定义压缩和分支摘要。
12.11.1 session_before_compact 事件
在自动压缩或 /compact 之前触发。可以取消或提供自定义摘要:
pi.on("session_before_compact", async (event, ctx) => {
const { preparation, branchEntries, customInstructions,
reason, willRetry, signal } = event;
// preparation.messagesToSummarize - 待摘要消息
// preparation.turnPrefixMessages - 分割轮次前缀(如果 isSplitTurn)
// preparation.previousSummary - 前一次压缩的摘要
// preparation.fileOps - 提取的文件操作
// preparation.tokensBefore - 压缩前的上下文 Token 数
// preparation.firstKeptEntryId - 保留消息开始位置
// preparation.settings - 压缩设置
// branchEntries - 当前分支上的所有条目
// reason - "manual"(/compact)、"threshold" 或 "overflow"
// willRetry - 中止的轮次是否在压缩后重试
// signal - AbortSignal(传给 LLM 调用)
// 取消压缩:
return { cancel: true };
// 自定义摘要:
return {
compaction: {
summary: "Your summary...",
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
usage: summaryResponse.usage, // 可选
details: { /* 自定义数据 */ },
}
};
});12.11.1.1 将消息转换为文本
import { convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent";
pi.on("session_before_compact", async (event, ctx) => {
const { preparation } = event;
// 将 AgentMessage[] 转为 Message[],然后序列化为文本
const conversationText = serializeConversation(
convertToLlm(preparation.messagesToSummarize)
);
// 使用你自己的模型生成摘要
const { summary, usage } = await myModel.summarize(conversationText);
return {
compaction: {
summary,
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
usage,
}
};
});12.11.2 session_before_tree 事件
在 /tree 导航之前触发。无论用户是否选择摘要都会触发。可以取消导航或提供自定义摘要:
pi.on("session_before_tree", async (event, ctx) => {
const { preparation, signal } = event;
// preparation.targetId - 导航目标
// preparation.oldLeafId - 当前位置(将被放弃)
// preparation.commonAncestorId - 共同祖先
// preparation.entriesToSummarize - 将被摘要的条目
// preparation.userWantsSummary - 用户是否选择摘要
// 完全取消导航:
return { cancel: true };
// 提供自定义摘要(仅在 userWantsSummary 为 true 时使用):
if (preparation.userWantsSummary) {
return {
summary: {
summary: "Your summary...",
usage: summaryResponse.usage,
details: { /* 自定义数据 */ },
}
};
}
});完整的自定义压缩示例(使用不同模型)请参阅 custom-compaction.ts。
12.12 设置
在 ~/.pi/agent/settings.json 或 .pi/settings.json 中配置压缩:
{
"compaction": {
"enabled": true,
"reserveTokens": 16384,
"keepRecentTokens": 20000
}
}| 设置 | 默认值 | 描述 |
|---|---|---|
compaction.enabled |
true |
启用自动压缩 |
compaction.reserveTokens |
16384 |
为 LLM 响应预留的 Token 数 |
compaction.keepRecentTokens |
20000 |
保留的最近 Token 数(不被摘要) |
设置 "enabled": false 可禁用自动压缩。你仍可通过 /compact 手动触发压缩。