缓存点设计

agent 成本 = token 数 × 每 token 单价——压缩管前者、缓存管后者,本篇只谈单价这根杠杆。缓存命中率是它上面最大的一个数 (命中 = 未缓存读的 10%)。讲清四个断点位置、怎么保持前缀字节稳定、压缩对缓存的后果;并说明这套断点只有 Anthropic 需要。

目标直说

一个长运行 agent 的成本可以拆成两个乘数:token 数 × 每 token 单价。这是同一坐标轴上的两根杠杆——压缩 缩减 token 数,缓存把已发送的前缀降到读取价。本篇只谈单价这根杠杆。

单价这根杠杆上最大的一个数,是 cache 命中率。按 Anthropic 定价,命中 = 未缓存读的 10%,写入 = 125%(5 分钟 TTL)或 200%(1 小时 TTL)。每个输入 token 恰好落进三个桶之一,期望单价就是三者按占比加权:

C = 0.10 × p(hit) + 1.25 × p(write, 5m) + 1.00 × p(uncached)

其中 C 是每输入 token 均价(以未缓存价为 1.0× 基准),三者占比之和 p(hit) + p(write) + p(uncached) = 1;1h TTL 把写入项系数从 1.25 换成 2.00。

输出单独计、不缓存。以 Sonnet 4.6 为例($/M token):输入 $3.00、缓存读 $0.30(0.1×)、缓存写 $3.75(5m,1.25×)/$6.00(1h,2×)、输出 $15.00。一个 30 步任务、稳定前缀每步重发、首步写其余 29 步读:每 M 前缀 token 从不缓存的 30 × $3.00 = $90,降到 $3.75 + 29 × $0.30 = $12.45——约 1/7。所以工程目标只有一个:把 p(hit) 推到尽可能接近 1,并在对话增长、压缩触发时守住。

只对 Anthropic:手动放缓存断点是 Anthropic 特有的要求。OpenAI、Gemini、Qwen 等多数 provider 在服务端自动缓存前缀,不接受也不需要 cache_control;对它们下面这套断点机制是 no-op(createCachedInstructions / markPrefixCacheBoundary 对非 Anthropic 模型原样返回)。


缓存怎么命中

一个断点(cache_control)在它所在的位置,把从请求起点到这一块为止的整段前缀哈希一次,拿这个哈希去查:

  • 之前写过一模一样的前缀 → 直接缓存,这段只花 0.1×;
  • 没查到 → 冷写一条新条目(花 1.0×–1.25×),供后续请求命中。

所以缓存匹配的是整段前缀、不是单个 block。唯一不变式由此而来:任何更靠前的字节一变,它之后每个断点的前缀哈希都变、全部作废。而“查到”要两件事同时成立:

  • 字节一致:从请求起点到断点的每个字节,与那条老条目逐字节相同。
  • 距离够近:老条目当初的写入位置,落在当前断点往前 20 个 block 以内——回溯只走这 20 格,更早的写入看不见。

任一不成立就是一次冷写。请求按 tools → system → messages 三层排列;改动落在哪一层,那一层连同它后面的全部作废、前面的照旧存活——所以越靠前的改动废得越狠:

改动落在作废(括号内为存活部分)
工具定义(任意字节 / 增删 / 换序)tools + system + messages(全废)
系统提示词(任意字节)system + messages(tools 存活)
tool_choice / thinking 开关 / 图片增删仅 messages(tools + system 存活)

还有一条硬门槛:断点处前缀低于模型下限时,写入被静默跳过(Opus / Haiku 4.5 需 4,096 token,Sonnet 4.6 需 2,048,更早的 Sonnet / Opus 1,024)。


断点放在哪

断点该放哪不靠 playbook,两条规则就定死了——它们直接来自上一节的两个命中条件:

  1. 只放在“下一轮字节不变”的位置。 断点的价值是让它的前缀下一轮被命中;前缀里只要有一个字节每轮都变,它就永远是冷写、白放。这条决定了哪些位置有资格放。
  2. 相邻断点间距 ≤ 20 个 block。 回溯只往前走 20 格,所以要把断点排成一条从尾到头、每档 ≤ 20 block 的梯子,回溯每一步都能踩到一档已缓存的。只在头部放一个不行——尾部一旦离它超过 20 block 就够不到,每轮都为整段头部冷写。这条决定了为什么要好几个

