Skip to content

6.6 Context 构造与 Compaction

本页分析版本earendil-works/pi@c13ffe12026-07-30

本章解决什么问题:发给大语言模型(LLM,Large Language Model)的那个 messages 数组,到底是从会话文件里怎么算出来的?当对话长到装不下模型的上下文窗口时,Pi 在哪一行代码决定「该压缩了」,压缩又具体做了什么、旧消息去哪了、会话怎么继续。 前置知识3.2 消息、上下文与 token3.7 Compaction、Extension 与 Skill5.5 Session 的创建、保存与恢复6.5 Session 存储格式与会话树学习目标:读完后你能 ① 说清「会话条目树 → messages 数组 → LLM 请求」这条投影链上每一步的函数名;② 写出 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:1097-1119),既不改旧行也不新建文件。你随时可以用文本编辑器打开会话文件,看到被「压掉」的那些消息原封不动地在那里。

最小示例:手写一次折叠

先脱离 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 源码:messages 是怎么拼出来的

第一步:条目 → 消息

packages/coding-agent/src/core/session-manager.ts · sessionEntryToContextMessages
earendil-works/pi@c13ffe1第 383–408 行在 GitHub 查看 ↗
把单个 SessionEntry 投影成 0 或 1 条 AgentMessage。message 直接透传;custom_message、branch_summary、compaction 各自造一条消息;其余类型返回空数组。

九种条目里,只有四种会进入模型上下文(源码事实):

条目类型投影结果说明
message原消息content 为 null 时补成 [](防御手写文件)
custom_messagecustom 角色消息扩展(Extension)注入的消息
branch_summarybranchSummary 角色消息分支摘要
compactioncompactionSummary 角色消息压缩摘要
model_change / thinking_level_change / custom / label / session_info[]只影响设置或显示,不进上下文

第二步:折叠

earendil-works/pi@c13ffe1第 418–454 行在 GitHub 查看 ↗
沿当前 leaf 路径找最后一个 compaction 条目;找到就重排为「摘要 + 从 firstKeptEntryId 起的尾巴 + 压缩点之后的全部」,被摘要的旧条目从上下文里消失。

buildSessionContextsession-manager.ts:461-470)把两步串起来:buildContextEntries(...).flatMap(sessionEntryToContextMessages),顺带从路径上推导出思考等级与模型(getSessionContextSettingssession-manager.ts:362-377)。

结果被灌进 Agent 状态:恢复会话时在 packages/coding-agent/src/core/sdk.ts:188 取、sdk.ts:364 赋值;每次压缩后在 agent-session.ts:2155-2156(自动)与 agent-session.ts:1874-1875(手动 /compact就地重算

第三步:AgentMessage → LLM 消息

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

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

这就是压缩摘要在模型眼里的最终形态messages.ts:11-17176-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@c13ffe1第 281–302 行在 GitHub 查看 ↗
Agent Loop(Agent 循环)里唯一做 AgentMessage[] → Message[] 转换的地方:先跑可选的 transformContext,再跑 convertToLlm,最后组装成 Context 交给 streamFunction。

注意 coding-agent 传进去的其实是包了一层图片拦截的 convertToLlmWithBlockImagessdk.ts:256-290,在 sdk.ts:301 作为 convertToLlm 传给 new Agent)。

图加载中…

图 6.6-1 从会话文件到一次模型请求的投影链
从左到右读。左边三步在 coding-agent 的 SessionManager 里,右边三步在 pi-agent-core 的 Agent Loop 里,agent.state.messages 是两者的交接点。

每个节点都能落到源码:buildSessionPathsession-manager.ts:334)、buildContextEntries418)、sessionEntryToContextMessages383)、transformContextconvertToLlmpackages/agent/src/agent-loop.ts:290-295)、streamFunctionagent-loop.ts:308)。请特别注意:压缩只发生在链条最左边——它改的是「哪些条目参与投影」,右边的转换逻辑对压缩一无所知。

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

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

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

