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 被追加之后 |
注意第 1
条:工具结果追加之后立刻检查。这是最容易爆上下文的地方——一次
read 读进来一个大文件,或者一段 bash
输出几千行,上下文可能瞬间翻倍。
2. 完整的五步
整个过程是这样:
┌─────────────────────────┐ |
逐个说。
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₁ |
这样早期的目标、约束、关键决定能一路传下去,不会因为压了三四轮就把最初的需求忘掉。当然代价是有损——摘要的摘要,细节会逐层衰减。
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 ] |
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 的处理是生成两份摘要:
┌──────────────────────────────┐ |
状态上用 isSplitTurn: true
标记,turnPrefixMessages
存的是从这一轮开始到切点之间的消息。
4. 摘要长什么样
Compaction 和 Branch Summarization 用的是同一套模板:
## Goal |
这个模板的取舍挺明显:它保的是目标、约束、决定和下一步,丢的是过程细节。换句话说,压缩之后 agent 记得"我要干什么、我决定了怎么干、我干到哪儿了",但不记得"当时那个函数第 37 行长什么样"。
最后两个标签是文件追踪,单独说。
5. 消息是怎么变成文本的
送去总结之前,消息先经 serializeConversation()
拍平成纯文本:
[User]: message text |
其中 tool result 截断到 2,000 字符,超出部分打截断标记。
这一步其实是压缩成本的关键。不截断的话,光是把待总结的消息喂给总结模型这一下,本身就可能又炸一次上下文——你要压缩的东西,正是因为太大才需要压缩。
6. 累积文件追踪
摘要里那两个 <read-files> /
<modified-files> 标签,来源有两处:
1. 被总结消息里的 tool calls |
第 2 条是重点:文件列表是跨压缩累积的。压了五轮之后,前四轮碰过的文件路径仍然在列表里。
这算是对"摘要有损"的一个补救——正文细节会衰减,但"我碰过哪些文件"这个索引一直是完整的。agent
真要用到,重新 read 一次就行。
对应的结构:
interface CompactionDetails { |
7. CompactionEntry 的完整结构
interface CompactionEntry<T = unknown> { |
几个字段值得留意:
parentId—— session 是树,每条 entry 都挂在父节点上firstKeptEntryId—— 重建上下文的锚点,也是下次压缩的起点tokensBefore—— 压缩前的上下文大小,用来观察压缩效果fromHook—— 这份摘要是不是扩展提供的(见第 10 节)
8. Branch Summarization
/tree 切分支时触发,五步:
1. 找到最深的公共祖先节点 |
第 3 步的顺序是从新到旧——预算不够时,先牺牲老的。这和 Compaction 倒着走找切点是同一个思路:近的比远的重要。
interface BranchSummaryEntry<T = unknown> { |
和 CompactionEntry
的差别就一个字段:firstKeptEntryId 换成了
fromId。因为语义不同——压缩是"从这里往后原样保留",分支总结是"我从那边过来的"。
9. 配置
配置文件在 ~/.pi/agent/settings.json 或
<project-dir>/.pi/settings.json:
{ |
| 配置项 | 默认值 | 作用 |
|---|---|---|
enabled |
true |
是否开启自动压缩 |
reserveTokens |
16384 |
给回复留的 token 缓冲 |
keepRecentTokens |
20000 |
原样保留的最近 token 量 |
按模型覆盖
{ |
key 是精确的 provider/modelId 字符串。回退顺序是:
模型覆盖 → 普通设置 → 内置默认 |
而且每个配置项独立回退——你只覆盖
reserveTokens,keepRecentTokens
仍然走普通设置或默认值,不需要整块重写。
上面那个 400000
的例子也点出了为什么需要按模型配:百万级上下文的模型,留 16k
缓冲太小了。
10. 扩展钩子
三个:
pi.on("session_before_compact", async (event, ctx) => { |
pi.on("session_compact_failed", async (event, ctx) => { |
pi.on("session_before_tree", async (event, ctx) => { |
session_before_compact
的能力其实很大:它能拿到待总结的原始消息,也能直接返回一份自己的
summary
顶替掉默认流程。想换个摘要模板、想接自己的总结模型、想在压缩前把某些内容强行保下来,都是在这儿做。
reason 的三个值
"manual" 用户敲了 /compact |
overflow 这条会带
willRetry: true——意思是这次压缩属于救场而不是打断,被中断的那一轮压缩完之后会重试。这个区分对做遥测有用:threshold
是正常运转,overflow 说明预留缓冲设小了。
11. 一个容易忽略的细节
文档里提了一句,我觉得挺有意思:
compaction 和 branch summary 的请求使用全新的 routing session ID,并且禁用 prompt cache 写入。
理由是这类一次性 prompt 基本不会被复用,写进缓存是白花钱。
这种地方不影响功能,但能看出成本意识已经渗进实现细节里了。Agent 的账单不只是主循环那几次调用——压缩本身也是要调模型的,而且压的越频繁调的越多。
12. 小结
把整个 Compaction 串起来:
上下文增长 |
几个我觉得值得抄的设计:
- 预留缓冲而不是压到满 ——
reserveTokens保证模型永远有回话的空间 - 倒着找切点 —— 保的是"最近多少",不是"压掉多少"
- 切点有合法性约束 —— tool call / tool result 必须成对,这是正确性问题不是优化问题
- 摘要迭代传递 —— 上一份摘要是下一次的输入,早期目标不会掉
- 文件列表跨压缩累积 —— 正文有损,但索引无损
- 压缩本身的成本也管 —— tool result 截断 2000 字符、禁用 cache 写入
第 3 点尤其值得强调。前五点都是"效果好不好"的问题,第 3 点是"能不能跑"的问题——切错地方直接出错误的上下文,这类约束在自己实现类似机制时最容易漏。
参考:Pi Docs — Compaction