按这两条规则扫一遍请求,字节稳定的位置从前到后正好这几处——这就是全部的槽点:

位置是什么为什么稳覆盖
① 系统提示词(+工具)头部,最后一个 system 块上的断点整个 session 不变tools + system(每请求 30–80% token)
② 压缩点Task Context 摘要块一个压缩 epoch 内不变到摘要为止的整段前缀
③ 尾块最后一块(reminder 之前)本轮内不变(新 step 追加在其后)到本轮为止的整段前缀

三档各司其职:① 最稳、最大,是主力降本;② 把回溯从尾部接力回头部(没有它,位置 ① 迟早够不到);③ 是“这轮写、下轮命中”的前瞻档。(代码还在 ② 与 ③ 之间按需补一档已完成轮边界,单步 > 20 block 时多垫一级台阶兜底;正好用满 Anthropic 的 4 断点上限,无摘要时干净退化回 ①③。)

reminder 不是槽点。 它每步都在变、违反规则 1,所以刻意追加在 ③ 之后、排除在缓存前缀外。由此得到一条硬性顺序:先打 ③ 的断点、再追加 reminder——反了,变动的 reminder 就混进前缀,下一轮必然 miss。(打法按 role:system 走 message 级、其余走 block 级,以挺过 adapter 的同 role 合并。)

缓存断点的四个位置 从头到尾三个断点排成梯子,reminder 排除在外 ① 系统提示词 + 工具 session 内不变 ② 压缩点 epoch 内不变 ③ 尾块 本轮内不变 reminder 每步在变 · 不缓存 缓存前缀:命中读 0.1× · 相邻断点 ≤ 20 block


保持前缀字节稳定

四个位置放对了,剩下唯一会打坏缓存的就是前缀里混进每次都变的字节——两个“语义相同、字节不同”的 block 对缓存就是两个 block。守则:

  • 头部冻结:别把时间戳、当前日期、user / session ID、随机 nonce 插进 system 或工具描述;动态内容往后放到 messages 里。
  • 确定性序列化:JSON 固定键顺序(Map / Set 迭代顺序、Intl 本地化、未排序的 json.dumps 都是隐形杀手)。
  • 工具列表每 session 定死一次:顺序一变(哪怕字母序 vs 定义序)四个断点一次废光。

反过来,有些负载开缓存反而更贵、直接别开:一次性 / 每请求独一无二的提示、头部混了高方差内容(先修方差再开)、低于最小前缀、以及无复用的扇出批处理。


压缩对缓存的后果

压缩是唯一会改写 block 流本身的操作(记忆、JIT 加载、子 agent 隔离都只往后追加、不动既有字节)。所以压缩器怎么改,直接决定缓存活不活:

  • 头部不可变:压缩绝不能碰位置 1 之前的 block。改了系统头,下一轮四个断点同时失效,没有局部恢复。
  • 整块替换,不原地编辑:被压的整轮换成一个 summary block(它就成了位置 2 的锚);tool_result 按固定长度整块截断,绝不改 block 内部字段——子块一改,其后每个前缀 hash 全变。
  • 节拍决定摊销:每次压缩 = 一个新 cache epoch(一次贵写 + 多次便宜读)。按任务边界压、别每几轮压一次,让读写比稳在 ~10 以上。

差距是二元的:同样一个 30 步任务,压缩器“追加式 + 整块 + 位置 2 就位”命中率 ≈ 0.9,而“中途重写系统头或原地改 tool_result”命中率 ≈ 0.2——总成本约差 5 倍。


度量

命中率是头牌指标:

hit_ratio = cache_read_input_tokens
          / (cache_read_input_tokens + cache_creation_input_tokens + input_tokens)

良好长任务第 3 轮后应 > 0.85、持续会话 > 0.90;低于 0.5 是 bug 不是折衷。最快的自检是冷启动断言:第 1 轮 cache_creation_input_tokens > 0cache_read_input_tokens == 0,第 2 轮 cache_read_input_tokens > 0第 2 轮读为零 = 前缀跨轮变了,回上一节逐条查非确定性。

markPrefixCacheBoundary 在 Anthropic 路径 emit cache.breakpoints_placed(字段 messagesCount / stepIndex / placedAt / lastRole),作为命中率 dashboard 的主数据源:placedAt 长度长期为 1(只有尾块)就说明中途锚一直没放、回溯够不到头部。


延伸阅读


Sources

这页有帮助吗?