我们这套 Prompt Cache 是怎么搭的
线上实测:单轮 opus 请求 prompt 49310 token,其中 47354 直接从缓存读回,命中率 96%。近 100 条请求里 74 条命中。
这份文档讲的不是「prompt caching 是什么」,而是我们具体把请求切成了哪几个方块、每个方块装什么、为什么这么切就能稳定命中这么高。
核心思路:把请求切成「会变」和「不变」两堆
缓存是前缀匹配的。从第一个字节开始比对,只要有一个字节和上次不同,从那往后全部失效重算。
所以我们做的唯一一件事,就是把一整个请求拆成方块,按「最不会变 → 最会变」的顺序码好,只给前面不变的方块挂缓存标记,把所有会变的东西全甩到最后面去。
命中率高不是因为用了什么黑科技,是因为我们的缓存前缀真的逐字节不变。
我们的方块布局(BP1 → BP4)
Anthropic 一个请求最多 4 个缓存断点。我们是这么分配的:
═══ system blocks(每块单独挂 cache_control)═══ ┌─────────────────────────────────────────────┐
│ BP1 人设 + 语言规则 + 工具说明 + 长期记忆 │ 几乎永不动
├─────────────────────────────────────────────┤
│ BP2 每日内容(gateway-bp2-daily.txt) │ 一天换一次
├─────────────────────────────────────────────┤
│ BP3 当前会话压缩摘要(这个 session 专属) │ 约 80K token 触发一次压缩
└─────────────────────────────────────────────┘
═══ messages 数组 ═══
...历史轮次(一字不改)...
┌─────────────────────────────────────────────┐
│ BP4 倒数第二条 user 消息挂标(rolling) │ 把全部历史纳进缓存
└─────────────────────────────────────────────┘
动态注入 ← 不挂标,所有会变的塞这里
当前这条 user 消息
每个方块为什么这么放
BP1 — 最稳的垫底。 人设、语言铁律、工具说明书、长期记忆,这些几乎永远不动,放最前面。它一变,后面全废,所以它必须是整个请求里最稳定的东西。
BP2 — 每天换一次的那层。 从 gateway-bp2-daily.txt 读,一天更新一次。它变了只影响 BP2 往后,不动 BP1。
BP3 — 这个会话自己的压缩摘要。 关键点:它是 session-only 的,不是全局长期骨架。当前窗口聊到约 80K token 就触发一次压缩,把老的上下文压成摘要。注意是「网关侧稳定摘要」才进 BP3 挂标;前端那种每轮都在变的当前窗口摘要不挂标,直接丢到下面的动态区,不然每轮重建缓存。
BP4 — rolling 断点,省最多的就是它。 默认缓存边界只到 system 末尾,历史对话不在缓存里。我们在倒数第二条 user 消息上再挂一个标,把缓存边界一路往下扩到包含全部历史。多轮长会话里,前面几十轮的 token 全部变成缓存读取,这是 96% 命中的主力。
挂在「倒数第二条」而不是「最后一条」,是因为最后一条 user 是这轮新输入,每次都不一样,挂上去等于没挂。挂倒数第二条,缓存边界正好停在「这轮之前的所有内容」。
会变的东西去哪了
时间戳、本轮记忆召回、临时纸条、前端的当前窗口摘要 —— 全部塞进 BP4 之后的一个伪 user 消息里:
仅供参考,勿复述:
(当前时间 / 本轮召回的记忆 / 摘要 / 纸条……)
它每轮都在变,但它排在所有断点之后,所以它怎么变都不碰缓存前缀。这是整套设计最关键的一刀:把易变内容隔离到缓存边界外面。
让请求粘在同一个后端:user_id
光挂 cache_control 还不够。中转的负载均衡会把请求随机派到不同后端节点,缓存写在 A 节点,下一次请求飘到 B 节点就读不到,表现就是「只写不读、命中永远 0」。
我们的解法:请求顶层固定带
"metadata": { "user_id": "sillage-anan-stable" }
固定字符串,让 sticky 路由永远把这个对话粘在同一个后端。这个不带,前面所有断点都白挂。不同渠道,挂法不一样
不是每个上游都吃同一套。网关按上游域名自动判定,分三种模式:
| 模式 | 上游 | 协议 | cache_control 挂哪 | TTL |
|---|---|---|---|---|
| anthropic-bp | 直连 Anthropic / msui / 金瓜瓜 | /v1/messages | system blocks 上 | 1h(金瓜瓜只支持 5m) |
| or-blocks | OpenRouter | OAI /chat/completions | 嵌进 messages 的 content block | 1h |
| oai-passthrough | 普通 OAI 中转 / 未知站 | OAI | 不挂,让站子自己隐式缓存 | 不可控 |
两条主路径的粘性机制不一样,绝对不能混:
- Anthropic 原生路径靠
metadata.user_id粘后端。 - OpenRouter 路径不看 user_id,靠
hash(system 第一段 + 第一条非 system 消息)粘后端。所以在 OR 上改人设、换世界书、抖一下开场白,hash 就变,这一轮缓存全废。OR 上开场白和人设要焊死。
cache_control 直接报 4xx。网关把报过错的 host 写进黑名单文件,之后对它永久降级成无缓存透传,不再反复撞 400。这是自愈,不用人管。5min 还是 1h
| TTL | 写入成本 | 读取成本 | 用在哪 | |---|---|---|---| | 5min | 1.25x | 0.1x | 连续快速对话 | | 1h | 2x | 0.1x | 隔半小时回来接着聊,稳赚 |
读取永远 0.1x(省 10 倍)。区别只在写入贵多少、能存多久。我们日常连发用 5m,长会话隔断回来用 1h。
怎么确认真的命中了
看响应 usage:
- Anthropic 原生:
usage.cache_read_input_tokens(读)/cache_creation_input_tokens(写) - OAI 兼容:
usage.prompt_tokens_details.cached_tokens
- 第一轮 read=0、write 一大坨 → 正常,在建缓存
- 第二轮起 read 非零 → 命中了
- 一直 read=0 write=0 → cache_control 根本没生效,故障
- 一直只 write 不 read → 路由飘了(user_id 没带 / OR 的 hash 变了)
pm2 logs chat-gateway-proxy | grep -E "HIT|MISS"
HIT 96% (47354/49310)← 我们现在的状态MISS (created 0)← 故障,没写也没读
https://catcatty.uk/llm/cache-stats ---
我们踩出来的铁律
- system 必须拆「稳定段 + 动态段」,只稳定段挂标。哪怕往 BP1 里塞一句「今晚 18 点」,整段缓存立刻废。
- 会变的全部排到 BP4 之后,丢进
gateway_volatile_context。时间、记忆召回、临时摘要,一个都别留在前缀里。 user_id固定不变,不带就只写不读。- 最末 user 加 rolling 标(挂倒数第二条),把历史纳进缓存,长会话省最多。
- 历史只读,不要 retry 重写前面的轮次。「重新生成上一条」会改前缀,整段重建。
- 工具 schema 顺序固定。按开关动态重排工具列表 = 整段 miss。
- OR 上人设和开场白焊死,它靠 hash 粘后端,一动就当新对话。
mode 和 ttl。我们的 96% 就是这么来的。--- Tranlated By Open Ai Tx | Last indexed: 2026-07-13 ---