Skip to content

6.6 Context 构造与 Compaction ​

本页分析版本earendil-works/pi@16787ad2026-09-21

本章解决什么问题:发给大语言模型(LLM,Large Language Model)的那个 messages 数组,到底是从会话文件里怎么算出来的?当对话长到装不下模型的上下文窗口时,Pi 在哪一行代码决定「该压缩了」,压缩又具体做了什么、旧消息去哪了、会话怎么继续。 前置知识:3.2 消息、上下文与 token、3.7 Compaction、Extension 与 Skill、5.5 Session 的创建、保存与恢复、6.5 Session 存储格式与会话树。 学习目标:读完后你能 ① 说清「会话条目树 → messages 数组 → LLM 请求」这条投影链上每一步的函数名,以及 context_edit 在哪一步生效;② 写出 Pi 触发自动压缩的那一行判断式,说出 token 数从哪来、预算怎么按模型取值;③ 解释切点算法为什么永远不在 toolResult 上切;④ 区分三个检查时机和「阈值」「溢出」两条路径,以及后者的一次性重试保险丝;⑤ 指出仓库里两套 compaction 实现的分工与差异。

建立直觉:上下文是会话树的一个投影 ​

上一章(6.5)留下的结论是:会话文件是一棵只追加(append-only)的条目树。但模型 API 只认一个扁平数组。这中间必然有一次投影——从树上选一条路径,再把路径上的每个条目翻译成 0 条、1 条或几条消息。

📘 概念上下文投影(Context projection)
把持久化的会话结构(树)压平成一次请求所需的线性消息数组的过程。投影是每次请求前重新算一遍的,不是一份被反复修改的状态。这意味着:改变投影规则,或者往树上追加一条「改写投影」的条目,就改变了模型看到的历史,而磁盘上已有的历史一个字都没动。

有了这个视角,3.7 里那句「压缩就是把旧消息换成摘要」可以说得更准确:上下文压缩(Context Compaction)不是「删掉旧消息」,而是在树上插入一个新节点,让下一次投影从更靠后的地方开始,前面的部分用一段摘要顶替。旧条目仍然躺在 .jsonl 文件里,只是不再被投影出来。3.7 把压缩概括成三步(选切点、生成摘要、替换并记录),本章把这三步逐一落到函数上,并回答它没展开的两个问题:什么时候压、压完怎么接着聊。

⚠️ 常见误解以为 compaction 会重写或删除会话文件
不会。压缩只调用一次 appendCompaction 往文件尾部追加一行(packages/coding-agent/src/core/session-manager.ts:1259-1284),既不改旧行也不新建文件。同样,溢出恢复时要让一条失败的回答「不再被模型看到」,Pi 也不删它,而是再追加一条 context_edit 条目(见下文)。你随时可以用文本编辑器打开会话文件,看到被「压掉」「省略掉」的那些消息原封不动地在那里。

最小示例:手写一次折叠 ​

先脱离 Pi,用四十行代码复现折叠规则。把下面两段依次拼进同一个 context-projection.mjs。第一段是「一棵已经压缩过的会话路径」和折叠函数:

js
const entries = [
	{ id: "e1", type: "message", role: "user", text: "第 1 轮提问" },
	{ id: "e2", type: "message", role: "assistant", text: "第 1 轮回答" },
	{ id: "e3", type: "model_change" },
	{ id: "e4", type: "message", role: "user", text: "第 2 轮提问" },
	{ id: "e5", type: "message", role: "assistant", text: "第 2 轮回答" },
	{ id: "e6", type: "compaction", summary: "前两轮的摘要", firstKeptEntryId: "e4" },
	{ id: "e7", type: "message", role: "user", text: "第 3 轮提问" },
];

function buildContextEntries(path) {
	let compaction = null;
	for (const e of path) if (e.type === "compaction") compaction = e; // 只认最后一个
	if (!compaction) return path;
	const idx = path.findIndex((e) => e.id === compaction.id);
	const out = [compaction];
	let keep = false;
	for (let i = 0; i < idx; i++) {
		if (path[i].id === compaction.firstKeptEntryId) keep = true;
		if (keep) out.push(path[i]);
	}
	out.push(...path.slice(idx + 1));
	return out;
}

第二段是「条目变消息」的投影和打印:

js
const toMessages = (e) =>
	e.type === "message"
		? [{ role: e.role, text: e.text }]
		: e.type === "compaction"
			? [{ role: "compactionSummary", text: e.summary }]
			: []; // model_change 不进上下文

const context = buildContextEntries(entries);
console.log("entries :", entries.map((e) => e.id).join(" "));
console.log("context :", context.map((e) => e.id).join(" "));
console.log("messages:", context.flatMap(toMessages).map((m) => `${m.role}=${m.text}`).join(" | "));

node context-projection.mjs 的输出(本书用 Node 26 实测):

entries : e1 e2 e3 e4 e5 e6 e7
context : e6 e4 e5 e7
messages: compactionSummary=前两轮的摘要 | user=第 2 轮提问 | assistant=第 2 轮回答 | user=第 3 轮提问

注意三件事:e1/e2 消失了(被摘要顶替);摘要条目 e6 被排到了最前面,尽管它在文件里是倒数第二行;e3 这种 model_change 条目投影出 0 条消息。这三点正是 Pi 的真实行为。真实 Pi 还多做两件这个玩具没做的事:压缩条目会顺带投影出一条系统提示词快照;另有一种 context_edit 条目可以事后把某一条消息从投影里拿掉或改写。下面逐一看。

回到 Pi 源码:messages 是怎么拼出来的 ​

第一步:条目 → 消息 ​

packages/coding-agent/src/core/session-manager.ts · sessionEntryToContextMessages
earendil-works/pi@16787ad第 439–466 行在 GitHub 查看 ↗
把单个 SessionEntry 投影成若干条 AgentMessage。message 直接透传(内容缺失时补空);custom_message、branch_summary 各自造一条消息;compaction 造一条摘要消息,若条目里存了 systemMessage 快照就把它排在摘要前面;其余类型返回空数组。

会话条目一共十一种(SessionEntry 联合类型,session-manager.ts:183-194),直接产出模型上下文的只有四种(源码事实):

