# 我们这套 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」。 我们的解法:请求顶层固定带 ```json "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 上开场白和人设要焊死。 另外有些站(比如 ekan)对 `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 变了) 看网关日志: ```bash pm2 logs chat-gateway-proxy | grep -E "HIT|MISS" ``` - `HIT 96% (47354/49310)` ← 我们现在的状态 - `MISS (created 0)` ← 故障,没写也没读 监控页:`https://catcatty.uk/llm/cache-stats` --- ## 我们踩出来的铁律 1. **system 必须拆「稳定段 + 动态段」**,只稳定段挂标。哪怕往 BP1 里塞一句「今晚 18 点」,整段缓存立刻废。 2. **会变的全部排到 BP4 之后**,丢进 `gateway_volatile_context`。时间、记忆召回、临时摘要,一个都别留在前缀里。 3. **`user_id` 固定不变**,不带就只写不读。 4. **最末 user 加 rolling 标**(挂倒数第二条),把历史纳进缓存,长会话省最多。 5. **历史只读,不要 retry 重写前面的轮次**。「重新生成上一条」会改前缀,整段重建。 6. **工具 schema 顺序固定**。按开关动态重排工具列表 = 整段 miss。 7. **OR 上人设和开场白焊死**,它靠 hash 粘后端,一动就当新对话。 做到这七条,剩下的就是按上游选对 `mode` 和 `ttl`。我们的 96% 就是这么来的。