第 39 章:Prompt Cache 稳定性
Provider 缓存的不是含义,而是前缀;第一个不同的字节,就是复用终止的位置。
快速通道(5 分钟掌握核心)
- 可缓存性是一份序列化契约——语义等价并没有帮助
- 按稳定性分配稀缺的 Breakpoint:跨用户、Tool Schema、滚动历史、单 Session
- 把易变值放在最后一个稳定边界之后;不要让时间戳污染工具前缀
- 在执行期检查权限,避免权限变化重排或删除 Schema
- 从实际发送的 Request 原样 Fork,只做追加,并观测每次有意重写
公开源码快照:实现细节取自 Kocoro 公开仓库
origin/main的4ec6772提交,复核日期为 2026-07-27。章节讲的是不变量;具体常量应以当前源码为准。
39.1 从一次什么都没改变、却花掉 $0.67 的重写说起
第 35 章最后留下了一个看似很小的截断 Bug。一条过大的用户消息按照当前历史推导出的边界被截断。再加一条追问,历史变长,边界移动,同一条消息便在另一个字节处被截断。
可见含义几乎没变,Cache 看到的却是一个新前缀。
在那次记录到的事故里,这一个移动的切口让整条消息在每次追问时都重新按 Cache Creation 计费:每 Turn 约 $0.67。准确率没有提高,速度没有加快;系统只是每次都用不同字节序列化同一个逻辑输入。
因此,理解 Prompt Cache 最有用的方式,不是把它当成 Provider 偶尔能发现的一项优化,而是把它当成 Request Builder 必须维护的接口。相同逻辑状态如果能产生不同字节,你拥有的就不是可缓存 Prompt,而是一场 Cache 抽奖。
39.2 Cache Key 在第一个差异处终止
设想连续两次请求:
request N: system | tools | history A B C | current turn
request N+1: system | tools | history A B C | current turn | follow-up
这是理想形状。Request N 是 Request N+1 逐字节一致的前缀,所以昂贵而稳定的部分可以复用。
现在让前部一个看似无害的值发生漂移:
request N: system | tools [a,b,c] | history ...
request N+1: system | tools [b,a,c] | history ...
^
reuse ends here
工具相同,集合相同,模型也能理解两种顺序。这些对前缀 Cache 都没有意义。
漂移来源往往很普通:遍历 Map、Skill 激活后过滤 Schema、把当前时间写入 System Prompt、把 {} 序列化成 null,或在压缩时修改历史消息。因此纪律也很普通:确定性排序、规范编码、明确区分稳定区与易变区,以及测试序列化后的字节,而不是只比较对象是否相等。
39.3 四个 Breakpoint 是一道分配题
公开快照里只有四个 Cache Breakpoint 可用。Kocoro 负责放置内容并发出一个纯文本 <!-- cache_break --> 标记;Cloud 再把这种布局转换成 Provider 的 cache_control Block。
| 历史编号 | Wire 上的位置 | 稳定边界 |
|---|---|---|
| BP #1 | system[0] | 同一 OS 上跨用户共享的 Persona 与核心规则 |
| BP #2 | tools[-1] | 完整且确定排序的 Tool Schema 数组 |
| BP #4 | messages[-2] 中最后一个合格 Block | 滚动的对话前缀 |
| BP #3 | 当前用户消息的前缀 | 单 Session 指令、Sticky Context 与用户工具目录 |
编号来自历史,不代表 Wire 顺序:BP #4 在 Wire 上出现在 BP #3 之前。BP #3 也不是“第一条用户消息”。持久化历史会剥离脚手架,所以无论 Session 多长,一次请求里始终恰好只有一个 <!-- cache_break -->。
这不是细枝末节。把滚动标记放错消息,会失去历史复用;为了保留旧标记而剥掉当前标记,可能会改变原本想匹配的 Block 字节。再增加第五个逻辑区域,Provider 也不会多给第五个插槽——必须移动或合并其他区域。
架构层面的教训是:按照谁共享这些字节、以及它们多久变化一次来分配边界。
cross-user stable → session-stable → rolling history → current-turn volatile
每个值都属于其中一种生命周期。如果说不清它属于哪一种,它迟早会落到代价最高的错误位置。
39.4 稳定与易变说的是位置
真正共享 System Block 的用户之间,这个 Block 必须逐字节一致。用户级 MCP 名称、Deferred Tool 列表与其他来自配置的值不能放在这里;它们应进入单 Session 前缀或易变尾部。
当前日期、工作目录、Memory Recall、原始用户文本、Skill 列表与语言指令都位于 <!-- cache_break --> 之后。创建它们的当前 Turn 不会缓存这些内容;到下一 Turn,滚动历史边界可以把它们吸收进去。
位置比标签更重要。在 System Prompt 内写一个 <!-- volatile --> 标记听上去更整洁,但在那次实现里,这些字节仍然位于 Tool Breakpoint 之前。一个每分钟变化的时间戳会每分钟让 Tool Cache 失效。在注释里写上“volatile”不会让字节隐形;只有 Wire 上的位置可以。
39.5 权限不能重塑前缀
第 38 章把发现和授权分开了。同样的分离也保护 Cache。
当活动 Skill 声明 allowed-tools 时,一个很诱人的做法是在下一次模型调用前删除所有不允许的 Schema。这看起来更安全,但 Tools 数组会在 Run 中途改变。BP #2 Miss,此后的所有字节也一起失去复用。
快照保持 Tools 数组稳定,并在 Call 真正运行前用 activeSkillFilter 做执行期检查。被拒绝的工具产生 Denial Result,绝不会执行。安全仍有唯一负责人,但授权不再修改 Schema 前缀。
这个区别承重很大:
- Deferral 决定模型最初看到哪些 Schema。
- Authorization 决定哪些 Call 可以执行。
- Caching 要求两种机制都维护自己声明的字节边界。
把三者合并成一个“Tool Filtering”步骤,它们就会开始互相破坏。
39.6 Fork Request,不要重建 Request
Turn 后建议和推测性调用都希望复用主请求的热前缀。从高层状态重新构建主请求是个陷阱:一个默认字段、一处 Schema 重排或不同的 Thinking Budget,就足以让 Fork 变冷。
更安全的形状是 BuildForkedRequest(main, opts):从 Dispatch 时捕获的确切请求开始,为 Messages 分配新 Slice,复制 Thinking 指针指向的值,并且只追加 Fork 专属消息。Tools Slice 保持共享,必须视为只读。这个函数只允许自己明确命名的差异。
返回后也不要再通过降低 MaxTokens、调整 Temperature、过滤工具或限制 Thinking 来“优化”Fork。这些值都参与 Cache 契约。一次 Cold Call 虽然单次便宜,却可能因为丢失父请求前缀而让总成本更高。
这是第 34 章里更广泛的模式:在公开 Seam 上捕获真实 Artifact。重建出的近似物,并不能证明实际发给 Provider 的字节得到保留。
39.7 测量漂移,而不只是命中
Cache Miss 能告诉你复用失败了,却不会告诉你第一个不同字节在哪里,也不会说明原因。
因此,当 SHANNON_CACHE_DEBUG=1 启用缓存调试遥测时,有意进行的原地重写会记录 Action、Message Index 与旧/新 Hash。随后可以把 Request Log 与紧邻它之前的重写关联起来:Tier 1 压缩、Observation Window 裁剪、Image Strip 或查询期预算。如果没有重写事件而前缀 Hash 仍在变化,就该去寻找非确定性序列化。
同时把归因和策略分开。cache_source 用来标记 Desktop、TUI、Channel、Schedule、Helper 与 One-shot 流量,从而比较各自成本和 Cache Ratio。在这个快照里,它不选择 TTL。把归因字段当成策略开关,会造出第二套没有文档的 Cache 系统。
应当同时追踪 Cache Read、Cache Creation、延迟、每个用户 Turn 的模型调用数与任务成功率。高命中率可能和过多调用并存;低 Creation/Read 比也可能掩盖已经无法完成任务的工作负载。Cache Metric 是运维信号,不是产品本身。
39.8 快照证据
| 观察 | 4ec6772 源码 |
|---|---|
| 移动的截断边界令每次追问产生约 $0.67 的新 Cache Creation | window.go L32 |
| 四个 Breakpoint 的分配与 Wire 上的精确位置 | cache-strategy.md L16 |
| 跨用户 BP #1 不变量与用户级内容路由 | cache-strategy.md L38 |
易变内容应位于 cache_break 之后 | cache-strategy.md L49 |
| 保持字节稳定的规范化规则 | cache-strategy.md L83 |
| 单滚动标记决策与实测取舍 | cache-strategy.md L97 |
| Fork Request 的字节一致契约 | forkedrequest.go L34 |
| Skill Allowlist 在执行期检查 | loop.go L2709 |
| 缓存调试事件用旧/新 Hash 归因有意的前缀重写 | gateway.go L320 |
这些内容描述的是一个有日期的实现与 Provider 布局,不是普遍 Cache 契约。
39.9 常见陷阱
测试对象而不是 Wire 字节。 两个 Map 可以 Deep Equal,却以不同顺序序列化。Snapshot 的对象应是实际 Request 表示。
为了权限过滤 Schema。 执行期安全了,BP #2 却变冷。授权与 Schema 展示必须分开。
把“易变”数据放在稳定 Breakpoint 之前。 名称没有作用;Tools 前的时间戳会让 Tools 失效。
重建 Fork。 默认值会漂移。捕获已 Dispatch 的请求,然后只做追加。
构建后继续修改 Fork。 更小的输出上限也可能改变 Cache Key 字段,毁掉 Fork 本来要获得的复用。
保留每一个旧 Marker。 Marker 越多并不自动等于复用越多。在固定上限下,腾出一个插槽可能会修改你原本想匹配的 Block。
把 cache_source 当成 TTL 策略。 在这个快照里它用于归因。策略属于 Cache Control 的所有者。
只看命中率。 成本、延迟、调用数与完成质量共同决定 Cache 设计是否有帮助。
本章要点
- 可缓存性是一份序列化契约。 含义等价的请求仍可能因字节不同而 Miss。
- Breakpoint 是稀缺架构资源。 按共享范围与变化频率来分配。
- 授权属于执行期。 不要为了表达运行期拒绝而修改稳定的 Tools 数组。
- 从已 Dispatch 的 Artifact Fork。 只做追加;重建与构建后定制都会引入漂移。
- 每次有意重写都需要归因。 一次找不到原因的 Miss,还没有足够的可观测性。
下一章:第 40 章会把同样的“持久化真实状态”纪律用到必须跨进程崩溃存活的 Turn 上。
延伸阅读
Prompt Cache 的字节稳定性测试记录了另一组工程实践:工具 Schema 固定、确定性排序与请求快照测试。结合本章阅读,可以对照检查自己的请求构建与缓存复用。