条目类型投影结果说明
message原消息包括 role: "system" 的系统提示词消息(见 6.7);content 为 null 时,system 补成 "",user/assistant/toolResult 补成 [](防御手写文件)
custom_messagecustom 角色消息扩展(Extension)注入的消息
branch_summarybranchSummary 角色消息分支摘要;summary 为空串时不产出
compactionsystemMessage(若有)+ compactionSummary 角色消息压缩摘要,以及压缩那一刻的系统提示词与工具状态快照(CompactionEntry.systemMessage,session-manager.ts:102-103)
context_edit[]自己不产出消息,但会改写它指向的那条目的投影,见第三步
model_change / thinking_level_change / usage / custom / label / session_info[]只影响设置、计费或显示,不进上下文

第二步:折叠 ​

earendil-works/pi@16787ad第 476–512 行在 GitHub 查看 ↗
沿当前 leaf 路径找最后一个 compaction 条目;找到就重排为「压缩条目 + 从 firstKeptEntryId 起的尾巴 + 压缩点之后的全部」,被摘要的旧条目从上下文里消失。保留的尾巴里跳过 system 消息,因为压缩条目自带的快照已经代表了当时的系统状态。

一个边界情况值得知道:appendCompaction 的 firstKeptEntryId 参数允许传 null,此时它把压缩条目自己的 id 写进 firstKeptEntryId(session-manager.ts:1276)。折叠循环只扫描压缩点之前的条目,永远碰不到这个 id,于是一条都不保留——官方文档称之为 retain-none compaction(packages/coding-agent/docs/compaction.md:83)。内置的压缩流程总会算出一个真实的切点;这种「只留摘要」的压缩来自扩展在边界事件里提交的压缩草稿(CompactionEntryDraft,packages/coding-agent/src/core/extensions/types.ts:781-788,注释写明 null 表示不保留任何前序条目;落地在 agent-session.ts:757-770)。

第三步:套用 context_edit,得到最终投影 ​

earendil-works/pi@16787ad第 543–573 行在 GitHub 查看 ↗
把前两步串起来并套用编辑:先收集折叠结果里所有 context_edit 条目(同一目标以最后一条为准),再对每个条目调用 projectContextEntry;保留区里如果还夹着更早的压缩条目,只让排在最前面的最新一次压缩产出摘要。返回值同时保留「每条消息来自哪个条目」的对应关系。

context_edit 条目(ContextEditEntry,session-manager.ts:175-180)只有两个字段:targetId 指向更早的一个条目,replacement 为 null 表示「从模型上下文里省略它」,否则用新内容只替换它的 content。套用逻辑在 projectContextEntry(session-manager.ts:519-540)。追加时 appendContextEdit(session-manager.ts:1358 起)会检查目标必须在当前分支上,且只能是 user/assistant/toolResult 消息或 custom_message。

这是一种很典型的只追加设计:想「删掉」一条消息,就再写一条「请忽略它」;原消息仍在文件里,导出、计费、历史搜索都还能看到它。Pi 自己用它来处理失败的请求(见下文溢出恢复),扩展也可以通过边界事件提交这种编辑(扩展机制见 7.1 Extension 系统)。

buildSessionContext(session-manager.ts:576-583)只是取 buildSessionProjection 的 messages,顺带返回从路径上推导出的思考等级与模型(getSessionContextSettings,session-manager.ts:418-433)。

第四步:每次请求前重投影 ​

投影结果怎么进入 Agent?有两处(源码事实):

  • Agent 状态:恢复会话时 packages/coding-agent/src/core/sdk.ts:194 算出 existingSession,在 sdk.ts:372 作为初始 messages 交给 new Agent;之后每次压缩、导航会话树、省略失败尝试之后,_refreshFinalizedContext(agent-session.ts:730-736)都用最新投影覆盖 agent.state.messages。
  • 每一次请求:AgentSession 在 Agent 上装了一个 prepareRequest 钩子(_installAgentRequestProjection,agent-session.ts:608-632),Agent Loop 每次发请求前都会调用它(packages/agent/src/agent-loop.ts:218),它把请求上下文里的 messages 直接换成 this.sessionManager.buildSessionProjection().messages(agent-session.ts:613)。

第二处说明「投影每次请求前重算」不是一句比喻:真正发出去的消息数组,每次都从会话树现算。

第五步:AgentMessage → LLM 消息 ​

投影出来的元素是 AgentMessage——它比模型 API 认识的 Message 多出四种自定义角色(bashExecution、custom、branchSummary、compactionSummary,声明见 packages/coding-agent/src/core/messages.ts:70-77)。发请求前必须翻译一次:

earendil-works/pi@16787ad第 148–196 行在 GitHub 查看 ↗
四种自定义角色全部翻译成 user 消息;压缩摘要被包进 COMPACTION_SUMMARY_PREFIX/SUFFIX;system/user/assistant/toolResult 原样透传;带 excludeFromContext 的 bash 消息直接丢弃。

这就是压缩摘要在模型眼里的最终形态(messages.ts:11-17、176-183):

ts
export const COMPACTION_SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary:

