缓存点设计

Agent 系统作者:如何设计一个 agent 以取得高命中率 —— 先讲 block 模型,再讲八条放置 4 个断点的设计动作,最后讲压缩设计对缓存的后果。与压缩页互为配套

目标直说

决定一个长运行 agent 要花多少钱的,是一个数字cache 命中率。按 Anthropic 定价,命中 = 未缓存读的 10%写入 = 未缓存读的 125%(5 分钟 TTL)或 200%(1 小时 TTL)。Agent 里每个 token 最终只会落在三个桶里之一:

每 token 成本  =  0.10 × p(hit)
              +  1.25 × p(write, 5m)      // 1h TTL 时为 2.00
              +  1.00 × p(uncached)

一个 30 步的 ReAct 任务,命中率 0.90 的成本大约是命中率 0.0 的 1/7这一页所有工程目标只有一个 —— 把 p(hit) 推到尽可能接近 1并且在对话增长、压缩触发时保持在那

这是一本设计 playbook。第 1 节是心智模型(事实层,对齐 Anthropic 官方文档)。第 2 节是完整的八条动作 playbook —— 怎么放置 4 个断点。第 3 节讲为什么其中三条(动作 4、5、6)是压缩决策 —— 压缩设计对缓存的后果。第 4 节是 Zapvol 的实现。第 5 节覆盖 single-agent happy path 之外的场景(子 agent、模型切换、开发迭代、什么情况别用 cache)。第 6–8 节是诊断(失效模式、度量、检查清单)。


1. 一页讲完心智模型

一次 cache 命中要两种连续性同时成立(同一个断点上):

  1. 字节续上 —— 从请求起点到断点为止的每个字节都与之前某条已写入的 cache 条目完全一致
  2. 空间续上 —— 那条老条目写入时的位置在当前断点往前 20 个 block 位置以内窗口把断点本身算作这 20 个位置里的第一个)。超出窗口就停止搜索更早的写入对 cache 不可见

任一续不上就是一次冷写把 4 个断点想成锚链上的 4 颗锚字节稳定 = 锚钉在岩石上的牢固程度20-位置的回溯 = 相邻两颗锚之间的最大链长下面 playbook 的每一条动作都在守住两种连续性中的一种或两种

被 hash 的是什么 —— block 模型

Anthropic 把请求序列化为一条 block 流

tool[0], tool[1], …  │  sys[0], sys[1], …  │  msg[0].content[0], …, msg[N].content[L]

一个 block 是一个工具定义、一个系统 text 块、或一条 message 里 content 数组的一项(texttool_usetool_resultimagedocumentthinking)。每个 block 在这条线性流里都有一个位置

cache_control 放在某个 block 上意味着“在这个位置把到这个 block 为止的每个字节 hash 一次;命中就从 cache 读;没命中就回溯最多 19 个更早位置找之前的写入;两条路径最终都会在这个断点写一条新条目供后续请求读(前缀低于模型下限时静默跳过)“每请求最多 4 个断点你无法 cache 半块 block、也无法强制中间位置写入hash 按字节敏感 —— 语义相同但 JSON 键顺序不同的两个 block 哈希值就不同这也是 Swift / Go 这类会在 JSON 序列化时随机化键顺序的 runtime 会默默打坏 cache 的原因Anthropic 不会替你规范化 JSON

匹配怎么跑

  1. 每个断点处 hash 完整前缀(从请求起点到这个断点为止),
  2. 没命中 → 向前走最多 20 个位置断点本身算第一个),每个位置都查之前有哪次请求在这里标过 cache_control 并成功写入了”。窗口内都找不到就停止搜索整段前缀按冷写处理
  3. 首个命中胜出命中点之后的 block 重新处理当前断点会以自己的前缀 hash 作为新条目写入

cache 索引的是前缀,不是单个 block一条 cache 条目覆盖的是”从请求起点到被标记 block 为止的每一个字节”不是被标记的那个 block 本身这就是”任何更早 block 的字节变了、其后每个断点都废”的原因 —— 它们都是同一段前缀的不同长度的 hash

