pi 的上下文压缩机制

pi 0.85.1(be26e32),行号都指这份源码。实测数据来自本机已装产物 ~/.npm-global/lib/node_modules/@earendil-works/pi-coding-agent/dist/core/compaction/compaction.js,第9章:上下文压缩 —— 当对话太长怎么办

判据本体

// packages/coding-agent/src/core/compaction/compaction.ts:235
export function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean {
    if (!settings.enabled) return false;
    return contextTokens > contextWindow - settings.reserveTokens;
}
字段 默认 读取位置
enabled true core/settings-manager.ts:829
reserveTokens 16384 settings-manager.ts:842
keepRecentTokens 20000 settings-manager.ts:846

三者由 getCompactionSettings()(settings-manager.ts:850)打包传给判定函数。

判定条件等价于「上下文 token 超过 contextWindow - 16384」。没有比例阈值、没有迟滞带、不看消息条数。函数只做一次减法比较,全部复杂度在它唯一的入参 contextTokens 上。

contextTokens

provider 返回的 usage 是那一刻整个上下文的快照,含之前所有消息、系统提示词、工具 schema。所以一条 usage 之后新增的消息不在那个数字里,之前的消息则已被覆盖,重算就是重复计数。estimateContextTokens()(compaction.ts:202)按这个约束分四步:

步 代码 行为
1 getLastAssistantUsageInfo:190 → getAssistantUsage:154(在 :203 处调用) 从后往前找第一条有效 assistant usage,排除 stopReason 为 aborted/error 的消息和四项全零的消息
2 calculateContextTokens:146 锚点取真实值:优先 usage.totalTokens,没有这个字段时用 input、output、cacheRead、cacheWrite 四项求和
3 循环 :220-222 锚点之后每条走 estimateTokens:266,即 Math.ceil(chars / 4) 累加
4 :205-216 全程找不到任何有效 usage 时,退化为对全部消息 chars/4 估算,lastUsageIndex 为 null

返回结构里 tokens = usageTokens + trailingTokens,tokens 才是喂给 shouldCompact 的值。

estimateTokens 按角色取字符数:assistant 累加 text、thinking、toolCall 的 name + JSON.stringify(arguments);toolResult/custom 累加 text,图片按常量 4800 字符计(ESTIMATED_IMAGE_CHARS:244);bashExecution 累加 command 与 output;branchSummary/compactionSummary 累加 summary。未匹配的角色返回 0。

会话数据

~/.pi/agent/sessions/--home-yangsen--/2026-09-06T07-59-53-280Z_01a075bb-21c0-7014-a710-4358a14e9b0a.jsonl,一次安装 codex CLI 的会话

i role stopReason usage(input/output/cacheRead/total) estimateTokens
13 assistant stop 114 / 126 / 11200 / 11440 60
14 user – – 109
15 assistant toolUse 785 / 95 / 10624 / 11504 73
16 toolResult – – 13
17 assistant toolUse 153 / 81 / 11392 / 11626 70
18 toolResult – – 14
19 assistant aborted 0 / 0 / 0 / 0 2153

[19] 是用户打断前的一段 thinking,8611 字符。函数输出:

{"tokens":13793,"usageTokens":11626,"trailingTokens":2167,"lastUsageIndex":17}

对得上:锚点 [17] 的 totalTokens 11626 与四项求和一致;trailingTokens = [18] 的 14 + [19] 的 2153 = 2167;[0] 到 [16] 的估算值一个都不参与,它们的内容已经被 11626 覆盖。

同一份输入换不同窗口:

shouldCompact(13793, 200000) => false
shouldCompact(13793,  32000) => false
shouldCompact(13793,  16000) => true

