第 39 章:Prompt Cache 稳定性

Provider 缓存的不是含义,而是前缀;第一个不同的字节,就是复用终止的位置。


快速通道(5 分钟掌握核心)

  1. 可缓存性是一份序列化契约——语义等价并没有帮助
  2. 按稳定性分配稀缺的 Breakpoint:跨用户、Tool Schema、滚动历史、单 Session
  3. 把易变值放在最后一个稳定边界之后;不要让时间戳污染工具前缀
  4. 在执行期检查权限,避免权限变化重排或删除 Schema
  5. 从实际发送的 Request 原样 Fork,只做追加,并观测每次有意重写

公开源码快照:实现细节取自 Kocoro 公开仓库 origin/main4ec6772 提交,复核日期为 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 #1system[0]同一 OS 上跨用户共享的 Persona 与核心规则
BP #2tools[-1]完整且确定排序的 Tool Schema 数组
BP #4messages[-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 Creationwindow.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 设计是否有帮助。

本章要点

  1. 可缓存性是一份序列化契约。 含义等价的请求仍可能因字节不同而 Miss。
  2. Breakpoint 是稀缺架构资源。 按共享范围与变化频率来分配。
  3. 授权属于执行期。 不要为了表达运行期拒绝而修改稳定的 Tools 数组。
  4. 从已 Dispatch 的 Artifact Fork。 只做追加;重建与构建后定制都会引入漂移。
  5. 每次有意重写都需要归因。 一次找不到原因的 Miss,还没有足够的可观测性。

下一章:第 40 章会把同样的“持久化真实状态”纪律用到必须跨进程崩溃存活的 Turn 上。

延伸阅读

Prompt Cache 的字节稳定性测试记录了另一组工程实践:工具 Schema 固定、确定性排序与请求快照测试。结合本章阅读,可以对照检查自己的请求构建与缓存复用。

引用本文 / Cite
Zhang, Wayland (2026). 第 39 章:Prompt Cache 稳定性. In AI Agent 架构:从单体到企业级多智能体. https://waylandz.com/ai-agent-book/%E7%AC%AC39%E7%AB%A0-Prompt-Cache%E7%A8%B3%E5%AE%9A%E6%80%A7/
@incollection{zhang2026aiagent_39_-Prompt-Cache,
  author = {Zhang, Wayland},
  title = {第 39 章:Prompt Cache 稳定性},
  booktitle = {AI Agent 架构:从单体到企业级多智能体},
  year = {2026},
  url = {https://waylandz.com/ai-agent-book/%E7%AC%AC39%E7%AB%A0-Prompt-Cache%E7%A8%B3%E5%AE%9A%E6%80%A7/}
}