6.6 Context 构造与 Compaction
本页分析版本earendil-works/pi@c13ffe12026-07-30本章解决什么问题:发给大语言模型(LLM,Large Language Model)的那个 messages 数组,到底是从会话文件里怎么算出来的?当对话长到装不下模型的上下文窗口时,Pi 在哪一行代码决定「该压缩了」,压缩又具体做了什么、旧消息去哪了、会话怎么继续。 前置知识:3.2 消息、上下文与 token、3.7 Compaction、Extension 与 Skill、5.5 Session 的创建、保存与恢复、6.5 Session 存储格式与会话树。 学习目标:读完后你能 ① 说清「会话条目树 → messages 数组 → LLM 请求」这条投影链上每一步的函数名;② 写出 Pi 触发自动压缩的那一行判断式,并说出 token 数从哪来;③ 解释切点算法为什么永远不在 toolResult 上切;④ 区分「阈值触发」和「上下文溢出触发」两条路径以及后者的一次性重试保险丝;⑤ 指出仓库里两套 compaction 实现的分工与差异。
建立直觉:上下文是会话树的一个投影
上一章(6.5)留下的结论是:会话文件是一棵只追加(append-only)的条目树。但模型 API 只认一个扁平数组。这中间必然有一次投影——从树上选一条路径,再把路径上的每个条目翻译成 0 条或 1 条消息。
有了这个视角,3.7 里那句「压缩就是把旧消息换成摘要」可以说得更准确:上下文压缩(Context Compaction)不是「删掉旧消息」,而是在树上插入一个新节点,让下一次投影从更靠后的地方开始,前面的部分用一段摘要顶替。旧条目仍然躺在 .jsonl 文件里,只是不再被投影出来。3.7 把压缩概括成三步(选切点、生成摘要、替换并记录),本章把这三步逐一落到函数上,并回答它没展开的两个问题:什么时候压、压完怎么接着聊。
appendCompaction 往文件尾部追加一行(packages/coding-agent/src/core/session-manager.ts:1097-1119),既不改旧行也不新建文件。你随时可以用文本编辑器打开会话文件,看到被「压掉」的那些消息原封不动地在那里。 最小示例:手写一次折叠
先脱离 Pi,用四十行代码复现折叠规则。把下面两段依次拼进同一个 context-projection.mjs。第一段是「一棵已经压缩过的会话路径」和折叠函数:
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;
}第二段是「条目变消息」的投影和打印:
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 是怎么拼出来的
第一步:条目 → 消息
sessionEntryToContextMessages九种条目里,只有四种会进入模型上下文(源码事实):
| 条目类型 | 投影结果 | 说明 |
|---|---|---|
message | 原消息 | content 为 null 时补成 [](防御手写文件) |
custom_message | custom 角色消息 | 扩展(Extension)注入的消息 |
branch_summary | branchSummary 角色消息 | 分支摘要 |
compaction | compactionSummary 角色消息 | 压缩摘要 |
model_change / thinking_level_change / custom / label / session_info | [] | 只影响设置或显示,不进上下文 |
第二步:折叠
buildContextEntriesbuildSessionContext(session-manager.ts:461-470)把两步串起来:buildContextEntries(...).flatMap(sessionEntryToContextMessages),顺带从路径上推导出思考等级与模型(getSessionContextSettings,session-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 多出四种自定义角色(bashExecution、custom、branchSummary、compactionSummary,声明见 packages/coding-agent/src/core/messages.ts:55-77)。发请求前必须翻译一次:
convertToLlm这就是压缩摘要在模型眼里的最终形态(messages.ts:11-17、176-183):
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 消息,正文告诉它「这段是压缩过的历史摘要」。
第四步:组装请求
streamAssistantResponse注意 coding-agent 传进去的其实是包了一层图片拦截的 convertToLlmWithBlockImages(sdk.ts:256-290,在 sdk.ts:301 作为 convertToLlm 传给 new Agent)。
图 6.6-1 从会话文件到一次模型请求的投影链
从左到右读。左边三步在 coding-agent 的 SessionManager 里,右边三步在 pi-agent-core 的 Agent Loop 里,agent.state.messages 是两者的交接点。
每个节点都能落到源码:buildSessionPath(session-manager.ts:334)、buildContextEntries(418)、sessionEntryToContextMessages(383)、transformContext 与 convertToLlm(packages/agent/src/agent-loop.ts:290-295)、streamFunction(agent-loop.ts:308)。请特别注意:压缩只发生在链条最左边——它改的是「哪些条目参与投影」,右边的转换逻辑对压缩一无所知。
token 从哪里来:usage 优先,估算兜底
要判断「上下文快满了」,得先知道当前用了多少 token。Pi 的第一数据源不是本地分词器,而是 Provider(模型服务提供方)在上一次响应里回报的 usage(源码事实):
// 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 时才退到估算:estimateContextTokens(compaction.ts:202-230)以「最后一条有效 assistant 消息的 usage」为锚点,把锚点之后的消息用 estimateTokens(compaction.ts:266-306)按字符数除以 4 估出来,图片按 4800 字符计(ESTIMATED_IMAGE_CHARS,compaction.ts:244)。getAssistantUsage(compaction.ts:154-167)会跳过 aborted、error 和全零 usage 的消息。
research/cli-captures/pi-tui-main.txt,尾行 0.0%/0 (auto))读的是 getContextUsage(agent-session.ts:3164-3208),该函数在「最近一次压缩之后还没有任何有效 assistant usage」时直接返回 percent: null(agent-session.ts:3195-3197),界面就把百分比渲染成 ?(modes/interactive/components/footer.ts:108-111 取值、150-152 渲染;采集到的 (auto) 后缀来自同文件 149 行的 autoIndicator)。 判据本身只有两行:
shouldCompact默认值 enabled: true、reserveTokens: 16384、keepRecentTokens: 20000(compaction.ts:132-136)。运行时的值由 SettingsManager.getCompactionSettings 汇总(packages/coding-agent/src/core/settings-manager.ts:781-787),它调用的三个 getter 各自写着兜底默认值:getCompactionEnabled(760-762,?? true)、getCompactionReserveTokens(773-775,?? 16384)、getCompactionKeepRecentTokens(777-779,?? 20000)。这三项可在 ~/.pi/agent/settings.json 或项目内 .pi/settings.json 覆盖——配置系统见 6.8 配置系统。
官方文档说明这套触发条件与默认值(来源文件:packages/coding-agent/docs/compaction.md 的 “When It Triggers” 一节),与源码一致。
什么时候检查:两个调用点、两种 Case
自动压缩不是定时任务,而是挂在两个时机上(源码事实):
- 每轮跑完之后:
_runAgentPrompt(agent-session.ts:1061-1073)里while (await this._handlePostAgentRun()) await this.agent.continue();,而_handlePostAgentRun(1075-1103)在1096行调用_checkCompaction(msg)。返回 true 就再跑一圈。 - 发新 prompt 之前:
prompt()在agent-session.ts:1199-1202对最后一条 assistant 消息再查一次,且传skipAbortedCheck=false(能捕获被用户中断的响应)。这里故意忽略返回值——注释说明用户的新 prompt 马上就发,不需要agent.continue()。
_checkCompaction图 6.6-2 _checkCompaction 的完整决策路径
自上而下读。前四个菱形是「前置闸门」,任何一个不满足都直接放弃;左侧分支是 Case 1 溢出,右侧是 Case 2 阈值。三个 C 节点最终都调用同一个 _runAutoCompaction,区别只在 willRetry 参数。
图中每个判断都能对上源码行:启用检查 1954-1955、中断检查 1958、同模型检查 1966-1967、时间戳检查 1972-1977、溢出分支 1983-2011、阈值分支 2017-2040。两个细节值得单独说:
- 同模型检查:注释解释了动机——用户从小窗口模型切到大窗口模型后,旧模型留下的溢出错误不该再触发压缩。
- 时间戳检查:
getLatestCompactionEntry(session-manager.ts:316)拿到最近一次压缩条目,凡是早于它的 assistant 消息一律跳过。没有这道闸门,压缩后保留下来的那些「旧 usage」会立刻把刚压缩完的会话再压一次。
上下文溢出:25 条正则背后的脏活
阈值判断依赖 usage 数字,但有些情况下模型直接报错、或者根本没给出可信数字。这就是 overflow.ts 存在的理由:
isContextOverflow- 报错型:
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_PATTERNS(overflow.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),以及重试分诊 _isRetryableError(agent-session.ts:2635-2639)——溢出错误明确不走普通重试,因为重试一次仍然会溢出,得先压缩(重试机制见 5.6 取消与错误处理)。
Case 1 里还有一个很值得学的工程细节:_overflowRecoveryAttempted 这个一次性保险丝(agent-session.ts:1990-2003)。压缩后自动重试只允许一次;再溢出就不再尝试,而是发一条 compaction_end 事件建议用户换大窗口模型。没有它,「压缩 → 重试 → 又溢出 → 再压缩」就是一个会烧钱的死循环。
一种看法是:把这三类判断塞进一个正则表清单,是在为「几十家 Provider 各说各话」买单,代价是每接一个新后端可能都要补一条正则(文件注释里甚至写了教用户自己加正则的步骤,overflow.ts:117-126)。收益是 Agent 主流程只需要问一句 isContextOverflow。
compaction 到底做了什么
准备:算切点
prepareCompaction三个要点(源码事实):最后一条已是 compaction 就返回 undefined(714-716);若存在上一次压缩,取它的 summary 作为 previousSummary 做迭代更新,起点回到上次的 firstKeptEntryId(726-733)——也就是说上次保留的消息这次会被重新摘要;待摘要消息与 turn 前缀消息双双为空时同样返回 undefined(765-767),自动路径据此静默放弃。
findCutPoint合法切点由 isCutPointMessage(compaction.ts:308-321)决定:user、assistant、bashExecution、custom、branchSummary、compactionSummary 都可以切,toolResult 永远不行。原因很直白——工具结果必须紧跟它的 tool call,单独留下一个「没有请求的结果」会让模型 API 直接报格式错误。
如果切点不是一个 turn 的起点(isTurnStartMessage,compaction.ts:323-336),就标记 isSplitTurn 并回溯出这个 turn 的起点(findTurnStartIndex,compaction.ts:369-376)。
生成:一次隔离的模型调用
compact()(compaction.ts:817-919)负责生成摘要。非 split-turn 时只调一次 generateSummaryWithUsage(622-686):先 convertToLlm 再 serializeConversation(compaction/utils.ts:109-150)把消息序列化成纯文本,包进 <conversation> 标签,配上结构化提示词(SUMMARIZATION_PROMPT,compaction.ts:467-498,要求输出 ## Goal / ## Progress / ## Next Steps 等固定小节;有 previousSummary 时改用 UPDATE_SUMMARIZATION_PROMPT,500-537)。
为什么要序列化成文本而不是直接把消息数组发过去?源码注释写得很明白:This prevents the model from treating it as a conversation to continue(utils.ts:103)。配套的系统提示词也在反复强调「不要接着聊,只输出摘要」(SUMMARIZATION_SYSTEM_PROMPT,utils.ts:156-158)。工具结果在序列化时截断到 2000 字符(utils.ts:89、144)。
split-turn 时额外调一次 generateTurnPrefixSummary(compaction.ts:924-969),两段用 **Turn Context (split turn):** 合并(881)。摘要末尾再拼上文件操作清单(905-906)。预算方面:主摘要 maxTokens = min(0.8 × reserveTokens, model.maxTokens)(637-640),turn 前缀是 0.5 ×(937-940)。
completeSummarization官方文档说明摘要请求会「使用全新的路由 session ID,并在 Provider 支持时禁用 prompt-cache 写入」(来源文件:packages/coding-agent/docs/compaction.md 概览一节),与这段实现一致。
落盘与继续
自动路径的收尾在 _runAutoCompaction(agent-session.ts:2047 起):
// 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/overflow,agent-session.ts:2072、1787)与 compaction_end(含 willRetry 与 errorMessage,2182、1901-1907)。扩展可以在 session_before_compact 里取消压缩或直接提供自己的摘要结果(2079-2105、1812-1831),压缩完成后收到 session_compact(2164-2172、1883-1891)——扩展机制见 7.1 Extension 系统。
图 6.6-3 压缩前后的消息数组对比
左边是压缩前发给模型的数组,右边是压缩后重算出来的数组。关注三点:数组长度骤降;被替换的那一段变成一条 compactionSummary 消息并排在最前;切点右侧的消息一条不动,连时间戳都不变。
B4 到 B1 这一段对应 prepareCompaction 的 messagesToSummarize(compaction.ts:750-754),B5/B6 对应 firstKeptEntryId 之后的保留区,A1 由 sessionEntryToContextMessages 从新的 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) 截出自定义指令,交给 handleCompactCommand(interactive-mode.ts:6058-6066,异常吞掉、靠 compaction_end 事件呈现)。远程过程调用(RPC,Remote Procedure Call)模式同样暴露(modes/rpc/rpc-mode.ts:530-533)。
真正的实现是 AgentSession.compact(agent-session.ts:1783 起),与自动路径同构,差别有三处(源码事实):① 先 _disconnectFromAgent() 并 await this.abort() 中断当前操作(1784-1785);② customInstructions 会拼到摘要提示词后面(compaction.ts:644-646 的 Additional focus:);③ 准备阶段失败时抛出可读错误——Already compacted 或 Nothing to compact (session too small)(1800-1807),而自动路径只是静默返回 false。
分支摘要:另一种「压缩」
/tree 导航离开当前分支时,被放弃那条路径上的工作不该凭空消失。branch-summarization.ts 干的就是这件事(文件头注释,packages/coding-agent/src/core/compaction/branch-summarization.ts:1-6)。
调用链(源码事实):navigateTree(agent-session.ts:2895)→ collectEntriesForBranchSummary(2921 → branch-summarization.ts:108-146)→ 扩展事件 session_before_tree(2951 起)→ generateBranchSummary(branch-summarization.ts:293-376)→ sessionManager.branchWithSummary(agent-session.ts:3040)→ 重算 agent.state.messages(3067-3068)。
三个与 compaction 不同的地方:
- 收集范围靠共同祖先算法:把旧 leaf 路径的 id 装进 Set,再从目标路径末尾往回找第一个命中,即最深公共祖先;然后从旧 leaf 沿
parentId收集到公共祖先(不含),最后反转成时间序(branch-summarization.ts:118-145)。 - 预算裁剪而非切点:预算由调用方算出(
contextWindow - reserveTokens,branch-summarization.ts:311-313),prepareBranchEntries(195-247)拿着它从最新往回装消息,装满即停;但 compaction/branch_summary 类条目在预算 90% 以内会被强行塞入(230-239),避免嵌套摘要丢失。 - 摘要挂在新位置:
branchWithSummary把BranchSummaryEntry建在导航目标处,而不是被放弃的旧分支上。生成的正文前置一段BRANCH_SUMMARY_PREAMBLE(253-256、364),maxTokens固定 2048(350),失败时返回{ aborted }或{ error }软失败(354-359)而不是抛异常。
注入上下文的形态与压缩摘要同理,也是一条 user 消息,只是换了前后缀(messages.ts:19-24、170-175)。
两套 compaction 目录
这里要补一个可能让读者困惑的点:3.7 讲触发条件时,引用的是 packages/agent/src/harness/compaction/compaction.ts(CompactionSettings:164-171、DEFAULT_COMPACTION_SETTINGS:174-178、shouldCompact: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 散参 + streamFn | Models 抽象的 completeSimpleWithRetries |
| 保留区表示 | 只存 firstKeptEntryId(按 id 引用) | 额外支持 retainedTail:把保留消息实体存进条目 |
| 自动触发 | 有(阈值 + 溢出,见上文) | 无:只有手动 compact()(agent-harness.ts:736)与 navigateTree()(791) |
retainedTail 的语义变化最值得看:harness 的 defaultContextEntryTransform(packages/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 字符截断、扩展事件、设置项等方面与源码一致。逐条核对后发现四处需要补充(均为本书核对源码后的发现):
- 溢出没有被列为触发条件。文档的 “When It Triggers” 只写了阈值公式和
/compact,溢出只在扩展事件小节的reason枚举里间接出现;而源码中它是与阈值平级的 Case 1(agent-session.ts:1983-2011),还带一次性 compact-and-retry 语义。 - 第 5 步写的是 “Reload: Session reloads”,实际实现并不重新加载会话文件,而是内存里重算(
agent-session.ts:2155-2156)。compaction.ts:4-5的文件头注释也用了 “the session is reloaded” 这种偏重的说法。 - 完全没提 harness 版实现与
retainedTail。 reserveTokens的作用被说少了:文档写它是「给模型回复预留的 token」,源码里它同时决定摘要调用自身的maxTokens上限(compaction.ts:637-640、937-940)。
实践任务
目标:不需要任何 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 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 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 是会话树的一次投影,每次请求重算:
buildSessionPath→buildContextEntries(压缩折叠)→sessionEntryToContextMessages→agent.state.messages→convertToLlm→Context。九种条目里只有四种进上下文。 - 压缩摘要在模型眼里就是一条普通 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.ts(DEFAULT_COMPACTION_SETTINGS:132、calculateContextTokens:146、estimateContextTokens:202、shouldCompact:235、isCutPointMessage:308、findCutPoint:403、completeSummarization:562、generateSummaryWithUsage:622、prepareCompaction:710、compact:817)、packages/coding-agent/src/core/session-manager.ts(getLatestCompactionEntry:316、sessionEntryToContextMessages:383、buildContextEntries:418、buildSessionContext:461、appendCompaction:1097)、packages/coding-agent/src/core/agent-session.ts(compact:1783、_checkCompaction:1953、_runAutoCompaction:2047、navigateTree:2895、getContextUsage:3164)、packages/coding-agent/src/core/messages.ts:148、packages/ai/src/utils/overflow.ts:132、packages/agent/src/harness/session/session.ts:59;测试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——上下文的另一半(
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。