chars/4 对中文偏低。[0] 的内容 "为我安装codex" 是 4 个汉字加 codex 5 个字母共 9 字符,Math.ceil(9 / 4) 得 3。实际分词后中文字数远高于这个值。compaction.ts:264` 的注释写「conservative (overestimates tokens)」,成立范围是英文和代码。

触发判定链

shouldCompact 在 _checkCompaction()(core/agent-session.ts:2157)里排在最后一步(case 3),:

行 条件 目的
:2162 stopReason === "aborted" 直接返回 false 用户取消不触发压缩;skipAbortedCheck=false 可绕过(:1266 走这条,抓被打断的响应)
:2170 消息的 provider/model 与当前模型不同则跳过溢出检查 从小窗口模型切到大窗口模型后,旧模型的溢出错误不该触发新模型的压缩
:2176-2179 消息 timestamp <= 最新 compaction entry 的时间戳则返回 false 压缩后第一条 prompt 拿着压缩前的陈旧 usage 会立刻再压一次
:2186-2187 isContextOverflow 或 isRecoverableLength 命中则走 case 1/2 溢出优先于阈值,且带一次 compact-and-retry

isContextOverflow(packages/ai/src/utils/overflow.ts:134)认三种形态:错误文案正则命中溢出模式且不属于限流类;stopReason:"stop" 但 input + cacheRead 超过窗口(z.ai 一类静默接受超长的后端);stopReason:"length"、output === 0 且输入填满窗口 99%(小米 MiMo 一类服务端截断的后端)。isRecoverableLength(:171)认 length 且 output 低于原始 maxTokens 的截断。case 1/2 由 _overflowRecoveryAttempted 限制只试一次,第二次直接报失败。

StopReason 的合法值七种:"pending" | "stop" | "length" | "toolUse" | "error" | "aborted" | "deferred"(packages/ai/src/types.ts:406)。工具调用轮次是 toolUse,纯文本收尾才是 stop。

四个压缩时机

message_end 是一条消息落定(每次 LLM 响应各一次,含带工具调用的响应),agent_end 是整轮结束:从你按回车到模型不再要求调用工具,中间可能夹着五次、十次 LLM 请求。一轮里的四个检查点用下面这个场景定位:「把 config.ts 的注释补全」,共三次模型请求。

时刻 发生什么 message_end agent_end 检查点
t1 你按回车,发第一次请求 – – 无
t2 模型回 read 调用 有 – 无
t3 文件内容进上下文,发第二次请求 – – A
t4 模型回 edit 调用 有 – 无
t5 发第三次请求 – – A
t6 模型回纯文本「已改完」,整轮结束 有 有 B 或 D

A 下一次请求发出之前

判定体 _compactBeforeNextAssistantResponse()(core/agent-session.ts:542),一个条件块三个子句(:546-550):模型存在、model.contextWindow > 0、shouldCompact(estimateContextTokens(context.messages).tokens, ...)。任一不过就在 :551 原样返回 context。过了则 :554 走 _runAutoCompaction("threshold", false),:555-558 用压缩后的 this.agent.state.messages.slice() 顶掉 context.messages。

挂载点 :568(prepareNextTurnWithContext 钩子体内第一行),钩子由循环在 packages/agent/src/agent-loop.ts:177 调用,:176 的 if (lastCompletedTurn) 保证首轮不跑,外层条件在 :175。这就是 t3 与 t5。

B 整轮结束之后

数据记在 :695:message_end 里把 assistant 消息存进 _lastAssistantMessage,注释 :693 写的就是等 agent_end 再查。agent.prompt() 在 :1108 resolve(agent_end 由 agent-loop.ts:217、:253、:272 三条退出路径发出),随后 :1109 进 _handlePostAgentRun()(:1120),:1141 调 _checkCompaction(msg)。

判定行顺序就是上一节那张表的四道门(:2162、:2170、:2176-2179、:2186-2187),四道都没命中才往下走到阈值::2233-2256 取数 → :2257 shouldCompact(contextTokens, contextWindow, settings) → :2258 _runAutoCompaction("threshold", false)。返回 true 后 :1110 的 agent.continue() 起新一轮,摘要进新上下文。

取数那一段的分支在 :2234(directContextTokens 取该消息 usage 求和)与 :2235(stopReason === "error" 或该值为 0 就改走 estimateContextTokens(messages),:2237),:2240-2252 再查估算依据的 usage 消息是否早于 :2176 拿到的压缩边界。

C 下一次按回车之前

:1262-1267,位置在 prompt() 里、用户消息组装之前。:1264 _findLastAssistantMessage(),:1266 _checkCompaction(lastAssistant, false),第二个参数 skipAbortedCheck=false 绕开 :2162 的 aborted 短路。:1263 注释说明这里不调 agent.continue(),压缩就地做完,用户那条输入照常发出去。上例如果你按 Esc 打断了第二次响应,那条 aborted 消息没有可用 usage,B 会跳过它;你接着敲「继续」,发送新内容之前由这一支兜住。

D 整轮结束之后

入口与 B 同一条(:1141),命中的是 :2186 或 :2187。:2189 先算 willRetry = assistantMessage.stopReason !== "stop":

行 情形 行为
:2193-2194 case 2:响应止于 stop _runAutoCompaction("overflow", false),不重试
:2197-2217 _overflowRecoveryAttempted 已置位 直接报失败,发 compaction_end 带错误文案
:2221-2226 case 1:响应是 error 或截断 置位,:2222-2225 从 agent.state.messages 尾部撤掉那条,:2226 _runAutoCompaction("overflow", willRetry)

溢出识别在 packages/ai/src/utils/overflow.ts:134(三种形态:错误文案正则、stop 但 input + cacheRead 超窗口、length 且 output 为 0 且输入填满窗口 99%)与 :171(length 且 output 低于原始 maxTokens)。_overflowRecoveryAttempted 的复位在 :697-700,下一条非 error 非 length 的 assistant 消息到达时清回 false。

上例如果第三次请求(t5)返的是 prompt is too long,t6 位置的判定走 case 1;如果 t5 返的是 stop 但 usage 已经超过窗口(z.ai 一类静默接受超长的后端),则走 case 2,只压不重试。

durable runtime 另有一条入口:packages/agent/src/harness/runtime/drive/structural.ts:1144,判定函数是 harness 那份镜像,入参用 prepareCompaction() 产出的 tokensBefore。

切点

findCutPoint(entries, startIndex, endIndex, keepRecentTokens)(core/compaction/compaction.ts:403)返回三个数:保留区起点 firstKeptEntryIndex、轮起点 turnStartIndex、是否切断了一轮 isSplitTurn。

// user 和 assistant 都是合法切点,toolResult 不是,从后往前累积
function findCutPoint(entries, keepRecentTokens) {
    const cutPoints = findValidCutPoints(entries);  // 排除 toolResult

    let accumulated = 0;
    for (let i = entries.length - 1; i >= 0; i--) {
        accumulated += estimateTokens(entries[i]);
        if (accumulated >= keepRecentTokens) {
            // 找到第一个 >= i 的有效切割点
            return 第一个 >= i 的 cutPoint;
        }
    }
    // 没找到压缩点不压缩
}

数据

还是那个安装 codex 的会话,落盘 25 条 entry。注意 entry 不等于消息:前四条是 model_change 与 thinking_level_change,最后一条是扩展写的 custom 记录,这五条都不会被序列化给模型。

entry 类型 / role 估算 token 可否当切点
0–3 model_change / thinking_level_change 0 否(不进上下文)
4 user 3 能
5 assistant 76 能
6 toolResult 4 否
7 assistant 44 能
8–16 toolResult 与 assistant 交替 86, 57, 26, 100, 7, 24, 14, 59, 26 toolResult 那几条否
17 assistant 60 能
18 user 109 能
19–22 assistant / toolResult 交替 73, 13, 70, 14 toolResult 否
23 assistant(被打断的 thinking,8611 字符) 2153 能
24 custom 0 否(不进上下文)

候选切点集实测 12 个:4、5、7、9、11、13、15、17、18、19、21、23。8 条 toolResult 与 5 条不进上下文的 entry 全部落选。

对照

行 逻辑 在例子里
:409 先算候选集,findValidCutPoints:351 扫 [startIndex, endIndex) 得到上面那 12 个下标
:355-357 跳过 compaction 类型的 entry 本会话没有压缩历史,无跳过
:358 一条 entry 展开出的可见消息里只要有一条满足 isCutPointMessage:308 就入候选 六种角色 true,只有 toolResult false
:411-413 候选集为空时直接返回起点,turnStartIndex = -1 不会发生在这个会话
:416 累计量归零 –
:417 切点默认值 = 最早的候选点 默认 4,含义是从第一条消息就保留,即什么都不丢
:419 i 从 endIndex - 1 倒着走到 startIndex 从 24 往 0
:421-424 本条 entry 的估算量 = 它展开出的可见消息逐条 estimateTokens 求和 sessionEntryToContextMessages(session-manager.ts:383-408)四个分支各返回 0 或 1 条,本例每条最多 1 条
:425 估算为 0 的直接 continue,不进累计也不看预算 24 号 custom、0–3 号元数据都从这里走掉
:426 累加 2153、2167、2237、2250……
:429 累计量第一次达到 keepRecentTokens 就进切点定位 预算 2160 时在 22 号达标
:431-436 从候选集里找第一个大于等于 i 的下标 22 不在候选集里,往后取到 23
:437 定位完就跳出倒序循环,不再看更旧的消息 –
:442-449 切点往前自吸收:紧邻的前一条 entry 如果不进上下文又不是 compaction,就把切点下标减一 预算 100000 时切点从 4 跑到 0,那四条元数据被归入保留区
:452-453 isTurnStartEntry:338 判断切点是不是一轮的开始;用的是另一张角色表 isTurnStartMessage:323,那里 assistant 也是 false 23 号是 assistant,不算轮起点
:454 不是轮起点就调 findTurnStartIndex:369,:370-374 从切点往回找第一个轮起点 从 23 回到 18
:459 isSplitTurn = 不是轮起点 且 回找到了轮起点 前两档都切在 assistant 上,true;第三档切在 model_change,往回找不到起点(:374 返回 -1),false

两个序列分开看是理解这个函数的关键::419 的 i 逐条踩 entry,包括不可切的;:431 的候选集只在撞预算那一刻才用。预算 2160 与 2170 实测差 10 个 token,切点差两条(23 与 21),就是达标位置与合法切点不重合造成的。

三种预算

keepRecentTokens 达标位置 切点 turnStartIndex 切点之后保留 token prepareCompaction 结果
2160 22(toolResult) 23(assistant) 18(user) 2153 摘要 14 条 + turn 前缀 5 条
2170 21(assistant) 21 18 2237 摘要 14 条 + turn 前缀 3 条
100000 未达标 0(model_change) –1 3018(全部) undefined,不压缩

第一行预算只有 2160,实际保留 2153;第二行预算 2170,实际保留 2237。保留量与预算不相等,因为切点只能落在候选集上,往哪个方向差取决于附近哪条合法。

第三行是完整的取消路径:预算永远填不满,切点停在默认值 4,:442-449 自吸收元数据到 0,保留区覆盖全部 entry,prepareCompaction:791-793 采到的待摘要集合为空,:805-807 返回 undefined,这轮压缩从头到尾不会发生,也不会有 compaction_start 事件。

切点三元组

prepareCompaction:778 拿到结果后分两段(:787):

行 行为
:791-793 从上一次压缩边界走到 historyEnd,这些消息进 messagesToSummarize,压缩后丢弃
:787 historyEnd 取 turnStartIndex(切断了一轮时)或 firstKeptEntryIndex(没切断)
:799-801 切断了一轮时,轮起点到切点之间的那段(例子里的 18–22)另走 turnPrefixMessages,单独生成一份前缀摘要
:782 切点那条 entry 没有 id 时直接放弃,会话需要迁移

切在 assistant 上是允许的,它对应的 tool result 排在后面,天然一起留在保留区;被禁的是从 tool result 开始保留,那会让保留区第一条是一个没有对应调用的工具结果。:347-349 的注释写的就是这一点。

主摘要覆盖的是被压缩的”完整历史”(多个完整 Turn)→ 用 6 section 结构化格式 turnPrefix 摘要覆盖的是被切断的”半个 Turn”(user 在主摘要里、assistant 在保留区)→ 用更轻量的 3 段格式(Original Request / Early Progress / Context for Suffix)

压缩产物拼接上下文

环节 位置 形态
落盘的 compaction entry :957-963 summary 文本 + firstKeptEntryId + tokensBefore + details
重建上下文时展开 core/session-manager.ts:404-406 一条 role: "compactionSummary" 的消息,字段只有 role、summary、tokensBefore、timestamp
发给模型前 core/messages.ts:176-183(入口 convertToLlm:148) role: "user" 的单条 text block,两头包固定文案(COMPACTION_SUMMARY_PREFIX:11 与 COMPACTION_SUMMARY_SUFFIX:16)

两头包的固定文案在 core/messages.ts:11-17:开头一句 The conversation history before this point was compacted into the following summary: 接一个 summary 开标签,结尾一个 summary 闭标签。

位置在重建后上下文的第一条。buildContextEntries(session-manager.ts:441-452)先把 compaction entry 放进结果,再放 firstKeptEntryId 起的保留区,最后放 compaction 之后的新消息。

--home-yangsen-intv--/2026-09-03T01-53-35-158Z_01a064f8-b1b6-70cd-a4a3-5c58abfa5634.jsonl(两次压缩,branch 1950 条 entry,重建后 1686 条 entry)。把这条消息跑过 convertToLlm 之后的实测结果:

role: user | blocks: text | 总字符: 22690

开头(原句):
    The conversation history before this point was compacted into the following summary:
    summary 开标签
    ……模型生成的摘要正文,markdown,带 Goal 小标题、代码引用与仓库地址列表……

结尾(原句):
    read-files 块:
        /tmp/hp/package/apps/cli/src/haya-pet-hook.js
        /tmp/hp/package/packages/pet-core/src/validation.js
    modified-files 块:
        /home/yangsen/.pi/agent/extensions/haya-pet-pi.ts
        /home/yangsen/pet-ironman/make_atlas.py
        /home/yangsen/pet-ironman/slice_atlas.py
    summary 闭标签

两个文件列表块由 formatFileOperations(core/compaction/utils.ts:72-83)拼在摘要字符串末尾(compaction.ts:951),路径是从被丢弃的消息里抽 read / write / edit 工具调用的 path 参数得集,不经模型手写。

模型看到的就是一条开头带「历史已被压成如下摘要」的用户消息,被丢弃的那段历史换成摘要正文,正文末尾挂着代码算出来的文件列表,最后是闭标签。

复现

node -e '
const D="/home/yangsen/.npm-global/lib/node_modules/@earendil-works/pi-coding-agent/dist";
const {SessionManager,buildSessionContext}=await import(D+"/core/session-manager.js");
const {estimateContextTokens,estimateTokens,shouldCompact}=await import(D+"/core/compaction/compaction.js");
const S="<session path>";
const sm=SessionManager.open(S);
const msgs=buildSessionContext(sm.getBranch()).messages;
const est=estimateContextTokens(msgs);
console.log(est, shouldCompact(est.tokens, 200000,
  {enabled:true,reserveTokens:16384,keepRecentTokens:20000}));
'

buildSessionContext(entries) 是生产路径本身——prepareCompaction 内部 :776 调的就是它,不是另造一份消息列表。