一、引言:为什么需要 compaction
前七篇文章讲了 pi agent 的完整核心:调 LLM、流式事件、循环、工具、hook、skills、Extension API。这些机制都建立在一个隐含前提上------context window 够用。
但 context window 是有限的。GPT-4o 是 128k token,Claude Sonnet 是 200k。听起来很大,但 agent 跑几十轮后就可能耗尽:
arduino
turn 1: user("帮我重构这个模块") → ~100 token
turn 1: assistant("好的,我先读一下文件") → ~200 token
turn 1: toolResult(read file.ts) → ~5000 token
turn 2: assistant("我来修改") → ~300 token
turn 2: toolResult(edit file.ts) → ~2000 token
...
turn 30: 累积 messages 总量 → ~100000 token
每轮调 LLM 都要把完整的 context.messages 历史发过去。agent 调的工具越多、读的文件越大,context 膨胀越快。
context rot------不只是"满了才出问题"
context window 不只是"满了会报错"------在接近上限时,LLM 的表现已经开始退化。
Anthropic 把这个现象称为 context rot 。在官方文档中明确写道:
As token count grows, accuracy and recall degrade, a phenomenon known as context rot. This makes curating what's in context just as important as how much space is available.
Chroma 的 Context Rot 技术报告量化验证了这一点------测试覆盖 18 个模型(GPT-4.1 / Claude 4 / Gemini 2.5 / Qwen3 等),无一例外:随 input token 数增加,模型从 context 中准确召回信息的能力持续下降,即使任务复杂度被控制不变。
更关键的是,Anthropic 的 prompting 最佳实践文档明确指出:
Claude may sometimes naturally try to wrap up work as it approaches the context limit.
即:模型在接近 context 上限时会自然倾向于提前收尾------主动性下降,不愿调工具、不愿探索、倾向于直接给答案而非继续工作。官方甚至给出了反制 prompt:"do not stop tasks early due to token budget concerns..."
这不是某个模型的 bug,而是所有 LLM 的共性。所以 compaction 不能等"快满了才做"------要在 context rot 明显影响表现之前就压缩。
compaction 的核心思路
- 保留最近的消息不动(最近的工作 LLM 还需要)
- 把旧消息发给 LLM 生成一段结构化摘要
- 用摘要替换掉旧消息------context 从"100 条完整消息"变成"1 条摘要 + 10 条近期消息"
这不是无损压缩------摘要会丢失细节。但 pi 的设计目标是"保留继续工作所需的关键信息"------Goal、Progress、Key Decisions、Next Steps、Critical Context。这些信息让 LLM 在 compaction 后仍然知道"我在做什么、做到哪了、接下来做什么"。
本章拆解 pi 的 compaction 机制:什么时候触发、在哪切割、怎么生成摘要、怎么持久化。这是文章 3 讲的 prepareNextTurn 钩子在真实场景里最重要的应用------compaction 就发生在 turn 结束后的间隙。
二、触发机制
compaction 不是凭空触发的------它需要判断"context 快满了"。pi 用两个函数做这个判断。
流程图
compaction 在每轮 turn 结束后检查。如果 token 超阈值,走完整压缩流程;否则继续下一轮。
1. token 估算
estimateContextTokens(compaction.ts:165-193)估算当前 context 的 token 总量:
typescript
// compaction.ts:165-193
export function estimateContextTokens(messages: AgentMessage[]): ContextUsageEstimate {
const usageInfo = getLastAssistantUsageInfo(messages); // 找最近一次 assistant 消息的 usage
if (!usageInfo) {
// 没有 usage 信息------纯字符估算(每条消息按 chars/4 估算)
let estimated = 0;
for (const message of messages) estimated += estimateTokens(message);
return { tokens: estimated, usageTokens: 0, trailingTokens: estimated, lastUsageIndex: null };
}
// 有 usage 信息------用 provider 报告的 token 数 + 后续消息的估算
const usageTokens = calculateContextTokens(usageInfo.usage); // provider 报的真实 token
let trailingTokens = 0;
for (let i = usageInfo.index + 1; i < messages.length; i++) {
trailingTokens += estimateTokens(messages[i]); // usage 之后的消息按估算
}
return { tokens: usageTokens + trailingTokens, usageTokens, trailingTokens, lastUsageIndex: usageInfo.index };
}
两层策略:
- 有 provider usage :LLM 每次回复都带
usage.totalTokens(文章 1 讲过),这是 provider 报告的真实 token 数。用这个数 + 后续消息的估算 = 当前总量。这是精确的------provider 自己算的 token 数最准。 - 没有 usage (如刚启动还没有 assistant 回复):纯字符估算------每条消息按
chars / 4估算 token 数(英文约 4 字符 1 token)。
estimateTokens(compaction.ts:220-260)按消息类型分别估算:
typescript
function estimateTokens(message: AgentMessage): number {
let chars = 0;
switch (message.role) {
case "user":
chars = estimateTextAndImageContentChars(message.content);
return Math.ceil(chars / 4);
case "assistant":
// text + thinking + toolCall 的参数
for (const block of assistant.content) {
if (block.type === "text") chars += block.text.length;
else if (block.type === "thinking") chars += block.thinking.length;
else if (block.type === "toolCall") chars += block.name.length + JSON.stringify(block.arguments).length;
}
return Math.ceil(chars / 4);
case "toolResult":
chars = estimateTextAndImageContentChars(message.content);
return Math.ceil(chars / 4);
}
}
图片消息按固定 4800 字符估算(ESTIMATED_IMAGE_CHARS = 4800)------因为图片是 base64 编码,字符数和 token 数的关系和文本不同。
2. 触发判断
shouldCompact(compaction.ts:196-199)判断要不要压缩:
typescript
export function shouldCompact(
contextTokens: number, // 当前 token 总量
contextWindow: number, // 模型的 context window(如 128000)
settings: CompactionSettings, // 压缩配置
): boolean {
if (!settings.enabled) return false;
return contextTokens > contextWindow - settings.reserveTokens;
}
逻辑很简单:当前 token 数 > context window - 预留 。预留是给模型回复留的空间------如果 context window 是 128k,reserveTokens 是 16384,那么 token 数超过 128000 - 16384 = 111616 就触发 compaction。
3. 配置
typescript
// compaction.ts:112-116
export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
enabled: true,
reserveTokens: 16384, // 给模型回复预留 16k token
keepRecentTokens: 20000, // 保留最近 20k token 不压缩
};
三个参数:
enabled:开关。关了就不压缩------长对话会撞上 context window 上限reserveTokens:给模型回复预留的空间。压缩后 context 必须留出这个空间给 LLM 生成回复keepRecentTokens:保留最近多少 token 不压缩。这部分消息原样保留,不进摘要
4. 触发时机
compaction 不在 runLoop 内部触发------它发生在 AgentHarness 层,每轮 turn 结束后检查。harness.compact()(agent-harness.ts:708-757)是一个显式调用------先发 session_before_compact 事件(Extension 可以 cancel),然后调 prepareCompaction + compact 生成摘要,最后持久化。
这意味着 compaction 不会打断正在进行的 LLM 调用或工具执行------它只在 turn 结束后、下一轮开始前的间隙做。agent 不会在工具执行到一半时突然压缩历史。
三、切割点查找
触发 compaction 后,第一个问题是:在哪切割? 保留哪些消息、压缩哪些消息?
1. 切割策略
pi 的策略是"从尾部保留最近 N token,前面的全部压缩":
sql
context.messages:
┌──────────────────────────────────────────────────────┐
│ 旧消息(压缩成摘要) │ 近期消息(保留不动) │
│ user → assistant → toolResult → ... │ user → assistant → ... │
└────────────────────────────────────────┴──────────────┘
← keepRecentTokens (20000)
但切割点不能在任意位置------必须在 turn 边界上切。一个 turn 是"user 消息 → assistant 回复 → toolResult → ... → 下一个 user 消息"的完整序列。如果从中间切断(如 assistant 和 toolResult 之间),LLM 下一轮会看到一个没有上下文的孤立 toolResult。
2. 合法切割点
伪代码(compaction.ts:261-298):
python
function findValidCutPoints(entries, startIndex, endIndex):
cutPoints = []
for entry in entries[startIndex..endIndex]:
if entry.type == "message":
switch entry.message.role:
case "user": # user 消息------新 turn 的开始,合法
case "assistant": # assistant 消息------合法
case "custom":
case "branchSummary":
case "compactionSummary":
cutPoints.push(entry.index)
case "toolResult": # toolResult 不能当切割点
# 必须和前面的 assistant(含 toolCall)连在一起
if entry.type == "branch_summary" or entry.type == "custom_message":
cutPoints.push(entry.index)
return cutPoints
关键规则:toolResult 不能当切割点。toolResult 必须和它前面的 assistant 消息(包含 toolCall)连在一起------LLM 下一轮需要看到"assistant 请求调工具 → 工具返回了什么"的完整序列。如果把 toolResult 切到摘要里、assistant 留在保留区,LLM 会看到一个请求了工具调用但没收到结果的 assistant 消息------这会让 LLM 困惑。
3. findCutPoint
伪代码(compaction.ts:329-377):
ini
function findCutPoint(entries, startIndex, endIndex, keepRecentTokens):
cutPoints = findValidCutPoints(entries, startIndex, endIndex)
if cutPoints.length == 0:
return { firstKeptEntryIndex: startIndex, isSplitTurn: false }
# 从尾部往前累积 token,直到达到 keepRecentTokens 预算
accumulatedTokens = 0
cutIndex = cutPoints[0]
for i = endIndex - 1 down to startIndex:
accumulatedTokens += estimateTokens(entries[i])
if accumulatedTokens >= keepRecentTokens:
# 达到预算------找一个 >= i 的合法切割点
cutIndex = first cutPoint where cutPoint >= i
break
# 检查切割点是不是 user 消息(turn 开始)
isUserMessage = entries[cutIndex].message.role == "user"
turnStartIndex = isUserMessage ? -1 : findTurnStartIndex(...)
return {
firstKeptEntryIndex: cutIndex,
turnStartIndex,
isSplitTurn: not isUserMessage and turnStartIndex != -1,
}
逻辑:
- 从尾部往前累积 token,直到达到
keepRecentTokens(20000)预算 - 在累积到的位置往后找最近的合法切割点
- 检查切割点是不是 user 消息(turn 开始)------如果不是,说明切在了一个 turn 的中间
4. split turn------切割点落在 turn 中间
如果切割点不是 user 消息(如切在 assistant 消息上),说明切割点落在一个 turn 的中间------这个 turn 的前半部分会被压缩,后半部分会保留。这叫 split turn:
css
turn 5: user("修改 bug")
→ assistant("我来读文件")
→ toolResult(read bug.ts)
→ assistant("我找到了 bug")
→ toolResult(edit bug.ts)
→ assistant("修好了")
↑ 切割点在这里(assistant 消息)
切割点前半部分(压缩成摘要):
user("修改 bug") + assistant("我来读文件") + toolResult(read bug.ts)
切割点后半部分(保留不动):
assistant("我找到了 bug") + toolResult(edit bug.ts) + assistant("修好了")
问题是:保留的后半部分缺少 turn 的开头------LLM 不知道这个 turn 的 user 请求是什么。pi 的解决办法是生成一个 turn prefix summary------把 turn 前半部分也摘要一下,附加到主摘要里:
ini
function compact(preparation):
if isSplitTurn and turnPrefixMessages.length > 0:
# 并行生成两段摘要
historyResult = generateSummary(messagesToSummarize) # 主历史摘要
turnPrefixResult = generateTurnPrefixSummary(turnPrefixMessages) # turn 前缀摘要
summary = historyResult + "---" + "Turn Context (split turn):" + turnPrefixResult
else:
summary = generateSummary(messagesToSummarize) # 正常摘要
两段摘要拼在一起------主摘要覆盖被压缩的完整 turn,turn prefix 摘要覆盖被切断的当前 turn 的前半部分。LLM 下一轮看到的是"历史摘要 + turn 前缀摘要 + 保留的近期消息"------完整理解上下文。
turn prefix 用的 prompt 不同------更简短,只关注"这个 turn 的前半部分做了什么":
ini
TURN_PREFIX_SUMMARIZATION_PROMPT:
## Original Request
[What did the user ask for in this turn?]
## Early Progress
- [Key decisions and work done in the prefix]
## Context for Suffix
- [Information needed to understand the retained recent work]
三个板块:Original Request(user 要什么)/ Early Progress(前半部分做了什么)/ Context for Suffix(理解后半部分需要什么信息)。比主摘要的六板块精简------因为只需要桥接一个 turn 的前后半部分,不需要覆盖完整历史。
四、摘要生成
切割点确定后,把要压缩的旧消息发给 LLM 生成摘要。这是 compaction 的核心------摘要质量直接决定 LLM 在 compaction 后还能不能正常工作。
1. 结构化 prompt
pi 用两个固定的 prompt 让 LLM 生成摘要(compaction.ts:379-414)。
system prompt------告诉 LLM 它是摘要助手,不是对话参与者:
typescript
export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant.
Your task is to read a conversation between a user and an AI coding assistant,
then produce a structured summary following the exact format specified.
Do NOT continue the conversation. Do NOT respond to any questions in the conversation.
ONLY output the structured summary.`;
三行"Do NOT"很关键------LLM 读到对话内容时可能忍不住"继续对话"或"回答问题"。system prompt 明确禁止------你只负责摘要,不负责回复。
user prompt------指定摘要的格式和内容:
typescript
const SUMMARIZATION_PROMPT = `The messages above are a conversation to summarize.
Create a structured context checkpoint summary that another LLM will use to continue the work.
Use this EXACT format:
## Goal
[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]
## Constraints & Preferences
- [Any constraints, preferences, or requirements mentioned by user]
- [Or "(none)" if none were mentioned]
## Progress
### Done
- [x] [Completed tasks/changes]
### In Progress
- [ ] [Current work]
### Blocked
- [Issues preventing progress, if any]
## Key Decisions
- **[Decision]**: [Brief rationale]
## Next Steps
1. [Ordered list of what should happen next]
## Critical Context
- [Any data, examples, or references needed to continue]
- [Or "(none)" if not applicable]
Keep each section concise. Preserve exact file paths, function names, and error messages.`;
六个板块,每个有明确职责:
Goal------用户要做什么。不知道目标就无法判断后续方向。允许多个目标(session 可能覆盖不同任务)。
Constraints & Preferences------用户提了什么限制。不能违背。如"不要用第三方库"、"测试覆盖率必须 >80%"。
Progress------做到哪了。分 Done / In Progress / Blocked 三档,用 checkbox 标记。这让 LLM 知道哪些工作已完成、避免重复。
Key Decisions------做了什么决策。保持一致性。如"用 Map 而不是 Object,因为需要保持插入顺序"------后续工作要遵循这个决策。
Next Steps------接下来做什么。直接指导 LLM 的下一步行动。
Critical Context------关键数据。文件路径、函数名、错误信息------这些硬信息不能丢失。prompt 最后一行强调"Preserve exact file paths, function names, and error messages"。
2. 增量摘要
如果之前已经做过一次 compaction(有 previousSummary),pi 不重新摘要全部历史,而是增量更新 已有摘要(compaction.ts:416-454):
typescript
const UPDATE_SUMMARIZATION_PROMPT = `The messages above are NEW conversation messages to incorporate
into the existing summary provided in <previous-summary> tags.
Update the existing structured summary with new information. RULES:
- PRESERVE all existing information from the previous summary
- ADD new progress, decisions, and context from the new messages
- UPDATE the Progress section: move items from "In Progress" to "Done" when completed
- UPDATE "Next Steps" based on what was accomplished
- PRESERVE exact file paths, function names, and error messages
- If something is no longer relevant, you may remove it
Use this EXACT format:
[same format as SUMMARIZATION_PROMPT]`;
增量摘要在 generateSummary 里选择 prompt(compaction.ts:471):
typescript
let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT;
有 previousSummary 就用增量 prompt,没有就用全量 prompt。这让多次 compaction 的摘要保持连贯------每次更新而不是重写。
3. generateSummary
generateSummary(compaction.ts:456-514)把旧消息序列化成文本,发给 LLM:
typescript
export async function generateSummary(
currentMessages: AgentMessage[],
model: Model<any>,
reserveTokens: number,
apiKey: string,
...
previousSummary?: string,
): Promise<Result<string, CompactionError>> {
const maxTokens = Math.min(Math.floor(0.8 * reserveTokens), model.maxTokens);
// 序列化对话------把 AgentMessage[] 转成纯文本
const llmMessages = convertToLlm(currentMessages);
const conversationText = serializeConversation(llmMessages);
// 构建 prompt
let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
if (previousSummary) {
promptText += `<previous-summary>\n${previousSummary}\n</previous-summary>\n\n`;
}
promptText += basePrompt;
// 调 completeSimple(文章 1 讲的同步调用)生成摘要
const response = await completeSimple(
model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: [{ role: "user", content: promptText, timestamp: Date.now() }] },
{ maxTokens, apiKey, ... },
);
// 提取文本
const textContent = response.content.filter(c => c.type === "text").map(c => c.text).join("");
return ok(textContent);
}
注意:摘要生成用的是 completeSimple------文章 1 讲的同步调用,不需要流式。摘要是一次性生成的内容,不需要打字机效果。
maxTokens 限制为 0.8 * reserveTokens------摘要最多占预留空间的 80%,留 20% 给后续追加的文件操作信息。
4. serializeConversation
serializeConversation(utils.ts:91-144)把消息序列化成纯文本------因为摘要 prompt 需要把对话作为文本发给 LLM:
typescript
export function serializeConversation(messages: Message[]): string {
const parts: string[] = [];
for (const msg of messages) {
if (msg.role === "user") {
parts.push(`[User]: ${content}`);
} else if (msg.role === "assistant") {
if (thinkingParts.length > 0) parts.push(`[Assistant thinking]: ${thinkingParts.join("\n")}`);
if (textParts.length > 0) parts.push(`[Assistant]: ${textParts.join("\n")}`);
if (toolCalls.length > 0) parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`);
} else if (msg.role === "toolResult") {
parts.push(`[Tool result]: ${truncateForSummary(content, 2000)}`); // toolResult 截断到 2000 字符
}
}
return parts.join("\n\n");
}
关键设计------toolResult 截断到 2000 字符 (TOOL_RESULT_MAX_CHARS = 2000)。toolResult 可能很长(如 read 一个大文件返回 5000 token),但摘要 prompt 不需要完整内容------只需要知道"读了什么文件、大概返回了什么"。截断防止摘要 prompt 本身爆 context。
assistant 消息分三部分序列化------thinking / text / tool calls 分开标注。这让 LLM 能区分"模型的推理过程"、"模型的回复文本"、"模型请求的工具调用"。
5. 文件操作追踪
摘要末尾追加文件操作信息(compaction.ts:697-698):
typescript
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
summary += formatFileOperations(readFiles, modifiedFiles);
extractFileOpsFromMessage(utils.ts:24-51)从 assistant 消息的 toolCall 里提取文件操作:
typescript
export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void {
for (const block of message.content) {
if (block.type !== "toolCall") continue;
const path = block.arguments.path;
switch (block.name) {
case "read": fileOps.read.add(path); break;
case "write": fileOps.written.add(path); break;
case "edit": fileOps.edited.add(path); break;
}
}
}
最终格式化成 XML 标签追加到摘要末尾:
xml
<read-files>
src/types.ts
src/utils.ts
</read-files>
<modified-files>
src/index.ts
src/handlers.ts
</modified-files>
这让 LLM 在 compaction 后知道"哪些文件读过了、哪些文件改过了"------避免重复读取,也方便定位之前的修改。
6. compact 完整流程
compact(compaction.ts:627-706)把以上步骤串起来:
css
compact(preparation, model, apiKey, ...)
│
├─ if isSplitTurn:
│ ├─ generateSummary(messagesToSummarize) → 主历史摘要
│ └─ generateTurnPrefixSummary(turnPrefixMessages) → turn 前缀摘要
│ summary = 主摘要 + "---" + turn 前缀摘要
│
├─ else:
│ └─ generateSummary(messagesToSummarize) → 摘要
│
├─ computeFileLists(fileOps) → 文件操作列表
├─ summary += formatFileOperations(readFiles, modifiedFiles)
│
└─ return { summary, firstKeptEntryId, tokensBefore, details }
五、Session 持久化
摘要生成后,需要持久化到 session 里------这样下次启动或切回这个 session 时,compaction 结果不会丢。理解持久化需要先了解 session 树的最小基础结构。
1. session 树最小基础
pi 的 session 是一棵树------通过 parentId 连成父子关系。树的根是 session 的第一条消息,叶节点是当前最新位置。而 entry 就是 session 树上的一个节点------记录"某一刻发生了什么"。它可以是用户发的一条消息、模型的一次回复、工具的一次执行结果,也可以是模型切换、工具集变更、compaction 摘要等"状态变更事件"。每条 entry 都有唯一 ID、父节点 ID 和时间戳。session 的完整对话历史就是从根到叶的一条路径上所有 entry 的序列。
所有 entry 类型共享一个基础结构(types.ts:334-339):
typescript
interface SessionTreeEntryBase {
type: string; // "message" / "compaction" / "branch_summary" / ...
id: string; // 唯一 ID
parentId: string | null; // 父节点 ID(根节点为 null)
timestamp: string; // 时间戳
}
主要 entry 类型:
| 类型 | 存什么 | 和 compaction 的关系 |
|---|---|---|
message |
user / assistant / toolResult 消息 | 被压缩的主体 |
compaction |
摘要 + firstKeptEntryId + tokensBefore | 压缩结果 |
branch_summary |
旧分支的摘要 + fromId | 切分支时的摘要(第 6 章讲) |
model_change |
provider + modelId | 记录模型切换 |
active_tools_change |
activeToolNames | 记录工具集切换 |
thinking_level_change |
thinkingLevel | 记录推理等级切换 |
custom_message |
自定义消息(extension 发的) | extension 注入的消息 |
leaf |
targetId | 标记当前叶节点(最新位置) |
getBranch()(session.ts:109-112)从叶节点往上追溯到根,返回当前路径上的所有 entry------这就是要发给 LLM 的完整对话历史。
2. CompactionEntry
compaction 结果存成一个 CompactionEntry(types.ts:362-369):
typescript
interface CompactionEntry<T = unknown> extends SessionTreeEntryBase {
type: "compaction";
summary: string; // 生成的摘要文本
firstKeptEntryId: string; // 保留的第一条消息的 ID
tokensBefore: number; // 压缩前的 token 总量
details?: T; // 文件操作列表(readFiles / modifiedFiles)
fromHook?: boolean; // 是否来自 hook 提供的自定义摘要
}
firstKeptEntryId 是关键------它标记"从这个 ID 开始的消息保留不动,之前的全部被摘要替换"。compaction entry 本身作为树上的一个节点,挂在被压缩的消息序列之后。
3. AgentHarness.compact
AgentHarness.compact(agent-harness.ts:708-757)是 compaction 的入口:
typescript
// agent-harness.ts:708-757(简化)
async compact(customInstructions?: string) {
this.phase = "compaction"; // 标记 harness 状态
// 1. 准备------找切割点、提取要压缩的消息
const branchEntries = await this.session.getBranch();
const preparationResult = prepareCompaction(branchEntries, DEFAULT_COMPACTION_SETTINGS);
const preparation = preparationResult.value;
// 2. 发 session_before_compact 事件------Extension 可以 cancel 或提供自定义摘要
const hookResult = await this.emitHook({
type: "session_before_compact",
preparation,
branchEntries,
customInstructions,
});
if (hookResult?.cancel) throw new CompactionError("cancelled");
// 3. 生成摘要------或用 hook 提供的摘要
const provided = hookResult?.compaction;
const compactResult = provided
? { ok: true, value: provided } // hook 提供了自定义摘要
: await compact(preparation, model, apiKey); // 正常生成摘要
// 4. 持久化------写入 session 树
const entryId = await this.session.appendCompaction(
result.summary,
result.firstKeptEntryId,
result.tokensBefore,
result.details,
provided !== undefined, // fromHook 标记
);
// 5. 发 session_compact 事件------通知 UI
await this.emitOwn({ type: "session_compact", compactionEntry: entry, fromHook: provided !== undefined });
this.phase = "idle";
return result;
}
五步:准备 → hook 事件 → 生成摘要 → 持久化 → 通知 UI。
session_before_compact 事件让 Extension 有两个选择:
- cancel:取消整个 compaction------如 Extension 认为当前任务不适合压缩
- 提供自定义摘要 :
hookResult.compaction------Extension 可以用自己的逻辑生成摘要,跳过 pi 的generateSummary。fromHook: true标记这个 entry 是 hook 提供的
4. context 重建------buildSessionContext
compaction 后,下一轮 LLM 调用时需要从 session 树重建 context。buildSessionContext(session.ts:22-80)负责这件事:
typescript
export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionContext {
let compaction: CompactionEntry | null = null;
// 第一遍:找最新的 compaction entry
for (const entry of pathEntries) {
if (entry.type === "compaction") compaction = entry;
}
const messages: AgentMessage[] = [];
if (compaction) {
// 有 compaction------用摘要替换旧消息
messages.push(createCompactionSummaryMessage(compaction.summary, ...));
// 从 firstKeptEntryId 开始的消息保留
const compactionIdx = pathEntries.findIndex(e => e.type === "compaction" && e.id === compaction.id);
let foundFirstKept = false;
for (let i = 0; i < compactionIdx; i++) {
if (pathEntries[i].id === compaction.firstKeptEntryId) foundFirstKept = true;
if (foundFirstKept) appendMessage(pathEntries[i]); // firstKeptEntryId 到 compaction 之间的消息保留
}
// compaction 之后的消息全部保留
for (let i = compactionIdx + 1; i < pathEntries.length; i++) {
appendMessage(pathEntries[i]);
}
} else {
// 没有 compaction------全部消息保留
for (const entry of pathEntries) appendMessage(entry);
}
return { messages, thinkingLevel, model, activeToolNames };
}
重建逻辑分三种情况:
没有 compaction:全部 entry 转成消息,按顺序放入 messages。
有 compaction:
- 先放一条
CompactionSummaryMessage------摘要作为特殊消息放在开头 - 从
firstKeptEntryId到 compaction entry 之间的消息保留------这些是 split turn 的前半部分或 compaction 保留区 - compaction entry 之后的消息全部保留------这是 compaction 之后新产生的消息
重建后的 context 长这样:
swift
context.messages = [
{ role: "compactionSummary", summary: "## Goal\n重构模块...\n## Progress\n..." }, // 摘要
{ role: "user", content: "修改 bug" }, // 保留的近期消息
{ role: "assistant", content: [...] },
{ role: "toolResult", ... },
{ role: "assistant", content: [...] },
]
从 100 条完整消息压缩成 1 条摘要 + 10 条近期消息------token 从 ~100k 降到 ~20k,腾出 80k 空间继续跑。
CompactionSummaryMessage 被 convertToLlm 转成 user 消息发给 LLM------LLM 不认识 compactionSummary role,转成 user 最安全。摘要文本以 ## Goal / ## Progress 等结构化格式呈现,LLM 能理解这是历史摘要而非当前用户指令。
六、branch summarization
compaction 是"同一个 session 分支内的压缩"。branch summarization 是"切换到另一个 session 分支时的压缩"。
1. 什么时候用
pi 的 session 是树结构------用户可以在某个 turn 分叉出新分支(如"从这个点开始换个方案试试")。切回旧分支时,旧分支的消息不在当前路径上,但 LLM 需要知道旧分支做了什么。
arduino
session 树:
turn 1 → turn 2 → turn 3 → turn 4 (分支 A)
└→ turn 3' → turn 4' (分支 B,当前)
用户从分支 B 切到分支 A:
分支 A 的 context 不含分支 B 的 turn 3'/4'
但 LLM 需要知道"分支 B 做了什么" → 生成 branch summary
2. collectEntriesForBranchSummary
切换分支时,先找出"旧分支独有"的 entry------即旧分支有、但目标分支路径上没有的 entry(branch-summarization.ts:69-98):
typescript
export async function collectEntriesForBranchSummary(
session: Session,
oldLeafId: string | null, // 旧分支的叶节点
targetId: string, // 要切到的目标节点
): Promise<CollectEntriesResult> {
if (!oldLeafId) return { entries: [], commonAncestorId: null };
// 旧分支的完整路径
const oldPath = new Set((await session.getBranch(oldLeafId)).map(e => e.id));
// 目标分支的完整路径
const targetPath = await session.getBranch(targetId);
// 找最近公共祖先------两个分支的分叉点
let commonAncestorId: string | null = null;
for (let i = targetPath.length - 1; i >= 0; i--) {
if (oldPath.has(targetPath[i].id)) {
commonAncestorId = targetPath[i].id;
break;
}
}
// 从旧叶节点往上走到公共祖先------这些是旧分支独有的 entry
const entries: SessionTreeEntry[] = [];
let current: string | null = oldLeafId;
while (current && current !== commonAncestorId) {
const entry = await session.getEntry(current);
entries.push(entry);
current = entry.parentId;
}
entries.reverse(); // 按时间正序排列
return { entries, commonAncestorId };
}
逻辑:
- 找两个分支的最近公共祖先------分叉点。公共祖先之前的消息两个分支共享,不需要摘要
- 从旧叶节点往上走到公共祖先------这段路径上的 entry 是旧分支独有的,需要摘要
- 反转成时间正序
3. prepareBranchEntries
找到要摘要的 entry 后,prepareBranchEntries(branch-summarization.ts:125-164)把它们转成消息,同时提取文件操作:
typescript
export function prepareBranchEntries(entries: SessionTreeEntry[], tokenBudget: number = 0): BranchPreparation {
const messages: AgentMessage[] = [];
const fileOps = createFileOps();
// 先从之前的 branch_summary 继承文件操作列表
for (const entry of entries) {
if (entry.type === "branch_summary" && !entry.fromHook && entry.details) {
const details = entry.details as BranchSummaryDetails;
for (const f of details.readFiles) fileOps.read.add(f);
for (const f of details.modifiedFiles) fileOps.edited.add(f);
}
}
// 从尾部往前收集消息,遵守 token 预算
for (let i = entries.length - 1; i >= 0; i--) {
const message = getMessageFromEntry(entries[i]);
if (!message) continue;
extractFileOpsFromMessage(message, fileOps);
const tokens = estimateTokens(message);
if (tokenBudget > 0 && totalTokens + tokens > tokenBudget) {
// 超预算了------但如果是 compaction/branch_summary 就尽量保留(它们是浓缩信息)
if (entries[i].type === "compaction" || entries[i].type === "branch_summary") {
if (totalTokens < tokenBudget * 0.9) {
messages.unshift(message);
totalTokens += tokens;
}
}
break;
}
messages.unshift(message);
totalTokens += tokens;
}
return { messages, fileOps, totalTokens };
}
关键设计:
- 从尾部往前收集 :如果超预算,保留最近的消息,丢弃最旧的------和 compaction 的
keepRecentTokens策略一致 - compaction/branch_summary 优先保留:如果超预算时遇到的 entry 是已有的摘要,尽量保留------摘要是浓缩信息,token 少但信息量大
- 文件操作继承 :如果旧分支里已经有 branch_summary,从它的
details里继承文件操作列表------避免丢失更早分支的文件操作记录
4. generateBranchSummary
generateBranchSummary(branch-summarization.ts:201-263)生成摘要:
typescript
export async function generateBranchSummary(
entries: SessionTreeEntry[],
options: GenerateBranchSummaryOptions,
): Promise<Result<BranchSummaryResult, BranchSummaryError>> {
const { messages, fileOps } = prepareBranchEntries(entries, tokenBudget);
if (messages.length === 0) {
return ok({ summary: "No content to summarize", readFiles: [], modifiedFiles: [] });
}
// 序列化对话 + 构建 prompt
const conversationText = serializeConversation(convertToLlm(messages));
const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${BRANCH_SUMMARY_PROMPT}`;
// 调 completeSimple 生成摘要
const response = await completeSimple(
model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: [{ role: "user", content: promptText }] },
{ apiKey, maxTokens: 2048, ... },
);
// 加前缀 + 文件操作信息
let summary = BRANCH_SUMMARY_PREAMBLE + response.content...join("\n");
summary += formatFileOperations(readFiles, modifiedFiles);
return ok({ summary, readFiles, modifiedFiles });
}
branch summary 的 prompt 和 compaction 的结构一样(六板块),但有一段前缀(branch-summarization.ts:166-168):
typescript
const BRANCH_SUMMARY_PREAMBLE = `The user explored a different conversation branch before returning here.
Summary of that exploration:
`;
这段前缀让 LLM 知道"这不是当前任务的摘要,而是用户在另一个分支探索过的内容"------LLM 可以参考但不应该把旧分支的进度当成当前进度。
maxTokens: 2048------branch summary 比 compaction summary 短。compaction 的摘要要覆盖完整历史,branch summary 只需要覆盖一个分支的探索。
5. BranchSummaryEntry
生成的摘要存成 BranchSummaryEntry(types.ts:371-377):
typescript
interface BranchSummaryEntry<T = unknown> extends SessionTreeEntryBase {
type: "branch_summary";
fromId: string; // 旧分支的叶节点 ID------标记从哪个分支摘要来的
summary: string; // 摘要文本(含前缀 + 六板块 + 文件操作)
details?: T; // 文件操作列表(readFiles / modifiedFiles)
fromHook?: boolean; // 是否来自 hook 提供的自定义摘要
}
和 CompactionEntry 的差异:
| CompactionEntry | BranchSummaryEntry | |
|---|---|---|
firstKeptEntryId |
有------保留的消息起点 | 无------整个分支都压缩 |
fromId |
无 | 有------标记从哪个分支来 |
tokensBefore |
有------压缩前 token 数 | 无 |
BranchSummaryEntry 挂在目标分支路径上------切到目标分支时,buildSessionContext 把它转成 BranchSummaryMessage 放入 messages 数组,LLM 下一轮就能看到"用户在另一个分支探索了什么"。
6. 和 compaction 的对比
| compaction | branch summarization | |
|---|---|---|
| 触发 | context token 超阈值 | 切换 session 分支 |
| 压缩范围 | 当前分支的旧消息 | 被切走的分支的独有消息 |
| 保留近期 | 是(keepRecentTokens) | 否(整个分支都压缩) |
| 摘要格式 | 六板块结构化 prompt | 相同(加前缀) |
| 持久化 | CompactionEntry | BranchSummaryEntry |
| prompt | SUMMARIZATION_PROMPT | BRANCH_SUMMARY_PROMPT(类似但带前缀) |
| maxTokens | 0.8 × reserveTokens | 2048(更短) |
两者共用 SUMMARIZATION_SYSTEM_PROMPT、serializeConversation、estimateTokens、文件操作追踪------摘要生成逻辑一样,只是触发场景、压缩范围和持久化结构不同。
七、Q&A
Q1:compaction 会丢失信息吗?丢失了怎么办?
会丢失。compaction 是有损压缩------摘要不可能完整保留所有细节。
pi 的设计策略是"保留继续工作所需的关键信息":Goal / Progress / Key Decisions / Next Steps / Critical Context。这些是 LLM 继续工作的最小必要信息。
丢失的主要是:
- toolResult 的完整内容 :
serializeConversation把 toolResult 截断到 2000 字符。如 read 返回的完整文件内容会丢失------但摘要里会保留"读了 src/types.ts"这个信息,LLM 需要时可以重新 read - 对话的语气和措辞:摘要只保留事实,不保留用户的原始措辞
- 中间尝试:如果 LLM 尝试了方案 A 失败再换方案 B,摘要可能只保留"方案 B 成功",不保留"方案 A 失败的详细原因"
如果 LLM 在 compaction 后发现缺少信息,它可以重新调工具获取------如重新 read 文件、重新 grep。compaction 丢的是"历史快照",不是"信息源"------文件还在磁盘上,随时可以重新读。
Q2:compaction 之后 LLM 的表现会变差吗?
会有一点。compaction 后 LLM 看到的 context 从"完整历史"变成"摘要 + 近期消息"。摘要不如完整历史详细------LLM 可能:
- 重复已做过的工作(如重新读一个已经读过的文件)------但文件操作列表(
<read-files>/<modified-files>)能减少这种情况 - 对早期决策的理解不够精确------但 Key Decisions 板块保留了决策和理由
- 丢失用户的具体偏好------Constraints & Preferences 板块尽量保留,但可能不完整
pi 的 keepRecentTokens: 20000 保证了最近的工作完整保留------当前任务的上下文不会丢。丢的是几十轮前的早期上下文,那些通常对当前工作影响较小。
Q3:可以手动触发 compaction 吗?
可以。AgentHarness.compact() 是公开方法,任何时候都可以调。pi 也提供了 /compact 命令让用户手动触发。
手动触发的典型场景:
- 用户知道接下来要做大任务,提前压缩腾空间
- 用户感觉 agent 回复变慢了(context 太大导致 LLM 响应慢),手动压缩
- 用户切完分支后想压缩旧分支信息
手动 compaction 和自动 compaction 走完全相同的流程------只是触发者不同。
Q4:compaction 和 branch summarization 能同时发生吗?
理论上能,但 pi 不会让它们同时发生------两者都在 harness.phase !== "idle" 时被拒绝(compact() 开头检查 if (this.phase !== "idle") throw "busy")。
实际场景中它们是串行的:切分支时先做 branch summarization,然后在新分支上如果 token 超阈值再做 compaction。两者不会同时运行。
Q5:如何替换默认的 compaction 策略?需要实现什么?
通过 Extension API 的 session_before_compact 事件。回顾第 5 章讲的 AgentHarness.compact(agent-harness.ts:723-734):
typescript
// 2. 发 session_before_compact 事件------Extension 可以 cancel 或提供自定义摘要
const hookResult = await this.emitHook({
type: "session_before_compact",
preparation,
branchEntries,
customInstructions,
});
if (hookResult?.cancel) throw new CompactionError("cancelled");
// 3. 生成摘要------或用 hook 提供的摘要
const provided = hookResult?.compaction;
const compactResult = provided
? { ok: true, value: provided } // hook 提供了自定义摘要
: await compact(preparation, model, apiKey); // 正常生成摘要
Extension 需要做两件事:
1. 注册 session_before_compact handler:
typescript
export default function (pi: ExtensionAPI) {
pi.on("session_before_compact", async (event, ctx) => {
const { preparation, customInstructions } = event;
// 拿到 preparation------包含 messagesToSummarize / turnPrefixMessages / fileOps 等
// 用自己的逻辑生成摘要
const mySummary = await myCustomSummarizer(preparation.messagesToSummarize, customInstructions);
// 返回自定义摘要------pi 跳过 generateSummary,直接用这个
return {
compaction: {
summary: mySummary,
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
details: { readFiles: [...], modifiedFiles: [...] },
},
};
});
}
2. 返回 compaction 字段:
handler 返回 { compaction: CompactionResult },pi 跳过默认的 generateSummary + compact 调用,直接用 Extension 提供的摘要。fromHook: true 标记存入 CompactionEntry。
也可以选择 cancel:
typescript
pi.on("session_before_compact", async (event, ctx) => {
// 某些条件下取消 compaction
if (shouldNotCompact(event.preparation)) {
return { cancel: true };
}
return undefined; // 不 cancel 也不提供自定义摘要------走默认流程
});
需要实现什么:
| 如果你想 | 需要实现什么 |
|---|---|
| 完全替换摘要策略 | 自己的 summarizer 函数 + 返回 { compaction: { summary, firstKeptEntryId, tokensBefore, details } } |
| 只修改 prompt | 不需要 hook------AgentHarness.compact(customInstructions) 接受 customInstructions 参数,追加到默认 prompt |
| 取消 compaction | 返回 { cancel: true } |
| 替换切割策略 | 不能------findCutPoint 在 hook 之前就执行了。preparation 是已切好的结果。如果要改切割,得 override AgentHarness 本身 |
关键限制:hook 拿到的是 preparation(已切好的结果),不是原始 entry 列表 。切割策略(findCutPoint / keepRecentTokens / split turn 检测)在 hook 之前执行,hook 不能改切割点------只能改摘要内容。
Q6:是否可以通过 Extension API 仅替换 SUMMARIZATION_SYSTEM_PROMPT?如何做?
可以,但不能只换 system prompt 不换 user prompt------得整体返回 compaction。
回顾 generateSummary(compaction.ts:496-499):
typescript
const response = await completeSimple(
model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
completionOptions,
);
SUMMARIZATION_SYSTEM_PROMPT 是硬编码的常量,没有参数可以覆盖它。AgentHarness.compact(customInstructions) 的 customInstructions 只追加到 user prompt 末尾(generateSummary 里 basePrompt = basePrompt + "\n\nAdditional focus: " + customInstructions),不碰 system prompt。
所以只换 system prompt 有两条路径:
路径 1:用 session_before_compact hook 整体替换
注册 handler,在里面用自己的 system prompt 调 completeSimple:
typescript
export default function (pi: ExtensionAPI) {
pi.on("session_before_compact", async (event, ctx) => {
const { preparation } = event;
const { messagesToSummarize, firstKeptEntryId, tokensBefore, fileOps, settings } = preparation;
// 用自己的 system prompt + pi 默认的 user prompt
const conversationText = serializeConversation(convertToLlm(messagesToSummarize));
const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${SUMMARIZATION_PROMPT}`;
const response = await completeSimple(
ctx.model,
{
systemPrompt: "你是一个中文摘要助手。用中文生成结构化摘要。", // ← 自定义 system prompt
messages: [{ role: "user", content: promptText, timestamp: Date.now() }],
},
{ apiKey: ctx.apiKey, maxTokens: Math.floor(0.8 * settings.reserveTokens) },
);
const summary = response.content.filter(c => c.type === "text").map(c => c.text).join("");
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
const finalSummary = summary + formatFileOperations(readFiles, modifiedFiles);
return {
compaction: {
summary: finalSummary,
firstKeptEntryId,
tokensBefore,
details: { readFiles, modifiedFiles },
},
};
});
}
但这意味着要自己调 completeSimple + 自己拼接 user prompt + 自己追加文件操作信息 ------不能只换 system prompt 让 pi 帮你干其余的活。因为 pi 的 generateSummary 是一个整体函数,不拆分成"system prompt / user prompt / LLM 调用 / 后处理"四个可独立替换的步骤。
路径 2:用 customInstructions 间接影响(不替换 system prompt)
如果只是想加一些额外指令(如"用中文"),用 customInstructions 更简单:
typescript
// 通过 AgentHarness API 调用时传
harness.compact("用中文生成摘要");
customInstructions 会追加到 user prompt 末尾("Additional focus: 用中文生成摘要"),LLM 会遵守。这不替换 system prompt,但能达到类似效果------大部分场景够用。
总结:
| 需求 | 怎么做 | 代价 |
|---|---|---|
| 只加额外指令 | customInstructions 参数 |
无------走默认流程 |
| 替换 system prompt | session_before_compact hook + 自己调 completeSimple |
要自己实现整个摘要生成流程 |
| 替换 user prompt | 同上 | 同上 |
| 完全替换摘要策略 | 同上 | 同上 |
pi 没有提供"只换 system prompt"的细粒度接口------generateSummary 是一个不可拆分的整体。如果要改任何一部分,就得整体接管。
八、下一章预告
下一篇文章将进入 pi 的 session 管理------session 树的完整结构、分支 / fork / switch 操作、JSONL 持久化格式、session 恢复机制。本章第 5 节讲了 session 树的最小基础(entry 类型 + getBranch + buildSessionContext),下一篇展开完整的树管理------怎么从任意节点分叉、怎么在分支间切换、session 怎么存成 JSONL 文件、启动时怎么恢复。这是 compaction 和 branch summarization 的基础设施。