Anthropic 的原话“It is looking for prior writes, not for stable content.” 翻译回溯只能找到”历史上有人在这里标过 cache_control 并成功写入”的位置只是”历史请求里出现过但从没被标过”的 block 对 cache 是不可见的 —— 它们压根不在 cache 的索引里

具体例子对应 Anthropic 文档里的场景):

  • 第 1 轮 —— 10 个 block,断点在 block 10 → 写入 E1 = hash(0..10)
  • 第 2 轮 —— 15 个 block,断点在 block 15 → hash(0..15) miss;回溯向前走,在位置 10 找到 E1在窗口内)。block 11–15 重新处理写入 E2 = hash(0..15)
  • 第 3 轮 —— 35 个 block,断点在 block 35 → hash(0..35) miss;回溯检查 20 个位置(block 35 到 16)都找不到E2(位置 15)正好在窗口外一格完全 MISS、全段重写

第 3 轮的这次 MISS 正是 slot 2(压缩边界)要解决的失败模式 —— 在尾部更近处多放一个显式断点,每一轮回溯都能落到一个已缓存的条目上

失效级联

因为 hash 覆盖到断点为止的全部字节任何更早 block 的任何一个字节变其后每一个断点都废。级联:

改动废掉
工具定义字节(任何)tools + system + messages
系统 text 字节(任何)system + messages
Web search 开关system + messages
Citations 开关system + messages
速度设置(speed: "fast" 与标准之间切换)system + messages
tool_choice 参数仅 messages
Extended-thinking 参数(开关 / 预算)仅 messages
任何位置增减图片仅 messages
非-tool-result 的用户内容(开 thinking)清掉之前的 thinking blocks

最小可缓存前缀

断点处的前缀低于模型下限时,cache 写入会被静默跳过

模型最小(token)
Claude Opus 4.7 / 4.6 / 4.54,096
Claude Haiku 4.54,096
Claude Sonnet 4.62,048
Claude Sonnet 4.5 / 41,024
Claude Opus 4.1 / 41,024

这就是全部模型下面的所有内容都是围绕它做工程


2. Playbook —— 八条按成本影响排序的动作

每条 = “做什么” + “为什么提升命中率” + “什么情况下会失手”从上往下应用下游动作假设上游已就位

四槽缓存布局 (The Four-Slot Cache Layout) 四个 cache_control 断点分布在一条 block 流上 —— tools → system → messages Tools System Messages tool[0] tool[1] tool[2] sys[0] sys[1] user q asst summary (已压缩) asst tool result asst last user last msg 槽位 1 槽位 2 槽位 3 槽位 4 槽位 1 —— 系统末尾 一次标记同时吃下所有工具定义与完整系统提示;单槽最大降本 槽位 2 —— 压缩边界 字节稳定的 mid-prefix 锚点;让 20-block 回溯仍能够到槽位 1 槽位 3 —— 最后一条用户消息 扛过 tool_result 内循环;assistant ↔ tool 链中命中 槽位 4 —— 最后一条消息 为下一轮预热 —— 现在写入,第 N+1 轮读取 每个断点 hash 其左侧的一切。槽位位置按稳定性梯度排布:不可变 → 每 epoch 稳定 → 每工具循环 → 每轮。

动作 1 —— 一个断点覆盖 tools + system 头部(槽位 1)

在最后一个 system text 块上放 cache_control

因为 cache 覆盖”从请求起点到断点 block 为止”的所有内容包括断点所在那一块本身),这一个标记同时吃下了整个工具列表和完整系统提示这在多数 agent 上占每请求 token 数的 30–80%从第二轮起是字节级稳定的把它从每轮的 1.0× 变成 0.1× 是最大的单项降本

失手条件系统提示或工具列表跨轮变化 —— 见动作 2 / 3