没有可用 usage 时才退到估算:estimateContextTokenscompaction.ts:202-230)以「最后一条有效 assistant 消息的 usage」为锚点,把锚点之后的消息用 estimateTokenscompaction.ts:266-306)按字符数除以 4 估出来,图片按 4800 字符计(ESTIMATED_IMAGE_CHARScompaction.ts:244)。getAssistantUsagecompaction.ts:154-167)会跳过 aborted、error 和全零 usage 的消息。

🌱 初学者提示为什么 Pi 不自带 tokenizer
不同厂商的分词方式不同,本地实现一份既大又不准。从源码结构看,Pi 选择「信任 Provider 回报的数字 + 粗糙估算兜底」,代价是:刚压缩完、还没发过新请求时,Pi 其实不知道当前 token 数。这个代价在界面上是可见的——真实采集的状态栏(research/cli-captures/pi-tui-main.txt,尾行 0.0%/0 (auto))读的是 getContextUsageagent-session.ts:3164-3208),该函数在「最近一次压缩之后还没有任何有效 assistant usage」时直接返回 percent: nullagent-session.ts:3195-3197),界面就把百分比渲染成 ?modes/interactive/components/footer.ts:108-111 取值、150-152 渲染;采集到的 (auto) 后缀来自同文件 149 行的 autoIndicator)。

判据本身只有两行:

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

默认值 enabled: truereserveTokens: 16384keepRecentTokens: 20000compaction.ts:132-136)。运行时的值由 SettingsManager.getCompactionSettings 汇总(packages/coding-agent/src/core/settings-manager.ts:781-787),它调用的三个 getter 各自写着兜底默认值:getCompactionEnabled760-762?? true)、getCompactionReserveTokens773-775?? 16384)、getCompactionKeepRecentTokens777-779?? 20000)。这三项可在 ~/.pi/agent/settings.json 或项目内 .pi/settings.json 覆盖——配置系统见 6.8 配置系统

官方文档说明这套触发条件与默认值(来源文件:packages/coding-agent/docs/compaction.md 的 “When It Triggers” 一节),与源码一致。

什么时候检查:两个调用点、两种 Case

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

  1. 每轮跑完之后_runAgentPromptagent-session.ts:1061-1073)里 while (await this._handlePostAgentRun()) await this.agent.continue();,而 _handlePostAgentRun1075-1103)在 1096 行调用 _checkCompaction(msg)。返回 true 就再跑一圈。
  2. 发新 prompt 之前prompt()agent-session.ts:1199-1202 对最后一条 assistant 消息再查一次,且传 skipAbortedCheck=false(能捕获被用户中断的响应)。这里故意忽略返回值——注释说明用户的新 prompt 马上就发,不需要 agent.continue()
earendil-works/pi@c13ffe1第 1953–2042 行在 GitHub 查看 ↗
压缩判定的全部逻辑:四道前置闸门,然后分成 overflow 与 threshold 两个 Case,返回值表示「外层是否需要继续跑一圈」。
图加载中…

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

图中每个判断都能对上源码行:启用检查 1954-1955、中断检查 1958、同模型检查 1966-1967、时间戳检查 1972-1977、溢出分支 1983-2011、阈值分支 2017-2040。两个细节值得单独说:

  • 同模型检查:注释解释了动机——用户从小窗口模型切到大窗口模型后,旧模型留下的溢出错误不该再触发压缩。
  • 时间戳检查getLatestCompactionEntrysession-manager.ts:316)拿到最近一次压缩条目,凡是早于它的 assistant 消息一律跳过。没有这道闸门,压缩后保留下来的那些「旧 usage」会立刻把刚压缩完的会话再压一次。

上下文溢出:25 条正则背后的脏活

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

