本集对应课程:DeepSeek Harness · 工程方法论 ·《KV Cache 是接口》
音频与本文配套,本文为文字版深度解读,可独立阅读。
prompt 前缀稳定性当成兼容性承诺来维护。KV Cache 按前缀逐字匹配,从第一个变化的 token 起全部作废——所以前缀不是文案,是接口。
模型处理请求时,会为每个 token 算出一堆中间结果(键和值)并缓存。下一条请求进来,只要开头的 token 序列与上一条逐字相同,这段前缀的计算就能直接复用,provider 按命中打折。
DeepSeek 官方定价中,命中缓存的输入 token 比未命中便宜一个数量级(具体倍率以官方价目页为准)。
缓存按前缀匹配:从第一个不同的 token 起,后面全部作废。
不是「从那个位置往后的一小段作废」,是后面整条线全部作废。
误解:缓存按片段匹配,改哪一段就只重算哪一段。
事实:它是从前往后的一条线,前面断了,后面整条线都得重来。
这是很多团队「优化半天不见效」的原因——他们优化了后面的内容,而断点在前面。
| 操作 | 是否伤缓存 | 失效起点 |
|---|---|---|
| 改 persona 里一个词 | ✗ 伤 | 那个词的位置 |
| 换工具顺序(内容相同、顺序不同) | ✗ 伤 | 第一个顺序不同的位置 |
| 在开头塞当前时间 | ✗ 伤 | 时间所在位置(每秒都在变) |
| 追加对话历史 | ✓ 不伤 | 无——新内容跟在可复用前缀后面 |
| 追加工具调用历史与结果 | ✓ 不伤 | 无——append-only |
对话历史 append-only 地增长,旧前缀原样保留,只为新增部分付全价。
前面讲持久化时讨论过它对一致性的好处,这里补上另一半:它对钱包同样有好处。
推论:越靠前的内容越碰不得。把易变的东西(时间、动态状态)往后放,把万年不变的身份和 schema 往前放。
本地快照中 packages 下 268 个包的 README,有 215 个带固定的 #### KV Cache effect 小节。
任何会出现在模型请求里的东西,文档必须按三段式交代:
正向陈述(packages/core/tools/README.md 第 145 行):
Prefix-stable while visible definitions and their order are unchanged. Registration, disposal, or scoped restriction may invalidate reuse from the first changed schema token.
反向陈述(同文件第 186–188 行):工具调用的历史和结果是 append-only 的,新内容跟在可复用前缀后面,不会打翻已有缓存。
这一步看着最笨,却解决了一个真问题:缓存影响是隐性的,写代码的人根本不知道自己改的那行字会让别人的账单翻倍。
只有把影响写进文档,它才能进入 review 的视野,才能被评审时问一句——这行改动会不会动到前缀。
问题:工具 schema 是前缀的大头,其顺序原本跟着插件注册顺序走。插件并发加载,注册顺序随环境抖动——DSH 在 CI 里实际观察到了不同的请求头(Agent Note 2026-07-06-explicit-tool-order)。
顺序影响请求字节 → 请求字节影响缓存 → 顺序成了必须显式治理的对象。
解法:
toolOrder 列表统一定序<unlisted-tools> 其余项标记assemble() 内部、waterfall 之前 → 注册顺序在任何可观测位置都不再出现源码(packages/core/system-prompt/src/index.ts 第 169–178 行):开头先做保留名检查——工具提供方敢用 <unlisted-tools> 这个保留名直接抛异常;往下是排序本体,两个失败分支:没配列表走字典序兜底,toolOrder 里写了未注册的工具名直接抛。抛异常时机在组装阶段、请求发出之前。
两个边界条件:
toolOrder 里写了个拼错的工具名会怎样?轮次在组装时失败,不开步骤、不记请求头、不向适配器发请求,每个轮次都同样失败直到配置修好,进程本身保持运行。persona 是模板,变量组严格按注册表解释。三个连着的抛异常分支,一个都不放过:
| 分支 | 管什么 | 触发条件 |
|---|---|---|
| 1 | 格式 | 变量名不匹配命名正则 → malformed prompt variable reference(连空名也走这条) |
| 2 | 注册 | 名字不在注册表 → unknown prompt variable,报错顺带列出全部已注册变量名 |
| 3 | 取值 | 注册了但本次组装没给值 → 抛异常 |
为什么宁可让整个轮次失败:静默容错 = 把一个悄悄变形的前缀发给模型,缓存悄悄失效,坏 prompt 还可能悄悄改变行为。大声失败反而便宜——立刻可见、立刻可修。
源码小心思(第 283 行):用 Object.hasOwn 而非普通属性访问查变量是否已注册。因为普通属性访问查 {{constructor}} 会顺着原型链摸到 Object 的内建方法,被误认为已注册变量。查自有属性 → 原型链上的名字一律算未注册。
保留名硬边界 + 严格插值 + Object.hasOwn,是同一种价值观的三种表现:防的就是悄悄放行的坏 prompt。
还原源码 restored-src/src/tools/AgentTool/prompt.ts 第 57–64 行注释记录了一次真实事故:
修法:把易变列表从静态前缀里挪出去,改成单独的 attachment 消息注入,工具描述保持可缓存。
与 DSH 的差别在时序:Claude Code 是账单上看到 10.2% 之后修的;DSH 在 CI 抖动阶段就把顺序治理掉了,还把纪律铺到了 215 份文档里。
system prompt 从预生成的模板解密渲染(crates/codegen/xai-grok-agent/src/prompt/template.rs),再拼上 AGENTS.md 和 skills 内容。
一个可以直接套用的估算式:
单次抖动损失 ≈(前缀总 token − 仍可复用的前缀 token)× 请求次数 ×(未命中单价 − 命中单价)
三个变量里,团队通常只盯着第二个(请求次数),而真正能改、且改动成本最低的是第一个——把易变内容往后挪,就是在扩大「仍可复用的前缀」那一段。
这也解释了为什么 DSH 把「越靠前越碰不得」当成一条纪律而不是一句提醒:它前面那一小段的长度,直接乘在每一次请求上。
packages/core/tools/README.md(第 145、186–188 行)、packages/core/system-prompt/src/index.ts(第 169–178、277–290 行)、Agent Note 2026-07-06-explicit-tool-order、Claude Code 还原源码 restored-src/src/tools/AgentTool/prompt.ts(第 57–64 行,材料出处 claude-code-sourcemap-main/study/chapters/05-multi-agent.md 第 106–121 行)、Grok Build crates/codegen/xai-grok-agent/src/prompt/template.rs。核对日期 2026-08-13,依据本地仓库 deepseek-harness-master。场景:agent 在 system prompt 第二行写了「当前时间:精确到秒」,每秒都在变。
Q1:每条请求的缓存从第几段开始失效?
第二行就变了 → 从第二行起后面全部作废 → 真正能复用的只剩第一行。
Q2:一天 1000 条请求多付多少重算 token?
(system prompt 总 token 数 − 第一行)× 1000 ×(命中与未命中的差价)。这个数字通常会让人坐直。
Q3:两个修复方案,哪个更好?
| 方案 | 做法 | 跨天边界表现 |
|---|---|---|
| A | 把时间挪到 prompt 末尾的动态上下文 | 时间彻底离开前缀敏感区,无论怎么变都不影响前面复用 |
| B | 把精度降到天 | 看起来一天只断一次,但断点不可避免,且断在每天第一条请求上,之后一整天才能复用 |
A 更好。 关键区别在于:B 只是减少了断的次数,A 是改变了断的位置。
核心洞察:要区分「断了几次」和「断在哪」——后者才是决定成本的变量。
给团队的动作:照着三段式(模型看到什么 / token 成本 / 缓存影响),为会进入模型请求的东西列清单:system prompt、工具 schema、动态注入上下文、RAG 检索结果。标出哪几项放错了位置。写完大概率会发现至少一个与那 10.2% 同款的问题。
*来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)*