动作 2 —— 工具定义跨轮字节级一致

每 session 算一次过滤后的工具列表用确定性 JSON 键顺序序列化工具避免工具描述里出现随时间变化的文本

Tools 是第一缓存级任何一个字节变都会级联到每一级两轮之间工具按不同顺序排列哪怕是”按名字字母序” vs “按定义序”就会把四个断点一次废光工具描述里含”当前时间是 14:32”每轮就是一次爆表

常见的隐藏非确定性工具构造器里 Map/Set 的迭代顺序、运行时权限过滤、Intl.DateTimeFormat 的本地化格式

动作 3 —— 每个 block 都做确定性序列化

从工具定义、系统 text、tool_result 输出里拔掉时间戳、request ID、随机 nonce、不稳定的 map 迭代顺序

两个”语义相同”但”字节不同”的 block 对 cache 而言是两个 block确定性序列化是动作 1 周围的篱笆 —— 让它在 session 与 session 之间都成立

可变字节常见潜入处错误对象的字符串化、日期的 JSON 化、在输出 payload 里 log 当前时间的工具包装、摘要器 prompt 里带 {{now}}

动作 4 —— 用压缩边界锚定 mid-prefix(槽位 2)

放第二个断点位置要选”压缩 epoch 内跨轮字节稳定”的那个用压缩边界 —— 最后一个被摘要的消息下标由压缩器发布

只有槽位 1 在长对话上不够用从槽位 1 被写起算新增超过 20 个 block 位置后20-block 回溯够不到槽位 1 了第 N+1 轮就要为整段头部付一次冷写槽位 2 通过给回溯提供一个更近的落点来堵这个洞

天真中点(Math.floor(length / 2))每一轮漂一格 —— 每次漂就是一次 miss压缩边界则在同一个 epoch 的每一步都字节级一致。Zapvol 里这通过 extraBreakpointAt 接入。为什么这个位置是 唯一 稳定的 mid-prefix 锚点 —— 而不是”刚好方便的一个” —— 在第 3 节展开。

失手条件压缩改写了边界之前的内容 —— 见动作 5

动作 5 —— 压缩在不可变头部之后追加式操作

设计压缩器让它永远不改槽位 1 断点之前的任何 block只把整轮片段替换成 summary block在槽位 2 位置之后)。

改写更早任何 block 的压缩哪怕只是往系统提示里加一句备注是 100% cache-miss 操作 —— 再聪明的断点放置也救不回来不变式是头部不动、尾部可被改写每次这样的改写 = 一个 cache epoch一次贵写 + 多次便宜读)。

动作 6 —— 压缩粒度为整轮 / 整块

把整轮替换成一个 summary block对 tool_result 按固定长度整块截断每次相同 block 被重新序列化都截到那个长度永远不编辑 block 内部的字段

子块改动击穿 cache 粒度一个为了去噪声而裁剪 tool_result 中段的压缩器会改该 block 的字节,进而改其后每个前缀 hash却只省下几 KB优选

  • 替换,不编辑“清空的” tool_result 变成一个新 stub block原 block 整个消失
  • 固定截断长度“4 KB”是稳定数字“方便时就截”不是
  • 轮次边界,不是消息边界一段被压缩的轮 = 一个 summary block半个被压的轮 = cache 陷阱

动作 7 —— 标记最后一条用户消息与最后一条消息(槽位 3、4)

在最后一条用户消息上放断点与 tail 不同时和最后一条消息上放断点

工具密集的内循环里,用户问题保持不变assistant 在累积 tool_usetool_result没有槽位 3一条长工具链超过 20 block 时回溯丢掉槽位 2槽位 4 是前瞻的槽位现在在 tail 写入 → 下一轮对这段精确前缀命中

失手条件最后一条消息里含时间戳或每请求 ID —— 见动作 3

动作 8 —— TTL 与压缩节拍匹配

