上下文压缩与分支摘要
LLM 的上下文窗口是有限的。当对话过长时,Pi 会使用上下文压缩来总结较早的内容,同时保留最近的工作。本页同时涵盖自动上下文压缩(auto-compaction)与分支摘要(branch summarization)。
源文件(pi-mono):
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- 共享工具(文件追踪、序列化)packages/coding-agent/src/core/session-manager.ts- 条目类型(CompactionEntry、BranchSummaryEntry)packages/coding-agent/src/core/extensions/types.ts- 扩展事件类型
如需项目内的 TypeScript 类型定义,请查看 node_modules/@earendil-works/pi-coding-agent/dist/。
Pi 有两种摘要机制:
| 机制 | 触发方式 | 用途 |
|---|---|---|
| 上下文压缩 | 上下文超过阈值,或 /compact |
总结旧消息以释放上下文 |
| 分支摘要 | /tree 导航 |
切换分支时保留上下文 |
两者使用相同的结构化摘要格式,并累积追踪文件操作。上下文压缩与分支摘要请求使用全新的路由会话 ID,且在模型提供方支持的情况下会禁用提示词缓存写入,因为这些一次性提示词不太可能被复用。
自动上下文压缩在满足以下条件时触发:
contextTokens > contextWindow - reserveTokens默认情况下,reserveTokens 为 16384 个 token(可在 ~/.pi/agent/settings.json 或 <project-dir>/.pi/settings.json 中配置)。这为 LLM 的回复预留了空间。
你也可以手动触发:/compact [instructions],其中可选参数 instructions 用于聚焦摘要内容。
- 找到切分点:从最新的消息向前遍历,累计 token 估算值,直到达到
keepRecentTokens(默认 20k,可在~/.pi/agent/settings.json或<project-dir>/.pi/settings.json中配置) - 提取消息:收集从上一个保留边界(或会话开始)到切分点之间的消息
- 生成摘要:调用 LLM 以结构化格式生成摘要,若存在上一次的摘要则将其作为迭代上下文传入
- 追加条目:保存带有摘要和
firstKeptEntryId的CompactionEntry - 重建上下文:会话为下一次请求重建上下文,使用摘要 + 从
firstKeptEntryId开始的消息
压缩前:
entry: 0 1 2 3 4 5 6 7 8 9 ┌─────┬─────┬─────┬─────┬──────┬─────┬──── ─┬──────┬─────┬─────┐ │ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│ └─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┘ └────────┬───────┘ └──────────────┬──────────────┘ messagesToSummarize kept messages ↑ firstKeptEntryId (entry 4)
压缩后(追加新条目):
entry: 0 1 2 3 4 5 6 7 8 9 10 ┌─────┬─────┬─────┬─────┬──────┬─────┬──── ─┬──────┬─────┬─────┬─────┐ │ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│ cmp │ └─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┴─────┘ └──────────┬──────┘ └──────────────────────┬───────────────────┘ not sent to LLM sent to LLM ↑ starts from firstKeptEntryId
LLM 看到的内容:
┌────────┬─────────┬─────┬─────┬──────┬──────┬─────┬──────┐ │ system │ summary │ usr │ ass │ tool │ tool │ ass │ tool │ └────────┴─────────┴─────┴─────┴──────┴──────┴─────┴──────┘ ↑ ↑ └─────────────────┬────────────────┘ prompt from cmp messages from firstKeptEntryId在多次压缩时,被总结的区间从上一次压缩的保留边界(firstKeptEntryId)开始,而不是从压缩条目本身开始;如果在路径中找不到该保留条目,则回退到上一次压缩之后的条目。这样,在上一次压缩中幸存下来的消息会在下一次摘要中被再次纳入,从而得到保留。Pi 还会在写入新的 CompactionEntry 之前,根据重建后的会话上下文重新计算 tokensBefore,使 token 计数准确反映实际被替换的压缩前上下文。
“回合”(turn)从一条用户消息开始,包含其后的所有助手回复和工具调用,直到下一条用户消息为止。正常情况下,上下文压缩在回合边界处切分。
当单个回合超过 keepRecentTokens 时,切分点会落在回合中间的一条助手消息上。这就是“分割回合”(split turn):
分割回合(单个超大回合超出预算):
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) ───────┘ └── kept (7-8)
isSplitTurn = true messagesToSummarize = [] (before 没有完整的回合) turnPrefixMessages = [usr, ass, tool, ass, tool, tool]对于分割回合,Pi 会生成两份摘要并合并它们:
- 历史摘要:之前的上下文(如有)
- 回合前缀摘要:分割回合的早期部分
合法的切分点是:
- 用户消息
- 助手消息
- BashExecution 消息
- 自定义消息(custom_message、branch_summary)
绝不可以在工具结果处切分(它们必须与其对应的工具调用保持在一起)。
CompactionEntry 结构
Section titled “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 usage fromHook?: boolean; // 若由扩展提供则为 true(遗留字段名) details?: T; // 实现特定的数据}
// 默认压缩用以下结构作为 details(来自 compaction.ts):interface CompactionDetails { readFiles: string[]; modifiedFiles: string[];}扩展可以在 details 中存储任何可被 JSON 序列化的数据。默认压缩会追踪文件操作,但自定义扩展实现可以使用自己的结构。生成的摘要与扩展提供的摘要在可用时会存储其 LLM usage,这样会话总量会包含摘要工作的消耗。
实现参见 prepareCompaction() 与 compact()。如需直接以编程方式生成摘要,generateSummary() 返回摘要文本,generateSummaryWithUsage() 返回 { text, usage }。
当你使用 /tree 导航到不同的分支时,Pi 会提议总结你正在离开的工作。这会将离开分支的上下文注入新分支。
- 找到共同祖先:旧位置与新位置共享的最深节点
- 收集条目:从旧叶节点回溯到共同祖先
- 按预算准备:纳入直到 token 预算的消息(最新优先)
- 生成摘要:以结构化格式调用 LLM
- 追加条目:在导航点保存
BranchSummaryEntry
导航前的树:
┌─ B ─ C ─ D (旧叶节点,正在被放弃) A ───┤ └─ E ─ F (目标)
共同祖先:A要总结的条目:B、C、D
携带摘要导航后:
┌─ B ─ C ─ D A ───┤ └─ E ─ F ─ [B、C、D 的摘要] (新叶节点)累积文件追踪
Section titled “累积文件追踪”上下文压缩与分支摘要都会累积追踪文件。在生成摘要时,Pi 会从以下来源提取文件操作:
- 被摘要消息中的工具调用
- 之前的压缩或分支摘要的
details(如有)
这意味着文件追踪会跨多次压缩或嵌套的分支摘要不断累积,完整保留读取与修改文件的历史。
BranchSummaryEntry 结构
Section titled “BranchSummaryEntry 结构”定义于 session-manager.ts:
interface BranchSummaryEntry<T = unknown> { type: "branch_summary"; id: string; parentId: string; timestamp: number; summary: string; fromId: string; // 我们导航自的条目 usage?: Usage; // 生成摘要所消耗的 LLM usage fromHook?: boolean; // 若由扩展提供则为 true(遗留字段名) details?: T; // 实现特定的数据}
// 默认分支摘要用以下结构作为 details(来自 branch-summarization.ts):interface BranchSummaryDetails { readFiles: string[]; modifiedFiles: string[];}与上下文压缩相同,扩展可以在 details 中存储自定义数据。
实现参见 collectEntriesForBranchSummary()、prepareBranchEntries() 与 generateBranchSummary()。
上下文压缩与分支摘要使用相同的结构化格式:
## Goal[用户试图达成的目标]
## Constraints & Preferences- [用户提到的要求]
## Progress### Done- [x] [已完成的任务]
### In Progress- [ ] [进行中的工作]
### Blocked- [问题,如有]
## Key Decisions- **[决策]**: [理由]
## Next Steps1. [接下来应该做什么]
## Critical Context- [继续所需的数据]
<read-files>path/to/file1.tspath/to/file2.ts</read-files>
<modified-files>path/to/changed.ts</modified-files>在摘要之前,消息会通过 serializeConversation() 序列化为文本:
[User]: 用户说的话[Assistant thinking]: 内部推理[Assistant]: 回复文本[Assistant tool calls]: read(path="foo.ts"); edit(path="bar.ts", ...)[Tool result]: 工具的输出这样可防止模型将内容当作可继续的对话来处理。
序列化时,工具结果会被截断为 2000 个字符。超出该限制的内容会被替换为标记,指示被截断了多少字符。这使摘要请求保持在合理的 token 预算内,因为工具结果(尤其是来自 read 和 bash 的)通常是上下文体积的最大贡献者。
通过扩展自定义摘要
Section titled “通过扩展自定义摘要”扩展可以拦截并自定义上下文压缩与分支摘要。事件类型定义参见 extensions/types.ts。
session_before_compact
Section titled “session_before_compact”在自动上下文压缩或 /compact 之前触发。可以取消压缩或提供自定义摘要。参见类型文件中的 SessionBeforeCompactEvent 和 CompactionPreparation。
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 - 压缩后是否重试被中断的回合(overflow 恢复) // signal - AbortSignal(传给 LLM 调用)
// 取消: return { cancel: true };
// 自定义摘要: return { compaction: { summary: "你的摘要...", firstKeptEntryId: preparation.firstKeptEntryId, tokensBefore: preparation.tokensBefore, // usage: summaryResponse.usage, // 可选;计入会话总量 details: { /* 自定义数据 */ }, } };});将消息转换为文本
Section titled “将消息转换为文本”要用你自己的模型生成摘要,请使用 serializeConversation 将消息转换为文本:
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) ); // 返回: // [User]: 消息文本 // [Assistant thinking]: 思考内容 // [Assistant]: 回复文本 // [Assistant tool calls]: read(path="..."); bash(command="...") // [Tool result]: 输出文本
// 现在发送给你的模型进行摘要 const { summary, usage } = await myModel.summarize(conversationText);
return { compaction: { summary, firstKeptEntryId: preparation.firstKeptEntryId, tokensBefore: preparation.tokensBefore, usage, } };});参见 custom-compaction.ts 获取使用不同模型的完整示例。
session_before_tree
Section titled “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: "你的摘要...", // usage: summaryResponse.usage, // 可选;计入会话总量 details: { /* 自定义数据 */ }, } }; }});参见类型文件中的 SessionBeforeTreeEvent 和 TreePreparation。
在 ~/.pi/agent/settings.json 或 <project-dir>/.pi/settings.json 中配置上下文压缩:
{ "compaction": { "enabled": true, "reserveTokens": 16384, "keepRecentTokens": 20000 }}| 设置 | 默认值 | 描述 |
|---|---|---|
enabled |
true |
启用自动上下文压缩 |
reserveTokens |
16384 |
为 LLM 回复预留的 token 数 |
keepRecentTokens |
20000 |
保留的最近 token 数(不参与总结) |
将 "enabled" 设为 false 可禁用自动上下文压缩。你仍可通过 /compact 手动压缩。