Pi Compaction:上下文压缩的完整流程

上一篇 Pi Architecture 里讲到 Compaction 时,只说了"上下文太长就总结一下"。但真正去看文档会发现,这件事比想象中讲究得多——从哪里下刀、下刀之后怎么重建、连续压缩好几次会不会把早期信息丢干净,每一步都有明确的设计。

这篇就把这个过程完整拆一遍。


0. 先分清两套机制

Pi 里其实有两套总结机制,容易混:

机制 触发时机 目的
Compaction 上下文超阈值,或手动 /compact 总结旧消息,腾出上下文
Branch Summarization /tree 切换分支时 切branch 时保住上下文

前者是"时间轴上往前压",后者是"从另一条分支跳过来时,把那条分支上发生过什么带过来"。两者的产物结构几乎一样,但触发路径完全不同。

先讲 Compaction。


1. 什么时候触发?

判断条件只有一行:

contextTokens > contextWindow - reserveTokens

reserveTokens 默认 16,384,可配置。它的含义是给模型回复预留的缓冲——不能等到上下文刚好填满才压,那时候模型连回答的空间都没有了。

检查发生在四个时机:

1. 多轮 agent run 中,tool result 被追加之后
2. 新的 user prompt 之前
3. low-level agent run 结束之后
4. 手动 /compact [instructions]

注意第 1 条:工具结果追加之后立刻检查。这是最容易爆上下文的地方——一次 read 读进来一个大文件,或者一段 bash 输出几千行,上下文可能瞬间翻倍。


2. 完整的五步

整个过程是这样:

┌─────────────────────────┐
│ Step 1 找切点 │
│ 从最新消息往回走 │
│ 累加 token 估算 │
│ 直到够 keepRecentTokens │
└───────────┬─────────────┘
▼
┌─────────────────────────┐
│ Step 2 取出待总结消息 │
│ 上次保留边界 → 切点 │
└───────────┬─────────────┘
▼
┌─────────────────────────┐
│ Step 3 生成摘要 │
│ 调 LLM,结构化格式 │
│ 把上一份摘要一起传进去 │
└───────────┬─────────────┘
▼
┌─────────────────────────┐
│ Step 4 追加 Entry │
│ summary │
│ + firstKeptEntryId │
└───────────┬─────────────┘
▼
┌─────────────────────────┐
│ Step 5 重建上下文 │
│ 摘要 + firstKeptEntryId │
│ 之后的消息 │
└─────────────────────────┘

逐个说。

Step 1:找切点

从最新的消息往回走,一路累加 token 估算,直到攒够 keepRecentTokens(默认 20,000,可配置)为止。走到的那个位置就是切点。

方向很重要:是倒着走,不是正着走。因为要保证的是"最近 2 万 token 原样留着",而不是"前面多少 token 被压掉"。

Step 2:取出待总结的消息

范围是:上一次保留边界 → 这次的切点。

这里有个关键细节,也是我觉得最值得注意的一点:

重复压缩时,被总结的区间从上一次压缩的保留边界(firstKeptEntryId)开始,而不是从上一条 compaction entry 开始。

差别在哪?如果从 compaction entry 开始,那么上一次"保留下来没被压"的那 2 万 token,这次就会被重新扫一遍——不会丢,但会重复劳动。从 firstKeptEntryId 开始,区间是严格衔接的,每条原始消息只会被总结进摘要一次。

Step 3:生成摘要

调 LLM,用结构化的格式(下面第 4 节会贴完整模板),并且把上一份摘要作为上下文一起传进去。

所以摘要是迭代累积的,不是每次从头重写:

第 1 次压缩:  [原始消息 A..J]            → Summary₁
第 2 次压缩: Summary₁ + [原始消息 K..T] → Summary₂
第 3 次压缩: Summary₂ + [原始消息 U..Z] → Summary₃

这样早期的目标、约束、关键决定能一路传下去,不会因为压了三四轮就把最初的需求忘掉。当然代价是有损——摘要的摘要,细节会逐层衰减。

Step 4:追加 CompactionEntry

写进去的核心是两样东西:摘要本身,和 firstKeptEntryId(从哪条开始是原样保留的)。

还有个小细节:tokensBefore 是从重建后的 session 上下文重新算的,然后才写进新的 entry,保证这个数字反映的是压缩前的真实上下文大小。

Step 5:重建上下文

session 用「摘要 + firstKeptEntryId 之后的消息」重建。

压缩前:  [ A B C D E F G H I J | K L M N ]
↑ 切点

压缩后: [ Summary | K L M N ]

3. 哪些位置可以切?