TTL写倍率适用
5 分钟1.25×内循环工具迭代、交互式 chat、dev/test
1 小时2.00×长运行 agent 作业、带空档的 HITL session、batch

决策规则TTL 内期望复用次数 × 0.9 > 写溢价收支平衡点5m 约 0.28 次1h 约 1.11 次

允许混合 TTL但更长 TTL 必须出现在更短 TTL 之前请求顺序上)。典型混合槽位 1–2 上 1h慢动的头和 mid+ 槽位 3–4 上 5m快动的尾)。

失手条件5m TTL + 每 20 分钟压一次 → cache 在能被复用前就过期 → 压缩后每一步都冷写


3. 压缩对缓存的影响

上面八条动作里,三条(动作 4、5、6)是纯粹的压缩决策,第四条(动作 8,TTL)与压缩节拍直接挂钩。这不是巧合 —— 压缩是唯一一个会改写 block 流本身的 context-engineering 操作其他所有技术 —— 记忆、JIT 加载、子 agent 隔离、prompt 设计 —— 都保持既有 block 字节稳定一旦改写 block,你要么把 cache epoch 往前延伸,要么把它炸掉

压缩设计的四个维度每一个都直接牵动 cache

压缩维度对 cache 的作用
边界增长型对话里,唯一一个能给 slot 2 作锚点的字节稳定位置
范围(改写到哪)碰到头部就是 4 个槽位在下一轮同时失效
粒度(整块 vs 子块)子块编辑破坏字节续上;整块替换才能保住
节拍(多久触发一次)决定每次写入能被后续多少次读取摊销

压缩边界是其他任何东西都提供不了的锚点

压缩器在 epoch 内的每一步都发布”最后一个已摘要消息的下标”这个下标是增长型对话里唯一一个 epoch 内前缀字节一致的位置它是 slot 2 唯一能坐下去还能反复命中的地方

没有压缩,slot 2 就无处可放Math.floor(length / 2) 每一轮漂。“每 15 个 block 放一个”也每一轮漂。消息尾部不存在另一个 epoch 稳定的位置压缩在创造锚点、缓存在消费锚点

动到头部就是灾难

压缩一旦改写 slot 1 断点之前的任何 block —— 比如未来新加一个”session 中途从记忆里刷新系统提示”的 feature —— slot 1 的前缀 hash 会变下一轮四个断点同时失效没有局部恢复不变式简单绝对头部不可变,尾部可改写违反它的代价是每一轮都要冷写整段前缀直到下一次 session 边界才算完

粒度决定字节续上

尾部改写有两种做法 —— 整轮替换成一个 summary block,或者编辑既有 block 里的字段去噪声第二种在摘要 token 上更便宜但它会打坏那个 block 的字节 hash连锁改变其后每一个前缀 hash保留下来的 block 必须在 epoch 的每一步里字节级一致唯一能保证这一点的办法是永远不原地编辑 —— 整块替换、固定长度截断、字段绝不动

节拍决定摊销

每次压缩事件 = 一个新的 cache epoch一次对新前缀的贵写,加上后续多次便宜读每 3 轮一压意味着频繁付写税按任务边界压则把同一次写摊到 20+ 次后续读上TTL 窗口内读写比就是节拍决定实际在控制的那个量节拍选得好,这个比例稳定在 ~10 以上,压缩几乎免费选得差,压缩本身就成为主要成本项

财务收支平衡点(来自动作 8)比 ~10 的运营目标低得多5m TTL 约 0.28 次复用/写、1h TTL 约 1.11 次。“收支平衡”和”~10”之间的差就是你对付方差的余量 —— 有的写根本没被复用(session 结束、用户放弃)、TTL 在下一次压缩前就过期、前缀因非确定性而变脏。设计时瞄运营目标,不是瞄收支平衡 —— 后者是悬崖,不是平台。

具体影响