<summary>
`;
// …(省略:convertToLlm 的 case "compactionSummary" 用前后缀包住 m.summary,role 设为 "user")

也就是说,模型收到的不是什么特殊字段,就是一条普通的 user 消息,正文告诉它「这段是压缩过的历史摘要」。

第六步:组装请求 ​

packages/agent/src/agent-loop.ts · streamAssistantResponse
earendil-works/pi@16787ad第 380–406 行在 GitHub 查看 ↗
Agent Loop(Agent 循环)里唯一做 AgentMessage[] → Message[] 转换的地方:先跑可选的 transformContext,再跑 convertToLlm,用 normalizeContext 包成只含 messages 的请求上下文,最后交给 streamFunction。

注意两点。第一,coding-agent 传进去的其实是包了一层图片拦截的 convertToLlmWithBlockImages(sdk.ts:268-301,在 sdk.ts:374 作为 convertToLlm 传给 new Agent)。第二,请求上下文里已经没有单独的 systemPrompt 和 tools 字段:normalizeContext(packages/ai/src/utils/transcript.ts:30-34)产出的请求上下文只有一个 messages 数组,系统提示词和工具声明都由数组里的 system 消息携带(细节见 6.7)。

图 6.6-1 从会话文件到一次模型请求的投影链
从左到右读。左边四步在 coding-agent 的 SessionManager 里,右边四步在 pi-agent-core 的 Agent Loop 里,AgentSession 装进 Agent 的 prepareRequest 钩子是两者的交接点。

每个节点都能落到源码:buildSessionPath(session-manager.ts:390)、buildContextEntries(476)、buildSessionProjection(543,内部调用 sessionEntryToContextMessages,439)、prepareRequest(agent-loop.ts:218 调用,agent-session.ts:608 安装)、transformContext 与 convertToLlm(agent-loop.ts:389-394)、normalizeContext(agent-loop.ts:396)、streamFunction(agent-loop.ts:402)。请特别注意:压缩和 context_edit 都只作用在链条左半边——它们改的是「哪些条目、以什么内容参与投影」,右边的转换逻辑对它们一无所知。

token 从哪里来:usage 优先,估算兜底 ​

要判断「上下文快满了」,得先知道当前用了多少 token。Pi 的第一数据源不是本地分词器,而是 Provider(模型服务提供方)在上一次响应里回报的 usage(源码事实):

ts
// packages/coding-agent/src/core/compaction/compaction.ts:162-164
export function calculateContextTokens(usage: Usage): number {
	return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
}

没有可用 usage 时才退到估算:estimateContextTokens(compaction.ts:218-246)以「最后一条有效 assistant 消息的 usage」为锚点,把锚点之后的消息用 estimateTokens(compaction.ts:320-371)按字符数除以 4 估出来,图片按 4800 字符计(ESTIMATED_IMAGE_CHARS,compaction.ts:298),system 消息把各个 section 与新增工具的 JSON 也算进去。getAssistantUsage(compaction.ts:170-183)会跳过 aborted、error 和全零 usage 的消息。

投影能被改写之后,usage 还多了一个陷阱:一条 assistant 消息回报的 usage,反映的是它发出时的上下文;如果之后又追加了 context_edit 或压缩,这个数字就过时了。estimateProjectedContextTokens(compaction.ts:249-286)专门处理这件事:先照常估算,再检查锚点 usage 所在的条目是否晚于分支上最近一次 context_edit 或 compaction;不是的话就整个丢掉 usage,对投影里的每条消息按字符数重估(system 消息只算当前生效的那一份)。

🌱 初学者提示为什么 Pi 不自带 tokenizer
不同厂商的分词方式不同,本地实现一份既大又不准。从源码结构看,Pi 选择「信任 Provider 回报的数字 + 粗糙估算兜底」,代价是:刚压缩完、还没发过新请求时,Pi 其实不知道当前 token 数。这个代价在界面上是可见的:状态栏读的是 getContextUsage(agent-session.ts:3858-3900),该函数在「最近一次压缩之后,投影里还没有任何有效 assistant usage」时直接返回 percent: null(agent-session.ts:3890),界面就把百分比渲染成 ?(modes/interactive/components/footer.ts:110-113 取值、152-155 渲染,后缀 (auto) 来自同文件 151 行的 autoIndicator)。真实采集的状态栏(research/cli-captures/pi-tui-main.txt,尾行 0.0%/0 (auto))是另一种情况:采集时没有可用模型,窗口为 0,getContextUsage 返回 undefined(agent-session.ts:3862-3863),界面按 0 显示。

判据本身只有两行:

earendil-works/pi@16787ad第 289–292 行在 GitHub 查看 ↗
触发公式:启用的前提下,当前 token 数超过「上下文窗口减去预留量」即压缩。

默认值 enabled: true、reserveTokens: 16384、keepRecentTokens: 20000(compaction.ts:148-152)。运行时的值由 SettingsManager.getCompactionSettings(model) 汇总(packages/coding-agent/src/core/settings-manager.ts:896-906),它接收当前模型:

  • reserveTokens 与 keepRecentTokens 各自按「模型覆盖 → 普通设置 → 内置默认」的顺序取值(getCompactionTokenSetting,settings-manager.ts:859-885,关键是 884 行的 override ?? ordinary ?? DEFAULT_COMPACTION_TOKEN_SETTINGS[field])。模型覆盖写在 compaction.modelOverrides 里,键是精确的 "provider/modelId"(settings-manager.ts:23-28)。值不是非负安全整数时,读取就直接抛错。
  • enabled 只有全局一份(getCompactionEnabled,settings-manager.ts:846-848,?? true),不能按模型开关。

所有压缩入口都把当前模型传进去:判定(agent-session.ts:2604)、自动压缩(2749)、手动压缩(2418)、轮间检查(589)。这些设置可在 ~/.pi/agent/settings.json 或项目内 .pi/settings.json 覆盖——配置系统见 6.8 配置系统。

官方文档说明了这套触发条件、默认值与按模型覆盖(来源文件:packages/coding-agent/docs/compaction.md 的 “When It Triggers” 与 “Per-model overrides” 两节,后者在 439-459 行),与源码一致。

什么时候检查:三个时机 ​

自动压缩不是定时任务,而是挂在三个时机上(源码事实):

  1. 一次运行内部、两轮之间:模型调用了工具、工具结果已经追加、下一次模型请求还没发出时,Agent Loop 会调用 prepareNextTurn(packages/agent/src/agent-loop.ts:185)。AgentSession 装的这个钩子(_installAgentNextTurnRefresh,agent-session.ts:687-694)第一件事就是 _compactBeforeNextAssistantResponse(agent-session.ts:587-606):用 estimateProjectedContextTokens 估算当前投影,超过阈值就先做一次阈值压缩,再把压缩后的投影交给下一次请求。这个检查只在循环确实要继续时才发生——工具批次要求结束运行、又没有排队消息时,循环直接退出,不会走到这里。
  2. 一次运行结束之后:_runAgentPrompt(agent-session.ts:1468-1490)在 agent.prompt() 返回后进入循环,每圈先调 _handlePostAgentRun(1492-1529),它在 1522 行调用 _checkCompaction(message, true, toolResults)。返回 true 就 agent.continue() 再跑一圈。
  3. 发新 prompt 之前:prompt() 在 agent-session.ts:1693-1698 对最后一条 assistant 消息再查一次,且传 skipAbortedCheck=false(能捕获被用户中断的响应)。这里故意忽略返回值——注释说明用户的新 prompt 马上就发,不需要 agent.continue()。

第 1 个时机只看阈值;溢出恢复需要知道「最后一次尝试失败了」,所以只在第 2、3 个时机里由 _checkCompaction 处理。

earendil-works/pi@16787ad第 2599–2735 行在 GitHub 查看 ↗
压缩判定的全部逻辑:三道前置闸门,然后分成溢出(含可恢复的 length 截断)与阈值两大类,返回值表示「外层是否需要继续跑一圈」。

图 6.6-2 _checkCompaction 的完整决策路径
自上而下读。前三个菱形是「前置闸门」,任何一个不满足都直接放弃;左侧分支是溢出,右侧是阈值。三个 C 节点最终都调用同一个 _runAutoCompaction,区别只在 reason 与 willRetry 参数。

图中每个判断都能对上源码行:启用检查 2604-2605、中断检查 2608、同模型检查 2616-2617、时间戳检查 2622-2627、溢出分支 2629-2697、阈值分支 2699-2733。几个细节值得单独说:

  • 同模型检查:注释解释了动机——用户从小窗口模型切到大窗口模型后,旧模型留下的溢出错误不该再触发压缩。
  • 时间戳检查:getLatestCompactionEntry(session-manager.ts:372)拿到最近一次压缩条目,凡是早于它的 assistant 消息一律跳过。没有这道闸门,压缩后保留下来的那些「旧 usage」会立刻把刚压缩完的会话再压一次。
  • 可恢复截断:除了溢出,isRecoverableLength(packages/ai/src/utils/overflow.ts:178-180)还认一种情况——stopReason 是 length,而输出 token 数低于模型原本的输出上限(model.maxTokens,agent-session.ts:2660-2661)。输出没写满就被截断,多半是上下文太挤,同样走一次「压缩再重试」。
  • 投影一致性:溢出判定还要确认这条 assistant 消息仍在当前投影里、之后没有被 context_edit 改写或省略过(agent-session.ts:2632-2659);阈值分支在投影里有 context_edit 时改用 estimateProjectedContextTokens(2705-2708)。两者都是为了不拿一份已经不代表当前上下文的 usage 做决定。

上下文溢出:二十多条正则背后的脏活 ​

阈值判断依赖 usage 数字,但有些情况下模型直接报错、或者根本没给出可信数字。这就是 overflow.ts 存在的理由:

earendil-works/pi@16787ad第 136–170 行在 GitHub 查看 ↗
三种溢出形态的统一检测:错误文本匹配、静默溢出、length-stop 溢出。contextWindow 参数缺省时只做第一种。
  • 报错型:stopReason === "error" 且错误文本命中 OVERFLOW_PATTERNS(24 条正则,overflow.ts:37-62,覆盖 Anthropic 与 z.ai 的 prompt too long、OpenAI 的 exceeds the context window、Together、Groq、llama.cpp、LM Studio、Kimi、Mistral、DashScope 等)。Cerebras 的「400/413 且没有响应体」单独成一条 CEREBRAS_BODYLESS_OVERFLOW_PATTERN(overflow.ts:64),只在 provider === "cerebras" 时才用(145-147),免得别家一个空响应体的 400 也被当成溢出。为防误判还有一份 3 条的排除清单 NON_OVERFLOW_PATTERNS(overflow.ts:75-79)——Bedrock 的限流错误文案里带 “Too many tokens”,不排除就会被当成溢出。
  • 静默型:stopReason === "stop",请求成功了,但 usage.input + usage.cacheRead 超过窗口(overflow.ts:152-157,文件头注释指名 z.ai 有时会这样)。
  • length-stop 型:stopReason === "length" 且 output === 0 且输入填满窗口的 99%(overflow.ts:162-167,注释指名小米 MiMo:服务端把超长输入截断到刚好塞满,于是一个 token 都吐不出来)。

这个函数被两处消费:_checkCompaction 的溢出分支(agent-session.ts:2655、2659),以及重试分诊 _isRetryableError(agent-session.ts:3326-3330)——溢出错误明确不走普通重试,因为重试一次仍然会溢出,得先压缩(重试机制见 5.6 取消与错误处理)。

溢出分支里有两个很值得学的工程细节(源码事实):

  1. 一次性保险丝 _overflowRecoveryAttempted(声明于 agent-session.ts:355)。压缩后自动重试只允许一次:再溢出就不再尝试,而是发一条带 errorMessage 的 compaction_end 事件建议用户换大窗口模型,同时通知扩展 session_compact_failed(agent-session.ts:2671-2692)。保险丝在新的 user 消息开始时(898),或收到一条既不是 error 也不是 length 的 assistant 回复时(948-950)复位。没有它,「压缩 → 重试 → 又溢出 → 再压缩」就是一个会烧钱的死循环。
  2. 失败的尝试被「省略」而不是删除。压缩之前,_omitRecoveryAttempt(agent-session.ts:1015-1031)为失败的 assistant 消息及其工具结果各追加一条 replacement: null 的 context_edit,再刷新投影(2694-2696)。于是重试时模型看不到那次失败,而原始记录仍在文件里。普通的自动重试也用同一个方法处理失败尝试(agent-session.ts:3403-3404)。官方文档把整个顺序列成了一张清单(packages/coding-agent/docs/compaction.md:85-98):先持久化最终回复、发完 turn_end 与 agent_end,再追加省略编辑、压缩,最后作为一次新运行开始重试;压缩失败时省略编辑保留、不会重试。

一种看法是:把这三类判断塞进一个正则表清单,是在为「几十家 Provider 各说各话」买单,代价是每接一个新后端可能都要补一条正则(文件注释里甚至写了教用户自己加正则的步骤,overflow.ts:121-130)。收益是 Agent 主流程只需要问一句 isContextOverflow。

compaction 到底做了什么 ​

准备:算切点 ​

earendil-works/pi@16787ad第 894–958 行在 GitHub 查看 ↗
纯函数准备阶段:处理「已经压缩过」的短路;在套用了 context_edit 的投影上定位上一次压缩与它的摘要;估算压缩前 token;算切点;切出待摘要消息与 turn 前缀消息;抽取文件操作清单。

几个要点(源码事实):

  • 最后一条已是 compaction 就返回 undefined(898-900)。
  • 一切都在 buildSessionProjection(pathEntries) 的结果上算(903),所以被 context_edit 省略的消息既不会被摘要,也不占切点预算。
  • 若存在上一次压缩,取它的 summary 作为 previousSummary 做迭代更新,起点紧跟在投影里的压缩条目之后(914-916)。投影已经选好了上次保留的尾巴,所以上次保留的消息这次会被重新摘要。
  • system 消息不参与摘要(getMessagesFromProjectedEntryForCompaction,compaction.ts:97-101,注释说明系统状态由压缩条目的快照负责)。
  • 待摘要消息与 turn 前缀消息双双为空时同样返回 undefined(936),自动路径据此静默放弃。

切点由 findProjectedCutPoint(compaction.ts:824-892)计算,它是 findCutPoint(468-523,仍然导出、有单测)的投影版,算法相同:

earendil-works/pi@16787ad第 824–892 行在 GitHub 查看 ↗
切点算法:从最新往回累加估算 token,攒够 keepRecentTokens 后,取该位置之后最近的一个合法切点;若切点之后只剩被省略的失败尝试,就把切点再往后挪一格;再往前吞掉不产生消息的元数据条目;最后判断是否切在了一个 turn 的中间。

合法切点由 isCutPointMessage(compaction.ts:373-386)决定:user、assistant、bashExecution、custom、branchSummary、compactionSummary 都可以切,toolResult 永远不行。原因很直白——工具结果必须紧跟它的 tool call,单独留下一个「没有请求的结果」会让模型 API 直接报格式错误。

如果切点不是一个 turn 的起点(isTurnStartMessage,compaction.ts:388-401),就标记 isSplitTurn 并回溯出这个 turn 的起点(findProjectedTurnStartIndex,compaction.ts:817-822)。

生成:一次隔离的模型调用 ​

compact()(compaction.ts:987-1093)负责生成摘要。非 split-turn 时只调一次 generateSummaryWithUsage(718-788):先 convertToLlm 再 serializeConversation(compaction/utils.ts:109-150)把消息序列化成纯文本,包进 <conversation> 标签,配上结构化提示词(SUMMARIZATION_PROMPT,compaction.ts:529-560,要求输出 ## Goal / ## Progress / ## Next Steps 等固定小节;有 previousSummary 时改用 UPDATE_SUMMARIZATION_PROMPT,599-601,规则正文在 562-597)。

为什么要序列化成文本而不是直接把消息数组发过去?源码注释写得很明白:This prevents the model from treating it as a conversation to continue(utils.ts:103)。配套的系统提示词也在反复强调「不要接着聊,只输出摘要」(SUMMARIZATION_SYSTEM_PROMPT,utils.ts:156-158,由 buildSummarizationContext 放进请求,compaction.ts:704-715)。工具结果在序列化时截断到 2000 字符(utils.ts:89、144)。摘要响应如果是 error 或被 length 截断,直接抛错而不是把半截摘要写进会话(getSummarizationFailure,compaction.ts:607-615,调用在 777-780);模型要是在摘要里调用工具,同样抛错(781-783)。

split-turn 时额外调一次 generateTurnPrefixSummary(compaction.ts:1098-1141),两段用 **Turn Context (split turn):** 合并(1054)。摘要末尾再拼上文件操作清单(1079-1080)。预算方面:主摘要 maxTokens = min(0.8 × reserveTokens, model.maxTokens)(734-737),turn 前缀是 0.5 ×(1112-1115)。这里的 reserveTokens 就是上文按模型取到的那个值。

earendil-works/pi@16787ad第 641–661 行在 GitHub 查看 ↗
所有摘要调用的唯一出口:强制关闭 prompt cache 写入;调用方没给 sessionId 时换一个全新的,以隔离路由;再用 retryAssistantCall 按重试策略容忍瞬时断流。

AgentSession 的默认压缩路径(_runDefaultCompaction,agent-session.ts:2360-2383)不传 sessionId,所以每次压缩都拿到新的路由 id。官方文档说明摘要请求会「使用全新的路由 session ID,并在 Provider 支持时禁用 prompt-cache 写入」(来源文件:packages/coding-agent/docs/compaction.md 概览一节),与这段实现一致。

落盘与继续 ​

自动路径的收尾在 _runAutoCompaction(agent-session.ts:2747 起):

ts
// packages/coding-agent/src/core/agent-session.ts:2838-2841
this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension, usage);
const newEntries = this.sessionManager.getEntries();
this._refreshFinalizedContext();
const estimatedTokensAfter = estimateMessagesTokens(this.sessionManager.buildSessionProjection().messages);

这几行就是「会话如何在压缩后继续」的答案:追加一个条目,然后把内存里的 messages 按新规则重算一遍。进程不重启,文件不重读。appendCompaction 追加条目时还会把当时生效的系统消息整份存进条目(session-manager.ts:1268、1281),这就是第一步里那份 systemMessage 快照的来源:保留区不必再带着零散的系统提示词补丁。

之后按 willRetry 分叉(2868-2872):溢出重试时直接返回 true,外层 _runAgentPrompt 随即 agent.continue()(失败尝试在压缩前已经被省略);阈值触发则只在有排队消息时返回 agent.hasQueuedMessages()。

事件方面,自动与手动两条路径都会发 compaction_start(reason 为 manual/threshold/overflow,agent-session.ts:2769、2409)与 compaction_end(含 willRetry 与 errorMessage,2866、2530)。扩展可以在 session_before_compact 里取消压缩或直接提供自己的摘要结果(2782-2802、2441-2460),压缩完成后收到 session_compact(2848-2856、2509-2517);压缩失败或被取消时,除了一条带错误信息的 compaction_end,还会收到 session_compact_failed(2876-2897、2542-2556,发送函数 _emitSessionCompactFailed 在 845-849)。扩展机制见 7.1 Extension 系统。

图 6.6-3 压缩前后的消息数组对比
左边是压缩前发给模型的数组,右边是压缩后重算出来的数组,两边都从上往下按顺序排列。关注三点:数组长度骤降;被替换的那一段变成一条 compactionSummary 消息,排在系统快照之后、其余消息之前;切点右侧的消息一条不动,连时间戳都不变。

B4 到 B1 这一段对应 prepareCompaction 的 messagesToSummarize(compaction.ts:927-929),B5/B6 对应 firstKeptEntryId 之后的保留区,A0/A1 由 sessionEntryToContextMessages 从新的 compaction 条目现造(session-manager.ts:461-464)。B0 本身不被摘要,压缩时它的最新状态被复制进了 A0。切点落在 B5 上是因为它是一个 turn 起点;如果 keepRecentTokens 恰好把切点算到了 B6(assistant 消息),就会触发 split-turn 双摘要。

手动 /compact ​

命令声明在 packages/coding-agent/src/core/slash-commands.ts:40(描述为 “Manually compact the session context”)。终端界面(TUI,Terminal User Interface)的分派在 modes/interactive/interactive-mode.ts:3187-3192:/compact 或 /compact <指令>,后者用 text.slice(9).trim() 截出自定义指令,交给 handleCompactCommand(interactive-mode.ts:6822-6830,异常吞掉、靠 compaction_end 事件呈现)。远程过程调用(RPC,Remote Procedure Call)模式同样暴露(modes/rpc/rpc-mode.ts:535-538)。

真正的实现是 AgentSession.compact(agent-session.ts:2406-2561),与自动路径同构,差别有三处(源码事实):① 先 await this.abort() 中断当前操作(2407),之后也不会重试或继续被中断的那一轮;② customInstructions 会拼到摘要提示词后面(compaction.ts:741-743 的 Additional focus:);③ 准备阶段失败时抛出可读错误——Already compacted 或 Nothing to compact (session too small)(2431-2437),失败时发完 compaction_end 与 session_compact_failed 后把错误继续抛给调用方(2537-2557),而自动路径只是返回 false。

分支摘要:另一种「压缩」 ​

/tree 导航离开当前分支时,被放弃那条路径上的工作不该凭空消失。branch-summarization.ts 干的就是这件事(文件头注释,packages/coding-agent/src/core/compaction/branch-summarization.ts:1-6)。

调用链(源码事实):navigateTree(agent-session.ts:3581)→ collectEntriesForBranchSummary(3612 → branch-summarization.ts:108-146)→ 扩展事件 session_before_tree(3642 起)→ generateBranchSummary(3678 → branch-summarization.ts:293-382)→ sessionManager.branchWithSummary(agent-session.ts:3731 → session-manager.ts:1593-1616)→ _refreshFinalizedContext 重算投影(agent-session.ts:3758)。

三个与 compaction 不同的地方:

  1. 收集范围靠共同祖先算法:把旧 leaf 路径的 id 装进 Set,再从目标路径末尾往回找第一个命中,即最深公共祖先;然后从旧 leaf 沿 parentId 收集到公共祖先(不含),最后反转成时间序(branch-summarization.ts:118-145)。
  2. 预算裁剪而非切点:预算是 contextWindow - reserveTokens(branch-summarization.ts:312-313,reserveTokens 取自 branchSummary 设置,agent-session.ts:3677、3686),prepareBranchEntries(195-247)拿着它从最新往回装消息,装满即停;但 compaction/branch_summary 类条目在预算 90% 以内会被强行塞入(232-239),避免嵌套摘要丢失。
  3. 摘要挂在新位置:branchWithSummary 把 BranchSummaryEntry 建在导航目标处,而不是被放弃的旧分支上;条目的 fromId 记的是被放弃分支原来的 leaf(session-manager.ts:1603)。生成的正文前置一段 BRANCH_SUMMARY_PREAMBLE(branch-summarization.ts:253-256、370),maxTokens 取 4096 与模型输出上限的较小者(345),失败时返回 { aborted } 或 { error } 软失败(356-365)而不是抛异常。

注入上下文的形态与压缩摘要同理,也是一条 user 消息,只是换了前后缀(messages.ts:19-24、170-175)。

两套 compaction 目录 ​

这里要补一个可能让读者困惑的点:3.7 讲触发条件时,引用的是 packages/agent/src/harness/compaction/compaction.ts(CompactionSettings:147-155、DEFAULT_COMPACTION_SETTINGS:157-161、shouldCompact:246-249),本章引用的却是 coding-agent 下的同名文件。两处不矛盾——仓库里有两份思路相同的 compaction 代码,公式与默认值逐字相同,但归属和实现不同(源码事实):

packages/coding-agent/src/core/compaction/packages/agent/src/harness/compaction/
使用者现役 pi 命令行界面(CLI)的 AgentSession:被 agent-session.ts:61-73 导入pi-agent-core 的 AgentHarness;coding-agent 只在 src/experimental/ 下使用 AgentHarness
规模compaction.ts 1141 行 + branch-summarization.ts 382 行865 行 + 300 行
错误处理直接 throw返回 Result(如 prepareCompaction 的返回类型,compaction.ts:634-637)
LLM 调用apiKey/headers/env 散参 + streamFnModels 抽象的 completeSimpleWithRetries(compaction.ts:127-145)
保留区表示只存 firstKeptEntryId(按 id 引用)必填的 retainedTail:把保留消息实体存进条目(harness/session/types.ts:33-41)
条目种类十一种,含 context_edit、usage四种:message、compaction、branch_summary、custom(harness/session/types.ts:64)
自动触发阈值 + 溢出,见上文也有:阈值(prepareCompactionThreshold,harness/runtime/drive/structural.ts:1118,在 checkpoint.ts:100 调用)与溢出(prepareOverflowCompaction,structural.ts:1155,在 response.ts:189-195 判定),溢出恢复同样一次性

retainedTail 的语义差别最值得看:harness 的 buildContextEntries(packages/agent/src/harness/session/context.ts:10-22)找到最新的压缩条目后直接停止回溯,只取它和它之后的条目;sessionEntryToContextMessages(context.ts:31-45)再用条目自带的 retainedTail 重建保留区,不需要回头按 id 去找旧条目。

需要澄清的是:这不是「通用实现 + 继承」的关系。coding-agent 的 src/core/ 不引用 harness 的 compaction 代码,两份代码目前平行存在;harness 版从 packages/agent/src/index.ts:62-72 导出,自己的阈值检查也调用了 shouldCompact(structural.ts:1145)。据此推断(尚未在源码中直接证实):harness 版是把同一套算法上收到通用 agent 包、以便脱离 coding-agent 复用的产物——仓库里没有迁移计划文档可以佐证两者将来是否合并。

与官方文档对照 ​

官方文档 packages/coding-agent/docs/compaction.md 在触发公式、默认值、按模型覆盖、轮间检查与溢出/截断恢复、五步流程、split-turn 双摘要、「永不在工具结果处切」、2000 字符截断、扩展事件(含 session_compact_failed,370-383 行)等方面与源码一致;它也写明了 reserveTokens 同时限制摘要输出(457 行)、压缩后是「重建上下文」而非重新加载(第 5 步,49 行)。逐条核对后发现三处需要补充(均为本书核对源码后的发现):

  1. “CompactionEntry Structure” 一节(140-160 行)列出的字段里没有 systemMessage,而源码的压缩条目会存这份系统状态快照(session-manager.ts:102-103),投影时它排在摘要前面。
  2. compaction.ts:1-6 的文件头注释仍写着 “after compaction the session is reloaded”,实际实现是内存里重算(agent-session.ts:2840)。
  3. 完全没提 harness 版实现与 retainedTail。

实践任务 ​

🛠 实践任务跑通 compaction 的三层测试,并解释被跳过的两个用例

目标:不需要任何 API Key,用真实测试验证本章讲的三层逻辑——纯函数(切点、阈值、折叠)、溢出检测正则、以及 AgentSession 的各种触发路径;再顺手弄清「为什么有 2 个用例被跳过」。

前提:在 Pi 仓库根目录(本书为 _sources/pi),依赖已安装。全程只读,不修改任何源码。

步骤 1 · 纯函数层:

npm test --workspace=@earendil-works/pi-coding-agent -- test/compaction.test.ts --reporter=verbose

预期现象(本书在锁定 commit 上实测):结尾出现 Test Files 1 passed (1) 与 Tests 26 passed | 2 skipped (28)。用例名里能直接读到本章的每个结论,例如 shouldCompact > should return true when context exceeds threshold、findCutPoint > should indicate split turn when cutting at assistant message、buildSessionContext > should handle multiple compactions (only latest matters)、prepareCompaction with previous compaction > should re-summarize previously kept messages when the recent window moves past them。

步骤 2 · 溢出检测层:

npm test --workspace=@earendil-works/pi-ai -- test/overflow.test.ts

预期现象:Tests 19 passed (19)。这 19 个用例既有逐 Provider 的正例,也有 Bedrock 限流、rate limit、429 等反例,还有 only treats bodyless 400 and 413 errors as overflow for Cerebras 和几条 isRecoverableLength 的用例。

步骤 3 · AgentSession 层:

npm test --workspace=@earendil-works/pi-coding-agent -- test/suite/agent-session-compaction.test.ts --reporter=verbose

预期现象:Tests 29 passed (29),其中 does not retry overflow recovery more than once 正是本章说的一次性保险丝,compacts successful overflow responses without retrying 对应静默溢出不重试,ignores stale pre-compaction assistant usage on pre-prompt checks 对应时间戳闸门,compacts after an oversized tool result in the same run (model override: true) 对应轮间检查与按模型覆盖,compacts and resumes after a length stop below the desired output limit 对应可恢复截断,notifies extensions when auto-compaction fails 对应 session_compact_failed。

步骤 4 · 对照 harness 版:

npm test --workspace=@earendil-works/pi-agent-core -- test/harness/compaction.test.ts

预期现象:Tests 22 passed (22)。其中 carries a previous compaction's retained tail into the next preparation 专门覆盖 retainedTail 在连续压缩之间的传递。

步骤 5 · 观察被跳过的用例:grep -n "describe.skipIf" packages/coding-agent/test/compaction.test.ts。你会在 compaction.test.ts:603 看到 describe.skipIf(!process.env.ANTHROPIC_OAUTH_TOKEN)("LLM summarization", …)——被跳过的两个用例是真正调用模型生成摘要的集成测试。

如何判断成功:① 四条命令全部 0 失败;② 你能说出为什么「切点」「阈值」「折叠」这三类逻辑可以完全离线测试,而「生成摘要」不能——因为前三者是纯函数,只有 compact() 那一步需要真实的 LLM 调用;③ 你能在步骤 1 的用例名里指出哪一个对应本章图 6.6-3 的 split-turn 情形。

常见错误:① 包名写错——目录名 packages/agent 对应的包名是 @earendil-works/pi-agent-core,写成 @earendil-works/pi-agent 会得到 npm error No workspaces found(本书实测);② 漏掉 -- 分隔符,文件名参数就到不了 vitest,会跑整个包的测试;③ 在仓库根目录直接 npm test:根脚本是 npm run test:scripts && npm run test --workspaces --if-present,会跑全量。想要完全隔离的环境(清空 API Key、隔离 HOME)可以用仓库自带的 ./test.sh,但它跑的是全量测试。

对应源码位置:packages/coding-agent/src/core/compaction/compaction.ts:289(shouldCompact)、:468(findCutPoint)、:824(findProjectedCutPoint)、:894(prepareCompaction)、packages/coding-agent/src/core/session-manager.ts:476(buildContextEntries)、:543(buildSessionProjection)、packages/ai/src/utils/overflow.ts:136(isContextOverflow)、packages/coding-agent/src/core/agent-session.ts:587(_compactBeforeNextAssistantResponse)、:2599(_checkCompaction)、:2747(_runAutoCompaction)。

本章小结 ​

  • 发给模型的 messages 是会话树的一次投影,每次请求重算:buildSessionPath → buildContextEntries(压缩折叠)→ buildSessionProjection(条目变消息 + 套用 context_edit)→ prepareRequest 注入请求 → convertToLlm → normalizeContext。十一种条目里只有四种直接产出上下文,context_edit 负责改写或省略别的条目。
  • 压缩摘要在模型眼里就是一条普通 user 消息,正文被 COMPACTION_SUMMARY_PREFIX/SUFFIX 包住;压缩条目还带一份系统状态快照,排在摘要前面。
  • token 数首选 Provider 回报的 usage,其次以最后一条有效 usage 为锚点 + 字符数除以 4 估算;usage 早于最近一次 context_edit 或压缩时整体改为估算。因此刚压缩完 Pi 会暂时「不知道」用了多少,状态栏显示 ?。
  • 触发判据只有一行:contextTokens > contextWindow - reserveTokens(默认预留 16384,保留最近 20000,两项都能按 "provider/modelId" 单独覆盖)。检查发生在两轮之间(工具结果之后、下一次请求之前)、一次运行结束之后、发新 prompt 之前。
  • 两类处理:阈值触发压缩后不自动重试;溢出(报错型/静默型/length-stop 型,由 isContextOverflow 统一识别)或可恢复截断,先用 context_edit 省略失败的尝试再压缩,随后自动重试,且只重试一次;失败会通知扩展 session_compact_failed。
  • 切点从最新往回攒够 keepRecentTokens 再找最近的合法位置,toolResult 永不可切;切在 turn 中间会额外生成一段 turn 前缀摘要。
  • 压缩落盘只是 appendCompaction 追加一行,随后内存里重算投影——不重启、不重读文件、不产生分支、不删除任何历史。
  • 仓库里有两套 compaction:coding-agent 版服务现役 CLI,agent/harness 版服务 AgentHarness(Result 化、Models 抽象、retainedTail,也有自动触发),二者目前平行且互不引用。
  • 关键术语:上下文压缩(Context Compaction)、上下文投影、上下文编辑(context_edit)、切点(cut point)、保留区(firstKeptEntryId)、split turn(切在回合中间)、上下文溢出(context overflow)、分支摘要(branch summary)、usage(Provider 回报的 token 计数)。
  • 关键源码索引:packages/coding-agent/src/core/compaction/compaction.ts(DEFAULT_COMPACTION_SETTINGS:148、calculateContextTokens:162、estimateContextTokens:218、estimateProjectedContextTokens:249、shouldCompact:289、isCutPointMessage:373、findCutPoint:468、completeSummarization:641、generateSummaryWithUsage:718、findProjectedCutPoint:824、prepareCompaction:894、compact:987)、packages/coding-agent/src/core/session-manager.ts(getLatestCompactionEntry:372、sessionEntryToContextMessages:439、buildContextEntries:476、projectContextEntry:519、buildSessionProjection:543、buildSessionContext:576、appendCompaction:1259、appendContextEdit:1358)、packages/coding-agent/src/core/agent-session.ts(_compactBeforeNextAssistantResponse:587、_omitRecoveryAttempt:1015、compact:2406、_checkCompaction:2599、_runAutoCompaction:2747、navigateTree:3581、getContextUsage:3858)、packages/coding-agent/src/core/settings-manager.ts:896(getCompactionSettings)、packages/coding-agent/src/core/messages.ts:148、packages/ai/src/utils/overflow.ts:136、packages/agent/src/harness/session/context.ts:10;测试 packages/coding-agent/test/compaction.test.ts、packages/coding-agent/test/suite/agent-session-compaction.test.ts、packages/ai/test/overflow.test.ts、packages/agent/test/harness/compaction.test.ts。
  • 自测问题:① 一次压缩之后,会话 .jsonl 文件里的行数是变多了、变少了还是不变?为什么?② 为什么切点算法宁可多保留几条消息也不切在 toolResult 上?③ 模型返回了 stopReason: "stop" 的正常回答,Pi 为什么可能仍然判定它是上下文溢出?④ 连续两次压缩时,第二次会把第一次保留下来的消息也摘要进去吗?在哪一行代码决定的?
  • 下一章:6.7 系统提示词与 Prompt Templates——上下文里那条打头的 system 消息是怎么拼出来的。
  • 尚未展开:session_before_compact / session_compact / session_compact_failed / session_before_tree 几个扩展事件的完整签名与用法,以及扩展如何通过边界事件提交 context_edit 与压缩草稿,留给 7.1 Extension 系统;状态栏百分比的渲染细节留给 6.9 pi-tui:终端界面库;/compact 在 RPC 模式下的请求响应格式留给 6.10 交互模式与 RPC 模式;摘要提示词的逐段设计留给 6.7 系统提示词与 Prompt Templates。
✅ 自测问题参考答案先自己回答,再点开对照
  1. 变多了一行。压缩落盘就是 appendCompaction 往文件末尾追加一条 compaction entry,不删除任何历史——文件是 append-only 的。(如果是溢出恢复触发的压缩,前面还会多出几条省略失败尝试的 context_edit。)变少的是「下一次请求携带的那份上下文」:buildContextEntries 在投影时把摘要之前、firstKeptEntryId 之外的旧条目折叠掉。磁盘上一条不少,所以你随时能回看完整历史,也能从压缩点之前分叉。
  2. 因为 toolResult 必须和发起它的那次 toolCall 成对出现。切在 toolResult 上会让保留区里出现一条「孤儿工具结果」——上文没有对应的 assistant 工具调用,多数 Provider 直接判为非法请求。所以 isCutPointMessage 把 toolResult 排除在合法切点之外,宁可往前多保留几条消息,也不制造一份发不出去的上下文。
  3. 因为有些 Provider 面对超长输入不报错:isContextOverflow 除了匹配错误文案,还识别两种「静默溢出」——① 提供了 contextWindow 时,stopReason 是 stop 但 input + cacheRead 已经超过窗口(服务端默默接受了);② stopReason 是 length 且 output 为 0、输入填满了 99% 的窗口(服务端先截断输入再生成,已经没地方可写)。没有这两条兜底,Pi 会以为一切正常,然后一次次发出注定被截断的请求。对第 ① 种,回答本身是完整的,所以 Pi 只压缩、不重试。
  4. 会。prepareCompaction 在当前投影上工作,而投影里上一次压缩条目排在最前、后面紧跟它保留下来的尾巴;它取上一次的 summary 作为 previousSummary,并把起点设在投影里压缩条目的下一位(compaction.ts:914-916)。所以上一次保留下来的消息会被再摘要一遍,上一次的摘要则作为 <previous-summary> 交给模型做迭代更新。投影之所以只认最后一个压缩,是 buildContextEntries(session-manager.ts:476-512)决定的。

本书分析的 Pi 版本:earendil-works/pi@16787ad(2026-09-21)