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 调的就是它,不是另造一份消息列表。