“按 cache 设计的压缩” vs “不考虑 cache 的压缩”之间的差距不是渐进的 —— 是二元且巨大的以 30 步 tool-heavy agent 在 Opus 4.7 上为例

  • 压缩器在 session 中途重写系统提示,或原地编辑 tool_result 字节命中率 ≈ 0.2
  • 压缩器追加式、整轮替换、边界发布为 slot 2命中率 ≈ 0.9
  • 同样的任务、同样的工具、同样的对话长度 —— 总成本大致相差 5 倍完全由压缩器怎么碰 block 流决定

这就是上面八条里三条(动作 4、5、6)给了压缩、第四条(动作 8,TTL)与压缩节拍挂钩的原因 —— agent harness 里再没有哪一块”每行代码改动能撬动的缓存收益”比压缩大每个压缩决策同时是一个缓存决策,在设计层面不可分


4. Zapvol 的实现

packages/backend/src/agent/model.ts 用两个函数实现 playbook:

  • createCachedInstructions(instructions, model) —— cache_control 包住系统提示隐式吃下其前所有工具定义这是动作 1
  • applyCacheControl(messages, model, { extraBreakpointAt }) —— 在最后一条消息上标(槽位 4)、在最后一条用户消息上标(若与上一个不同)(槽位 3)、在 mid-prefix 锚点上标(槽位 2)。从压缩器拿 extraBreakpointAt动作 4);只在压缩未触发且对话超过 20 条时回退到 Math.floor(length / 2)

单位说明:Anthropic 的回溯窗口按 block 计(20 个 block / 断点);上面 Zapvol 的回退阈值按 消息 计(20 条消息)。一条消息通常含 2–5 个 block(text + tool_use + tool_result),所以”20 条消息”是 Anthropic”20 blocks”规则的保守代理 —— 到这个阈值触发时,尾部实际上已经远超 20 block 了。用更紧的代理会更早触发,代价是短对话里提前吃掉一个断点。

调用方(agent-round.ts)把压缩 epoch 的 compactedPrefixEnd 穿到 cache 层让动作 4、5、6 协同压缩器生产稳定边界,cache 层在该精确位置锚住槽位 2,被压缩过的前缀在 epoch 的每一步里字节级一致

遥测applyCacheControl emit cache.breakpoints_placed,字段 messagesCountcompactedPrefixEndextraBreakpointUsedplacedAtlastRole运维 dashboard 把这份 shape 作为命中率主数据源查 —— 不要在不同时更新 dashboard 的情况下改它

槽位 2 —— 压缩边界 vs length/2 同一个 epoch 内:extraBreakpointAt 字节级一致;Math.floor(length/2) 每一轮都在漂 A. Math.floor(length / 2) —— 每追加一个 block 就漂一次 第 N 步 —— 14 blocks idx 7 prefix hash = H a WRITE 第 N+1 步 —— 16 blocks(tail 追加了 2 个 tool_result) idx 8 (漂移) prefix hash = H b ≠ H a MISS B. extraBreakpointAt (压缩边界) —— 钉在 epoch 内最后一个被摘要的 block 上 第 N 步 —— 14 blocks idx 10 (边界) prefix hash = H e WRITE 第 N+1 步 —— 16 blocks,边界位置不变 idx 10 (不变) prefix hash = H e —— 相同 HIT length-midpoint 每追加一个 block 就跳一次;压缩边界只在压缩真正触发时才动。

自动缓存 —— 以及 Zapvol 为什么不用

Anthropic 提供了”自动缓存”模式在请求顶层(不挂在任何具体 block 上)设 cache_control,系统会在每一轮自动把断点放在最后一个可缓存的 block 上随对话增长自动往后推每一轮写入新 tail之前的轮次通过 20-位置回溯从 cache 读取

这本质上等于”由 API 替你做了 slot 4”对 chat 式 agent(每轮追加 1–2 块)单独就够 —— 回溯永远能够到上一轮的写入而且头部(tools + system)小到全量重读也不心疼

