把这条规则记住,压缩的大部分行为就都能推出来。从最新消息往回累加 token,攒够 keepRecentTokens 就想切;但切口只能落在「模型视角下的一条完整消息边界」上,于是实际切点会往前滑到最近的合法位置。只要待压缩的历史本身超过这个预算,实际保留量就总是 ≥ 目标值,而不是恰好等于。
还有两件事值得先知道:
CompactionEntry),不是运行时的临时裁剪。这意味着压缩不可撤销,也意味着它可回放。export function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean {
if (!settings.enabled) return false;
return contextTokens > contextWindow - settings.reserveTokens;
}
触发点有两个,语义不同:
| reason | 时机 | 位置 |
|---|---|---|
threshold | run 结束(agent_end)后或提交 prompt 前主动检查,还没撞墙 | agent-session.ts:2049 |
overflow | provider 已经报了上下文溢出之后补救 | agent-session.ts:1998 |
manual | 用户敲 /compact | — |
threshold 这条路在 CLI 里的检查点是 一次 run 结束(agent_end)之后,以及提交下一个 prompt 之前:agent-session.ts:1952。它不在 LLM 调用中途处理,也不在 run 内部的 turn 之间处理。agent 包的 README 示例给了另一种接法——用 第 03 章 §2 的 shouldStopAfterTurn 钩子在 turn 边界停下、压缩完再继续:agent/README.md:299;锚点上的 coding-agent 没有采用它。
合法切点由一个 role 白名单决定,toolResult 是唯一被排除的:compaction.ts:308–320
function isCutPointMessage(message: AgentMessage): boolean {
switch (message.role) {
case "user":
case "assistant":
case "bashExecution":
case "custom":
case "branchSummary":
case "compactionSummary":
return true;
case "toolResult":
return false;
}
原因是协议级的:所有 provider 都要求 toolResult 前面必须有对应的 tool call。切在 toolResult 上会产出一个「有结果没调用」的孤儿,请求直接被拒。这是压缩实现里唯一一条不能违反的硬约束,其余都是启发式。
findCutPoint 从最新往回累加 token 估算,攒够 keepRecentTokens 后,取第一个下标 ≥ 当前位置的合法切点。compaction.ts:403
结果里带一个 isSplitTurn 标志:切点如果不是 user 消息,说明这一刀切进了某个 turn 的中间(比如切在 assistant 上,它的 tool results 跟在后面)。此时会额外记下该 turn 的起始位置,供摘要生成时补上「这个 turn 是从哪开始的」。
换句话说:token 预算决定「大概切在哪」,消息结构决定「实际切在哪」,两者不一致时结构赢。
prepareCompaction 算出切点与待摘要区间,compact 发起调用并把结果写成 CompactionEntry。compaction.ts:710 compaction.ts:817
上游文档补了一条实现细节,代码之外看不出来:压缩与分支摘要的请求使用全新的路由 session id,并在 provider 支持时禁用 prompt-cache 写入——这类一次性 prompt 不太可能被复用,写缓存是净亏。compaction.md:23
摘要生成时会把上一次的摘要作为迭代上下文传进去,所以多次压缩后的摘要是层层叠加而非各自独立的。代价是误差也会层层叠加:第三次压缩的摘要,是「对『对摘要的摘要』的摘要」。
docs/compaction.md 的 Source files 只列 packages/coding-agent/src/core/compaction/*。compaction.md:6packages/agent 另有一份 harness/compaction/compaction.ts(848 行),从 @earendil-works/pi-agent-core 公开导出同名的 compact / shouldCompact / findCutPoint。agent/src/index.ts:75isCutPointMessage 谓词并作用于「entry 展开后的消息」,agent 那份是直接对 entry 类型做内联 switch。harness/compaction/compaction.ts:312@earendil-works/pi-agent-core 写代码的人调到另一份。文档无任何提示。这是 harness-v2 迁移期的中间态(见 第 08 章),但对库使用者是当下的事实。锚点之后d3 已经消解:7fd478a(2026-10-01) 随 harness 一起删掉了 agent 包里那份 harness/compaction/,pi-agent-core 的入口不再导出这组函数;CLI 用的仍是自己那份,例如 compaction.ts:267。收敛靠的是删掉副本,不是把 CLI 迁过去。见 漂移记录 · 上游后续。
临时裁剪更灵活:换模型、换 window 就重算一次。pi 选择把摘要写成会话条目,换来三样东西:摘要只生成一次(省钱)、会话可精确回放、/tree 导航时摘要是树上的一个节点而不是某次请求的副产品。
代价是不可逆。CompactionEntry 一旦写入,原始消息虽然还在文件里,但重建上下文时会从 firstKeptEntryId 起算。换到一个 window 大得多的模型,也不会自动「解压」回去。
有了主动阈值检查,为什么还需要溢出后的补救?因为 contextTokens 来自上一次 provider 返回的 usage,是滞后一拍的估算。一个 turn 里如果工具返回了个 200KB 的文件,这一拍的增量就足以越过窗口,而阈值检查还在用上一拍的数字。
所以 overflow 不是冗余,是承认「token 计数在客户端永远是估算」这一事实后的必要兜底。值得注意的是它只在不会重试时才触发压缩:agent-session.ts:1997–2000 注释解释了原因——如果 assistant 已经答完(stopReason === "stop"),就该压缩但不能重试,因为 agent.continue() 无法从一条 assistant 消息续跑(正是 第 03 章 里 agentLoopContinue 的那条前置约束)。
「保留最近 20000 token」假设近期消息比早期消息更有价值。对编码任务大体成立(刚读的文件、刚跑的测试最相关),但有一类情况明显不成立:任务目标写在第一条 user 消息里。pi 的对策是让摘要承载这类信息,而不是给早期消息特殊豁免——没有任何「pin 住某条消息」的机制。
keepRecentTokens 是下限不是上限(在历史足够长时)。切点往前滑之后保留量只会更多;历史本身不足 20000 token 的小窗口场景是唯一例外——那时能保留的就是全部。如果一个 turn 本身就超过 20000 token(比如工具吐了个巨大文件),那一整个 turn 都保留。检查过但未发现问题:findValidCutPoints 显式跳过 compaction 类型的 entry(compaction.ts:355–357),所以不会出现「切在上一次压缩点上」导致摘要嵌套错乱的情况。
场景往回累加到第 42 条时刚好攒够 20000 token,而第 42 条是一条 toolResult。切点落在哪?
C。toolResult 不是合法切点,findCutPoint 取第一个下标 ≥ 当前位置的 cutPoints 元素。keepRecentTokens 是下限。compaction.ts:308
场景你 import { compact } from "@earendil-works/pi-agent-core",然后照 docs/compaction.md 读源码理解它的行为。有问题吗?
A。两份独立实现,diff 880 行,切点判定的结构都不同。文档的 Source files 一节只列 CLI 那份。agent/src/index.ts:75
ac4ac9e(2026-08-07 快照)。