earendil-works/pi@c13ffe1第 132–161 行在 GitHub 查看 ↗
三种溢出形态的统一检测:错误文本匹配、静默溢出、length-stop 溢出。contextWindow 参数缺省时只做第一种。
  • 报错型stopReason === "error" 且错误文本命中 OVERFLOW_PATTERNS(25 条正则,overflow.ts:38-62,覆盖 Anthropic 的 prompt is too long、OpenAI 的 exceeds the context window、Together、Groq、llama.cpp、LM Studio、Kimi、Mistral、DashScope,直到 Cerebras 的「400/413 且没有响应体」)。为防误判还有一份 3 条的排除清单 NON_OVERFLOW_PATTERNSoverflow.ts:75-77)——Bedrock 的限流错误文案里带 “Too many tokens”,不排除就会被当成溢出。
  • 静默型stopReason === "stop",请求成功了,但 usage.input + usage.cacheRead 超过窗口(overflow.ts:143-148,注释指名 z.ai)。
  • length-stop 型stopReason === "length"output === 0 且输入填满窗口的 99%(overflow.ts:153-158,注释指名小米 MiMo:服务端把超长输入截断到刚好塞满,于是一个 token 都吐不出来)。

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

Case 1 里还有一个很值得学的工程细节:_overflowRecoveryAttempted 这个一次性保险丝(agent-session.ts:1990-2003)。压缩后自动重试只允许一次;再溢出就不再尝试,而是发一条 compaction_end 事件建议用户换大窗口模型。没有它,「压缩 → 重试 → 又溢出 → 再压缩」就是一个会烧钱的死循环。

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

compaction 到底做了什么

准备:算切点

earendil-works/pi@c13ffe1第 710–789 行在 GitHub 查看 ↗
纯函数准备阶段:处理「已经压缩过」的短路、定位上一次压缩的边界与摘要、估算压缩前 token、算切点、切出待摘要消息与 turn 前缀消息、抽取文件操作清单。

三个要点(源码事实):最后一条已是 compaction 就返回 undefined(714-716);若存在上一次压缩,取它的 summary 作为 previousSummary迭代更新,起点回到上次的 firstKeptEntryId726-733)——也就是说上次保留的消息这次会被重新摘要;待摘要消息与 turn 前缀消息双双为空时同样返回 undefined(765-767),自动路径据此静默放弃。

earendil-works/pi@c13ffe1第 403–461 行在 GitHub 查看 ↗
切点算法:从最新往回累加估算 token,攒够 keepRecentTokens 后,取该位置之后最近的一个合法切点;再往前吞掉不产生消息的元数据条目;最后判断是否切在了一个 turn 的中间。

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

如果切点不是一个 turn 的起点(isTurnStartMessagecompaction.ts:323-336),就标记 isSplitTurn 并回溯出这个 turn 的起点(findTurnStartIndexcompaction.ts:369-376)。

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

compact()compaction.ts:817-919)负责生成摘要。非 split-turn 时只调一次 generateSummaryWithUsage622-686):先 convertToLlmserializeConversationcompaction/utils.ts:109-150)把消息序列化成纯文本,包进 <conversation> 标签,配上结构化提示词(SUMMARIZATION_PROMPTcompaction.ts:467-498,要求输出 ## Goal / ## Progress / ## Next Steps 等固定小节;有 previousSummary 时改用 UPDATE_SUMMARIZATION_PROMPT500-537)。

为什么要序列化成文本而不是直接把消息数组发过去?源码注释写得很明白:This prevents the model from treating it as a conversation to continueutils.ts:103)。配套的系统提示词也在反复强调「不要接着聊,只输出摘要」(SUMMARIZATION_SYSTEM_PROMPTutils.ts:156-158)。工具结果在序列化时截断到 2000 字符(utils.ts:89144)。

split-turn 时额外调一次 generateTurnPrefixSummarycompaction.ts:924-969),两段用 **Turn Context (split turn):** 合并(881)。摘要末尾再拼上文件操作清单(905-906)。预算方面:主摘要 maxTokens = min(0.8 × reserveTokens, model.maxTokens)637-640),turn 前缀是 0.5 ×937-940)。

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

官方文档说明摘要请求会「使用全新的路由 session ID,并在 Provider 支持时禁用 prompt-cache 写入」(来源文件:packages/coding-agent/docs/compaction.md 概览一节),与这段实现一致。

落盘与继续

自动路径的收尾在 _runAutoCompactionagent-session.ts:2047 起):

ts
// packages/coding-agent/src/core/agent-session.ts:2153-2156
this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension, usage);
const newEntries = this.sessionManager.getEntries();
const sessionContext = this.sessionManager.buildSessionContext();
this.agent.state.messages = sessionContext.messages;

