ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Claude API 缓存命中率优化:从四步实操到监控告警,彻底砍掉重复计费

Claude API 缓存命中率优化:从四步实操到监控告警,彻底砍掉重复计费 1. 缓存命中率为什么直接等于账单金额1.1 先看懂三档计价的差距两个月前我接手一个基于 Claude API 的客服问答服务月账单稳定在四位数美元。看到成本报表时人有点懵调用量没涨账单却比上个月多了三成。把每次请求的usage拉出来一算才明白缓存的毛病很严重——命中率只有 17%。换句话说83% 的输入 token 都在按原价重复计费。同样的系统提示词、同样的工具定义、同样的历史对话每天被发送几万次每次都重新付一遍钱。很多人对 Claude API 的缓存有一个误解以为它像数据库缓存一样自动生效或者像 HTTP 缓存那样内容相同就不收费。实际上 Anthropic 的 prompt caching 是一套需要你在请求里主动标记的功能它把输入 token 分成三种计费档位计费类型价格系数相对基础输入价触发场景基础输入1.0没有命中缓存、也没有写入缓存的 token缓存写入约 1.25首次把一段前缀写入缓存时发生缓存读取约 0.1后续请求命中这段缓存前缀时发生不同模型的具体美元单价不一样但这个比例关系基本恒定缓存读取大约只要基础输入价的一折缓存写入则比基础价略贵。也就是说如果你的请求内容大部分能稳定命中缓存输入成本的下降空间是肉眼可见的。我举一个具体的数字例子。假设你的固定上下文系统提示词加工具定义是 5000 个 token接口一天被调 100 次不开缓存5000 × 100 50 万 token全部按基础输入全价计费开缓存并稳定命中第一次请求写缓存付 5000 × 1.25剩下 99 次全部从缓存读付 5000 × 0.1。总等效费用约 6.25K 49.5K 55.75K token不到原来的一成。这就是把重复计费砍下来的核心逻辑你不是减少调用次数而是让每次调用里那些必须携带的重复内容不再按原价收钱。1.2 重复计费到底是怎么发生的要理解为什么会有重复计费这回事得先接受一个事实Claude API 是无状态的。模型不记得你上一次请求发过什么每次请求都是独立完成的。哪怕你上一秒刚问过今天天气怎么样下一秒再问明天呢模型看到的仍然是一整段完整上下文系统提示词、工具定义、历史对话、最新问题一样都不能少。所以一个典型的客服机器人请求实际上是这样一个庞然大物系统提示词角色设定、回复风格、业务规则、敏感词拦截清单通常几百到几千 token工具定义十几个函数的 JSON Schema几百 token历史对话这个会话之前的全部问答几十轮下来可能上万 token最新的用户消息唯一真正变化的部分。前面三部分在这次请求之前你其实已经为它们付过钱了。不做缓存的情况下每次请求都会重新按基础价收一遍这就是标题里说的重复计费。它跟你调不调接口无关跟网络快慢无关纯粹是 token 计费机制的问题。我见过最夸张的一个项目固定上下文有 2.8 万 token每次业务请求实际需要模型处理的新内容只有 300 token 左右。在没有缓存的日子里用户每点一次按钮系统都在为上一条回复已经付过费的 2.8 万 token 再掏一次钱。开缓存并稳定命中之后同样场景下输入成本直接掉了七成以上。这也是为什么我后来把缓存命中率当成了这个服务的核心成本指标而不是只看调用次数和输出 token。2. 讲清楚前缀匹配缓存不是按内容去重而是按位置去重2.1 前缀匹配规则设计优化方案之前必须把 Claude 缓存的匹配机制搞清楚。它跟你想象的关键词去重、语义去重完全不同本质是前缀匹配prefix matching。模型收到的输入是一串 token 序列从第 0 个 token 开始往后排列。缓存系统记录的是从开头到某个位置这一段完整序列。后续请求如果从开头到那个位置的内容跟缓存里完全一致就命中如果中间任何地方出现一个字节的差异哪怕只是多了一个空格、换了个标点、某个单词拼写变了整段前缀缓存直接失效全部按原价重新计费。你可以把它理解成一条代码提交链commit A 之后是 commit B再之后是 commit C。只要中间的 commit B 被修改过commit C 的校验和就会变整条链后面的东西都得重新算。缓存也是这样前面的内容只要有一丁点变化后面所有内容全部作废。这个机制带来两个关键推论第一稳定内容必须放在最前面。系统提示词、工具定义这类几乎不变的东西天然适合作为缓存前缀。凡是会发生变化的动态内容都要尽量往序列后面挪。第二缓存命中讲究的是逐字一致。不是意思差不多就能命中而是 token 级别的完全一致。同一个意思用两种不同的说法写进系统提示词它们互相之间不可能共享缓存哪怕同一份系统提示词在请求 A 里排在第一位、在请求 B 里排在第三位前面的序列不一样缓存也断掉。我踩过的一个典型坑是为了让系统提示词包含当前日期我在每天的第一次请求里动态拼入当天的日期字符串。结果就是每天凌晨零点之后所有请求的缓存全部失效整整一天都在按全价付费。后来我改了架构把日期从系统提示词里挪到用户消息末尾命中率立刻回到 90% 以上。这一点在后面四步优化里会详细展开。2.2 cache_control 断点怎么放知道了前缀匹配规则下一步是理解cache_control这个标记。它的作用是在请求内容里划定缓存边界告诉 API从上一个边界到这里这段内容请给我缓存。一个最简单的示例在系统提示词块上放缓存标记{ model: claude-3-5-sonnet-20241022, system: [ { type: text, text: 你是一个电商客服助手。请遵循以下规则……, cache_control: { type: ephemeral } } ], messages: [ { role: user, content: 我的订单为什么还没发货 } ] }cache_control可以出现在系统提示词块上也可以出现在消息的内容块content block上。它的含义是把从上一个断点或消息开头到当前这个块的内容作为一个缓存段。你可以把断点理解成给缓存上拉链的位置——拉链拉到哪里哪里就能被缓存。关于断点的放置我用的是这几个原则断点要放在稳定段落的末尾而不是动态内容的中间。比如系统提示词末尾放一个断点工具定义末尾放一个断点这样前面所有稳定内容成为一个整体缓存段。多轮对话里每条历史消息都可以放断点。这样每次新增一条用户消息时前面的历史全部是缓存读取只有新增的那一小段需要重新计费。不要指望缓存小片段能省钱。官方文档也提到缓存最适合至少 1024 个 token 以上的前缀系统提示词只有几十个 token 的话写入缓存的开销可能比省下的还多。另外一个容易忽略的点缓存是有 TTL 的官方默认行为是缓存条目在 5 分钟内没有被访问就会过期。也就是说如果两次请求间隔太长缓存会被回收下一次请求又得重新花缓存写入的钱。这一点是很多人开了缓存但没省钱的隐藏原因之一后面我会专门讲怎么处理。3. 四步优化实操把重复计费砍下来的完整方案3.1 第一步系统提示词彻底固定所有易变内容移出去优化第一步也是最关键的一步把系统提示词变成一个绝对稳定的常量。这里的稳定不是指你写完就不再改而是指在运行时它的内容逐字不变。实际操作中我建议把系统提示词做成一个独立文件由代码直接读取禁止在业务逻辑里对它做字符串拼接。很多团队会把用户姓名、当前日期、随机 ID 之类的动态信息直接拼进系统提示词这是最伤缓存的做法因为每进来一个新用户、每过一天系统提示词就变一次缓存直接全断。正确做法是给系统提示词划分静态区和动态区静态区放角色人设、语气规范、输出格式、业务规则、知识库摘要、敏感词清单。这些内容基本不随请求变化。动态区放当前时间、用户上下文、临时变量、页面数据。这些内容全部放到用户消息里而且放在用户消息的末尾。举个例子我之前维护的一个法律咨询机器人系统提示词有 1500 多 token里面写了律师身份、回答规范、免责声明。技术同事为了让回答更有针对性把今天是 2025 年 04 月 14 日直接拼到了系统提示词的开头。结果就是每次日期变化整段 1500 token 的缓存全部失效。我把日期改放到用户消息里今天是 2025 年 04 月 14 日请参考以上规则回答我的问题。系统提示词从此一个字节都不变命中率从 40% 出头直接拉到 90%。这一步最核心的心法是把系统提示词当成一道不可变的哈希键来对待。每次请求生成前先拿它的哈希跟历史请求比对如果有变化就该在业务代码里查问题而不是让它带着变化发到 API 去。3.2 第二步工具定义与请求结构标准化禁止抖动如果你在 API 请求里带了tools参数工具定义就是前缀里非常重要的一段。它的问题跟系统提示词一样工具定义的顺序、描述文案、参数类型任何一处变化都会让工具前缀以及它后面的所有内容缓存失效。这里有个很多文档不会提的坑工具定义的变化不一定是业务上的也可能是序列化抖动。举个例子你的后端用 Python 的 dict 存工具列表每次请求重新从 dict 里取出再转 JSON。Python 3.7 之后 dict 保序但如果你在不同环境里用不同方式构建这个 dict或者某些字段的值是浮点数序列化结果就可能出现细微差别——键的顺序变一下整个 JSON 字符串就变了缓存就断了。我自己做过的标准化操作有三条工具列表写死顺序。给每个工具一个固定的编号或固定的数组顺序不要用集合类型如 set来存储工具因为集合不保证顺序。使用统一的序列化函数。整个服务只用一个入口去序列化请求 JSON禁止各业务模块各自拼 JSON。对工具描述、参数 Schema 做代码评审。任何改动工具定义的需求都要评估这次改动会导致多少缓存失效再决定是否值得。还有一点必须注意tools参数在请求里的位置是固定的紧跟在system之后。你需要确保每次请求的字段顺序一致。虽然 JSON 对象本身的键顺序理论上不影响解析但在实践中我见过有些 SDK 或者网关层会对请求做签名、缓存、日志记录时把键排序导致实际发出去的内容出现差异。最稳妥的做法是构造请求时固定字段顺序并且把请求体本身作为幂等校验的对象。3.3 第三步多轮对话按轮次管理缓存断点客服问答、多轮 agent 这类场景请求里的历史消息会越来越长。如果不做断点设计每轮请求都会把整个历史从头开始重新计费多轮对话越长亏得越多。正确姿势是把断点放在每一条历史消息的内容块上。具体来说系统提示词块放一个cache_control工具定义块放一个cache_control每条历史消息assistant 回复、tool result、用户消息都放一个cache_control只有最后一条用户消息不放断点因为它是新增内容还没有被缓存的价值。我贴一段多轮消息缓存的示例结构{ system: [ { type: text, text: 固定系统提示词……, cache_control: { type: ephemeral } } ], tools: [ { name: get_order_status, description: 查询订单状态, input_schema: { type: object, properties: {} }, cache_control: { type: ephemeral } } ], messages: [ { role: user, content: [ { type: text, text: 我的订单为什么还没发货, cache_control: { type: ephemeral } } ] }, { role: assistant, content: [ { type: text, text: 请稍等我正在查询。, cache_control: { type: ephemeral } } ] }, { role: user, content: [ { type: text, text: 好的等你回复。 } ] } ] }这样设计之后第一次请求把系统和工具写入缓存第二个请求把第一轮消息追加写入缓存第三个请求来的时候系统、工具、第一轮消息全部是缓存读取只有新增的好的等你回复和后续的 assistant 回复按基础价计费。这套断点方案有一个平衡点断点越密集缓存写入越碎但命中覆盖面更大。对于大部分对话场景每条消息放断点是划算的因为对话历史通常只有一份且不断被后续请求读到。对于那种一次性请求请求之间没有关联的场景则不需要在消息里放断点只需要缓存系统和工具就够了。3.4 第四步动态内容全部下沉到请求尾部最后一步是对前面三部的兜底把所有每次必变的内容全部集中放到请求序列的最末尾。因为前缀匹配是前面一变后面全废所以你要保证的是缓存边界之前的所有内容是逐字不变的真正会变的东西只能出现在最后一个未缓存的位置。常见的动态内容清单当前时间、日期请求 ID、会话 ID、随机 nonce用户输入原文每一次都不同从数据库/外部 API 实时拉取的数据温度、top_p 这类采样参数注意模型参数不影响缓存但会影响输出结果是否纳入历史要保持一致。拿一个典型 RAG 场景举例。假设你做一个文档问答助手用户问题进来之后系统要检索相关文档片段拼进上下文里再发给模型。如果你把检索到的文档片段放到系统提示词里每次检索结果不同前缀必变系统提示词的缓存就废了。正确做法是系统提示词只保留你是文档助手请依据下面资料回答这类固定话术检索到的资料作为一条 user 消息塞到请求尾部紧跟着用户问题。实际操作中我一般把请求体设计成这样的固定骨架系统提示词静态缓存断点工具定义静态缓存断点历史对话增量每条消息缓存断点本次检索到的资料动态放在倒数第二位用户本次提问动态放在最末位这样无论动态内容怎么变前面积累的缓存段都不会受影响。新内容作为哈希链的新尾巴被即时写入下一轮请求它就变成稳定历史的一部分可以被缓存读取了。4. 实测中那些让缓存悄悄失效的隐形杀手四步优化做完命中率大概率能到 85% 以上。但你以为完事了其实还有一堆隐形杀手在暗处等着。我把自己实际踩过、也帮别人排查过的几种典型失效原因列出来建议你逐一对照检查。4.1 时间戳和唯一 ID 污染前缀最经典的杀手就是时间戳。只要请求里有一丁点动态字符串出现在缓存前缀中那这个缓存对这个请求就形同虚设。更隐蔽的是唯一 ID有些团队会在系统提示词或消息里塞request_id、trace_id本意是方便日志追踪但它每次请求都变等于亲手把缓存前缀炸掉。我的排查方法是在日志里加上前缀哈希字段每次请求发出前把 system tools 历史消息的前 N 个 token 算一个哈希。如果发现同一类请求的哈希经常变就去比对到底是哪个字段在变。这个方法非常笨但极其有效。4.2 消息顺序与序列化抖动前面提到工具列表的顺序问题。同样的问题也会出现在消息列表里消息必须严格按照时间顺序排列中间的插入、删除、压缩都会导致前缀变化。有一个典型的业务场景对话超过一定轮数后系统会把最早的历史消息压缩成一段摘要丢给模型。这个操作一旦发生整个历史前缀就变了后面所有已经缓存的内容全部失效。有的团队为了省 token 做历史摘要结果把缓存也一起摘没了成本反而更高。我现在的策略是对话历史能做无损拼接就绝不做摘要实在要压缩就干脆开一个新的会话不要在旧会话里打断前缀链。此外如果你用的是带工具调用的 agent历史里会包含tool_call和tool_result。工具调用 ID 这类字段在每次调用时由 SDK 生成如果它进入了历史消息并被当作前缀的一部分只要你在后续重建上下文时没有严格按原样回放缓存也会断。4.3 工具输出在会话间的差异第三个杀手容易被漏掉工具输出本身就是动态的。假设你有一个查询订单状态的工具用户问一次你把查询结果塞进消息历史。下一个请求来看这个结果已经变成历史的一部分只要你不修改它它就能被缓存。但如果你的工具输出里包含时间戳或者实时状态比如当前排队人数23人那它虽然写进了历史下一次请求它作为前缀的一部分时只要业务逻辑要求重新查询最新状态并且是替换掉旧结果而不是追加新结果前缀就会断。这里我学到的教训是实时数据要么放末尾要么换会话不要进入稳定的历史前缀。我会把工具结果里会变化的部分单独放在最新一轮 user 消息附近而把那些固定不变的比如订单号、商品名留在历史里。这样既保证模型能看到最新状态又不污染缓存前缀。4.4 缓存 TTL 与请求频率的博弈还有一个不算失效、但同样影响成本的隐藏因素TTL。默认情况下缓存条目 5 分钟无访问就会被回收。如果你的请求间隔经常超过 5 分钟那每次请求都得重新花缓存写入的钱甚至可能出现写缓存的钱 读缓存的钱 不开缓存的钱这种倒挂。针对这个情况我的处理方式有三个如果请求模式是短时间高频比如 QPS 大于 0.1TTL 问题基本不存在如果是低频但稳定的调用可以评估是否值得用带有更长 TTL 缓存模式的配置具体以官方文档的缓存选项为准实在不行就把会反复用的固定上下文放到完全无动态的部分改为在业务层面做本地缓存减少对 API 缓存的依赖。个人建议如果你的业务请求本身 QPS 就很低比如每天只有几十次缓存优化的收益空间其实不大首要是先把固定上下文做小而不是死磕命中率。5. 落地监控把命中率钉在成本看板上优化做完不监控等于白做。缓存命中率不是一个由平台直接给你的现成指标它藏在每次响应的usage字段里需要你自己算。下面是我在服务里实际使用的监控方案。5.1 从 usage 字段里读命中率Claude API 的响应usage对象通常包含这几个字段字段含义input_tokens本次请求未命中缓存、按基础价计费的输入 token 数cache_creation_input_tokens本次请求写入缓存的 token 数cache_read_input_tokens本次请求命中缓存、按缓存读取价计费的 token 数命中率计算公式我一般这么写def compute_cache_hit(usage: dict) - float: read_tokens usage.get(cache_read_input_tokens, 0) fresh_tokens usage.get(input_tokens, 0) if fresh_tokens read_tokens 0: return 0.0 return read_tokens / (fresh_tokens read_tokens)注意这个公式分母用的是input_tokens cache_read_input_tokens把cache_creation_input_tokens排除在外。因为写入缓存的那部分 token 属于本次新产生的内容跟是否命中历史缓存不是一回事。把每次请求的命中率打点记录到日志或时序数据库里再按小时/天聚合你就能看到缓存命中率的整体走势。我的经验值是如果命中率稳定在 85% 以上成本基本处于健康状态掉到 60% 以下就该排查是不是有人改了系统提示词、加了动态字段或者改了工具定义。5.2 用回归测试防止缓存回退代码是不断演进的。你这次优化到位了六周后同事为了加一个功能往系统提示词里塞了一段动态拼接的问候语命中率可能一夜回到解放前。所以我强烈建议做两道防线第一道请求模板的单测。在 CI 里构造一个固定输入的请求体断言请求体从第二次到第五次发送时system tools 历史消息拼接后的前缀哈希完全一致。这个测试能第一时间捕捉到谁引入了动态字段。第二道日志告警。给命中率设置阈值低于阈值触发告警。有了告警哪怕缓存失效发生在线上你也能在半小时内定位而不是等到月底账单出来才发现。我在代码里一般会加一个请求前校验函数把系统提示词和工具定义做成配置文件跑一个不可变对象的 hash。如果 hash 在两次请求之间发生了变化就直接打印告警日志。这一步曾经帮我抓到过一次很隐蔽的问题某个同事为了让某个场景的回答更友好在业务代码里给系统提示词追加了半句话结果那半句话成了全场景的毒药直接把所有请求的前缀全部污染。5.3 建立一张自己的成本对照表最后我会在自己的运维文档里保存一张当前项目实际抓到的成本对照数据。不用太复杂记录四列就够了日期、请求数、平均输入 token 数、平均缓存命中率。每天扫一眼就能看到成本是否在正常波动范围内。我截取一个真实项目的粗略数据做参考阶段平均命中率每万次请求输入成本估算优化前无任何缓存标记0%全价加了 system 缓存断点55%降约四成完成四步优化 标准化89%降约七成某次动态字段误入前缀41%回到高位这张表告诉我两件事第一优化空间真实存在第二缓存命中率是极其脆弱的指标一次无意的改动就能让节省归零。所以我后来在团队里立了一条规矩任何涉及提示词、工具定义、历史消息格式的改动都要先过一遍这是否影响缓存前缀的检查。宁可多花十分钟开会讨论也不要让全量请求多付一个月的原价输入费。就我自己目前维护的这套服务而言四步优化做完之后输入相关的账单从总成本的 60% 降到了 20% 出头而实际功能没有任何削减。如果你也在用 Claude API 做线上服务我强烈建议你打开日志看看cache_read_input_tokens的占比。只要命中率低于六成大概率还有一份重复计费的钱正躺在那张月度账单里等你优化。
返回列表