不是随便哪儿都能下刀。合法切点只有四类:

  • User messages
  • Assistant messages
  • BashExecution messages
  • Custom messages(custom_message、branch_summary)

绝对不能在 tool result 处切——工具结果必须和它对应的 tool call 待在一起。

原因很直接:如果切点落在 tool call 和 tool result 中间,重建出来的上下文里就会出现一个"发起了工具调用但永远没有结果"的悬空调用。大部分模型 API 会直接报错,就算不报错,模型也会困惑。

Split turn:一轮就超预算怎么办

有一种情况躲不掉——单独一轮对话本身就超过了 keepRecentTokens。比如一轮里读了十个大文件。这时候倒着走 2 万 token 还没走出这一轮,切点只能落在这一轮内部的某条 assistant message 上,形成 split turn。

Pi 的处理是生成两份摘要:

┌──────────────────────────────┐
│ 之前的历史 │ → 摘要 1(history summary)
├──────────────────────────────┤
│ 这一轮的前半段 │ → 摘要 2(turn prefix summary)
│ ── 切点 ── │
│ 这一轮的后半段(原样保留) │
└──────────────────────────────┘
↓
两份摘要合并后写进
同一个 CompactionEntry

状态上用 isSplitTurn: true 标记,turnPrefixMessages 存的是从这一轮开始到切点之间的消息。


4. 摘要长什么样

Compaction 和 Branch Summarization 用的是同一套模板:

