Compaction 与分支摘要
Pi 把上下文压缩这件事做成了一等公民:自动触发、结构化摘要、可扩展、可手动控制。它同时承担两种角色——compaction 在「上下文快爆了」时收尾,branch summary 在「切到不同分支」时存档。两套机制共用同一套文件跟踪与摘要格式。
两种机制一个套路
| 机制 | 触发 | 目的 |
|---|---|---|
| Compaction | 上下文超阈值 或 /compact | 把旧消息摘要掉以释放空间 |
| Branch summarization | /tree 导航切分支 | 把离开的分支摘要成一段上下文 |
两者的差异是触发点和写入位置:compaction 写在「old messages → firstKeptEntry」之间,branch summary 写在「从旧 leaf 切到新 leaf」的节点上。共用同一套结构化摘要格式与文件跟踪累积机制。
何时自动触发
自动 compaction 的条件非常直白:
contextTokens > contextWindow - reserveTokens
reserveTokens默认 16384 tokens,给 LLM 响应留空间- 配置在
~/.pi/agent/settings.json或<project>/.pi/settings.json
{
"compaction": {
"enabled": true,
"reserveTokens": 16384,
"keepRecentTokens": 20000
}
}
| 字段 | 默认 | 含义 |
|---|---|---|
enabled | true | 是否启用自动 compaction |
reserveTokens | 16384 | 给 LLM 响应保留的 tokens |
keepRecentTokens | 20000 | 摘要后保留的最近 tokens |
把 enabled 设成 false 可以关掉自动 compaction,但 /compact 仍然可用。
压缩流程
Pi 的 compaction 不是一个简单的「截断前 N 条」,而是一段端到端的流程:
- 找切割点。从最新消息向前回溯,按 token 估算累加,直到累加到
keepRecentTokens(默认 20k)停止。切割点之前的是要摘要的「历史」,之后是「保留」。 - 提取消息。从「上一保留边界」到切割点之间所有 entry,构造出
messagesToSummarize。 - 生成摘要。调用 LLM,把上一段 summary 作为迭代上下文,让它生成新 summary(增量式而非每次重头)。
- 追加 CompactionEntry。包含
summary、firstKeptEntryId、tokensBefore。 - 重载 session。会话上下文用
summary + 从 firstKeptEntryId 起的消息重建。
关键设计:重复 compaction 从上一 compaction 的保留边界开始,而不是从 compaction 节点本身。这样 N 次压缩后,磁盘上其实只有「第一段历史摘要 + 第二段增量摘要 + 保留段」三块,而不是 N 段独立摘要。每次写入新 CompactionEntry 之前,Pi 从重建后的上下文重新计算 tokensBefore——这是个容易漏掉的细节,作用是「压缩后的总 token 数」始终反映真实状态。
Split Turn:单轮超过 keepRecentTokens 时
如果一个 user→assistant 轮次本身就超过 keepRecentTokens,切割点不能落在 user/tool result 上(这两种 entry 不能作为摘要边界),只能落在该轮次中间的 assistant 消息上。这就是 split turn。Pi 生成两个摘要:
- History summary:本轮之前的所有上下文
- Turn prefix summary:本轮已发生的早期部分
LLM 收到的实际输入是 [history_summary] + [turn_prefix_summary] + [本轮剩余消息]——三条摘要化的连续段拼起来。
摘要格式
Pi 把「摘要」写成了一个结构化的 schema,让 LLM 输出可被解析的字段:
## Goal
[用户目标]
## Constraints & Preferences
- [用户需求]
## Progress
### Done
- [x] [已完成]
### In Progress
- [ ] [进行中]
### Blocked
- [阻塞项]
## Key Decisions
- **[决策]**: [原因]
## Next Steps
1. [下一步]
## Critical Context
- [继续所需数据]
<read-files>
path/to/file1.ts
</read-files>
<modified-files>
path/to/changed.ts
</modified-files>
<read-files> 和 <modified-files> 是从被摘要的消息里提取的工具调用,累积跨多次 compaction。这意味着即使经过多轮压缩,最终摘要里仍然完整保留了「本会话读过/改过哪些文件」的清单——这对 coding agent 来说是高价值信息,因为模型不需要重新 grep 整个仓库就能知道已经碰过哪些路径。
消息序列化
把对话喂给 LLM 摘要前,Pi 用 serializeConversation() 把消息数组转成文本:
[User]: ...
[Assistant thinking]: ...
[Assistant]: ...
[Assistant tool calls]: read(path="..."); edit(...)
[Tool result]: ...
Tool result 截断到 2000 字符——超过部分用标记替代,避免一个超长输出把整个摘要 prompt 撑爆。
CompactionEntry 结构
interface CompactionEntry<T = unknown> {
type: "compaction";
id: string;
parentId: string;
timestamp: number;
summary: string;
firstKeptEntryId: string;
tokensBefore: number;
fromHook?: boolean; // true = 扩展生成
details?: T; // 扩展可存任意 JSON
}
interface CompactionDetails {
readFiles: string[];
modifiedFiles: string[];
}
details 字段允许扩展塞入额外数据(实际压缩用 CompactionDetails,但 schema 是开放的)。
扩展点:session_before_compact
这是最重要的自定义 hook。Pi 在执行 compaction 之前触发它,让扩展可以完全替换摘要生成:
pi.on("session_before_compact", async (event, ctx) => {
const { preparation, branchEntries, customInstructions,
reason, willRetry, signal } = event;
// preparation 字段:
// - messagesToSummarize 待摘要的消息
// - turnPrefixMessages split turn 时本轮早期部分
// - previousSummary 上一段 summary(用于增量)
// - fileOps 文件操作累积
// - tokensBefore 压缩前 token 数
// - firstKeptEntryId 保留段起始
// - settings 当前 compaction 配置
// reason: "manual" | "threshold" | "overflow"
// willRetry: 溢出恢复时是否重试
// 取消:
return { cancel: true };
// 自定义摘要:
return {
compaction: {
summary: "...",
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
details: { /* 自定义数据 */ },
}
};
});
最实用的用法是用自己的模型摘要——比如用本地小模型、或者专门的 summary 模型:
import { convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent";
pi.on("session_before_compact", async (event, ctx) => {
const { preparation } = event;
const conversationText = serializeConversation(
convertToLlm(preparation.messagesToSummarize)
);
const summary = await myModel.summarize(conversationText);
return {
compaction: {
summary,
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
}
};
});
官方在 examples/extensions/custom-compaction.ts 给出了完整例子。
分支摘要:切分支时不丢上下文
/tree 切到一个不同分支时,Pi 默认让你选:
- 不摘要
- 用默认 prompt 摘要
- 用自定义指令摘要
流程:
- 找共同祖先——新旧位置共享的最深节 点
- 收集从旧 leaf 回溯到共同祖先的 entries
- 在 token 预算内选消息(最新优先)
- 调 LLM 生成 summary
- 追加 BranchSummaryEntry 到导航点
BranchSummaryEntry 结构跟 CompactionEntry 类似:
interface BranchSummaryEntry<T = unknown> {
type: "branch_summary";
id: string;
parentId: string;
timestamp: number;
summary: string;
fromId: string;
fromHook?: boolean;
details?: T;
}
interface BranchSummaryDetails {
readFiles: string[];
modifiedFiles: string[];
}
切换后,新分支上 LLM 看到的输入 = 「旧分支的 branch_summary + 新分支的实际消息」。历史不会被丢掉,只是被压缩进了左分支——磁盘上完整保留,将来想回旧分支只需 branch() 切回去。
累积文件跟踪
两种机制都做了「累积式文件跟踪」——读/改过的文件清单从以下来源累加:
- 本次摘要消息中的 tool 调用
- 之前 compaction 或 branch summary 的
details字段
跨多次 compaction 或嵌套 branch summary 累积,最终 summary 里保留完整的 read/modified 文件历史。这避免了一个常见 bug:压缩 5 次后,模型已经忘了最初读过哪些文件,需要重新 grep。
扩展点:session_before_tree
pi.on("session_before_tree", async (event, ctx) => {
const { preparation, signal } = event;
// preparation 字段:
// - targetId /tree 目标
// - oldLeafId 当前 leaf
// - commonAncestorId 共同祖先
// - entriesToSummarize 即将离开的分支
// - userWantsSummary 是否需要摘要
// 取消导航:
return { cancel: true };
// 自定义摘要(仅 userWantsSummary 为 true 时使用):
if (preparation.userWantsSummary) {
return {
summary: {
summary: "...",
details: { /* 自定义数据 */ },
}
};
}
});
关键源码位置
| 功能 | 文件 |
|---|---|
| Auto-compaction 主逻辑 | packages/coding-agent/src/core/compaction/compaction.ts |
| Branch summarization | packages/coding-agent/src/core/compaction/branch-summarization.ts |
| 共享工具(文件跟踪、序列化) | packages/coding-agent/src/core/compaction/utils.ts |
| Entry 类型定义 | packages/coding-agent/src/core/session-manager.ts |
| 扩展事件类型 | packages/coding-agent/src/core/extensions/types.ts |
| 完整自定义示例 | examples/extensions/custom-compaction.ts |
下一章讲 Extensions。Pi 的扩展机制是整个架构的核心——所有「核心不内置但用户又能拿到」的能力,都是通过 TypeScript 扩展装配的。理解 extensions 才能理解 Pi 的设计哲学。