“上下文太长就总结一下”隐藏了四个不同问题:按什么请求算 token;哪一段可以切而不拆开 tool call/result;摘要是追加还是替换;恢复时 system、tools、provider config 和 session prefix 从哪里回来。只回答第一个,得到的只是一个偶尔能跑的 prompt trick。

DSH 把它建成一条可审计的状态转换。ctx.tokenMeter 为真实 request envelope 和 current surface 定价;pruner 可先处理纯体积问题;compaction backend 在日志里建立 bracket;一次摘要替换只提交一条带 provenance 的 user/message replacement。

阅读契约。读完以后,你应该能解释:0.8 和 0.16 分别控制什么;为什么低于 pressure 时不能顺手 prune;为什么区间按 surface position 而不是 numeric seq;summary request 怎样复用旧 KV cache、未来 request 又从哪里失效;以及 raw log、current surface、request/header 各自负责哪一部分重建。

证据边界。本文固定在提交 ddefc45。默认值属于 compaction-basic,不是 compaction seam 的永久协议;不同 provider 的 capacity、usage 与 KV-cache 计费仍由 adapter 报告。源码保证 replacement 和事件关系,不保证模型摘要一定保留所有业务语义。

一、先为完整请求定价,再决定是否压缩

1.1 Pressure 不是 messages.length

BasicCompactionEngine 读取最近一次 canonical logged envelope 和同一 log revision 的 current surface。计量因此覆盖 system prompt、tool schemas、routing、assistant completion、tool results,以及已进入日志的 context 与 steering,而不是只数对话字符串。

容量来自最近 durable provider/model route 的 adapter。默认在窗口的 thresholdRatio: 0.8 处触发,并按窗口的 retainRatio: 0.16 为最近历史保留 token 预算;不是保留 16% 的消息条数。每个精确 provider/model 可覆盖策略。adapter 不提供 capacity 时,显式 pressure 检查抛配置错误,自动 pre-step listener 则按目标警告一次并继续完整历史;provider 确认的 canonical overflow 仍能尝试缩减。图片节点计价会采用该 route 声明的图片价格,replacement 的日志 shadow price 仍使用固定启发式,两者职责不同。

1.2 只选择完整、平衡、可替换的 surface units

Range selection 从最老的非 system-head 节点开始,把 recent tail 留在右侧,并用 tool-pairing boundary helpers 防止 unanswered call 穿过切口。surface 第 0 项若是 system/message,永不进入压缩范围;之后追加的 system 更新则属于可压缩历史。turn boundary 不会保护一次 runaway turn 里的旧 step;相反,一个仍开放、不可分割的尾部会让 compaction 暂时 decline,等它闭合。

区间是 surface-position span,不是 seq 数字范围。旧范围被 replacement 后,新事件虽然拥有更高 seq,却占回被遮蔽区间的位置;再次压缩必须按当前位置读取,不能用 start <= seq <= end 猜成员。

二、先做无模型裁剪,再付摘要调用成本

2.1 tool-result pruner 只改模型表面

Tool result pruning:完整 tool result 经过 ctx.toolResultPruner 后,current surface 只保留 head、固定 omission marker 和 tail,并用 surfaceOp replace 与 sourceEventSeqs 引用 originalSeq;append-only log 仍保存完整原事件

ctx.toolResultPruner 默认只在 text blocks 合计超过 8192 个 Unicode code points 时动作,保留前 4096、固定 marker 和后 1024。非文本 block 保持相对顺序;切片不会拆 UTF-16 surrogate pair,但可能拆 grapheme cluster。

新 tool/result 复制原 data,仅改 content,并用 surfaceOp: replace 遮蔽原节点;sourceEventSeqs: [originalSeq] 引用被替换结果。裁剪输入来自原节点的当前消息投影,因此已经记录的图片卸载标记也保留在替换内容中。每次 replacement 之前还紧邻追加 compaction/prune,记录被遮蔽节点与启发式 token 价格,供计量投影扣除旧价格。原事件仍在 append-only log;新文本严格变小且不再超过阈值,所以重复扫描不会继续裁剪同一文本。

