跳到主内容
创建时间:最后更新:作者:白川

上下文装配与压缩

上下文从哪来、什么时候被压缩、压缩写了什么

模型每次看到的「上下文」不是一段连续的聊天记录,而是一次现场装配的结果。装配的原料有三样:磁盘上那份 JSONL 会话树、系统提示的各个分片、以及工具清单。它们被拼成消息数组,交给模型。装配不出来的时候,Pi 做压缩——用一段摘要顶掉一整段历史,但原始条目一条都不删。

这一篇要说清三件事:

  1. 上下文到底由什么拼成
  2. 什么时候开始压缩,以及触发条件究竟是什么
  3. 压缩往会话里写了什么

第二件是最容易讲错的地方。先给结论,后面展开:

触发条件是 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.

展开成可核对的清单:

输入来自哪里在请求里是什么
系统提示
system-prompt.ts 现场构建
开头的一条 system 消息
活跃分支
会话树叶子到根的路径(14.3 节)
user / assistant / toolResult 消息
工具定义
当前 loadout(内置 + 扩展 + MCP)
每次请求的 tools 字段
模型设置
当前 model + thinkingLevel
请求参数,不占消息

扩展还能往上加东西:skill 描述、上下文文件(AGENTS.md 等)、扩展的 prompt 贡献。skill 只进描述,完整指令按需加载。

系统提示是一组「分片」,不是一个字符串

buildSystemPromptSections()(src/core/system-prompt.ts:128)返回的是一个按名索引的对象。源码注释定义了这个约定:

typescript
/** 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
角色说明。默认是「You are an expert coding assistant operating inside pi…」
tools
每个可见工具一行 - <name>: <snippet>
rules
从工具的 promptGuidelines 汇总的行为规则,加上两条硬规则
docs
告诉模型 Pi 的文档和示例在哪
addendum
APPEND_SYSTEM.md 的内容
project_context
发现到的项目指令文件,每份包在 <project_instructions path="…"> 里
skills
skill 描述列表
cwd
当前工作目录

preamble 不加标签,其余每个分片都被包成同名标签:

typescript
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 里对自定义分片名有校验:

typescript
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):

typescript
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 响应还没发」的那个窗口:

text
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 和消息里声明的工具是两份东西。源码注释一句话说清了:

typescript
// Messages declare the provider-visible loadout; context.tools keeps executable implementations.

声明给模型的(模型看得见)和可执行的(context.tools 里真的能跑)可以不一致。这正是 exposure 模型存在的理由,见 工具系统与信任边界。

token 数是怎么估出来的

阈值判断需要一个「当前上下文多少 token」,Pi 有两个独立的估算器,常数不一样:

实现常数位置
pi-ai 的通用估算
chars / 3.5
packages/ai/src/utils/estimate.ts:15 CHARS_PER_TOKEN
压缩用的估算
chars / 4
src/core/compaction/compaction.ts:298 estimateTokens()

两者都刻意保守(宁可高估)。图像按 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。这两个数不是一回事,而这一点在你读下一节之前最值得先记住:

contextWindowreserveTokens触发点触发百分比
128000
16384
111616 tokens
87.2%
200000
16384
183616 tokens
91.8%
1000000
16384
983616 tokens
98.4%

因为 reserveTokens 是固定的绝对值,换窗口大小时触发百分比是变化的:**上下文窗口越大,Pi 越晚开始压缩。**这是「按百分比压缩」这种设计不会出现的行为。如果你对 1M 窗口的模型沿用 87% 的预期,实际会等到 98% 才动手。

底栏还有第三种取值:?。ContextUsage 的 tokens 可以是 null,条件是「最近一次压缩之后还没有过一条有效 assistant 响应」——此时真实值未知:

typescript
/** 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:

typescript
export function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean {
    if (!settings.enabled) return false;
    return contextTokens > contextWindow - settings.reserveTokens;
}

默认值在同一个文件 :126:

typescript
export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
    enabled: true,
    reserveTokens: 16384,
    keepRecentTokens: 20000,
};

三个数分别是:自动压缩开、预留 16384、保留最近 20000 token 不摘要。

两个值作用在不同地方,容易混:

参数作用在含义
reserveTokens
阈值
剩下这么多就动手
keepRecentTokens
切点
最近这么多原样留着,一句不摘

reserveTokens 管「什么时候」,keepRecentTokens 管「切在哪」。压缩之后,上下文大致会落到 contextWindow - reserveTokens 附近,因为切点保证了最近 20000 token 是完整的。

检查点在哪

阈值判断在一轮运行里出现三次,每次用的都是刚算出来的投影:

时机代码位置触发后
工具执行完、下一条 assistant 响应前
_compactBeforeNextAssistantResponse()(agent-session.ts:777),挂在 prepareNextTurnWithContext 上
压缩,不重试
一次运行结束后
_checkCompaction()(agent-session.ts:2978)
按情况压缩并可能重试
供应商报上下文溢出 / 响应被长度截断
同上,走 _runAutoCompaction("overflow", …)
压缩后重试一次
用户发新提示词之前
同一个准备流程
同第一行

第一种是主力。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 只有三个取值,直接告诉你这次压缩为什么发生:

reason触发者会重试吗
"manual"
你敲了 /compact
不会
"threshold"
越过阈值
不会
"overflow"
供应商报溢出,或 stopReason: "length"
一次

overflow 的重试是只给一次的。源码里有个 _overflowRecoveryAttempted 标记,第二次撞上溢出就放弃并发错误:

typescript
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 参数,但它是被显式弃用的:

typescript
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:

typescript
const historyResult = await generateSummaryWithUsage(
    messagesToSummarize, model, undefined, apiKey, headers, signal, …);

所以整条链路上,reserveTokens 没有以任何形式影响摘要的长度。

createSummarizationOptions() 里除了思考等级什么都没设——它不设 maxTokens,不设 thinking level:

typescript
const options: SimpleStreamOptions = { signal, apiKey, headers, env, sessionId };
if (model.reasoning && thinkingLevel && thinkingLevel !== "off") {
    options.reasoning = thinkingLevel;
}

两个推论:

第一,摘要走模型默认的输出上限。摘要太长会被模型的输出上限截断,而不是被 reserveTokens 限住。截断会被检测出来并且不写条目:

typescript
if (response.stopReason === "length") {
    return `${label} failed: generation hit the token cap and the summary is incomplete`;
}

这个检查存在的意义就是——**宁可不压缩,也不能把一段截断的摘要写成检查点。**因为一旦落盘,它会永久顶掉原始消息。

第二,思考等级没有被强制降级。thinkingLevel 一路从会话传下来:_getSummarizationRequestAuth() 返回 { …, thinkingLevel: this.thinkingLevel }——就是会话当前那一档。摘要请求在模型支持 reasoning 且等级不是 "off" 时,把这一档原样传上去。

这有两个实际影响:

常见误解实际情况
「压缩是后台低优先级的活,所以强制 minimal 省钱」
沿用你当前那一档。你设成 high,压缩也用 high
「压缩固定 16k 输出上限」
没有这回事。走模型默认上限

如果你真的想要便宜的摘要,那是你该自己写的事——挂一个 session_before_compact 钩子,用另一个便宜模型出摘要。官方示例是 examples/extensions/custom-compaction.ts。

摘要请求不写 prompt cache

completeSummarization() 里有一行容易被略过的设置:

typescript
const requestOptions: SimpleStreamOptions = {
    ...options,
    cacheRetention: "none",
    sessionId: options.sessionId ?? uuidv7(),
};

注释解释了原因:Avoid cache writes for one-off summaries.

判断很实在:**一次性摘要提示词几乎不会被重放,写进缓存只是浪费缓存空间和写入计费。**关掉它同时省了钱和一个潜在的额度上限。

顺带注意 sessionId 的兜底:分支摘要那些没有会话 ID 的调用会拿到一个新的随机 ID,保证路由仍然可用。

关掉自动压缩会怎样

json
{
  "compaction": {
    "enabled": false
  }
}

enabled 检查出现在两处:shouldCompact() 的第一行,以及 _checkCompaction() 的开头。两处都直接返回 false。

而手动路径 compact() 根本不检查 enabled——它只检查「有没有东西可压缩」。这就是「关掉自动压缩不影响 /compact」的实现依据,15.5 节展开。

enabled 是全局的,不参与按模型覆盖。源码和文档都这么说:

json
{ "compaction": { "modelOverrides": { "…": { "reserveTokens": 400000 } } } }

覆盖的是 reserveTokens 和 keepRecentTokens 两个 token 值。

按模型覆盖

每个值独立回退,顺序是:模型覆盖项 → 普通设置 → 内置默认。

json
{
  "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。

三条容易踩的校验规则:

规则后果
值必须是非负安全整数
匹配到的覆盖项里有非法值 → 读取时报错,不回退
覆盖项必须是对象
同上
普通的 token 设置非法也报错
即使当前模型的覆盖项是合法的

第三条很反直觉:一个模型专属的合法值救不了另一个模型的非法全局值。配置这类键要整体检查,不要只改一处。

覆盖在全局和项目 settings 里都有效,且文件先递归合并再查模型,所以全局的模型专属值会压过项目的普通值——项目必须显式覆盖那个模型项才能改回来。

解析后的值对手动压缩、全部自动阈值检查、溢出恢复、以及扩展看到的 preparation.settings 都生效。已经在跑的压缩用启动时捕获的模型和设置,不会中途改变。切换模型影响后续检查,不改普通设置。分支摘要的设置不受影响——15.4 节讲为什么。

15.3 压缩写了什么

五步

prepareCompaction() + compact() 拆开来是五步:

  1. 找切点:在投影上从后往前累加 token 估算,累积到 keepRecentTokens(默认 20000)就停
  2. 抽消息:收集从上次保留边界(或会话开头)到切点之间的消息
  3. 生成摘要:调模型,用结构化格式;有上一份摘要时把它作为迭代上下文一起传进去
  4. 追加条目:写一条 CompactionEntry
  5. 重建上下文:下次请求用「摘要 + firstKeptEntryId 起的消息」

第 5 步不是「物理删除」,而是投影时跳过。上一节和 14.3 节讲过的 buildContextEntries() 就是这一步的实现。

条目结构

typescript
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 tokensBefore from the rebuilt, context-edited session projection before writing the new CompactionEntry.

所以这个数字反映的是真正被顶掉的那部分,包含了 context_edit 的效果。

systemMessage —— 压缩边界的完整提示词快照。v3 之前的会话没有这个字段。它在重建上下文时成为第一条 system 消息,保留区间里原有的 system 消息会被丢弃:

typescript
if (entry.systemMessage) return [entry.systemMessage, summary];
return [summary];

写进去的原文长什么样

把 docs/compaction.md 的示意图翻译一遍。假设一条直线会话:

text
压缩前:
  条目  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 还在线上。它们没被删,只是落在了保留区间之前。

模型看到的是:

text
  ┌────────┬─────────┬────┬─────┬──────┬──────┬─────┬──────┐
  │ system │ summary │ usr│ ass │ tool │ tool │ ass │ tool │
  └────────┴─────────┴────┴─────┴──────┴──────┴─────┴──────┘
      ↑        ↑     └────────────┬─────────────┘
   提示词    来自 cmp       来自 firstKeptEntryId 的消息

文件里条目 10 的样子:

json
{"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"]}}

切点不能落在工具结果上

合法的切点位置只有四种:

typescript
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 有专门的图:

text
一个用户消息跨度超出了预算:
  条目  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 生成两份摘要再合并:

  1. 历史摘要——更早的上下文(有就更新,没有就用上一份摘要)
  2. 本轮前缀摘要——被劈开的那一轮的前半段

为什么要拆开而不是直接整轮摘要?因为整轮摘要会把「保留区间里还留着的那几条消息」也一起摘掉,导致模型看到的是「摘要里说了一件刚发生的事,紧接着又是这件事的原始消息」这种自相矛盾的输入。拆开之后,前半段进摘要、后半段原样保留,语义上是连贯的。

摘要的格式

压缩摘要是固定的 markdown 结构,SUMMARIZATION_PROMPT 里的原话是「Use this EXACT format」。六节的顺序和职责:

节内容
Goal
用户想达成什么
Constraints & Preferences
用户提过的约束和偏好,没有就写 (none)
Progress
三个子节:Done / In Progress / Blocked
Key Decisions
每条决策 + 简短理由
Next Steps
接下来该做什么,有序
Critical Context
继续工作需要的数据、示例、引用,没有就写 (none)

每次都提一句的要求:**「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() 生成:

text
<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()):

text
[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:

text
持久化最后一次 assistant 响应
→ 扩展/公开的 turn_end
→ 扩展/公开的 agent_end
→ 为选中的那次尝试追加 context_edit 省略
→ 对 overflow/length:跑 session_before_compact,成功则追加 compaction
→ 作为一次全新的运行开始重试

关键性质:**已完成的那次尝试仍然对 turn_end 和 agent_end 可见。**压缩是在事件之后发生的,重试是一次新运行。这样扩展看到的事件序列是连续的,模型看到的是修好的上下文。

失败的话:**保留省略 edit,不追加压缩,不安排重试。**已经排队的消息照常按 steering / follow-up 规则走。

什么时候压缩会被拒绝

prepareCompaction() 返回 undefined 有两个条件:

  1. 路径上最后一条已经是 compaction 条目(不能连着压两次)
  2. 既没有要摘要的消息,也没有本轮前缀消息(会话太小)

compact() 把这两种情况翻译成人话:

typescript
if (lastEntry?.type === "compaction") {
    throw new Error("Already compacted");
}
throw new Error("Nothing to compact (session too small)");

另外还有一个压缩内部的失败:getSummarizationFailure() 检查供应商返回,stopReason === "error" 或 "length" 都会被拒。理由是摘要一旦落盘就会永久顶掉原始消息,一个截断的摘要比不压缩糟糕得多。

15.4 分支摘要

和压缩是两套东西

先摆清区别,否则很容易把两者的参数混用:

压缩分支摘要
触发
上下文超阈值 / 溢出 / /compact
/tree 导航离开一条分支
目的
腾出上下文空间
把走过的路留下来
摘要什么
当前分支较早的部分
被放弃的那条分支
写到哪
当前分支上
新分支的落点
条目类型
compaction
branch_summary
reserveTokens 的角色
阈值,决定何时动手
真实 token 预算,决定摘要多少内容
带工具结果吗
带(截断到 2000 字符)
不带,直接跳过

最后两行是本文要重点说的,因为它们正好和「reserveTokens 是阈值不是上限」那条形成对照。

收集什么

collectEntriesForBranchSummary()(branch-summarization.ts:108):找共同祖先(新旧两个位置上都出现的最深节点),从旧叶子沿 parentId 往回走到共同祖先,反转成时间顺序。

text
导航前:                      导航后(带摘要):
         ┌─ 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)把它减成了内容预算:

typescript
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 里继承文件列表
  • 第二趟:从新到旧加消息,加到超预算为止

第一趟先做全量扫描,是为了嵌套摘要的文件列表不会因为预算截断而丢。代码里也标了来源过滤:

typescript
if (entry.type === "branch_summary" && !entry.fromHook && entry.details) {

!entry.fromHook 的意思是:**只继承 Pi 自己生成的摘要的文件列表,扩展生成的不继承。**文档把这条讲得更直接:

Pi does not automatically carry file lists from extension-generated summaries whose fromHook field is true; extensions manage their own details format.

理由是 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):

组合效果
只有 customInstructions
追加在标准 prompt 后面,作为「Additional focus」
replaceInstructions + customInstructions
完全替换标准 prompt

条目结构

typescript
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() 里:

typescript
case "compaction":
    return createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp);

被摘要的分支里如果有压缩条目,它的摘要也会被当作一条「压缩摘要消息」送进分支摘要的输入。所以一份分支摘要里可能嵌着另一份摘要的摘要——三层嵌套也是合法的。

另一面:**分支摘要的设置不会被压缩的模型覆盖影响。**文档明确说 "Branch summarization settings are unaffected." 两条路径的 token 设置是分开的配置项(compaction.* 和 branchSummary.*)。

15.5 手动压缩

/compact [instructions]

指令是可选的,用来告诉摘要「这次特别要保住什么」:

text
/compact 重点保住我们关于缓存失效策略的讨论和最终选型

带指令时,指令会追加到标准 prompt 后面。compact() 的签名:

typescript
async compact(customInstructions?: string): Promise<CompactionResult>

三条实现事实

**第一,手动路径不检查 enabled。**对比一下三条路径:

路径入口查 settings.enabled 吗
自动(阈值)
shouldCompact()
是,!enabled 直接 return false
自动(轮间检查)
_checkCompaction()
是,!enabled 直接 return false
手动
compact()
否

所以 "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 时翻译成人话:

报错含义怎么办
Already compacted
路径最后一条就是压缩条目
这是保护,不是错误。想再压一次先发条消息
Nothing to compact (session too small)
会话内容还没超过 keepRecentTokens
等内容多一些,或者调小 keepRecentTokens

第二个报错在会话刚开始时很容易撞上:keepRecentTokens 默认 20000,会话不到那个量级就没有可摘要的部分。

什么时候值得手动压缩

自动压缩只在超阈值时动手,所以有几种情况手动更合适:

场景为什么手动
即将切到一个完全不同的任务
让摘要带着 /compact <指令>,明确保住哪些决策
上下文里堆了很多失败的尝试
手动压缩 + 指令「只保留成功路径,失败的实验可以扔掉」
要切小窗口模型
手动压一次比让它撞溢出再恢复干净
想在干净状态归档
手动压一次,之后的文件小很多

反面:**不要靠手动压缩来「省 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/ 下,其余路径为仓库相对路径:

断言来源
触发条件 contextTokens > contextWindow - reserveTokens
src/core/compaction/compaction.ts:267 shouldCompact()
enabled 默认 true;reserveTokens 16384;keepRecentTokens 20000
src/core/compaction/compaction.ts:126 DEFAULT_COMPACTION_SETTINGS;src/core/settings-defaults.ts:9-13
分支摘要 reserveTokens 默认 16384
branch-summarization.ts:305
分支摘要的 reserveTokens 真的被减成 token 预算
branch-summarization.ts:313 tokenBudget = contextWindow - reserveTokens
分支摘要在模型无 contextWindow 时按 128000 兜底
branch-summarization.ts:312
摘要请求的 _reserveTokens 参数已弃用,走模型输出上限(#9512)
src/core/compaction/compaction.ts:644、:696 的参数注释
调用点传 undefined 作为该参数
src/core/compaction/compaction.ts:984 的 generateSummaryWithUsage(…, undefined, …)
摘要请求不设思考等级之外的参数,只在非 off 时透传
src/core/compaction/compaction.ts:595 createSummarizationOptions()
摘要沿用会话当前 thinkingLevel
src/core/agent-session.ts:573 _getSummarizationRequestAuth()
摘要请求 cacheRetention: "none"
src/core/compaction/compaction.ts:618 completeSummarization()
摘要 stopReason: "length" 时拒绝落盘
src/core/compaction/compaction.ts:585 getSummarizationFailure()
自动阈值检查挂在 prepareNextTurn
src/core/agent-session.ts:777 _compactBeforeNextAssistantResponse();:905 prepareNextTurnWithContext
contextWindow <= 0 时不做阈值检查
src/core/agent-session.ts:767 _exceedsCompactionThreshold()
reason 取值 manual / threshold / overflow
src/core/agent-session.ts:209、docs/compaction.md
溢出恢复只尝试一次
src/core/agent-session.ts:3040 _overflowRecoveryAttempted
溢出恢复先用 context_edit 隐藏失败尝试
src/core/agent-session.ts:1240 _omitRecoveryAttempt()
压缩的 token 估算用 chars / 4
src/core/compaction/compaction.ts:298 estimateTokens()
pi-ai 的通用估算用 chars / 3.5
packages/ai/src/utils/estimate.ts:15 CHARS_PER_TOKEN
usage 不可信的三种情况(aborted / error / 全零)
src/core/compaction/compaction.ts:148 getAssistantUsage()
context_edit / compaction 会使之前的 usage 失效
src/core/compaction/compaction.ts:227 estimateProjectedContextTokens()
系统提示分片集合与命名规则
src/core/system-prompt.ts:128 buildSystemPromptSections()
分片名必须匹配 ^[a-z][a-z0-9_-]*$ 且不能叫 preamble
src/core/system-prompt.ts:56、:145
非 preamble 分片被包成同名标签
src/core/system-prompt.ts:188-191
分片 diff:变了写新值,缺失写 null,空 diff 返回 undefined
src/core/system-prompt.ts:217 diffSystemPromptSections()
每次有分片变化就追加一条 system 消息
src/core/agent-session.ts:1743 _preparePromptAndToolLoadout()
四份输入(提示词 / 活跃分支 / 工具 / 模型设置)
docs/how-pi-works.md(Context 与 Agent loop 段)
ContextUsage.tokens 可为 null,此时显示 ?
src/core/extensions/types.ts:306;src/core/agent-session.ts:4267 getContextUsage()
压缩后尚无有效 assistant 响应时 token 未知
src/core/agent-session.ts:4277 及其上注释
CompactionEntry 字段结构
src/core/session-manager.ts:91
BranchSummaryEntry 字段结构
src/core/session-manager.ts:106
firstKeptEntryId 语义与「什么都不保留」自指
docs/session-format.md(CompactionEntry 段)
systemMessage 成为压缩后上下文的首条 system 消息
src/core/session-manager.ts:439 sessionEntryToContextMessages()
重建时取最后一个压缩、丢弃保留区间里的 system 消息
src/core/session-manager.ts:476 buildContextEntries()
连续压缩从上一次保留边界开始摘要
docs/compaction.md(How It Works 段末)
切点合法角色与「toolResult 永不切」
src/core/compaction/compaction.ts:351 isCutPointMessage()
一轮超预算时的 split user-message span 与两份摘要
docs/compaction.md(Split user-message spans 段);src/core/compaction/compaction.ts:789 findProjectedCutPoint()
压缩摘要六节格式与 UPDATE prompt
src/core/compaction/compaction.ts:507、:540、:577
分支摘要五节格式(无 Critical Context)
branch-summarization.ts:258 BRANCH_SUMMARY_PROMPT
摘要输入先 serializeConversation() 拍扁
src/core/compaction/utils.ts:120
thinking 块被省略及其理由
src/core/compaction/utils.ts:106-119 注释
工具结果截断到 2000 字符
src/core/compaction/utils.ts:94 TOOL_RESULT_MAX_CHARS
文件列表只从 read / write / edit 的 path 抽取
src/core/compaction/utils.ts:47 addFileOp()
文件列表累积,fromHook: true 的不继承
branch-summarization.ts:204;docs/compaction.md(Cumulative File Tracking 段)
分支摘要跳过 toolResult
branch-summarization.ts:159 getMessageFromEntry()
分支摘要从新到旧按预算收集
branch-summarization.ts:195 prepareBranchEntries()
共同祖先与待摘要条目的收集
branch-summarization.ts:108 collectEntriesForBranchSummary()
摘要条目追加在新分支落点
src/core/agent-session.ts:4140
手动压缩不检查 enabled
src/core/agent-session.ts:2794 compact() 对比 :2982 _checkCompaction()
手动压缩先 abort() 且不重试
src/core/agent-session.ts:2795 及上方注释
「Already compacted」与「Nothing to compact」两条报错
src/core/agent-session.ts:2815-2817
session_before_compact / session_compact_failed / session_before_tree 事件
docs/compaction.md(Custom Summarization via Extensions 段)
compaction.* 与 branchSummary.* 全部设置项及默认值
docs/settings.md:73-89
按模型覆盖的三层回退与校验规则
docs/compaction.md(Per-model overrides 段)
enabled 是全局的、不参与模型覆盖
同上
压缩时导航被拒的反向检查
src/core/agent-session.ts:4001;src/modes/interactive/interactive-mode.ts:5644
溢出恢复的完整事件时序
docs/compaction.md(Overflow and Length Recovery Ordering 段)

完整事实表(含每条断言的推导命令)见专栏仓库的 research/pi-agent/FACTS.md。