## Goal
[User's objective]

## Constraints & Preferences
- [Requirements]

## Progress
### Done
- [x] [Completed]
### In Progress
- [ ] [Current work]
### Blocked
- [Issues]

## Key Decisions
- **[Decision]**: [Rationale]

## Next Steps
1. [What's next]

## Critical Context
- [Essential data]

<read-files>
path/to/file1.ts
</read-files>

<modified-files>
path/to/changed.ts
</modified-files>

这个模板的取舍挺明显:它保的是目标、约束、决定和下一步,丢的是过程细节。换句话说,压缩之后 agent 记得"我要干什么、我决定了怎么干、我干到哪儿了",但不记得"当时那个函数第 37 行长什么样"。

最后两个标签是文件追踪,单独说。


5. 消息是怎么变成文本的

送去总结之前,消息先经 serializeConversation() 拍平成纯文本:

[User]: message text
[Assistant thinking]: reasoning
[Assistant]: response
[Assistant tool calls]: read(path="foo.ts"); edit(path="bar.ts", ...)
[Tool result]: output

其中 tool result 截断到 2,000 字符,超出部分打截断标记。

这一步其实是压缩成本的关键。不截断的话,光是把待总结的消息喂给总结模型这一下,本身就可能又炸一次上下文——你要压缩的东西,正是因为太大才需要压缩。


6. 累积文件追踪

摘要里那两个 <read-files> / <modified-files> 标签,来源有两处:

1. 被总结消息里的 tool calls
2. 上一份 compaction / branch summary 的 details

第 2 条是重点:文件列表是跨压缩累积的。压了五轮之后,前四轮碰过的文件路径仍然在列表里。

这算是对"摘要有损"的一个补救——正文细节会衰减,但"我碰过哪些文件"这个索引一直是完整的。agent 真要用到,重新 read 一次就行。

对应的结构:

interface CompactionDetails {
readFiles: string[];
modifiedFiles: string[];
}

7. CompactionEntry 的完整结构

interface CompactionEntry<T = unknown> {
type: "compaction";
id: string;
parentId: string;
timestamp: number;
summary: string;
firstKeptEntryId: string;
tokensBefore: number;
usage?: Usage;
fromHook?: boolean;
details?: T;
}

几个字段值得留意:

  • parentId —— session 是树,每条 entry 都挂在父节点上
  • firstKeptEntryId —— 重建上下文的锚点,也是下次压缩的起点
  • tokensBefore —— 压缩前的上下文大小,用来观察压缩效果
  • fromHook —— 这份摘要是不是扩展提供的(见第 10 节)

8. Branch Summarization

/tree 切分支时触发,五步:

1. 找到最深的公共祖先节点
2. 从旧 leaf 往回收集到祖先为止的 entries
3. 按 token 预算纳入消息(从新到旧)
4. 调 LLM 生成摘要
5. 在导航落点追加 BranchSummaryEntry

第 3 步的顺序是从新到旧——预算不够时,先牺牲老的。这和 Compaction 倒着走找切点是同一个思路:近的比远的重要。

interface BranchSummaryEntry<T = unknown> {
type: "branch_summary";
id: string;
parentId: string;
timestamp: number;
summary: string;
fromId: string; // 从哪个节点跳过来的
usage?: Usage;
fromHook?: boolean;
details?: T;
}

和 CompactionEntry 的差别就一个字段:firstKeptEntryId 换成了 fromId。因为语义不同——压缩是"从这里往后原样保留",分支总结是"我从那边过来的"。


9. 配置

配置文件在 ~/.pi/agent/settings.json 或 <project-dir>/.pi/settings.json:

{
"compaction": {
"enabled": true,
"reserveTokens": 16384,
"keepRecentTokens": 20000
}
}
配置项 默认值 作用
enabled true 是否开启自动压缩
reserveTokens 16384 给回复留的 token 缓冲
keepRecentTokens 20000 原样保留的最近 token 量

按模型覆盖

{
"compaction": {
"modelOverrides": {
"provider/modelId": {
"reserveTokens": 400000
}
}
}
}

key 是精确的 provider/modelId 字符串。回退顺序是:

模型覆盖  →  普通设置  →  内置默认

而且每个配置项独立回退——你只覆盖 reserveTokens,keepRecentTokens 仍然走普通设置或默认值,不需要整块重写。

上面那个 400000 的例子也点出了为什么需要按模型配:百万级上下文的模型,留 16k 缓冲太小了。


10. 扩展钩子

三个:

pi.on("session_before_compact", async (event, ctx) => {
// 可访问:preparation.messagesToSummarize、turnPrefixMessages、
// previousSummary、fileOps、tokensBefore、firstKeptEntryId、settings
// reason: "manual" | "threshold" | "overflow"
// willRetry: 压缩之后被中断的这一轮要不要重试

return { cancel: true }; // 或者自己提供一份 summary
});
pi.on("session_compact_failed", async (event, ctx) => {
const { reason, errorMessage, aborted, willRetry, fromExtension } = event;
});
pi.on("session_before_tree", async (event, ctx) => {
const { preparation, signal } = event;
// preparation.targetId、oldLeafId、commonAncestorId、entriesToSummarize
return { cancel: true }; // 或者自己提供一份 summary
});

session_before_compact 的能力其实很大:它能拿到待总结的原始消息,也能直接返回一份自己的 summary 顶替掉默认流程。想换个摘要模板、想接自己的总结模型、想在压缩前把某些内容强行保下来,都是在这儿做。

reason 的三个值

"manual"     用户敲了 /compact
"threshold" 超过阈值,正常触发
"overflow" 跑到一半上下文溢出了

overflow 这条会带 willRetry: true——意思是这次压缩属于救场而不是打断,被中断的那一轮压缩完之后会重试。这个区分对做遥测有用:threshold 是正常运转,overflow 说明预留缓冲设小了。


11. 一个容易忽略的细节

文档里提了一句,我觉得挺有意思:

compaction 和 branch summary 的请求使用全新的 routing session ID,并且禁用 prompt cache 写入。

理由是这类一次性 prompt 基本不会被复用,写进缓存是白花钱。

这种地方不影响功能,但能看出成本意识已经渗进实现细节里了。Agent 的账单不只是主循环那几次调用——压缩本身也是要调模型的,而且压的越频繁调的越多。


12. 小结

把整个 Compaction 串起来:

             上下文增长
│
▼
contextTokens > contextWindow - reserveTokens ?
│
┌──────┴──────┐
No Yes
│ │
继续 ▼
倒着走 keepRecentTokens
找切点
│
(落在 tool result 上?
不行,继续往前找)
│
┌──────┴──────┐
正常切点 切在turn 内部
│ │
│ split turn
│ 两份摘要
└──────┬──────┘
▼
上次 firstKeptEntryId → 切点
送去总结
(带上一份摘要)
│
▼
CompactionEntry
summary + firstKeptEntryId
│
▼
摘要 + 切点之后的原始消息
重建上下文

几个我觉得值得抄的设计:

  1. 预留缓冲而不是压到满 —— reserveTokens 保证模型永远有回话的空间
  2. 倒着找切点 —— 保的是"最近多少",不是"压掉多少"
  3. 切点有合法性约束 —— tool call / tool result 必须成对,这是正确性问题不是优化问题
  4. 摘要迭代传递 —— 上一份摘要是下一次的输入,早期目标不会掉
  5. 文件列表跨压缩累积 —— 正文有损,但索引无损
  6. 压缩本身的成本也管 —— tool result 截断 2000 字符、禁用 cache 写入

第 3 点尤其值得强调。前五点都是"效果好不好"的问题,第 3 点是"能不能跑"的问题——切错地方直接出错误的上下文,这类约束在自己实现类似机制时最容易漏。


参考:Pi Docs — Compaction