Part II · Kernel — Compaction

06 压缩 切在哪一刀,比切掉多少更重要

已发布 触发线 window − 16384 保留 ≈20000 token 同名实现 两份
§1 Mental Model 一句话心智模型

先记住这一句

压缩不是「删掉旧消息」,是「在一个合法的切点上把前半段换成一段摘要」。切点的合法性由一条硬规则定义:永远不能切在 toolResult 上——它必须紧跟自己的 tool call。

把这条规则记住,压缩的大部分行为就都能推出来。从最新消息往回累加 token,攒够 keepRecentTokens 就想切;但切口只能落在「模型视角下的一条完整消息边界」上,于是实际切点会往前滑到最近的合法位置。只要待压缩的历史本身超过这个预算,实际保留量就总是 ≥ 目标值,而不是恰好等于。

还有两件事值得先知道:

§2 Evidence 代码实证

触发线:一个减法

packages/coding-agent/src/core/compaction/compaction.ts原文 L235–L238
export function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean {
    if (!settings.enabled) return false;
    return contextTokens > contextWindow - settings.reserveTokens;
}
默认 reserveTokens: 16384、keepRecentTokens: 20000。compaction.ts:132–136 留出的 16k 是给模型回答用的,不是安全余量。

触发点有两个,语义不同:

reason时机位置
thresholdrun 结束(agent_end)后或提交 prompt 前主动检查,还没撞墙agent-session.ts:2049
overflowprovider 已经报了上下文溢出之后补救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 没有采用它。

切点规则:toolResult 是禁区

合法切点由一个 role 白名单决定,toolResult 是唯一被排除的:compaction.ts:308–320

packages/coding-agent/src/core/compaction/compaction.ts原文 L308–L319
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;
    }
源码注释说得更直白:“Never cut at tool results (they must follow their tool call).” compaction.ts:347

原因是协议级的:所有 provider 都要求 toolResult 前面必须有对应的 tool call。切在 toolResult 上会产出一个「有结果没调用」的孤儿,请求直接被拒。这是压缩实现里唯一一条不能违反的硬约束,其余都是启发式。

切点搜索:往回累加,再往前滑

findCutPoint 从最新往回累加 token 估算,攒够 keepRecentTokens 后,取第一个下标 ≥ 当前位置的合法切点。compaction.ts:403

结果里带一个 isSplitTurn 标志:切点如果不是 user 消息,说明这一刀切进了某个 turn 的中间(比如切在 assistant 上,它的 tool results 跟在后面)。此时会额外记下该 turn 的起始位置,供摘要生成时补上「这个 turn 是从哪开始的」。

换句话说:token 预算决定「大概切在哪」,消息结构决定「实际切在哪」,两者不一致时结构赢。

摘要是一次真实的 LLM 调用

prepareCompaction 算出切点与待摘要区间,compact 发起调用并把结果写成 CompactionEntry。compaction.ts:710 compaction.ts:817

上游文档补了一条实现细节,代码之外看不出来:压缩与分支摘要的请求使用全新的路由 session id,并在 provider 支持时禁用 prompt-cache 写入——这类一次性 prompt 不太可能被复用,写缓存是净亏。compaction.md:23

摘要生成时会把上一次的摘要作为迭代上下文传进去,所以多次压缩后的摘要是层层叠加而非各自独立的。代价是误差也会层层叠加:第三次压缩的摘要,是「对『对摘要的摘要』的摘要」。

同名函数,两份实现

漂移 d3 · 详见 漂移记录
文档说
docs/compaction.md 的 Source files 只列 packages/coding-agent/src/core/compaction/*。compaction.md:6
代码是
packages/agent 另有一份 harness/compaction/compaction.ts(848 行),从 @earendil-works/pi-agent-core 公开导出同名的 compact / shouldCompact / findCutPoint。agent/src/index.ts:75
差在哪
两份文件 diff 达 880 行。切点判定的结构就不同:CLI 那份把判定抽成 isCutPointMessage 谓词并作用于「entry 展开后的消息」,agent 那份是直接对 entry 类型做内联 switch。harness/compaction/compaction.ts:312
影响
读文档找源码的人看到 CLI 那份;用 @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 迁过去。见 漂移记录 · 上游后续。

§3 Design 设计动机与权衡

为什么压缩要落盘,而不是每次请求临时裁剪

临时裁剪更灵活:换模型、换 window 就重算一次。pi 选择把摘要写成会话条目,换来三样东西:摘要只生成一次(省钱)、会话可精确回放、/tree 导航时摘要是树上的一个节点而不是某次请求的副产品。

代价是不可逆。CompactionEntry 一旦写入,原始消息虽然还在文件里,但重建上下文时会从 firstKeptEntryId 起算。换到一个 window 大得多的模型,也不会自动「解压」回去。

threshold 与 overflow:两条路说明了什么

有了主动阈值检查,为什么还需要溢出后的补救?因为 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 住某条消息」的机制。

§4 Limits 边界与失效条件

检查过但未发现问题:findValidCutPoints 显式跳过 compaction 类型的 entry(compaction.ts:355–357),所以不会出现「切在上一次压缩点上」导致摘要嵌套错乱的情况。

§5 Takeaway 拿走什么
§ Self-check 自测

场景往回累加到第 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

基于 earendil-works/pi @ ac4ac9e(2026-08-07 快照)。
仓库 commit 速度约 428/月,行号引用仅对该 SHA 有效。