四行代码就是「会话如何在压缩后继续」的全部答案:追加一个条目,然后把内存里的 messages 按新规则重算一遍。进程不重启,文件不重读。

之后按 willRetry 分叉(2184-2195):溢出重试时,若重算后的最后一条仍是 error assistant 消息就摘掉它并返回 true,外层 _runAgentPrompt 随即 agent.continue();阈值触发则只在有排队消息时返回 agent.hasQueuedMessages()

事件方面,两条路径都会发 compaction_start(reason 为 manual/threshold/overflowagent-session.ts:20721787)与 compaction_end(含 willRetryerrorMessage21821901-1907)。扩展可以在 session_before_compact 里取消压缩或直接提供自己的摘要结果(2079-21051812-1831),压缩完成后收到 session_compact2164-21721883-1891)——扩展机制见 7.1 Extension 系统

图加载中…

图 6.6-3 压缩前后的消息数组对比
左边是压缩前发给模型的数组,右边是压缩后重算出来的数组。关注三点:数组长度骤降;被替换的那一段变成一条 compactionSummary 消息并排在最前;切点右侧的消息一条不动,连时间戳都不变。

B4B1 这一段对应 prepareCompactionmessagesToSummarizecompaction.ts:750-754),B5/B6 对应 firstKeptEntryId 之后的保留区,A1sessionEntryToContextMessages 从新的 compaction 条目现造(session-manager.ts:404-406)。切点落在 B5 上是因为它是一个 turn 起点;如果 keepRecentTokens 恰好把切点算到了 B6(assistant 消息),就会触发 split-turn 双摘要。

手动 /compact

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

真正的实现是 AgentSession.compactagent-session.ts:1783 起),与自动路径同构,差别有三处(源码事实):① 先 _disconnectFromAgent()await this.abort() 中断当前操作(1784-1785);② customInstructions 会拼到摘要提示词后面(compaction.ts:644-646Additional focus:);③ 准备阶段失败时抛出可读错误——Already compactedNothing to compact (session too small)1800-1807),而自动路径只是静默返回 false。

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

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

调用链(源码事实):navigateTreeagent-session.ts:2895)→ collectEntriesForBranchSummary2921branch-summarization.ts:108-146)→ 扩展事件 session_before_tree2951 起)→ generateBranchSummarybranch-summarization.ts:293-376)→ sessionManager.branchWithSummaryagent-session.ts:3040)→ 重算 agent.state.messages3067-3068)。

三个与 compaction 不同的地方:

  1. 收集范围靠共同祖先算法:把旧 leaf 路径的 id 装进 Set,再从目标路径末尾往回找第一个命中,即最深公共祖先;然后从旧 leaf 沿 parentId 收集到公共祖先(不含),最后反转成时间序(branch-summarization.ts:118-145)。
  2. 预算裁剪而非切点:预算由调用方算出(contextWindow - reserveTokensbranch-summarization.ts:311-313),prepareBranchEntries195-247)拿着它从最新往回装消息,装满即停;但 compaction/branch_summary 类条目在预算 90% 以内会被强行塞入(230-239),避免嵌套摘要丢失。
  3. 摘要挂在新位置branchWithSummaryBranchSummaryEntry 建在导航目标处,而不是被放弃的旧分支上。生成的正文前置一段 BRANCH_SUMMARY_PREAMBLE253-256364),maxTokens 固定 2048(350),失败时返回 { aborted }{ error } 软失败(354-359)而不是抛异常。

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

两套 compaction 目录

这里要补一个可能让读者困惑的点:3.7 讲触发条件时,引用的是 packages/agent/src/harness/compaction/compaction.tsCompactionSettings:164-171DEFAULT_COMPACTION_SETTINGS:174-178shouldCompact:262-266),本章引用的却是 coding-agent 下的同名文件。两处不矛盾——仓库里有两份高度相似的 compaction 代码,公式与默认值逐字相同,但归属和成熟度不同(源码事实):

