自己搭企业内部中转站,怎么让请求更容易命中缓存
前言
公司自建中转站之后,账单并没有降下来。我一开始以为是模型选贵了,拉 usage 一看,cache_read_input_tokens 几乎是 0。
缓存不是「开了就有」。它按前缀精确匹配,中转站每转发一次就顺手改一点,前缀就变了,缓存永远打不中。钱照付,延迟也没省。
这篇讲三件事:缓存到底按什么命中、中转站哪些动作会破坏它、以及怎么设计才能把命中率提上来。
一、先分清三种缓存
「缓存」这个词在中转站里经常被混着用,其实是三件不同的事:
- 上游 prompt cache:模型厂商提供的缓存,命中的输入 token 打折,省的是真金白银;
- 中转站自己的 response cache:同样的请求直接返回上次的结果,省的是整次调用;
- 应用层 cache:工具结果、检索结果、用户会话,和模型无关。
这篇文章主要讲第一种。它是成本大头,也最容易被中转站破坏。
二、缓存按什么命中
不管哪家厂商,核心规则都差不多:从请求的第一个 token 开始,逐 token 匹配,连续一致的部分才算命中。
几个关键点:
- 缓存的是前缀,不是整段请求。前缀里任何一个字符变了,从变化点往后全部失效。
- 有最小 token 门槛。前缀太短,厂商根本不给你缓存,具体数值看模型文档。
- 有 TTL。Anthropic 默认几分钟,可以延长;OpenAI 是自动前缀缓存,也有一段时间的保留窗口。会话闲置太久,缓存就没了。
- 断点位置决定命中范围。Anthropic 用
cache_control显式打断点,最多几个;OpenAI 是自动的,按前缀块递增。
所以「命中率低」通常不是厂商的问题,是请求本身每次都长得不一样。
三、中转站最容易破坏缓存的七个动作
第一,在 system prompt 最前面注入动态信息。
text
当前时间:2026-11-02 21:37:12
请求 ID:req_8f2a...
用户:张三
这些内容每次都不一样,放在最前面,等于把整段前缀废掉。
坏的做法:
text
system: 你是专业助手。当前时间 2026-11-02 21:37:12,请求 ID req_8f2a,用户张三。
好的做法:
text
system: 你是专业助手。
metadata: {"time": "2026-11-02 21:37:12", "request_id": "req_8f2a", "user": "张三"}
时间、用户、trace id 这些中转站需要,模型不一定需要。真要给模型,就放在消息末尾或者单独一条 user 消息里,不要放 system 开头。
第二,每次重新拼 messages。
有的中转站会做「消息裁剪」「补 system」「合并连续消息」,本意是省 token,实际把前缀改了。裁剪本身没问题,但要从尾部裁,不要动前面的稳定部分。
第三,工具列表顺序不稳定。
工具定义通常放在请求前部,顺序一变,前缀就变。用 HashMap、Set 遍历生成 tools,每次顺序可能不同,缓存必然打不中。生成前按名字排序,固定下来。
第四,把检索结果放在最前面。
RAG 场景很常见:系统提示、检索到的文档、用户问题。如果每次检索结果都插在 system 后面,前缀每次都不一样。稳定的放前面,检索结果放后面,或者单独放一条消息。
第五,轮询或最少连接路由。
同一个会话的请求被轮到不同渠道、不同区域、不同账号,缓存大概率不共享。尤其是多区域部署,缓存是跟着区域走的。
第六,模型别名映射到不同版本。
对外叫 claude-opus,背后今天映射到 A 版本、明天映射到 B 版本,或者同名多渠道随机切,缓存都会断。要做故障切换可以,但同一会话尽量粘住同一个上游。
第七,中转站自己加「优化」提示词。
有些网关会统一注入一段「你是专业助手」之类的系统提示,或者做 prompt 改写。只要这段内容每次不同,或者位置在稳定前缀里,缓存就废了。要注入就注入固定内容,并且接受它成为前缀的一部分。
四、缓存友好的中转站怎么设计
能透传就透传。 中转站的第一原则是少改。请求体原样转发,别做「聪明」的重写。确实要改,也改尾部,不动前缀。
稳定前缀。 把不变的东西按顺序放前面:tools、system、few-shot 示例;把动态内容放后面:用户输入、检索结果、时间、会话状态。断点打在稳定前缀的末尾。
保留 cache_control。 客户端标的断点要原样透传,别在中转站里丢掉或者重排。支持多断点的厂商,断点数量也有限制,别超。
会话粘性路由。 用 session id 或 conversation id 做哈希,同一个会话固定走同一个渠道、同一个 key、同一个区域。渠道挂了再切,切过去接受一次 miss。
java
public Channel pick(String sessionId, List<Channel> channels) {
int idx = Math.floorMod(sessionId.hashCode(), channels.size());
return channels.get(idx);
}
渠道扩容时,普通取模会让大量会话重新分布,缓存成片失效。渠道数量会变的话,用一致性哈希,只迁移一小部分会话。
缓存感知的负载均衡。 如果必须做多渠道路由,优先选「这个会话最近用过、缓存还热」的渠道,而不是简单轮询或最少连接。可用性优先,缓存其次。
模型映射要稳定。 对外别名背后的真实模型、区域、版本,在一个会话周期内不要变。要升级就整体切,别让同一个会话一半走旧版一半走新版。
工具 schema 固定。 工具名、描述、参数顺序都固定。描述里不要放时间戳、请求 ID、动态数据。工具版本升级时,接受一次缓存失效,但别每次请求都变。
动态信息放 header 或 metadata。 租户、用户、trace id、权限范围,这些中转站需要,但模型不一定需要。需要就放请求末尾,不需要就别塞进 prompt。
五、中转站自己的 response cache 怎么做
prompt cache 省的是输入 token,response cache 省的是整次调用。两件事不冲突,可以一起做。
response cache 的 key 至少要包含:模型、完整的 messages、tools、temperature、max_tokens、租户。少一个,就可能把 A 租户的答案返回给 B 租户。
几条边界:
- 个性化数据不要缓存。 用户画像、权限过滤后的数据、订单详情,缓存了就是越权风险。
- temperature 大于 0 的请求慎用。 同样的输入本来就不保证同样的输出,缓存等于把随机性去掉,业务能不能接受要评估。
- 语义缓存只用在低风险场景。 FAQ、产品说明、公开文档可以;涉及金额、权限、实时状态的不行。阈值要保守,宁可多打一次模型。
- TTL 要短。 尤其是业务数据,宁可失效快一点,也不要返回过期结果。
- 流式响应也能缓存。 先把完整结果缓存下来,再从缓存里按流式返回,首 token 延迟反而更低。
六、怎么度量
先看厂商返回的 usage 字段:
- Anthropic:
cache_creation_input_tokens、cache_read_input_tokens; - OpenAI:
prompt_tokens_details.cached_tokens。
命中率可以简单算成:
text
cache_read / (cache_read + cache_creation + 未缓存的 input)
然后按渠道、模型、租户、会话类型拆开看。整体命中率 60%,可能是少数几个高频场景撑起来的,长尾场景全是 0。
再做一次 A/B:同一批请求,一组走当前中转站,一组走直连或不做改写的透传,对比成本和延迟。差距就是中转站改写带来的损耗。
七、一份检查清单
- system prompt 开头有没有时间、请求 ID、用户名;
- tools 顺序是否固定,描述里有没有动态内容;
- 检索结果、用户上下文是不是放在前缀里;
cache_control有没有被中转站丢掉或重排;- 同一会话是否粘在同一个渠道、key、区域;
- 模型别名背后的真实版本是否稳定;
- 中转站有没有偷偷注入或改写 prompt;
- response cache 的 key 是否包含租户和权限;
- TTL 是否符合业务的数据新鲜度要求;
- 有没有按渠道、模型、租户看命中率。
这十条里,前四条造成的浪费最大。很多时候不用改架构,只是把动态内容从前面挪到后面,命中率就能从个位数回到一半以上。