2.2 Prune 是 pressure-qualified pass,不是后台清洁工

低于 pressure 的 step check 不 prune。只有 pressure 或 canonical overflow 已经成立,basic backend 才调用可选 pruner,随后重新用 token meter 测量;若已经安全,就跳过摘要调用。这个顺序把便宜、确定的 syntactic shrink 放在模型调用前,又避免仅因“工具输出看起来大”就破坏仍可复用的 KV prefix。

character budget 也不是 token budget。若 pruned tool unit 仍因非文本内容或不可裁部分超窗,或者 system/tools envelope 本身过大,surface compaction 无法修复。

2.3 图片预算溢出由独立的 offload 事件处理

图片卸载:adapter 报告 IMAGE_OFFLOAD_REQUIRED 后,记录 image/offload 选择旧输入图片,后续请求用附件文字与只读路径替代这些图片,再尝试请求;原消息节点与日志仍保留

一条工具结果可能只有几百字,却含有多张高成本图片;文本 pruner 无法处理这种压力。image-offload 插件 只响应带 offloadImages 数量的 IMAGE_OFFLOAD_REQUIRED。它按失败请求的消息顺序选最老的 user/tool 输入图片,跳过 assistant 输出与已卸载图片,把消息 seq 和深度优先图片索引记为 image/offload。

// 形状级示例:选择当前消息节点 42 的第一张图片
{ type: 'image/offload', data: { targets: [{ seq: 42, imageIndexes: [0] }] } }
// 后续模型请求:该图片变成附件名称和可用只读路径的文字占位
// 原消息事件及其 surface 节点身份不变

这条路径没有新建 replacement 节点:消息投影标记被选中的图片,adapter 发出文字占位,token meter 也按这个选择重新计价。Agent 重新生成 request/header 后重试,不消耗 provider retry budget,也不记录 llm/retry。换大模型、恢复会话或后续压缩不会自动还原这些图片;重新读取附件只能创建一次新的图片出现。代价是本次必须先经历 adapter 拒绝,而且缓存从第一条变化消息处不再匹配。

三、一份成功摘要只提交一次区间替换

3.1 五步 bracket 把来源、替换和锁串在一起

两条账本:append-only SessionEvent 日志保留旧 user、assistant、tool 事件和 compaction start、summary、replacement、end;current surface 遮蔽旧区间,让高 seq replacement 占据旧位置并保留 recent tail
  1. compaction/start 同步追加,获取 log-recorded lock。
  2. backend 生成 summary,并重新验证选择的 span 没漂移。
  3. compaction/summary 记录原始 summary、range、shadowed seqs、token 计量、摘要调用目标及 usage;它是 log-only。
  4. 一条 user/message 携带 checkpoint 内容、compactCheckpointSource(compactionId) 与 surfaceOp: replace;其 sourceEventSeqs 依次引用 start、summary 和全部 shadowed seq,这是摘要提交唯一的 surface replacement。
  5. compaction/end 最后释放 lock。