packages/coding-agent/src/core/compaction/packages/agent/src/harness/compaction/
使用者现役 pi 命令行界面(CLI):被 agent-session.ts:54-64 导入pi-agent-core 的 AgentHarness;仓库内消费者是 evals 与自身测试
规模compaction.ts 969 行 + branch-summarization.ts 376 行880 行 + 275 行
错误处理直接 throw返回 Result<T, CompactionError>
LLM 调用apiKey/headers/env 散参 + streamFnModels 抽象的 completeSimpleWithRetries
保留区表示只存 firstKeptEntryId(按 id 引用)额外支持 retainedTail:把保留消息实体存进条目
自动触发有(阈值 + 溢出,见上文)无:只有手动 compact()agent-harness.ts:736)与 navigateTree()791

retainedTail 的语义变化最值得看:harness 的 defaultContextEntryTransformpackages/agent/src/harness/session/session.ts:59-90)遇到带 retainedTail 的压缩条目时直接停止回溯,用条目自带的尾部消息重建上下文,找不到才退回 firstKeptEntryId 扫描。字段注释写着 “Optional during Pi 2.0 transition”(packages/agent/src/harness/compaction/compaction.ts:112-113)。

需要澄清的是:这不是「通用实现 + 继承」的关系。全 packages/coding-agent/src 搜不到对 harness 的任何引用,两份代码目前平行存在;shouldCompact 虽然从 packages/agent/src/index.ts:28 导出,但 harness 内部一次都没调用它。据此推断(尚未在源码中直接证实):harness 版是面向 Pi 2.0 把同一套算法上收到通用 agent 包的产物,自动触发与溢出恢复会在后续迁移中补上——仓库里没有任何迁移计划文档可以佐证。

与官方文档对照

官方文档 packages/coding-agent/docs/compaction.md 在触发公式、默认值、五步流程、split-turn 双摘要、「永不在工具结果处切」、条目结构、2000 字符截断、扩展事件、设置项等方面与源码一致。逐条核对后发现四处需要补充(均为本书核对源码后的发现):

  1. 溢出没有被列为触发条件。文档的 “When It Triggers” 只写了阈值公式和 /compact,溢出只在扩展事件小节的 reason 枚举里间接出现;而源码中它是与阈值平级的 Case 1(agent-session.ts:1983-2011),还带一次性 compact-and-retry 语义。
  2. 第 5 步写的是 “Reload: Session reloads”,实际实现并不重新加载会话文件,而是内存里重算(agent-session.ts:2155-2156)。compaction.ts:4-5 的文件头注释也用了 “the session is reloaded” 这种偏重的说法。
  3. 完全没提 harness 版实现与 retainedTail
  4. reserveTokens 的作用被说少了:文档写它是「给模型回复预留的 token」,源码里它同时决定摘要调用自身的 maxTokens 上限(compaction.ts:637-640937-940)。

实践任务

🛠 实践任务跑通 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 24 passed | 2 skipped (26)。用例名里能直接读到本章的每个结论,例如 shouldCompact > should return true when context exceeds thresholdfindCutPoint > should indicate split turn when cutting at assistant messagebuildSessionContext > 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 14 passed (14)。这 14 个用例既有逐 Provider 的正例,也有 Bedrock 限流、rate limit、429 三个反例。

步骤 3 · AgentSession 层

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

预期现象Tests 16 passed (16),其中 does not retry overflow recovery more than once 正是本章说的一次性保险丝,compacts successful overflow responses without retrying 对应静默溢出不重试,ignores stale pre-compaction assistant usage on pre-prompt checks 对应时间戳闸门。

步骤 4 · 对照 harness 版

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

预期现象Tests 23 passed (23)。其中有一个用例专门覆盖 retainedTail 缺失时回退到 firstKeptEntryId 的路径。

步骤 5 · 观察被跳过的用例grep -n "describe.skipIf" packages/coding-agent/test/compaction.test.ts。你会在 compaction.test.ts:544 看到 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:235(shouldCompact)、:403(findCutPoint)、:710(prepareCompaction)、packages/coding-agent/src/core/session-manager.ts:418(buildContextEntries)、packages/ai/src/utils/overflow.ts:132(isContextOverflow)、packages/coding-agent/src/core/agent-session.ts:1953(_checkCompaction)、:2047(_runAutoCompaction)。

