压缩与分支总结
本文详细介绍了pi-mono项目中LLM对话的压缩与分支总结机制。压缩用于在上下文窗口超限时自动或手动(/compact)总结历史消息,保留最近Token;分支总结在切换工作分支时(/tree)生成摘要以保持上下文连续性。两者均采用结构化摘要格式(目标、约束、进度、决策等)并追踪文件操作。文章阐述了触发条件、切割点规则、压缩条目结构、累积文件跟踪、自定义扩展接口及设置配置。
概述
Pi 有两种摘要机制:
| 机制 | 触发条件 | 目的 |
|---|---|---|
| 压缩 | 上下文超过阈值,或 /compact | 总结旧消息以释放上下文空间 |
| 分支摘要 | /tree 导航 | 切换分支时保留上下文 |
两者都使用相同的结构化摘要格式,并累积跟踪文件操作。压缩和分支摘要请求使用新的路由会话 ID,并且在提供商支持的情况下,会禁用提示缓存写入,因为这些一次性提示不太可能被重复使用。
压缩
触发时机
自动压缩在以下条件满足时触发:
contextTokens > contextWindow - reserveTokens
默认情况下,reserveTokens 为 16384 个 Token(可在 ~/.pi/agent/settings.json 或 <project-dir>/.pi/settings.json 中配置)。这为 LLM 的响应预留了空间。
你也可以使用 /compact [指令] 手动触发,其中可选指令用于聚焦摘要内容。
工作原理
- 寻找截断点:从最新消息向前遍历,累计 Token 估算值,直到达到
keepRecentTokens(默认 20000,可在~/.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│
└─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┘
└────────┬───────┘ └──────────────┬──────────────┘
需要摘要的消息 保留的消息
↑
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 │
└─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┴─────┘
└──────────┬──────┘ └──────────────────────┬───────────────────┘
不发送给 LLM 发送给 LLM
↑
从 firstKeptEntryId 开始
LLM 看到的内容:
┌────────┬─────────┬─────┬─────┬──────┬──────┬─────┬──────┐
│ system │ summary │ usr │ ass │ tool │ tool │ ass │ tool │
└────────┴─────────┴─────┴─────┴──────┴──────┴─────┴──────┘
↑ ↑ └─────────────────┬────────────────┘
提示信息 来自 cmp 从 firstKeptEntryId 开始的消息
在多次压缩时,摘要范围从上次压缩的保留边界(firstKeptEntryId)开始,而不是从压缩条目本身开始。如果该保留条目在路径中找不到,则回退到上一次压缩后的条目。这样,通过在后续的摘要过程中也包含这些消息,可以保留那些在早期压缩中幸存下来的消息。Pi 还会在写入新的 CompactionEntry 之前,根据重建的会话上下文重新计算 tokensBefore,因此 Token 计数反映了被替换的实际压缩前上下文。
拆分轮次
一次“轮次”从用户消息开始,包含所有助手响应和工具调用,直到下一条用户消息。通常,压缩会在轮次边界处截断。
当单个轮次超过 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) ───────┘
└── kept (7-8)
isSplitTurn = true
messagesToSummarize = [] (之前没有完整轮次)
turnPrefixMessages = [usr, ass, tool, ass, tool, tool]
对于拆分轮次,Pi 生成两个摘要并合并:
- 历史摘要:之前的上下文(如果有)
- 轮次前缀摘要:拆分轮次的前半部分
截断点规则
有效的截断点包括:
- 用户消息
- 助手消息
- BashExecution 消息
- 自定义消息(custom_message、branch_summary)
永远不要在工具结果处截断(它们必须与对应的工具调用一起保留)。
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 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 ─ [B、C、D 的摘要]
A ───┤
└─ E ─ F (新叶子节点)
累积文件跟踪
压缩和分支摘要都会累积跟踪文件。在生成摘要时,Pi 会从以下来源提取文件操作:
- 被摘要消息中的工具调用
- 上一次压缩或分支摘要的
details(如果有)
这意味着文件跟踪会在多次压缩或嵌套分支摘要中累积,从而保留完整的已读和已修改文件历史。
BranchSummaryEntry 结构
定义于 session-manager.ts:
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(来自 branch-summarization.ts):
interface BranchSummaryDetails {
readFiles: string[];
modifiedFiles: string[];
}
与压缩相同,扩展可以在 details 中存储自定义数据。
实现见 collectEntriesForBranchSummary()、prepareBranchEntries() 和 generateBranchSummary()。
摘要格式
压缩和分支摘要使用相同的结构化格式:
## 目标
[用户试图完成的目标]
## 约束与偏好
- [用户提出的需求]
## 进展
### 已完成
- [x] [已完成的任务]
### 进行中
- [ ] [当前正在进行的工作]
### 阻塞项
- [问题,如果有的话]
## 关键决策
- **[决策]**:[理由]
## 后续步骤
1. [接下来应该做什么]
## 关键上下文
- [继续工作所需的数据]
<read-files>
path/to/file1.ts
path/to/file2.ts
</read-files>
<modified-files>
path/to/changed.ts
</modified-files>
消息序列化
在进行摘要之前,消息通过 serializeConversation() 序列化为文本:
[用户]:他们说的话
[助手思考]:内部推理
[助手]:响应文本
[助手工具调用]:read(path="foo.ts"); edit(path="bar.ts", ...)
[工具结果]:工具的输出
这可以防止模型将其视为需要继续的对话。
在序列化过程中,工具结果会被截断到 2000 个字符。超出该限制的内容会被替换为一个标记,指示被截断了多少字符。这可以使摘要请求保持在合理的 Token 预算内,因为工具结果(尤其是来自 read 和 bash 的结果)通常是上下文大小的主要贡献者。
通过扩展自定义摘要
扩展可以拦截并自定义压缩和分支摘要。事件类型定义见 extensions/types.ts。
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 - 拆分轮次的前缀(如果是拆分轮次)
// 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: { /* 自定义数据 */ },
}
};
});
将消息转换为文本
要使用自己的模型生成摘要,请使用 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)
);
// 返回:
// [用户]: 消息文本
// [助手思考]: 思考内容
// [助手]: 响应文本
// [助手工具调用]: read(path="..."); bash(command="...")
// [工具结果]: 输出文本
// 现在发送给你的模型进行摘要
const { summary, usage } = await myModel.summarize(conversationText);
return {
compaction: {
summary,
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
usage,
}
};
});
完整的示例(使用不同模型)见 custom-compaction.ts。
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 手动压缩。
- 原文链接: github.com/badlogic/pi-m...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~