因此模型下一次保留 system head,并看见一个 <compacted-summary> checkpoint 和 recent tail;人类 transcript 与精确审计仍能读旧 append-origin 事件。summary 不会作为第二份 history 追加,compaction/* 也从不进入 model surface。

3.2 Convergence 和 changed-span 都必须显式失败

Basic backend 拒绝不比 source 更小的 summary;已成功缩减但压力仍高时,才按 compactionRetries 继续压缩 checkpoint,耗尽后抛错。自动 pre-step listener 捕获操作错误并警告、继续 turn;canonical overflow 恢复则只有 replacement generation 前进才允许重试,即使先完成 pruning、后面的摘要失败也可满足这一条件。没有任何缩减进展时,原 provider 错误保持有效。

摘要请求失败时还有同步 compaction/summary-error waterfall。图片卸载插件只选择这次摘要区间内的图片;一旦记录选择,backend 重新派生消息与计价,再在同一个 bracket 内调用摘要。若之后失败或取消,已经记录的图片卸载仍有效。因此“没有提交摘要 replacement”不等于“模型可见输入完全没变”。待替换区间的其他变化会拒绝过期摘要;关闭 bracket 失败仍会留下阻塞当前 lifecycle 的 orphan start。

四、摘要调用和未来请求有相反的 cache 方向

4.1 Summarizer replay 旧前缀,只有尾部 instruction 是新的

KV 缓存的两种影响:摘要请求重放相同前缀,让 provider 有机会复用缓存,再追加新指令;后续会话请求在首次替换位置之前仍可复用旧前缀,此后摘要与近期尾部需重新计算;实际命中取决于 provider 合同

默认 summarizer 直接调用一次 ctx.llm.stream():先从 surface 第 0 项派生 system 消息,再接被遮蔽区间的消息,tools 则取自最新 request/header,最后追加固定 compaction instruction。system head 不进入摘要替换范围,空 head 不产生 wire message。保持同 route 和相同前缀让 provider 有机会复用 warm cache;这仍取决于 provider 的缓存合同,不能从本地 replay 推导必然命中。换 route、非 head 区间或图片 offload 都可能减少这份优势。

调用设置 purpose: 'compaction',但不改变 model-visible body。只有返回的 text 进入 checkpoint;reasoning 与 tool calls 不进入,image output 以 UNSUPPORTED_CONTENT 失败,避免生成孤儿调用或静默丢内容。

4.2 Replacement 让未来请求从第一个变化 token 起失效

未来 conversation request 的 system、tools 和 replacement 前未改变的 history 仍可复用;从第一个被遮蔽的 token 起,旧 cache 不再匹配,checkpoint 与 recent tail 需要重新计算。append-only log 没变不等于 request bytes 没变:KV cache 服从模型输入表面,不服从审计存储。

五、System 消息与 request header 分工重建请求

5.1 System 已经属于派生消息序列

请求重建:current surface 的 system、user、assistant、tool 由 deriveMessages 产生 messages;request/header 经 foldRequestHeader 恢复 config、tools 与 adapterDefaults,再组合请求

deriveMessages() 现在同时派生 system、user、assistant 与 tool 消息。system prompt 来自 system/message,通常以 surface 第 0 项作为稳定 head;支持 in-history 更新的 route 还可在后续位置看到 system 更新。request/header 只保存 config、adapterDefaults 与 tools,不能再从 header.system 取 system。

foldRequestHeader() 仍选择日志前缀中的最后一个完整 snapshot,空 tools 规范化为 absent,schemas 按顺序比较。除 initial、resume、change 外,reason 还有 series:即使 config/tools 没变,surface replacement 或显式新消息系列也要记录边界;header 同时变化时由 startsSeries 保留该事实。重建输入须组合派生 messages 与 header,并恢复已注册的消息投影(例如 image/offload),不能只拼回旧文本。

5.2 end-seed 区分 live lock 和旧 crash evidence

最新 unmatched compaction/start 若位于最近 session/end-seed 之后,就是当前 lifecycle 的 live lock,返回 busy;若在 boundary 之前,只是上一进程留下的 stale evidence,不阻塞新 compaction。锁存在日志里而不是 WeakSet,恢复才有证据做这个判断。

六、这套压缩协议带来的工程判断

  1. 不要把 compaction 实现成 delete。遮蔽模型表面即可;审计、恢复和人类历史需要旧事实。
  2. 先量 envelope,再量 history。system prompt 或 schemas 已经吃满窗口时,摘要再多轮也救不了。
  3. 优先确定性 pruning。它可能免掉一次模型调用;自动 pressure 路径必须先确认压力,再裁剪并重新计量。
  4. Replacement 必须有 provenance。checkpoint 没有 compactionId 与 sourceEventSeqs,就无法证明自己替代了什么。
  5. 缓存判断以 request bytes 为准。日志 append-only 不意味着模型 cache 永不失效。
  6. 摘要是有损的。事件关系可验证,不代表 summary 语义完备;关键路径仍应依赖结构化状态和外部事实。图片 offload 也是持久化的有损选择,不能把路由变大当成自动恢复。

下一篇转向多个 agent:spawn、fork、workflow 与 Ralph loop 怎样共享同一个 SubagentRuntime,又怎样限制 session lineage、工具范围、并发和 delegation depth。

参考源码