对 tool-heavy agent 不够一次 ReAct step 能追加 5–10 块(tool_use + tool_result + assistant text)。两三步后 20-位置窗口就够不到头部每个请求都要为整段 tools + system 付冷写修复方式一定需要显式的 slot 1头部和 slot 2mid-prefix 锚在压缩边界)—— 到这一步,slot 4 顺手显式标一下成本几乎为零

所以 Zapvol 用的是全显式四槽createCachedInstructions + applyCacheControl),没有在请求顶层开自动缓存四个槽全部由我们调度、不交给 API

如果未来开启顶层自动缓存,这些 edge case 必须记住

  • 自动缓存会占用 4 个槽位之一
  • 最后一块已经有显式 cache_control 且 TTL 相同自动缓存 no-op
  • 最后一块已经有显式 cache_control 但 TTL 不同API 返回 400
  • 已有 4 个显式断点自动缓存无槽可用、API 返回 400
  • 最后一块不是 eligible cache target自动缓存向前找最近的 eligible block都找不到就静默跳过

5. Single-agent happy path 之外

上面的 playbook 和 Zapvol 实现都假设了”单 agent、单一模型、提示稳定”的主路径。实际部署会撞上四个让缓存故事发生变化的分支。

子 agent 各自拥有独立的缓存链

注册的子 agent(browserwrite-todos、task 子任务等)作为独立的 API 请求序列运行,每个都有自己的 applyCacheControl 栈。子 agent 的对话不继承父的消息历史(CLAUDE.md 称之为 “sub-agent isolation”),所以父和子有各自独立的前缀 hash —— 缓存在父子之间永远不会流动。后果:

  • 每个子 agent 在一个 session 里首次被调用时,都要为自己的槽位 1 付一次冷写;同一个子 agent 在 TTL 内的后续调用可以读缓存。
  • 命中率 dashboard 按 agent 类型分段 —— 把父 agent 和子 agent 的读混在一个平均里,会掩盖哪一条链实际上是健康的。
  • 不要尝试设计父子共享缓存。独立请求、独立 hash,就是这样。

中途切换模型会重置缓存

Anthropic 的缓存按 (model, prefix) 键入。中途切换模型(BYOK 换 key、用户降级、A/B 测试)意味着:

  • 切换后的第一个请求:cache_creation_input_tokens > 0, cache_read_input_tokens = 0这是正常现象,不是回归
  • 不同模型的最小 token 门槛不同。从 Opus(4,096)切到 Sonnet 4.6(2,048)能让一些原本不可缓存的提示变得可缓存;反向也成立。
  • 冷启动断言(第 8 节)需要在每个模型切换边界都触发,而不仅是 session 开始。如果测试套件切换模型,要相应地接入”第 1 轮行为”的预期。

开发迭代会不断打脏缓存

活跃开发系统提示、工具描述或压缩格式期间,每一次编辑 = 新的前缀 hash = 每次请求都是冷写。代价:

  • 打脏的前缀 1.25×,命中率 ≈ 0,直到提示稳定下来。
  • 建议:重度迭代期,要么把开发期成本接受为”便宜的信号”,要么在 dev flag 下暂时不走 createCachedInstructions。提示稳定后再打开,通过第 8 节冷启动检查验证第 2 轮有读。
  • 不要在 prod 里意外留着”关闭缓存”的 dev 配置 —— 用 NODE_ENV 或显式 flag gate 住。

什么情况别用缓存

有些工作负载开了缓存反而更贵。这些场景跳过缓存:

  • 一次性 / 每请求独一无二的提示(对用户独特文本做分类、一次性分析):每个请求都是冷写、永远读不到 —— 纯 1.25× 税。
  • 头部里混进了每请求方差大的内容(时间戳、每请求 ID、工具定义里的用户特定数据):写总是冷的,哪怕”意图”是稳定的。先修方差(动作 3)再开 cache,不要反过来
  • 低于最小 token 下限的提示(第 1 节表格):写会被静默跳过、cache_control 成空操作。只有当提示会被跨请求复用时才值得垫长度
  • 扇出批处理(1 万个并行独立请求、没有复用):缓存是纯负担。

