M6.1 · 跨轮 prompt 缓存命中治理(system 静态化 + 运行时上下文外置)—— 开发文档(面试向)#
这篇讲为什么这么做、做了哪些取舍,不讲代码。读完能用大白话复述。 是
M6-上下文压缩.md的直接续集:M6 保住了轮内缓存命中,这篇解决跨轮(下一个 query 能不能吃上一个 query 的 KV cache)。
一句话概括#
把 system prompt 做成纯静态(全天不变),把每轮都变的运行时上下文(长期偏好 / 近期行为历史 / 会话级 P_t)从 system prompt 里搬出来、统一拼进当轮用户消息。这样 [system 静态段 + 干净历史 (q,a)] 成为一条逐轮只在尾部增长的稳定前缀,下一轮的模型调用能命中上一轮的 KV cache;顺带给 system 段单独打一个缓存标记,并把「缓存命中率」这个指标从后台日志拉到用户可见的每轮 token 面板。
打个比方:M6 让「一轮之内反复调工具」不重算前缀;这篇让「隔了一轮再问」也不用从头算——只要中间没塞会变的东西进前缀里。
1. 背景:M6 保住了轮内命中,但跨轮首调用仍在冷启动#
M6 把「较旧区压完冻结、单调增长」做成了隐式缓存最爱的形状,一轮之内的多次工具调用命中很好(实测轮内命中率从冷启动的 1024 token 一路爬到 90%+)。但盯真实多轮对话的数据会发现:每一轮的第一次模型调用仍然冷启动——上一轮辛辛苦苦暖热的前缀,下一轮第一刀没吃到。
对一个「用户逐轮收窄需求」的购物 Agent 来说,这正是最该省的地方:4 轮对话就有 4 次冷启动,每次都要把 7000+ token 的 system prompt + 全部历史重新预填充一遍。
2. 根因:把「每轮都变」的东西塞进了 system prompt 的中部#
prompt cache 的铁律是前缀逐字匹配——从第一个变化的 token 起,后面全部作废。而我们的 system prompt 当时长这样:
[ role / workflow / tool_policy ... 静态指令 ]
[ <user_long_term_preferences>{每轮变} ] ← 动态块夹在中间
[ <user_recent_history>{每轮变} ] ← 动态块夹在中间
[ termination / constraints ... 静态指令 ] ← 被动态块顶到后面,跟着一起失效plaintext这两个动态块每轮都变:偏好按本轮 query 语义 top-k 裁剪、行为历史每轮收尾覆盖。它们一变,它们后面的 ~2600 token 静态指令 + 全部对话历史在缓存里全部作废——这就是跨轮首调用冷启动的元凶。这直接违反 refdocs/05 §4.4 的心法:按易变性分层,越易变的越要靠后。
讽刺的是:会话级 P_t 早就因为「每轮变会打断前缀」被移出了 system prompt(见 M7.4 / 会话级短期记忆),但同样每轮变的
recent_history和preferences却留在了 system prompt 中部——同一个道理没有一视同仁。这篇把它补齐。
3. 方案 B:system 纯静态 + 运行时上下文统一外置到当轮 human#
两步:
- system prompt 纯静态化:拿掉
{long_term_preferences}/{recent_history}两个注入位,system prompt 里只留一句静态旁白(<runtime_context>),告诉模型「你的偏好 / 历史 / 会话约束会随本轮消息一起送达」。这样 system prompt 全天不变、跨轮跨会话都字节稳定。 - 运行时上下文统一拼进当轮 human message:长期偏好 + 行为历史 + P_t 一起打包进最后一条用户消息(背景约束在前、本轮诉求在最后),和早已在这么做的 P_t 合并成一条统一的注入。
改完之后,每轮请求的结构是:
[ system 静态段 ] ← 跨轮 / 跨会话字节稳定,命中
[ 干净历史 (q1,a1)(q2,a2)... ] ← 累加、旧的不变,逐轮往后长的稳定前缀
─────── Cache Breakpoint ───────
[ 当轮 human:偏好+历史+P_t+query ] ← 每轮必变,本就在断点之后、永不缓存plaintext一个关键前提正好成立:跨轮回喂的历史是干净原始 query(落盘时存的是用户原话、不含任何注入),所以历史 (q,a) 对逐字稳定、天然是合格的缓存前缀。「每轮必变」的那一坨被彻底隔离在最后一条消息里,不再往回牵连任何前缀。
一个必须避开的陷阱:外置 ≠ 命中,关键在「放到哪」#
直觉会把偏好/历史挪到「system 之后的第一条 message」。那是错的——它俩每轮变,放在历史前部,照样顶掉它后面的全部历史 + 当前 query,跨轮还是废。外置但放错位置 = 白外置。必须放到最后一条 human(越易变越靠后),这才是 §2 那条心法的真正落点。
一个必须诚实交代的取舍#
和 M6 §3 的「滚动缓存」是同款诚实:当轮那条含注入的 human,不会以「含注入」的形态进入下一轮——下一轮回喂的是干净 query。所以每轮命中的是「截止到上一轮之前的干净历史前缀」,上一轮的 (q,a) 要到再下一轮才以干净形态并入可命中前缀。这是滚动增量缓存的正常形态、零额外损失(那条注入包本就该重算)。
轮内也不亏:当轮 human 在该轮第 1 次调用后就固定,后续几次工具循环照样把它缓存进前缀——偏好/历史只在每轮第 1 次全价发送,而那本就是该轮的冷启动点。
额外白赚一笔:主 / 子 fork 共享 system 缓存#
system 变纯静态后,主 Agent 与 fork 子 Agent 的 system prompt 字节完全相同(此前主 Agent 的 system 含偏好/历史、子 Agent 不含 → 两者 system 段不同,每个子 Agent 每次 fork 都白白冷启动 7000+ token 的 system)。改完后子 Agent 首次调用直接命中主 Agent 已算好的 system 缓存——同质 fork 的缓存红利这才真正兑现。这对我们「跨平台并行 fork 出 N 个子 Agent」的架构是实打实的省。
4. system 段独立缓存层:apply_cache_control 够不到它#
查 ModelRequest 结构时发现一个决定性事实:system prompt 不在 request.messages 里,而在独立的 request.system_message 字段。M6 的 apply_cache_control 只作用于 messages,所以它从来没碰到过 system 段——system 段此前没有任何显式缓存标记。
补法:新增一个函数专门给 request.system_message 打 content-block 缓存标记(对齐 refdocs/05 §4.4「system + tools 是独立的、全天不变的最长缓存层」)。它和 apply_cache_control 各管一段、互补:后者标记 messages 里的早期历史前缀(每轮往后挪),前者标记独立的 system 字段。两者各打 1 个标记、合计 2 个,远在「≤4 标记」的硬约束内。
这里恰好躲过 M6 §4 记的那个坑:DeepSeek / Qwen 只支持 message 级断点(同一条消息塞多个标记不会生成多个断点)。我们这两个标记打在两条不同的载体上(一个 system_message、一个历史 message),不是往同一条消息塞两个,所以天然兼容。
诚实边界:对 DeepSeek 的隐式缓存,system 段字节稳定本身就会自动命中,这个显式标记是给 DashScope 显式缓存档(10% 计费)吃的锦上添花;跨轮命中的大头仍是 §3 的前缀稳定。由 COMPRESS_CACHE_CONTROL 统一门控。
5. 让效果可测:把命中率从后台拉到用户面前#
M6 §9 已经在 usage.py 里算了缓存命中率喂 Langfuse。但落到用户可见的每轮 token 面板(turns.json → 前端)时,只报了 input / output / cost,把 cache_read 和命中率丢了——成本虽然早就是 cache-aware 的(命中部分按折扣计价),但「缓存到底有没有真生效」这个健康指标被藏起来了。
这次把 cache_read + cache_hit_rate 一路透传到 turns.json 和前端 tooltip。价值很直接:这次跨轮命中的验证,全靠它才「看得见」——没有这一步,方案 B 做了也无从证明。
6. 实测:跨轮命中的「台阶」#
拿两组完全不相关的多轮对话跑真实链路(全 fork 树聚合命中率):
| 轮 | Run A(F1/T恤) | Run B(降噪耳机) |
|---|---|---|
| turn 1(无上轮可命中) | 86.2% | 88.1% |
| turn 2 | 92.9% | 88.2% |
| turn 3 | 91.4% | 91.7% |
| turn 4 | 89.7% | 95.6% |
| overall | 90.2% | 91.0% |
跨轮命中的信号是那道台阶:turn 1(没有上一轮可吃、纯轮内 + fork 共享)最低,后续轮爬升。如果跨轮 / fork 共享没生效,后续轮会因每个子 Agent 冷启动 system 而掉到 turn 1 以下,而不是升上去——升高这一截正是方案 B 兑现的跨轮 + fork 缓存红利。两组不相关意图都复现 90–91%,说明不是碰巧。
诚实边界:① 给不出干净的改前/改后数字对比——因为改之前 turns.json 根本不记 cache_read(那正是 §5 修的遥测缺口),这是第一次让命中率变得可观测;要严格 A/B 得 checkout 父提交重跑旧代码。② 跨轮命中由供应商侧缓存窗口调度,不是 100% 保证(DeepSeek 隐式缓存窗口比 Anthropic 5min 长,但仍非承诺);目标是「显著提升跨轮命中率」,不是「保证命中」。
7. 一处顺手修的收尾健壮性(与缓存无关)#
同批还修了个跟缓存无关的小坑:shopping_summary 用关推理的快模型生成清单,偶尔把某件商品的选购理由生成到一半就停(实测出现过「评分4.1,标题」这种半句话直接进了商品卡)。修法是收尾时对每条 reason 做完整性校验,检出被截断的就用候选自带字段(评分/价格/入选理由)确定性重建一句完整的,不再走 LLM(不加延迟)。放在这篇只为记录同批改动,不属缓存主题。
8. 一处连带的文档失效(提醒)#
方案 B 把 <user_long_term_preferences> 标签从 system prompt 移到了当轮 human。历史规划稿 docs/plans/system_prompt治理-前后对比预览.md 的「硬护栏」里写着「test_m0_base 断言该标签必须在 system prompt」——这条已随本次改动失效(对应测试已更新为断言它不在 system、改在当轮 human)。那份 plan 是点位快照、不回改;此处标注以免后来人照它踩空。
9. 一句话面试总结 + 可能的追问#
总结:M6 让缓存吃到「较旧区单调增长的稳定前缀」保住了轮内命中;这篇把「每轮都变的运行时上下文」从 system prompt 中部搬到最后一条 human,让 system + 干净历史成为跨轮 / 跨会话 / 主子 fork 都命中的稳定前缀——外置的关键不是「搬出 system」而是「搬到最靠后」;再补一个 system 段的独立缓存标记(system 在 request 的独立字段、原有标记逻辑够不到),并把命中率透传到前端让效果可测。实测两组多轮 90–91%,靠 turn1→turn4 的命中台阶佐证跨轮确实吃上了。
可能的追问:
- 「你怎么证明是跨轮命中,不是轮内碰巧高?」 → 看台阶:turn 1 无上轮可吃是最低点,后续轮爬升;若跨轮没生效,子 Agent 冷启动 system 会把后续轮拖到 turn 1 以下。
- 「偏好搬去 human,精挑还准吗?」 → 准。P_t / 偏好对 item_picker 的硬执行走 ContextVar(exclude / attenuate / 预算兜底),prompt 里的文本只是给模型「看」的第二条路;方案 B 只改了「给模型看」的注入位置,没碰「机制执行」的通路。
- 「第一轮 P_t 是空的,精挑不就没约束了?」 → 第一轮的约束来自本轮 planner 拆解出的参数 + 长期偏好,不靠 P_t;P_t 是跨轮机制(把前几轮确认过的约束在后续轮记住),第一轮没有「前几轮」,空是设计使然。
- 「5 分钟 TTL 不是很容易过期?」 → 那是 Anthropic 的数;本项目走 DeepSeek/DashScope,隐式缓存窗口长得多,且我们对齐的是「静态段字节稳定」这个思想,不照抄 Anthropic 的
ttl:"1h"写法。