上下文装配与压缩
上下文从哪来、什么时候被压缩、压缩写了什么
模型每次看到的「上下文」不是一段连续的聊天记录,而是一次现场装配的结果。装配的原料有三样:磁盘上那份 JSONL 会话树、系统提示的各个分片、以及工具清单。它们被拼成消息数组,交给模型。装配不出来的时候,Pi 做压缩——用一段摘要顶掉一整段历史,但原始条目一条都不删。
这一篇要说清三件事:
- 上下文到底由什么拼成
- 什么时候开始压缩,以及触发条件究竟是什么
- 压缩往会话里写了什么
第二件是最容易讲错的地方。先给结论,后面展开:
触发条件是
contextTokens > contextWindow - reserveTokens。 默认reserveTokens = 16384。这是一个绝对值减法,不是百分比——同一个模型上,它对应的触发百分比是固定的;换一个上下文窗口的模型,触发点就变。更重要的是:
reserveTokens只决定何时开始压缩,它不是摘要请求的输出上限。 摘要请求用模型自己的输出上限。压缩也不会把思考等级强制改成 minimal,它沿用会话当前的思考等级。
第三条最容易理解反,所以先给一句话:压缩是往树上追加一条 entry,不是删掉旧 entry。 旧消息还在文件里,只是不再进模型请求。
会话条目模型(id / parentId / 12 种 entry 类型)在 会话树、分支与会话文件格式 里已经完整讲过,本篇直接引用不再重复。
15.1 上下文从哪来
四份输入,一次装配
docs/how-pi-works.md 的 agent 循环描述是:
Pi builds a model request from the system prompt, active branch, available tools, and model settings.
展开成可核对的清单:
扩展还能往上加东西:skill 描述、上下文文件(AGENTS.md 等)、扩展的 prompt 贡献。skill 只进描述,完整指令按需加载。
系统提示是一组「分片」,不是一个字符串
buildSystemPromptSections()(src/core/system-prompt.ts:128)返回的是一个按名索引的对象。源码注释定义了这个约定:
/** Ordered system prompt sections, keyed by name. `preamble` is untagged text; every other
* section is wrapped in a tag of the same name so the model can match later updates to it.
* These become `SystemMessage.sections` in the transcript. */Pi 自己生成的默认分片有这些:
preamble 不加标签,其余每个分片都被包成同名标签:
const sections: SystemPromptSections = { preamble: promptSections.preamble };
for (const [name, content] of Object.entries(promptSections)) {
if (name !== "preamble") sections[name] = `<${name}>\n${content}\n</${name}>`;
}**为什么要分片而不是拼成一个大字符串?**因为扩展可以替换其中任意一段。system-prompt.ts 里对自定义分片名有校验:
const SYSTEM_PROMPT_SECTION_NAME = /^[a-z][a-z0-9_-]*$/;
…
if (!SYSTEM_PROMPT_SECTION_NAME.test(name) || name === "preamble") {
throw new Error(`Invalid system prompt section name: ${name}`);
}名字必须是小写开头的字母数字加 - _,而且不能叫 preamble——因为 preamble 是唯一不带标签的,不能被模型侧的分段逻辑匹配到。这是接口约束,不是风格偏好。
分片的变化被记成增量条目
每次准备下一轮时,Pi 会把新算出来的分片和会话里现有的分片做 diff(src/core/agent-session.ts:1743):
const sections = diffSystemPromptSections(
getCurrentSystemMessage(messages)?.sections ?? {},
buildSystemPromptSections(options),
);
return sections ? { role: "system", content: "", sections, timestamp: Date.now() } : undefined;diffSystemPromptSections() 的规则只有两条:内容变了的写新值,上一轮有而这一轮没有的写 null(表示删除)。diff 为空就返回 undefined,不产生条目。
这和 14.2 节讲的 SystemMessage 重放模型是同一件事:从文件里重放所有 system 消息的 sections,就得到当前提示词。
所以「改个 AGENTS.md 会发生什么」有确定答案:下一轮追加一条 system 条目,sections.project_context 是新内容;其他分片不变,不重复记录。
一轮请求是怎么组装的
时序在 prepareNextTurnWithContext(src/core/agent-session.ts:905),也就是「上一轮工具结果已经写完、下一条 assistant 响应还没发」的那个窗口:
1. buildSessionProjection() ← 从会话树算出消息数组
2. shouldCompact? ← 15.2 节
3. 是 → 压缩,重算一次 projection
4. diffSystemPromptSections() ← 算提示词增量
5. 追加 system 条目(若有变化)
6. 把 projection.messages + 新 system 消息交给 agent第 1 步的投影和第 4 步的 diff 都作用在同一个投影上,所以顺序不能换:先压缩再算 diff,否则新压缩条目带来的提示词快照会被 diff 判成「有变化」,多写一条 system 条目。
顺带一个容易忽略的点:context.tools 和消息里声明的工具是两份东西。源码注释一句话说清了:
// Messages declare the provider-visible loadout; context.tools keeps executable implementations.声明给模型的(模型看得见)和可执行的(context.tools 里真的能跑)可以不一致。这正是 exposure 模型存在的理由,见 工具系统与信任边界。
token 数是怎么估出来的
阈值判断需要一个「当前上下文多少 token」,Pi 有两个独立的估算器,常数不一样:
两者都刻意保守(宁可高估)。图像按 4800 字符折算(ESTIMATED_IMAGE_CHARS),thinking 块按文本长度算,toolCall 按「名字长度 + 参数 JSON 长度」算。
估算结果优先用上一次 assistant 消息的真实 usage,只在它不再可信时退回估算。两种不可信的情况:estimateProjectedContextTokens() 发现 usage 之后还有 context_edit 或 compaction 条目(这个 usage 描述的是压缩/编辑之前的上下文);或者最后一条 assistant 是 aborted / error / usage 全零,getAssistantUsage() 直接跳过这三种。
还有一条相关取舍:没有 provider usage 时也会尝试压缩(比如持续 529,或者供应商返回了格式错误的零 usage 响应)。源码注释说这是为了「不重置上下文记账」。
底栏的百分比不是触发阈值
底栏显示的是占用率,来源是 getContextUsage(),格式是 ${percent}%/${contextWindow}。
它算的是 tokens / contextWindow,而压缩触发算的是 tokens > contextWindow - 16384。这两个数不是一回事,而这一点在你读下一节之前最值得先记住:
因为 reserveTokens 是固定的绝对值,换窗口大小时触发百分比是变化的:**上下文窗口越大,Pi 越晚开始压缩。**这是「按百分比压缩」这种设计不会出现的行为。如果你对 1M 窗口的模型沿用 87% 的预期,实际会等到 98% 才动手。
底栏还有第三种取值:?。ContextUsage 的 tokens 可以是 null,条件是「最近一次压缩之后还没有过一条有效 assistant 响应」——此时真实值未知:
/** Estimated context tokens, or null if unknown (e.g. right after compaction, before next LLM response). */所以刚压缩完的那一刻底栏显示 ? 是正常的,15.3 节会解释为什么。
15.2 压缩何时触发
公式
shouldCompact() 一共三行,src/core/compaction/compaction.ts:267:
export function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean {
if (!settings.enabled) return false;
return contextTokens > contextWindow - settings.reserveTokens;
}默认值在同一个文件 :126:
export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
enabled: true,
reserveTokens: 16384,
keepRecentTokens: 20000,
};三个数分别是:自动压缩开、预留 16384、保留最近 20000 token 不摘要。
两个值作用在不同地方,容易混:
reserveTokens 管「什么时候」,keepRecentTokens 管「切在哪」。压缩之后,上下文大致会落到 contextWindow - reserveTokens 附近,因为切点保证了最近 20000 token 是完整的。
检查点在哪
阈值判断在一轮运行里出现三次,每次用的都是刚算出来的投影:
第一种是主力。prepareNextTurn 里还有一条注释解释它的插入点:
During a multi-turn agent run, Pi checks the canonical projected context after tools finish and their results are appended, before starting the next assistant response.
还有一个边界:docs/compaction.md 明确说,如果工具批次结束时整轮运行就要终止、也没有排队消息,这个轮间检查会跳过。也就是说压缩不总是立刻发生——它可能晚到下一条用户消息之前。
三种 reason
事件和扩展钩子里的 reason 只有三个取值,直接告诉你这次压缩为什么发生:
overflow 的重试是只给一次的。源码里有个 _overflowRecoveryAttempted 标记,第二次撞上溢出就放弃并发错误:
const errorMessage = contextOverflow
? "Context overflow recovery failed after one compact-and-retry attempt. Try reducing context or switching to a larger-context model."
: "Truncated response recovery failed after one compact-and-retry attempt.";错误文案本身给了你正确的下一步:减少上下文,或者换一个大窗口的模型。
reserveTokens 不是摘要的输出上限
这是本文最重要的一条纠错,而且源码里有直接证据。
generateSummaryWithUsage() 的签名里确实有一个 token 参数,但它是被显式弃用的:
export async function generateSummaryWithUsage(
currentMessages: AgentMessage[],
model: Model<any>,
// Unused. Kept for existing callers; summaries use the model's output limit (#9512).
_reserveTokens: number | undefined,
…参数名前面那个下划线不是笔误,是 TypeScript 里「这个参数我不打算用」的惯例。注释直接写着:摘要用模型自己的输出上限(问题编号 #9512)。generateSummary() 也有同样的注释。
调用点传的也是 undefined:
const historyResult = await generateSummaryWithUsage(
messagesToSummarize, model, undefined, apiKey, headers, signal, …);所以整条链路上,reserveTokens 没有以任何形式影响摘要的长度。
createSummarizationOptions() 里除了思考等级什么都没设——它不设 maxTokens,不设 thinking level:
const options: SimpleStreamOptions = { signal, apiKey, headers, env, sessionId };
if (model.reasoning && thinkingLevel && thinkingLevel !== "off") {
options.reasoning = thinkingLevel;
}两个推论:
第一,摘要走模型默认的输出上限。摘要太长会被模型的输出上限截断,而不是被 reserveTokens 限住。截断会被检测出来并且不写条目:
if (response.stopReason === "length") {
return `${label} failed: generation hit the token cap and the summary is incomplete`;
}这个检查存在的意义就是——**宁可不压缩,也不能把一段截断的摘要写成检查点。**因为一旦落盘,它会永久顶掉原始消息。
第二,思考等级没有被强制降级。thinkingLevel 一路从会话传下来:_getSummarizationRequestAuth() 返回 { …, thinkingLevel: this.thinkingLevel }——就是会话当前那一档。摘要请求在模型支持 reasoning 且等级不是 "off" 时,把这一档原样传上去。
这有两个实际影响:
如果你真的想要便宜的摘要,那是你该自己写的事——挂一个 session_before_compact 钩子,用另一个便宜模型出摘要。官方示例是 examples/extensions/custom-compaction.ts。
摘要请求不写 prompt cache
completeSummarization() 里有一行容易被略过的设置:
const requestOptions: SimpleStreamOptions = {
...options,
cacheRetention: "none",
sessionId: options.sessionId ?? uuidv7(),
};注释解释了原因:Avoid cache writes for one-off summaries.
判断很实在:**一次性摘要提示词几乎不会被重放,写进缓存只是浪费缓存空间和写入计费。**关掉它同时省了钱和一个潜在的额度上限。
顺带注意 sessionId 的兜底:分支摘要那些没有会话 ID 的调用会拿到一个新的随机 ID,保证路由仍然可用。
关掉自动压缩会怎样
{
"compaction": {
"enabled": false
}
}enabled 检查出现在两处:shouldCompact() 的第一行,以及 _checkCompaction() 的开头。两处都直接返回 false。
而手动路径 compact() 根本不检查 enabled——它只检查「有没有东西可压缩」。这就是「关掉自动压缩不影响 /compact」的实现依据,15.5 节展开。
enabled 是全局的,不参与按模型覆盖。源码和文档都这么说:
{ "compaction": { "modelOverrides": { "…": { "reserveTokens": 400000 } } } }覆盖的是 reserveTokens 和 keepRecentTokens 两个 token 值。
按模型覆盖
每个值独立回退,顺序是:模型覆盖项 → 普通设置 → 内置默认。
{
"compaction": {
"reserveTokens": 16384,
"keepRecentTokens": 20000,
"modelOverrides": {
"anthropic/claude-sonnet-4-5": { "reserveTokens": 400000 }
}
}
}键是精确的、大小写敏感的 provider/modelId,包括 model ID 内部自己的斜杠。所以 openrouter/anthropic/claude-sonnet-4-5 这样写才对。
文档给的效果说明是:一个 1M 窗口的模型用 400000 覆盖,触发点变成 600K,而 keepRecentTokens 仍是默认的 20000。其它模型继续用 16384。
三条容易踩的校验规则:
第三条很反直觉:一个模型专属的合法值救不了另一个模型的非法全局值。配置这类键要整体检查,不要只改一处。
覆盖在全局和项目 settings 里都有效,且文件先递归合并再查模型,所以全局的模型专属值会压过项目的普通值——项目必须显式覆盖那个模型项才能改回来。
解析后的值对手动压缩、全部自动阈值检查、溢出恢复、以及扩展看到的 preparation.settings 都生效。已经在跑的压缩用启动时捕获的模型和设置,不会中途改变。切换模型影响后续检查,不改普通设置。分支摘要的设置不受影响——15.4 节讲为什么。
15.3 压缩写了什么
五步
prepareCompaction() + compact() 拆开来是五步:
- 找切点:在投影上从后往前累加 token 估算,累积到
keepRecentTokens(默认 20000)就停 - 抽消息:收集从上次保留边界(或会话开头)到切点之间的消息
- 生成摘要:调模型,用结构化格式;有上一份摘要时把它作为迭代上下文一起传进去
- 追加条目:写一条
CompactionEntry - 重建上下文:下次请求用「摘要 +
firstKeptEntryId起的消息」
第 5 步不是「物理删除」,而是投影时跳过。上一节和 14.3 节讲过的 buildContextEntries() 就是这一步的实现。
条目结构
interface CompactionEntry<T = unknown> {
type: "compaction";
id: string;
parentId: string | null;
timestamp: string;
summary: string;
firstKeptEntryId: string;
tokensBefore: number;
usage?: Usage; // 生成这份摘要的 LLM 用量
fromHook?: boolean; // true = 扩展生成的
details?: T; // 默认是 { readFiles, modifiedFiles }
systemMessage?: SystemMessage;
}四个必填字段各自的作用:
summary —— 顶掉被摘要掉那段的文本。
firstKeptEntryId —— 从哪一条开始原样保留。这是最关键的一个字段:重建上下文时,Pi 把压缩点之前的条目用摘要替换,保留从这个 ID 开始的区间。
有一个特殊情况:「什么都不保留」的压缩把自己的 ID 写进这个字段。这样重建时就没有任何前序条目需要保留,摘要独占头部。
tokensBefore —— 压缩前的上下文 token 数。而且它是重算出来的,不是沿用上一次:
Pi also recalculates
tokensBeforefrom the rebuilt, context-edited session projection before writing the newCompactionEntry.
所以这个数字反映的是真正被顶掉的那部分,包含了 context_edit 的效果。
systemMessage —— 压缩边界的完整提示词快照。v3 之前的会话没有这个字段。它在重建上下文时成为第一条 system 消息,保留区间里原有的 system 消息会被丢弃:
if (entry.systemMessage) return [entry.systemMessage, summary];
return [summary];写进去的原文长什么样
把 docs/compaction.md 的示意图翻译一遍。假设一条直线会话:
压缩前:
条目 0 1 2 3 4 5 6 7 8 9
┌────┬────┬─────┬──────┬────┬─────┬──────┬──────┬────┬────┐
│ hdr│ usr│ ass │ tool │ usr│ ass │ tool │ tool │ ass│tool│
└────┴────┴─────┴──────┴────┴─────┴──────┴──────┴────┴────┘
└───┬────┘ └────────┬──────────┘
要摘要的部分 保留的消息
↑
firstKeptEntryId(条目 4)
压缩后(新追加一条):
条目 0 1 2 3 4 5 6 7 8 9 10
┌────┬────┬─────┬──────┬────┬─────┬──────┬──────┬────┬────┬───┐
│ hdr│ usr│ ass │ tool │ usr│ ass │ tool │ tool │ ass│tool│cmp│
└────┴────┴─────┴──────┴────┴─────┴──────┴──────┴────┴────┴───┘
└────┬────┘ └───────────┬────────────┘
不发给 LLM 发给 LLM关键点:条目 1、2、3 还在线上。它们没被删,只是落在了保留区间之前。
模型看到的是:
┌────────┬─────────┬────┬─────┬──────┬──────┬─────┬──────┐
│ system │ summary │ usr│ ass │ tool │ tool │ ass │ tool │
└────────┴─────────┴────┴─────┴──────┴──────┴─────┴──────┘
↑ ↑ └────────────┬─────────────┘
提示词 来自 cmp 来自 firstKeptEntryId 的消息文件里条目 10 的样子:
{"type":"compaction","id":"f6g7h8i9","parentId":"e5f6g7h8","timestamp":"…","summary":"User discussed X, Y, Z...","firstKeptEntryId":"c3d4e5f6","tokensBefore":50000,"systemMessage":{"role":"system","content":"","sections":{…},"toolsAdded":[],"timestamp":…},"usage":{…},"details":{"readFiles":["a.ts"],"modifiedFiles":["b.ts"]}}切点不能落在工具结果上
合法的切点位置只有四种:
function isCutPointMessage(message: AgentMessage): boolean {
switch (message.role) {
case "user": case "assistant": case "bashExecution":
case "custom": case "branchSummary": case "compactionSummary":
return true;
case "toolResult":
return false;
}
return false;
}**toolResult 永远不是合法切点。**理由很直接:toolResult 通过 toolCallId 挂在某个 toolCall 上。如果切点把 toolCall 摘走、toolResult 留着,请求里就会出现一条没有对应调用的工具结果,多数供应商会直接报错。
这条约束还有一个连带效应:切点落在 assistant 上时,它的 toolResult 会跟着保留区间走——因为 toolCall 本身在 assistant 消息的 content 块里。源码注释把这条配合关系写明了:「When we cut at an assistant message with tool calls, its tool results follow it and will be kept.」
一轮太长时切点会落进中间
正常情况下切点落在用户消息边界上——「一轮」定义为从一条用户消息到下一条用户消息之前的所有消息。
但如果单独一轮就超过了 keepRecentTokens,切点会落在这轮的中间(某个 assistant 消息上)。这叫 split user-message span,docs/compaction.md 有专门的图:
一个用户消息跨度超出了预算:
条目 0 1 2 3 4 5 6 7 8
┌────┬────┬─────┬────┬──────┬──────┬────┬────┬──────┐
│ hdr│ usr│ ass │tool│ ass │ tool │tool│ ass│ tool │
└────┴────┴─────┴────┴──────┴──────┴────┴────┴──────┘
↑ ↑
turnStartIndex = 1 firstKeptEntryId = 7
└──────── turnPrefixMessages ────────┘
└── 保留(7-8)
isSplitTurn = true
messagesToSummarize = [] (没有更早的用户消息跨度)
turnPrefixMessages = [usr, ass, tool, ass, tool, tool]这种情况下 Pi 生成两份摘要再合并:
- 历史摘要——更早的上下文(有就更新,没有就用上一份摘要)
- 本轮前缀摘要——被劈开的那一轮的前半段
为什么要拆开而不是直接整轮摘要?因为整轮摘要会把「保留区间里还留着的那几条消息」也一起摘掉,导致模型看到的是「摘要里说了一件刚发生的事,紧接着又是这件事的原始消息」这种自相矛盾的输入。拆开之后,前半段进摘要、后半段原样保留,语义上是连贯的。
摘要的格式
压缩摘要是固定的 markdown 结构,SUMMARIZATION_PROMPT 里的原话是「Use this EXACT format」。六节的顺序和职责:
每次都提一句的要求:**「Preserve exact file paths, function names, and error messages.」**这条对编码 Agent 很重要——摘要顶掉的是几十轮真实交互,路径和函数名错了后面全错。
有两份 prompt:首次的 SUMMARIZATION_PROMPT,和更新用的 UPDATE_SUMMARIZATION_PROMPT。后者把上一份摘要包在 <previous-summary> 标签里一起发,指令里有一条关键的——「PRESERVE all existing information from the previous summary」。
所以连续压缩是增量式的:第二次压缩只处理新的一段,旧摘要被合并进去。这就是为什么上面那张图里 firstKeptEntryId 指的是「上一次压缩保留的边界」而不是「上一次压缩条目本身」——这样上一次侥幸活下来的消息也会被纳入下一次摘要,不会在两次压缩之间的缝里丢失。
摘要末尾还会追加文件列表,由 formatFileOperations() 生成:
<read-files>
src/auth/refresh.ts
</read-files>
<modified-files>
src/auth/service.ts
</modified-files>这两个列表不是模型写的,是从工具调用里抽出来的(utils.ts 的 addFileOp() 只认 read / write / edit 三个工具的 path 参数)。所以「这个会话动过哪些文件」是一份机械的账,不依赖模型总结得准不准。
而且它是累积的:从 assistant 消息的 toolCall 里抽,同时从上一条 Pi 自己生成的压缩里把列表继承下来。扩展生成的摘要(fromHook: true)不继承——扩展自己管自己的 details 格式。
送进模型之前先拍扁
摘要请求不是把消息数组原样发出去,而是先序列化成纯文本(utils.ts 的 serializeConversation()):
[User]: What they said
[Assistant]: Response text
[Assistant tool calls]: read(path="foo.ts"); edit(path="bar.ts", ...)
[Tool result]: Output from tool源码注释说明了动机:「This prevents the model from treating it as a conversation to continue.」——如果不拍扁,模型很可能顺手回你一句而不是产出摘要。
还有两条有实际影响的处理:
**thinking 块被丢掉。**源码注释给的理由里有一句是决定性的:「Including it can make the summarization request larger than the context it summarizes.」——带着 thinking 去摘要,摘要请求本身可能比它要摘要的上下文还大。
工具结果截断到 2000 字符(TOOL_RESULT_MAX_CHARS = 2000),超出部分替换成 [... N more characters truncated] 标记。
这个数字和 06 篇讲的模型侧 50 KB 上限是两回事:这里截断的是摘要请求里的,最终写进 compaction 条目的摘要仍然只是文本。read 和 bash 的输出经常是上下文的绝对大头,截掉它们能显著压缩摘要请求本身。
溢出恢复会先删再压
overflow 这条路径比普通阈值压缩多一步:它先把失败的那次尝试从上下文里隐藏掉,再压缩。隐藏的方式是 _omitRecoveryAttempt() 追加一条 context_edit,replacement 为 null——意思是「把这个条目从模型上下文里拿掉」(14.3 节)。原始条目在文件里、UI 里、导出里、计费统计里都还在,只有模型看不见了。
完整时序写在 docs/compaction.md:
持久化最后一次 assistant 响应
→ 扩展/公开的 turn_end
→ 扩展/公开的 agent_end
→ 为选中的那次尝试追加 context_edit 省略
→ 对 overflow/length:跑 session_before_compact,成功则追加 compaction
→ 作为一次全新的运行开始重试关键性质:**已完成的那次尝试仍然对 turn_end 和 agent_end 可见。**压缩是在事件之后发生的,重试是一次新运行。这样扩展看到的事件序列是连续的,模型看到的是修好的上下文。
失败的话:**保留省略 edit,不追加压缩,不安排重试。**已经排队的消息照常按 steering / follow-up 规则走。
什么时候压缩会被拒绝
prepareCompaction() 返回 undefined 有两个条件:
- 路径上最后一条已经是
compaction条目(不能连着压两次) - 既没有要摘要的消息,也没有本轮前缀消息(会话太小)
compact() 把这两种情况翻译成人话:
if (lastEntry?.type === "compaction") {
throw new Error("Already compacted");
}
throw new Error("Nothing to compact (session too small)");另外还有一个压缩内部的失败:getSummarizationFailure() 检查供应商返回,stopReason === "error" 或 "length" 都会被拒。理由是摘要一旦落盘就会永久顶掉原始消息,一个截断的摘要比不压缩糟糕得多。
15.4 分支摘要
和压缩是两套东西
先摆清区别,否则很容易把两者的参数混用:
最后两行是本文要重点说的,因为它们正好和「reserveTokens 是阈值不是上限」那条形成对照。
收集什么
collectEntriesForBranchSummary()(branch-summarization.ts:108):找共同祖先(新旧两个位置上都出现的最深节点),从旧叶子沿 parentId 往回走到共同祖先,反转成时间顺序。
导航前: 导航后(带摘要):
┌─ B ─ C ─ D(旧叶子) ┌─ B ─ C ─ D
A ───┤ A ───┤
└─ E ─ F(目标) └─ E ─ F ─ [B,C,D 的摘要](新叶子)
共同祖先:A 要摘要的条目:B, C, D要点:摘要的是「离开的那段」,挂在「回来的那条路」上。旧分支一条不少,新分支里挂一条摘要把前路接上。条目落在新落点上而不是旧分支——源码注释专门标注了这点(见 14.4 节引的那段 // Summary is attached at the navigation target position)。
预算:这里的 reserveTokens 是真的在算
generateBranchSummary()(branch-summarization.ts:293)把它减成了内容预算:
const { …, reserveTokens = 16384, … } = options;
// Token budget = context window minus reserved space for prompt + response
const contextWindow = model.contextWindow || 128000;
const tokenBudget = contextWindow - reserveTokens;
const { messages, fileOps } = prepareBranchEntries(entries, tokenBudget);和压缩的 reserveTokens 是同一个名字、同一个默认值,但是完全不同的用途。这里它真的被减出来当预算了:分支摘要要装进单次请求,所以必须保证装得下。
这带来一个实际后果:**分支摘要的 reserveTokens 应该是上下文窗口的函数,不是常数。**16384 对 128k 的模型是 12.8%,对 1M 的模型只有 1.6%——后者几乎等于「整条分支全送进去」。想在大窗口模型上限制摘要规模,得显式调大这个值。
对比一下压缩的 reserveTokens:它是「还剩多少就动手」,和模型要输出多少东西无关。
顺带注意 model.contextWindow || 128000 这个兜底:模型没报上下文窗口时按 128000 算。
工具结果被跳过
getMessageFromEntry() 里第一行分支就返回 undefined:if (entry.message.role === "toolResult") return undefined;,注释写的是「Skip tool results - context is in assistant's tool call」。
**分支摘要不带工具结果,压缩带。**这是个有意的差异,理由值得想清楚:
分支摘要要的是决策信息——试了方案 A、失败了、改用方案 B。这些信息在 assistant 的文本和 toolCall 名字里。原始的工具输出(一堆编译错误、目录列表)在摘要里只会占预算。
而压缩要面对的是「接下来可能还要用这份输出」的场合,所以工具结果截断到 2000 字符保留。
文件列表照常累积,但有个例外
prepareBranchEntries() 有两趟:
- 第一趟:扫全部条目,不管在不在预算内,从已有的
branch_summary条目的details里继承文件列表 - 第二趟:从新到旧加消息,加到超预算为止
第一趟先做全量扫描,是为了嵌套摘要的文件列表不会因为预算截断而丢。代码里也标了来源过滤:
if (entry.type === "branch_summary" && !entry.fromHook && entry.details) {!entry.fromHook 的意思是:**只继承 Pi 自己生成的摘要的文件列表,扩展生成的不继承。**文档把这条讲得更直接:
Pi does not automatically carry file lists from extension-generated summaries whose
fromHookfield istrue; extensions manage their owndetailsformat.
理由是 details 的结构由生成方定义。Pi 生成的用 {readFiles, modifiedFiles},扩展可能用完全不同的结构,Pi 无从判断哪些字段对应「读过的文件」。
摘要格式少一节
分支摘要是压缩摘要的子集。压缩有六节,分支摘要五节——没有 Critical Context。
少的那一节是压缩专用的:压缩顶掉的是接下来还要用的消息,那些数据(报错原文、配置值)必须留在摘要里。分支摘要顶掉的是另一条路的探索记录,那些具体数据在回到这条路时多半用不上了。
其余五节(Goal / Constraints & Preferences / Progress / Key Decisions / Next Steps)完全一样,「Preserve exact file paths, function names, and error messages」这条也一样。
自定义指令有两种用法(replaceInstructions):
条目结构
interface BranchSummaryEntry<T = unknown> {
type: "branch_summary";
id: string;
parentId: string | null; // 新分支继续的位置
timestamp: string;
summary: string;
fromId: string; // 刚才离开的那个叶子
usage?: Usage;
fromHook?: boolean;
details?: T; // 默认 { readFiles, modifiedFiles }
}和 compaction 对照,只有三处不同:没有 firstKeptEntryId(它不切任何东西)、没有 tokensBefore(它不释放空间)、多了 fromId(它需要记住从哪来)。
parentId + fromId 这一对是分支摘要的最小充分信息:我接在哪、我从哪来。
会被压缩影响吗
会,而且这是两个机制的交点。getMessageFromEntry() 里:
case "compaction":
return createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp);被摘要的分支里如果有压缩条目,它的摘要也会被当作一条「压缩摘要消息」送进分支摘要的输入。所以一份分支摘要里可能嵌着另一份摘要的摘要——三层嵌套也是合法的。
另一面:**分支摘要的设置不会被压缩的模型覆盖影响。**文档明确说 "Branch summarization settings are unaffected." 两条路径的 token 设置是分开的配置项(compaction.* 和 branchSummary.*)。
15.5 手动压缩
/compact [instructions]
指令是可选的,用来告诉摘要「这次特别要保住什么」:
/compact 重点保住我们关于缓存失效策略的讨论和最终选型带指令时,指令会追加到标准 prompt 后面。compact() 的签名:
async compact(customInstructions?: string): Promise<CompactionResult>三条实现事实
**第一,手动路径不检查 enabled。**对比一下三条路径:
所以 "enabled": false 之后 /compact 依然能用。文档和源码在这点上是一致的——docs/compaction.md 的原话是 "Disabling auto-compaction does not disable the manual command."
第二,手动压缩会先中止当前运行。compact() 的第一句是 await this.abort(),把排队消息退回编辑器,然后才发 compaction_start 事件。这意味着 /compact 会打断你正在等的一轮——如果你在等长输出,先让它跑完。
**第三,手动压缩不重试、不续跑被打断的那一轮。**源码注释直接写了:Manual compaction never retries or continues the interrupted agent turn. 对比 overflow 路径会 agent.continue() 重新开始。
两个常见报错
prepareCompaction() 返回 undefined 时翻译成人话:
第二个报错在会话刚开始时很容易撞上:keepRecentTokens 默认 20000,会话不到那个量级就没有可摘要的部分。
什么时候值得手动压缩
自动压缩只在超阈值时动手,所以有几种情况手动更合适:
反面:**不要靠手动压缩来「省 token 费」。**摘要是一次真实的模型调用,价格和普通请求同级(而且因为 cacheRetention: "none" 不享受缓存命中)。频繁手动压缩反而更贵。
扩展可以接管
session_before_compact —— 自动压缩和 /compact 都会触发,能取消({cancel: true})或提供自己的摘要。它的事件对象 preparation 里有做摘要需要的一切:messagesToSummarize(要摘要的消息)、turnPrefixMessages(劈轮时的本轮前缀)、previousSummary、fileOps、tokensBefore、firstKeptEntryId、settings(应用模型覆盖后的有效设置),以及 reason("manual" / "threshold" / "overflow")和 willRetry。
最有价值的用法是换个便宜模型出摘要——这正是 15.2 节那句「想要便宜的摘要就自己写钩子」的落点。官方示例 examples/extensions/custom-compaction.ts 演示了完整流程,包括怎么用 serializeConversation(convertToLlm(preparation.messagesToSummarize)) 自己把消息拍扁再发给另一个模型。
session_compact_failed —— 失败或被取消时触发,作用是把 session_before_compact 的尝试和终态配对。字段有 reason、errorMessage、aborted(true 表示被取消/中止,不是失败)、willRetry、fromExtension。
它的价值在于:压缩失败在界面上通常只是一条状态行,没有可编程的信号。要做「压缩成功率」看板就靠它。
分支摘要有对应的事件 session_before_tree,不管用户选不摘要都会触发,可以取消整个导航或者提供自定义摘要。
一个容易忽略的耦合
如果压缩正在进行,/tree 导航会被拒,报错是 "Wait for the current compaction or tree navigation to finish before navigating the session tree."。反过来 navigateTree() 自己也检查了一遍。两个方向都拦,错误信息还是同一句。
原因是并发改树指针的语义不明确——分支摘要要挂在「新落点」上,落点在压缩完成前后可能不一样。
15.6 事实来源
本文所有数字、默认值和开关名都可在仓库里复算。下表中 branch-summarization.ts、compaction.ts、utils.ts 都在 packages/coding-agent/src/core/compaction/ 下,其余路径为仓库相对路径:
完整事实表(含每条断言的推导命令)见专栏仓库的 research/pi-agent/FACTS.md。