本章小结

  • 发给模型的 messages 是会话树的一次投影,每次请求重算:buildSessionPathbuildContextEntries(压缩折叠)→ sessionEntryToContextMessagesagent.state.messagesconvertToLlmContext。九种条目里只有四种进上下文。
  • 压缩摘要在模型眼里就是一条普通 user 消息,正文被 COMPACTION_SUMMARY_PREFIX/SUFFIX 包住。
  • token 数首选 Provider 回报的 usage,其次以最后一条有效 usage 为锚点 + 字符数除以 4 估算;因此刚压缩完 Pi 会暂时「不知道」用了多少,状态栏显示 ?
  • 触发判据只有一行:contextTokens > contextWindow - reserveTokens(默认预留 16384,保留最近 20000)。检查发生在每轮跑完之后与发新 prompt 之前。
  • 两个 Case:阈值触发压缩后不自动重试;溢出触发(报错型/静默型/length-stop 型,由 isContextOverflow 统一识别)压缩后自动重试,且只重试一次。
  • 切点从最新往回攒够 keepRecentTokens 再找最近的合法位置,toolResult 永不可切;切在 turn 中间会额外生成一段 turn 前缀摘要。
  • 压缩落盘只是 appendCompaction 追加一行,随后内存里 buildSessionContext() 重算——不重启、不重读文件、不产生分支、不删除任何历史。
  • 仓库里有两套 compaction:coding-agent 版是现役实现(含自动触发),agent/harness 版是 Pi 2.0 的通用移植(Result 化、Models 抽象、retainedTail),二者目前平行且互不引用。
  • 关键术语:上下文压缩(Context Compaction)、上下文投影、切点(cut point)、保留区(firstKeptEntryId)、split turn(切在回合中间)、上下文溢出(context overflow)、分支摘要(branch summary)、usage(Provider 回报的 token 计数)。
  • 关键源码索引packages/coding-agent/src/core/compaction/compaction.tsDEFAULT_COMPACTION_SETTINGS:132calculateContextTokens:146estimateContextTokens:202shouldCompact:235isCutPointMessage:308findCutPoint:403completeSummarization:562generateSummaryWithUsage:622prepareCompaction:710compact:817)、packages/coding-agent/src/core/session-manager.tsgetLatestCompactionEntry:316sessionEntryToContextMessages:383buildContextEntries:418buildSessionContext:461appendCompaction:1097)、packages/coding-agent/src/core/agent-session.tscompact:1783_checkCompaction:1953_runAutoCompaction:2047navigateTree:2895getContextUsage:3164)、packages/coding-agent/src/core/messages.ts:148packages/ai/src/utils/overflow.ts:132packages/agent/src/harness/session/session.ts:59;测试 packages/coding-agent/test/compaction.test.tspackages/coding-agent/test/suite/agent-session-compaction.test.tspackages/ai/test/overflow.test.tspackages/agent/test/harness/compaction.test.ts
  • 自测问题:① 一次压缩之后,会话 .jsonl 文件里的行数是变多了、变少了还是不变?为什么?② 为什么切点算法宁可多保留几条消息也不切在 toolResult 上?③ 模型返回了 stopReason: "stop" 的正常回答,Pi 为什么可能仍然判定它是上下文溢出?④ 连续两次压缩时,第二次会把第一次保留下来的消息也摘要进去吗?在哪一行代码决定的?
  • 下一章6.7 系统提示词与 Prompt Templates——上下文的另一半(systemPrompt)是怎么拼出来的。
  • 尚未展开session_before_compact / session_compact / session_before_tree 三个扩展事件的完整签名与用法留给 7.1 Extension 系统;状态栏百分比的渲染细节留给 6.9 pi-tui:终端界面库/compact 在 RPC 模式下的请求响应格式留给 6.10 交互模式与 RPC 模式;摘要提示词的逐段设计留给 6.7 系统提示词与 Prompt Templates

本书分析的 Pi 版本:earendil-works/pi@c13ffe1(2026-07-30)