门槛规则(出自动作 8):TTL 内预期命中次数 × 0.9 > 写溢价低于收支平衡点(5m 约 0.28 次、1h 约 1.11 次),别开


6. 失效模式(反向 playbook)

每一次命中率回归都能归到某条动作被违反拿这份表当诊断索引

症状违反了哪条修复
第 2 轮 cache_read_input_tokens = 0动作 1 或 3验证断点就位;审计 tool + system 的字节
对话增长超过 20 个 block 后命中率崩动作 4把槽位 2 锚在压缩边界上,让回溯还能够到头部
压缩后立刻命中率崩动作 5 或 6检查压缩是否”追加式 + 整块”
每轮命中率都在 0.2–0.4动作 2 或 3猎杀 tool / message 序列化里的非确定性
第 1 轮 cache_creation_input_tokens = 0前缀低于下限垫系统提示或接受代价
压缩后每步都完全 miss动作 8TTL 太短、与压缩节拍不匹配
用户发任务中段 text 后命中率崩Thinking工作流允许时把中段输入走 tool_result
命中率随 tool_choice 剧烈波动动作 2Session 开始定死 tool_choice

7. 度量

命中率 —— 头牌指标

hit_ratio = cache_read_input_tokens
          / (cache_read_input_tokens + cache_creation_input_tokens + input_tokens)

良好行为长任务的目标第 3 轮后 > 0.85持续多轮 session > 0.90低于 0.5 是 bug 而不是折衷

cache.breakpoints_placed 事件

值得建的 dashboard

  • 断点数量直方图 —— 应集中在 3 或 4众数在 1 意味着只缓存了系统提示
  • extraBreakpointUsed 随任务年龄的比例 —— 任务越过压缩阈值后应升到接近 1
  • 相邻 placedAt 间距分布 —— 相邻标记应保持 < 20 block

冷启动验证

# 第 1 轮
assert response.usage.cache_creation_input_tokens > 0    # 动作 1 生效
assert response.usage.cache_read_input_tokens == 0
# 第 2 轮
assert response.usage.cache_read_input_tokens > 0        # 动作 2/3 稳住了

第 2 轮读为零 → 前缀跨轮变了 —— 回上面的失效模式表逐行过


8. 上线检查清单

  1. 动作 1 —— cache_control 在最后一个 system 块槽位 1 缓存 tools + system
  2. 动作 2 —— 工具列表每 session 算一次JSON 键顺序固定
  3. 动作 3 —— 任何被 cache 的 block 里都没有时间戳、request ID、不稳定 map 迭代
  4. 动作 4 —— 槽位 2 锚定在压缩边界上或压缩未触发时的 length-midpoint 回退)。
  5. 动作 5 —— 压缩器不改动槽位 1 之前的任何 block
  6. 动作 6 —— 压缩替换整轮 / 整块截断长度固定
  7. 动作 7 —— 最后一条用户消息与最后一条消息各带一个断点
  8. 动作 8 —— TTL 与压缩节拍匹配紧循环 5m、长运行 1h)。
  9. 遥测就位 —— cache.breakpoints_placed emitcache_read_input_tokens 捕获命中率进 dashboard
  10. 前两轮已验证 —— 第 1 轮写入、第 2 轮读取

延伸阅读


Sources

  • Prompt caching —— Anthropic, Claude API docs(block 模型、20-block 回溯、失效规则、定价的一手参考
  • Effective Context Engineering for AI Agents —— Anthropic, 2026(缓存感知的压缩指引
  • packages/backend/src/agent/model.ts —— Zapvol 的 applyCacheControl / createCachedInstructions
  • packages/backend/src/agent/agent-round.ts —— 把压缩边界穿到 cache 层的调用点
这页有帮助吗?