Automatic compaction fires mid-conversation, right after the loop warmed the provider's KV cache with the last routed request. The default summarizer then issued a separate request whose prefix shared nothing with that warm request — a bespoke summarizer system prompt followed by the older history flattened to one rendered transcript string — so a differing first token invalidated the entire cached prefix and every compaction re-processed the whole replayed history twice. Move the compaction directive from the FRONT (a fresh system prompt) to the END (a trailing user message), and replay the last routed request's own system prompt, tools, message prefix, and shadowed-region messages verbatim via session.requestHeader() + deriveEventMessage. The auxiliary call is now a genuine prefix-extension of the warm request, so the provider reuses the cached tokens up to the trailing instruction. SummarizationInput carries the replayed prefix instead of a flat string; the now-unused renderTranscript/renderContentBlocks path is removed with its spec. Cache reuse is best-effort (head compaction guarantees a hit; a mid-range compaction or a differently-routed summarizer forgoes it), correctness is not.
5.9 KiB
Agent Note: 摘要调用回放对话前缀以复用 KV 缓存
Status: implemented
English | 中文
Problem
自动压缩(compaction)在对话中途触发,恰好在循环用最后一个已路由请求(system + tools + messagePrefix + 派生历史)预热了提供方的 KV 缓存之后。随后默认摘要器发出一个独立的辅助请求,其前缀与那个已预热请求没有任何共享部分:一个专门的摘要器 system 提示词,后接被拍平成单个渲染后 transcript(文本记录)字符串的较早历史。提供方基于请求起始的 token 序列做缓存,因此第一个 token 只要不同(即一个不同的系统提示词),整个已缓存前缀就会失效。于是每次压缩都要为整段回放的历史付出两次完整的提示词处理成本:一次用于触发压力的对话请求,另一次用于摘要调用,恰好在对话最大时让缓存失去作用。
Decision
摘要指令从请求的前端(一个全新的 system 提示词)移到对话的末尾(最后一条 user 消息)。辅助调用现在逐字复现最后一个已路由请求的前缀,并追加一条尾部指令,因此它是已预热请求的真正前缀扩展,提供方会复用已缓存的 token。
SummarizationInput 携带回放的前缀,而非渲染后的字符串
summarize()(以及内部的 summarizeWithLlm)接受一个 SummarizationInput({ system?, tools?, messages })而不是一个扁平的 transcript 字符串。region.ts 用 session.requestHeader()(持久的 system、tools 和 messagePrefix)加上经 session.deriveEventMessage 映射的被遮蔽区域来构建它,后者产出与 deriveMessages() 折叠进已路由请求的内容字节级一致的 Message 对象。summarizeWithLlm 把 system 和 tools 转发到 GenerateOptions,并发送 [...input.messages, { role: 'user', content: COMPACTION_INSTRUCTION }]。tools 会一同带上,即便摘要器从不调用任何工具:丢弃它们会缩短 token 序列,破坏与已缓存请求的对齐。
指令是一条尾部 user 消息
COMPACTION_INSTRUCTION 以 "You are now acting as a compaction engine…" 开头,指示模型浓缩上方的对话。它保留先前检查点的结构化标题,并在其新位置上新增了两条前置系统提示词此前不需要的规则:不要提及摘要请求,以及只输出检查点文本而不调用任何工具。被遮蔽区域总是结束在工具配对平衡的边界上,因此在其后追加一条 user 消息,对 OpenAI 兼容适配器和 DeepSeek 适配器而言是合法的消息排序。
缓存复用是尽力而为,正确性不是
自动压缩总是锚定在表层头部,因此被遮蔽区域就是已路由请求的头部,回放的前缀与之完全匹配,这就是保证命中的情形。手动的中段 compactRegion 仍然回放真实的前缀并保持正确,但会放弃复用,因为它的被遮蔽区域不是请求头部。配置的 summarizationProvider/summarizationModel 若与对话的路由不同,也会放弃复用;这是部署方明确的权衡,而非缺陷。目标解析(配置的覆盖值 → 最新的已路由 header → agent(智能体)选项,否则抛出)保持不变。
Alternatives considered
- 保留摘要器系统提示词但复用其余部分——否决:system 槽位正是提供方最先做缓存的 token 区域,因此一个不同的摘要器系统提示词无论后面跟着什么都会使整个前缀失效。只有把指令移离前端才能恢复缓存。
- 只发送被遮蔽区域而不带
system/tools/messagePrefix头部——否决:更短或头部不同的序列在第一个 token 处仍然与已缓存请求分叉,因此缓存效果并不更好,反而丢失了摘要所需的框架。 - 从摘要请求中省略
tools(模型从不调用任何工具)——否决:工具 schema 是已缓存 token 序列的一部分;省略它们会让后续每个 token 失去对齐,破坏复用。 - 为快照回放专门建立一个发出
assistant/chunk的摘要子会话——此处超出范围;该回放缺口早于本次改动,记录在 compaction-seam Agent Note 中。
Consequences
dsh-compact-basic拥有SummarizationInput;受保护的summarize(input, agent, signal?)钩子签名发生变化(发布前可接受),并且region.ts新增了buildSummarizationInput,它在 header 前缀之后对被遮蔽的 seq 折叠deriveEventMessage。- 移除无用的渲染表面。 旧的拍平路径(
renderTranscript/renderContentBlocks及其在dsh-compact中的 spec)已无消费方,连同其导出一并删除。 - README 的 Model Experience 现在把
dsh-compact-basic的辅助请求记述为回放的前缀加上一条尾部压缩指令消息,并把其 KV 缓存效果记述为复用已预热的对话前缀。 - 带框架的检查点输出未改变,因此落地的
user/message和每个对话请求快照都不受影响;只有辅助请求的形状发生了变化。
Testing
- 单元:
compact-basic.spec.ts断言辅助调用转发system/tools/前导消息,并把压缩指令作为最后一条消息追加,且compactRegion回放最新的已路由 header 前缀。现有的内容断言通过回放的消息而非 transcript 字符串来读取摘要器输入。 - 循环:
compact-loop-repro.spec.ts依据摘要请求尾部 user 消息中的压缩指令对其分类,溢出恢复测试则继续在真实循环中固定对话请求与摘要请求的数量。 - 快照缺口未变: 摘要调用仍然不发出
assistant/chunk事件,因此它仍处于无密钥回放之外;这一既有缺口归 compaction-seam Agent Note 所有。