第七章讲了压缩"为什么"和"大致流程"。这一章拆开引擎盖: 切点怎么找、一个巨型 turn 怎么办、摘要长什么样、文件操作怎么累积。
自动压缩在这个条件成立时触发:
contextTokens > contextWindow - reserveTokens
reserveTokens 默认 16384(为 LLM 的回复留出空间),keepRecentTokens 默认 20000(保留最近这么多 token 不压缩)。都可在 ~/.pi/agent/settings.json 或项目的 .pi/settings.json 配置:
{
"compaction": {
"enabled": true,
"reserveTokens": 16384,
"keepRecentTokens": 20000
}
}也可手动 /compact [instructions],可选的 instructions 用来引导摘要重点。
注:第七章示例里的
reserveTokens: 40000是旧默认值;当前源码默认是16384。以本章为准。
@startuml
skinparam backgroundColor transparent
skinparam ActivityBackgroundColor #f8f9fa
skinparam ActivityBorderColor #dee2e6
start
:1. 找切点\n从最新消息倒着走,累加 token\n直到达到 keepRecentTokens;
:2. 抽取待压缩消息\n从上个保留边界(或会话起点)到切点;
:3. 生成摘要\n用结构化格式调 LLM\n有上次摘要则作为迭代上下文传入;
:4. 追加 CompactionEntry\n带 summary 和 firstKeptEntryId;
:5. 重载\n用 摘要 + firstKeptEntryId 之后的消息;
stop
@enduml一张图看清切点:
压缩前:
entry: 0 1 2 3 4 5 6 7 8 9
┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┐
│ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│
└─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┘
└────────┬───────┘ └──────────────┬──────────────┘
messagesToSummarize 保留的消息
↑
firstKeptEntryId (entry 4)
LLM 实际看到:
┌────────┬─────────┬─────┬─────┬──────┬──────┬─────┬──────┐
│ system │ summary │ usr │ ass │ tool │ tool │ ass │ tool │
└────────┴─────────┴─────┴─────┴──────┴──────┴─────┴──────┘
↑ ↑ └──────── firstKeptEntryId 起的消息 ────────┘
prompt 来自 cmp entry
重复压缩的关键处理:第二次压缩时,待摘要区间的起点是上一次压缩的保留边界(firstKeptEntryId),而不是上一个 compaction entry 本身。这样上次压缩后幸存的消息,会在这次摘要里再被覆盖一遍,不会丢。Pi 还会在写新 CompactionEntry 前,从重建的会话上下文重新计算 tokensBefore,让 token 数反映真正被替换掉的上下文。
一个 "turn" 从一条 user 消息开始,包含之后所有 assistant 回复和工具调用,直到下一条 user 消息。正常情况下压缩在 turn 边界切——不会把半个 turn 留下。
但如果单个 turn 就超过了 keepRecentTokens,切点会落在 turn 中间的某条 assistant 消息上。这就是 split turn:
一个巨型 turn 超过预算:
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 = [] ← 前面没有完整 turn 可摘要
turnPrefixMessages = [usr, ass, tool, ass, tool, tool]
这时 Pi 生成两段摘要并合并:
- History summary:之前的上下文(如果有)
- Turn prefix summary:这个被切开的 turn 的前半部分
合法的切点只有这几种消息:
- User 消息
- Assistant 消息
- BashExecution 消息
- Custom 消息(
custom_message、branch_summary)
永远不切在工具结果(tool result)上——工具结果必须和它对应的工具调用待在一起。否则 LLM 会看到一个"孤儿"工具结果,或者一个没有结果的工具调用,两者都会让 Provider 报错(回想第二章的"孤立工具调用修复")。
这是第十一章埋的伏笔。CompactionEntry 有两种记录"保留了什么"的方式:
interface CompactionEntry<T = unknown> {
type: "compaction";
id: string;
parentId: string;
timestamp: number;
summary: string;
firstKeptEntryId: string; // 旧方式:指针,指向保留区间的起点
tokensBefore: number;
retainedTail?: AgentMessage[]; // 新方式:直接内嵌保留的尾部消息
usage?: Usage; // 生成摘要消耗的 token(计入会话总量)
fromHook?: boolean; // 是否由扩展生成(历史遗留字段名)
details?: T; // 实现特定数据
}| 方式 | 机制 | 重建时 |
|---|---|---|
firstKeptEntryId |
指针,指向树中某个 entry | 需回溯:从该指针走到 compaction |
retainedTail |
内嵌完整的 AgentMessage[] |
自包含:无需读取更早的 entry |
新版 harness 生成的压缩会内嵌 retainedTail,使 compaction entry 成为一个自包含检查点——重建上下文时不必再走到压缩点之前的旧 entry。firstKeptEntryId 保留只是为了兼容旧会话。这正是第十一章 buildContextEntries 里那个分支判断的由来。
压缩和分支摘要用同一套结构化格式——不是随意的自然语言总结,而是固定小节:
## 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>结构化的意义:让 Agent 压缩后仍记得目标、约束、已做的决策、以及碰过哪些文件——而不是丢失一切只剩一句模糊总结。
摘要前,消息会先被 serializeConversation() 转成带角色标签的纯文本:
[User]: 用户说的话
[Assistant thinking]: 内部推理
[Assistant]: 回复文本
[Assistant tool calls]: read(path="foo.ts"); edit(path="bar.ts", ...)
[Tool result]: 工具输出
为什么要序列化成这种"剧本"格式?防止模型把它当成一段需要继续的对话,而是当成需要总结的材料。
一个关键预算控制:工具结果在序列化时被截断到 2000 字符,超出部分替换为标记(注明截断了多少字符)。因为 read、bash 的工具结果通常是上下文里最大的贡献者,不截断的话摘要请求本身就会超预算。
默认压缩会从被摘要的消息里提取文件操作,存进 details:
interface CompactionDetails {
readFiles: string[];
modifiedFiles: string[];
}关键词是累积。生成摘要时,文件操作来自两个源:
- 被摘要消息里的工具调用
- 上一个 compaction / branch summary 的
details(如果有)
所以经过多次压缩或嵌套分支摘要,读过/改过的文件列表会一路累积下来,不会因为压缩而"忘记"早期碰过的文件。
压缩不是黑箱——扩展可以拦截并自定义(回想第九章的钩子)。两个事件:
// 自动压缩或 /compact 前触发
pi.on("session_before_compact", async (event, ctx) => {
const { preparation, reason, willRetry, signal } = event;
// reason: "manual" | "threshold" | "overflow"
// 取消这次压缩:
// return { cancel: true };
// 用自己的模型生成摘要:
const text = serializeConversation(
convertToLlm(preparation.messagesToSummarize)
);
const { summary, usage } = await myModel.summarize(text);
return {
compaction: {
summary,
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
usage, // 可选,计入会话总量
},
};
});
// /tree 分支导航前触发
pi.on("session_before_tree", async (event, ctx) => {
// 可取消导航,或在 userWantsSummary 时提供自定义摘要
});reason 里的 "overflow" 值得注意:它表示上下文已经溢出、当前 turn 被中断,压缩后 Pi 会(据 willRetry)重试那个被中断的 turn——这是溢出恢复路径,区别于主动的 "threshold" 触发。
- 记得触发公式
contextTokens > contextWindow - reserveTokens和两个默认值 - 理解正常切点在 turn 边界,split turn 为何出现、如何双摘要合并
- 说得出为什么绝不能切在 tool result 上
- 讲清
firstKeptEntryId(指针)与retainedTail(自包含)的区别 - 知道摘要为何序列化成"剧本"格式、工具结果为何截断到 2000 字符
- 理解文件操作为什么要跨压缩累积
- 知道扩展通过哪两个事件接管压缩与分支摘要
上一章:第十一章 · Session 树与上下文构建
下一章:第十三章 · SDK 与嵌入集成 — 把 Pi 嵌进你自己的应用