上下文管理:压缩与分支摘要
最后更新: 2026年8月17日
$ pi –compact
TL;DR。 Pi 在对话变长时跑两套摘要机制:自动压缩 在 contextTokens > contextWindow - reserveTokens 时触发,把较早的消息总结成一条 CompactionEntry,并保留最近的 keepRecentTokens(默认分别为 16384 与 20000);分支摘要 在 /tree 切分支时触发,从旧叶回溯到公共祖先,把总结追加为新叶上的 BranchSummaryEntry。两者共用同一套结构化摘要(Goal / Constraints & Preferences / Progress / Key Decisions / Next Steps / Critical Context + <read-files> / <modified-files>),并在多次压缩之间累计文件操作。扩展可通过 session_before_compact 与 session_before_tree 事件拦截其中任一流程。压缩与分支摘要请求使用全新的路由 session id,并在 provider 支持时关闭 prompt-cache 写入——这些是一次性提示,不会被复用。
本指南是官方 Compaction 文档的深度配套。它把 packages/coding-agent/docs/compaction.md 与 packages/coding-agent/src/core/compaction/ 中的实现规则汇总到一处,方便一次性读完。上游文档如有变更,本指南会滞后——动手前请始终核对官方版本。
何时触发压缩
自动压缩在会话 token 数超过上下文窗口减去预留量时触发:
contextTokens > contextWindow - reserveTokens
reserveTokens 默认 16384,是留给 LLM 响应的余量;可在 ~/.pi/agent/settings.json 或 <project-dir>/.pi/settings.json 中调整。任何时候都可以用 /compact [instructions] 手动触发——可选的 instructions 用于聚焦摘要方向(「聚焦测试失败」「保留 API 契约」「压缩 agent 的探索过程但保留每一条用户决策」)。
压缩也可以在会话中途作为 溢出恢复 触发:单次请求即将超过上下文窗口时,被中止的回合在压缩之后被重试(session_before_compact 事件上的 willRetry: true)。
压缩如何遍历历史
阈值触发后,pi 对会话条目跑下面这套流程:
- 找切点。 从最新条目往回遍历,累加 token 估算,直到累计
keepRecentTokens(默认 20000)。 - 抽取待摘要消息。 收集从会话起点(或上一次压缩的
firstKeptEntryId)到切点之间的所有条目。 - 生成结构化摘要。 用下面的格式调用 LLM;如已有上一次摘要,作为迭代上下文传入。
- 追加
CompactionEntry。 写入 summary、firstKeptEntryId与tokensBefore。 - 为下一条请求重建上下文。 会话重建为「system prompt + summary + 从 firstKeptEntryId 起的后续条目」。
重复压缩时,「待摘要区间」的起点是上一次压缩的 kept boundary,而不是上一次压缩条目本身;如果那个 kept 条目已经不在当前路径上,则回退到上一次压缩之后的条目。这样设计是为了把上一次保留下来的消息继续纳入下一次摘要,而不是把它们当成已经处理过的内容丢弃。Pi 还会基于重建后的会话上下文重新计算 tokensBefore,再写入新的 CompactionEntry,所以 token 数始终反映「正在被替换掉的真实前置上下文」。
切点规则
合法的切点是用户消息、assistant 消息、BashExecution 消息、自定义消息(custom_message、branch_summary)。绝不能在工具结果处切——它们必须和触发它们的工具调用放在一起,否则 LLM 会看到一条没有返回结果的工具调用。
切分回合
正常情况下压缩在回合边界处切(用户消息及其后到下一条用户消息之间的全部内容)。当单个回合本身就超过 keepRecentTokens 时,切点落在回合内部的 assistant 消息上,该条目被标记 isSplitTurn: true。对切分回合,pi 会生成两份摘要并合并:
- 历史摘要:覆盖回合之前的上下文(若有)。
- 回合前缀摘要:覆盖被切分的回合的前半段。
合并结果作为单条 CompactionEntry 的 summary 写入。
结构化摘要格式
压缩与分支摘要共用同一套格式。摘要块用 markdown 围栏包裹;read-files 与 modified-files 用尖括号标签分隔,以便 pi 栈的后续环节能够确定性地抽取:
## 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>
在消息交给 LLM 之前,serializeConversation() 会先把它们压平成文本:
[User]: 用户的话
[Assistant thinking]: 内部推理
[Assistant]: 回复正文
[Assistant tool calls]: read(path="foo.ts"); edit(path="bar.ts", ...)
[Tool result]: 工具输出
这样能防止模型把摘要提示当成一段对话继续往下写。序列化期间,工具结果会被截断到 2000 字符;超过的部分用一段标记替换,告知实际截断长度。截断是必要的:工具结果(尤其是 read 与 bash)通常是上下文的最大来源,加上限才能保证摘要请求本身不超出预算。
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:
interface CompactionDetails {
readFiles: string[];
modifiedFiles: string[];
}
details 是开放的——扩展可以在里面存任何 JSON 可序列化的负载。默认实现按累加方式跨压缩追踪文件操作:生成下一份摘要时,pi 既会从被摘要的消息里抽工具调用得到的文件操作,也会与上一次压缩的 details.readFiles / details.modifiedFiles 合并。内置实现和扩展实现的摘要都会写入 LLM usage,让会话的累计统计包含摘要工作的开销。
/tree 上的分支摘要
/tree 打开会话树导航器。当你切到另一分支时,pi 会询问是否要把放弃的一侧总结成摘要;若同意,会执行:
- 找到旧叶与目标之间最深的公共祖先。
- 从旧叶往回走到祖先。
- 在 token 预算内(按时间倒序)准备要总结的条目。
- 用同样的结构化格式调用 LLM。
- 在新叶上追加
BranchSummaryEntry——每次切分支只生成一条,而不是每条分支一条。
分支摘要挂在切点位置,这样新叶就能拿到被放弃一侧的上下文,而不必沿着树回走:
切分支前:
┌─ B ─ C ─ D(要放弃的旧叶)
A ───┤
└─ E ─ F(目标)
带摘要的切分支后:
┌─ B ─ C ─ D
A ───┤
└─ E ─ F ─ [B、C、D 的摘要](新叶)
BranchSummaryEntry 与 CompactionEntry 结构相似,但用 fromId(切出去的源条目)替代 firstKeptEntryId,并复用同样的 details 形状(readFiles / modifiedFiles),这样累计文件追踪在嵌套分支摘要里也能正常工作。
通过扩展自定义摘要
扩展可以拦截其中任一流程。两个事件互相独立,都在对应动作之前触发。
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 - compaction 配置块
// branchEntries - 当前分支上的全部条目(用于自定义状态)
// reason - "manual"(/compact)、"threshold" 或 "overflow"
// willRetry - 被中止的回合是否在压缩后重试
// signal - AbortSignal,传递给 LLM 调用
return { cancel: true };
// 或返回自定义摘要:
// return {
// compaction: {
// summary: "你的摘要...",
// firstKeptEntryId: preparation.firstKeptEntryId,
// tokensBefore: preparation.tokensBefore,
// usage, // 可选;纳入会话累计统计
// details: { /* 自定义数据 */ },
// },
// };
});
如果用其他模型生成摘要,先把准备好的消息转成文本:
import { convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent";
const conversationText = serializeConversation(
convertToLlm(preparation.messagesToSummarize),
);
const { summary, usage } = await myModel.summarize(conversationText);
return {
compaction: { summary, firstKeptEntryId: preparation.firstKeptEntryId, tokensBefore: preparation.tokensBefore, usage },
};
完整的「用别的模型做压缩」示例见 examples/extensions/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, // 可选
details: { /* 自定义数据 */ },
},
};
}
});
配置项
在 ~/.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 不受影响。当前的 settings schema 还没有单独的 branchSummary.* 块——分支摘要沿用压缩配置,未来若新增每分支覆写项会落到这里。
调参经验法则:如果任务经常中途撞穿模型窗口,提高 keepRecentTokens 让 agent 保留更多原始历史,摘要覆盖的范围就更小;如果摘要丢失关键细节,提高 reserveTokens 让压缩更早触发(每次待摘要的上下文更少,LLM 有更多空间写得详尽);如果想可控成本,把 reserveTokens 调低,让压缩刚好踩点触发,再按「摘要 + 保留窗口」估算会话预算。
压缩与 prompt cache
压缩与分支摘要请求使用 全新的路由 session id,并在 provider 支持时对这次摘要调用关闭 prompt-cache 写入。原因在于:这些是一次性提示——不属于任何用户可见的对话轨迹,缓存它们只会占用 cache slot 而没有命中率来回收成本。如果你的扩展用 session_before_compact 调别的模型,同理:传一个新的 session id,并考虑在摘要请求里关闭 prompt-cache 写入。
延伸阅读
- Compaction——本指南所基于的官方上游文档
- Sessions and history——会话条目类型与存储;与压缩一起构成会话生命周期
- Extensions——事件面,包括
session_before_compact与session_before_tree - Settings——
compaction.*配置块的位置与覆写 packages/coding-agent/src/core/compaction/compaction.ts——具体实现