
1. 为什么缓存命中率是 Claude API 成本控制的命门做过大模型应用落地的朋友应该都有体会API 账单里最让人肉疼的不是单次调用贵而是同一段内容被反复计费。尤其是做 RAG 检索增强、多轮对话、Agent 工具调用这类场景系统提示词、知识库片段、历史上下文经常被一遍遍塞进请求里token 消耗像开了水龙头一样止不住。Claude API 的Prompt Caching提示缓存机制就是冲着这个痛点来的——它允许你把重复出现的前缀内容缓存起来后续请求命中缓存的部分按更低的费率计费官方给出的缓存读取价格通常只有标准输入价格的十分之一左右这个差距在规模化调用下非常可观。但问题在于缓存不是自动生效的也不是你加了cache_control标记就一定能命中。我见过太多团队接完 API 之后发现账单没降多少一查日志才发现缓存命中率低得可怜有的甚至不到 20%。这背后的原因五花八门缓存断点位置放错了、前缀内容每次都在变、TTL 过期了没续上、请求结构不符合缓存匹配规则等等。缓存命中率这个指标本质上反映的是你的请求有多少比例真正复用了已缓存内容它直接决定了你省下来的钱有多少。这篇文章面向的是已经在用或准备用 Claude API 做生产级应用的开发者尤其是那些调用量大、成本敏感、正在被重复计费困扰的团队。我会把缓存命中率的优化拆成 4 个可落地的步骤从缓存结构设计、断点策略、内容稳定性治理到监控调优每一步都配上原理说明和实操细节。不管你是刚接触 Claude API 的新手还是已经踩过一些坑的老手都能从中找到可以直接抄作业的方案。核心关键词Claude API、缓存命中率、优化、计费会贯穿全文我们直接进入正题。2. 先搞懂 Claude API 缓存的底层逻辑再动手2.1 缓存到底缓存的是什么很多人对 Claude API 缓存有个误解以为它是把整个响应结果存起来下次直接返回。不是的。它缓存的是请求的前缀部分也就是你发过去的 prompt 里从开头到某个缓存断点之间的那段内容。当后续请求的前缀和已缓存的前缀完全一致时这部分就不需要重新计算直接复用缓存计费按缓存读取价走。这里的关键词是完全一致。缓存匹配是前缀匹配从第一个 token 开始逐字比对只要有一个字符不同后面的缓存就全部失效。这跟 Git 的 commit hash 有点像——你改了历史里的任何一个字后面所有 commit 的 hash 都会变。所以缓存优化的核心思路就是把稳定不变的内容放在前面把经常变化的内容放在后面。Claude API 目前支持在请求中通过cache_control参数标记缓存断点一个请求最多可以设置 4 个断点。每个断点代表一个缓存边界系统会尝试缓存从上一个断点或请求开头到这个断点之间的内容。理解这个层级结构很重要因为它决定了你的缓存粒度。2.2 缓存的生命周期与计费规则缓存不是永久有效的。Claude API 的缓存默认有5 分钟的 TTL生存时间也就是说如果一个缓存断点创建后 5 分钟内没有任何请求命中它它就会失效下次请求需要重新创建缓存。创建缓存的操作本身是要额外付费的——缓存写入的价格通常比标准输入价格高 25% 左右。这就引出一个重要的权衡如果缓存创建后命中次数太少你反而可能亏钱。我给大家算一笔账。假设某段前缀有 10000 个 token标准输入价格是每百万 token 3 美元缓存写入是 3.75 美元缓存读取是 0.3 美元。创建一次缓存的成本是 10000/1000000 × 3.75 0.0375 美元。如果不缓存每次请求这段前缀要花 0.03 美元。缓存读取每次是 0.003 美元。那么命中次数不缓存总成本缓存总成本是否划算1 次0.030.0375 0.003 0.0405亏2 次0.060.0375 0.006 0.0435省 27%5 次0.150.0375 0.015 0.0525省 65%10 次0.300.0375 0.03 0.0675省 77%可以看到至少命中 2 次才能回本命中次数越多省得越多。所以缓存策略的设计目标很明确让每段被缓存的前缀在 TTL 窗口内尽可能多地被命中。这就涉及到断点位置的选择和请求频率的匹配。2.3 哪些场景最适合上缓存不是所有场景都值得做缓存优化。根据我的经验以下几类场景收益最明显固定系统提示词 动态用户输入比如客服机器人、角色扮演应用system prompt 可能几千 token 且完全固定每个用户请求都带着它这种缓存命中率能做到 90% 以上。RAG 知识库检索如果知识库的某些文档片段被高频检索到可以把这些片段放在缓存断点前。但要注意检索结果顺序不稳定的话会破坏缓存。多轮对话的历史上下文把对话历史作为缓存前缀每轮新增的内容放在断点后。这样前几轮的内容在后续轮次中都能命中缓存。Few-shot 示例集大量固定的示例样本放在前面实际任务放在后面。Agent 工具定义工具描述、参数 schema 这些固定内容非常适合缓存。反过来如果你的请求前缀每次都不一样或者调用频率极低比如一天就几次那缓存基本帮不上忙甚至可能因为写入成本而亏钱。先判断自己的场景适不适合再动手优化别盲目上。3. 第一步重构请求结构把稳定内容前置3.1 识别请求中的稳定层与变化层优化缓存的第一步不是写代码而是审计你的请求结构。把每次请求的 prompt 拆开逐段分析哪些内容是固定的、哪些是半固定的、哪些是每次都变的。我一般会画一张表把所有内容块列出来标注它们的稳定性和大致 token 量。举个例子一个典型的 RAG 问答请求可能长这样[系统角色定义] - 固定约 500 token [输出格式要求] - 固定约 200 token [工具/函数定义] - 固定约 1500 token [检索到的知识片段] - 半固定约 3000 token但顺序和内容会变 [对话历史] - 变化约 1000-5000 token [当前用户问题] - 每次不同约 50-200 token分析下来你会发现前三块加起来 2200 token 是完全固定的这是最理想的缓存对象。知识片段虽然内容相对稳定但顺序一变缓存就废了需要额外处理。对话历史和当前问题属于变化层应该放在缓存断点之后。3.2 按稳定性排序重排 promptClaude API 的缓存是前缀匹配所以 prompt 的排列顺序直接决定了缓存效率。原则很简单越稳定的越靠前越易变的越靠后。按照这个原则上面的请求应该重排成[系统角色定义] - 最稳定放最前 [输出格式要求] - 稳定 [工具/函数定义] - 稳定 [对话历史] - 相对稳定逐轮增长 [检索到的知识片段] - 半稳定需要排序治理 [当前用户问题] - 每次变化放最后等等这里有个细节需要斟酌。对话历史和知识片段谁在前这取决于你的业务逻辑。如果对话历史是逐轮累积的那它天然适合做缓存前缀——第 N 轮的对话历史包含了前 N-1 轮的内容只要前面的内容不变缓存就能命中。而知识片段如果每次检索结果不同放在对话历史后面反而会破坏缓存。我的建议是把逐轮累积的对话历史放在知识片段之前因为对话历史的稳定性是单调递增的——它只会追加不会修改已有内容。而知识片段如果检索逻辑不稳定放在后面即使变化了也只影响它自己那一段的缓存不会波及前面的对话历史。3.3 用 cache_control 标记断点结构排好之后就要在请求里设置缓存断点了。Claude API 的请求体里content 数组的每个元素都可以带cache_control字段。一个典型的设置是这样的import anthropic client anthropic.Anthropic(api_keyyour-api-key) response client.messages.create( modelclaude-sonnet-4-20250514, max_tokens1024, system[ { type: text, text: 你是一个专业的客服助手负责回答产品相关问题..., cache_control: {type: ephemeral} } ], messages[ { role: user, content: [ { type: text, text: 以下是产品知识库内容\n knowledge_base_text, cache_control: {type: ephemeral} }, { type: text, text: 用户问题 user_question } ] } ] )这里我在 system prompt 和知识库内容后面各设了一个断点。{type: ephemeral}表示这是一个临时缓存TTL 为默认的 5 分钟。注意断点最多 4 个不要浪费在没必要的地方。提示断点应该设在稳定内容的末尾而不是变化内容的开头。因为缓存的是断点之前的内容断点本身标记的是缓存边界。3.4 断点数量的取舍4 个断点听起来很多但实际用起来要精打细算。每个断点都会创建一份缓存而缓存写入是要额外付费的。如果你设了 4 个断点但其中某个断点对应的内容很少被命中那就是纯亏。我的经验法则是只为 token 量大于 1024 且命中频率高的内容段设断点。Claude API 对缓存的最小 token 数有要求通常是 1024 token不同模型可能略有差异低于这个阈值的内容设了断点也不会被缓存。所以先算清楚每段内容的 token 量再决定断点位置。另外断点之间是嵌套关系。如果你在位置 A 和位置 B 各设一个断点那么 A 之前的是一级缓存A 到 B 之间的是二级缓存。请求命中时系统会从最长的匹配前缀开始复用。所以断点不是越多越好而是要形成有意义的层级。4. 第二步治理内容稳定性消除缓存杀手4.1 那些悄悄破坏缓存的隐形变量结构排好了断点也设了但命中率还是上不去大概率是内容里有隐形变量在捣乱。这些东西看起来无关紧要但会让每次请求的前缀产生微小差异导致缓存全部失效。我踩过的坑包括时间戳有些开发者习惯在 system prompt 里加当前时间比如现在是 2025 年 X 月 X 日。这一加缓存永远命中不了因为每次时间都不同。随机 ID请求 ID、会话 ID、追踪 ID 如果被拼进了前缀同样会破坏缓存。动态排序知识片段、工具列表如果每次顺序不同即使内容一样前缀也不一致。空白字符差异多余的空格、换行、制表符肉眼看不出来但 token 层面就是不同。JSON 序列化顺序如果把结构化数据序列化成字符串放进 prompt字典 key 的顺序不稳定会导致内容不同。这些问题有一个共同特征它们不影响语义但影响字节级的一致性。缓存匹配是字节级的所以必须把这些变量全部清理掉。4.2 把动态信息挪到断点之后处理隐形变量的核心思路是任何会变化的信息都不能出现在缓存断点之前。具体做法时间戳、请求 ID 这类信息如果业务上确实需要就放到断点之后的用户消息里。比如# 错误做法时间戳在缓存前缀里 system_prompt f当前时间{datetime.now()}\n你是一个助手... # 正确做法时间戳放在断点之后 system_prompt 你是一个助手... # 带 cache_control user_message f[当前时间{datetime.now()}]\n用户问题{question}知识片段的排序问题解决方案是固定排序规则。比如按文档 ID 升序排列或者按检索得分排序后取固定数量。关键是排序逻辑要确定性同样的输入永远产生同样的顺序。如果检索结果本身就不稳定那这部分内容就不适合放在缓存断点前应该挪到后面。4.3 用规范化函数统一内容格式为了彻底消除格式差异我建议写一个内容规范化函数所有进入 prompt 的文本都过一遍。这个函数做几件事import re import json def normalize_text(text: str) - str: # 统一换行符 text text.replace(\r\n, \n).replace(\r, \n) # 去除行尾空白 text \n.join(line.rstrip() for line in text.split(\n)) # 压缩连续空行 text re.sub(r\n{3,}, \n\n, text) # 去除首尾空白 return text.strip() def normalize_json(obj) - str: # 固定 key 顺序确保序列化结果稳定 return json.dumps(obj, sort_keysTrue, ensure_asciiFalse, separators(,, :))这个函数看起来简单但能挡掉大量缓存失效问题。我实测下来光是加上换行符统一和 JSON 排序这两条某项目的缓存命中率就从 40% 出头涨到了 70% 多。原因就是之前不同代码路径生成的文本换行符不一致Windows 环境是\r\nLinux 是\n混在一起缓存就废了。注意规范化函数本身也要保证确定性不能引入新的随机性。比如不要在里面用set来去重因为 set 的遍历顺序在不同 Python 版本或不同运行环境下可能不同。4.4 版本化你的 prompt 模板还有一个容易被忽视的点prompt 模板的版本管理。如果你经常调整 system prompt 的措辞每次改动都会让所有缓存失效。这在开发阶段无所谓但生产环境频繁改 prompt 会导致缓存命中率剧烈波动。我的做法是给 prompt 模板打版本号改动时评估影响范围。如果只是微调尽量攒一批一起改避免一天改好几次。同时把版本号记录在监控里这样命中率下降时能快速定位是不是 prompt 变更导致的。5. 第三步匹配调用频率与 TTL 策略5.1 算清楚你的请求频率够不够前面算过缓存至少要命中 2 次才回本。但 5 分钟的 TTL 意味着如果你的请求频率太低缓存还没被命中就过期了。所以第二步优化之后要检查你的请求频率是否匹配 TTL。假设你的应用每分钟收到 10 个请求且这些请求共享同一段缓存前缀那 5 分钟内会有 50 个请求缓存能命中 49 次非常划算。但如果你的应用每小时才 10 个请求那 5 分钟 TTL 内可能只有 1 个请求缓存创建完就过期了纯亏。对于低频场景有几个应对思路延长 TTLClaude API 支持通过特定参数设置更长的缓存时间比如 1 小时但价格更高。需要重新算账看延长 TTL 带来的命中收益能否覆盖额外成本。合并请求如果业务允许把多个小请求合并成批量请求提高单次请求的缓存复用率。放弃缓存低频且前缀不固定的场景老老实实不用缓存反而更省钱。5.2 用预热请求保持缓存活跃对于中等频率的场景有个技巧是缓存预热。在缓存即将过期前主动发一个轻量请求去命中它刷新 TTL。这样即使真实用户请求间隔较长缓存也不会断。import time import threading class CacheWarmer: def __init__(self, client, warmup_interval240): self.client client self.warmup_interval warmup_interval # 4分钟留1分钟余量 self.running False def start(self): self.running True thread threading.Thread(targetself._loop, daemonTrue) thread.start() def _loop(self): while self.running: try: # 发一个最小请求命中缓存 self.client.messages.create( modelclaude-sonnet-4-20250514, max_tokens1, system[{ type: text, text: SYSTEM_PROMPT, cache_control: {type: ephemeral} }], messages[{role: user, content: hi}] ) except Exception as e: print(f预热失败: {e}) time.sleep(self.warmup_interval)预热请求本身也要花钱但max_tokens1让输出成本几乎为零主要成本是缓存读取费相比缓存失效后重建的费用要低得多。这个策略适合那些请求频率不稳定、有明显波峰波谷的场景。5.3 多断点的 TTL 协同如果你设了多个断点要注意它们的 TTL 是独立的。一级缓存和二级缓存可能在不同时间过期。如果一级缓存过期了但二级还在请求会命中二级缓存但一级需要重建。这种情况下预热策略要针对最外层最长前缀的断点来做因为它的重建成本最高。我一般会监控每个断点的命中情况找出最容易过期的那个针对性优化。有时候把断点位置调整一下让高频命中的内容集中在同一个断点下能显著提升整体命中率。6. 第四步建立监控闭环持续调优命中率6.1 从响应里读出缓存指标Claude API 的响应里会返回缓存相关的用量信息这是监控的基础。在usage字段里你能看到cache_creation_input_tokens本次请求创建缓存的 token 数cache_read_input_tokens本次请求命中缓存的 token 数input_tokens未走缓存的普通输入 token 数用这三个值就能算出单次请求的缓存命中率def calc_cache_hit_rate(usage): cache_read usage.cache_read_input_tokens or 0 cache_create usage.cache_creation_input_tokens or 0 normal_input usage.input_tokens or 0 total cache_read cache_create normal_input if total 0: return 0.0 return cache_read / total注意这里的分母包含了缓存创建的部分。因为创建缓存虽然是一次性成本但它也是这次请求处理的内容。如果你想看纯复用率可以只用cache_read / (cache_read normal_input)把创建部分排除。两个指标各有用途我一般两个都记。6.2 搭建命中率监控看板光算单次不够要看趋势。我建议按小时或按天聚合记录以下指标指标含义健康值参考整体命中率cache_read / 总输入 token 60%缓存创建频率单位时间内 cache_creation 次数越低越好平均缓存复用次数总命中次数 / 创建次数 3各断点命中分布每个断点的命中情况无明显冷断点命中率与 prompt 版本关联版本变更前后的对比变更后无骤降这些数据可以打到日志系统里用 Grafana 之类的工具做可视化。关键是设置告警命中率跌破阈值、缓存创建频率异常升高、某个断点突然不命中了都要能及时收到通知。6.3 定位命中率下降的排查路径命中率突然下降时按这个顺序排查看是不是 prompt 变更了对比版本号确认最近有没有改过模板。看是不是内容源变了知识库更新、工具定义调整都会影响。看是不是流量模式变了请求频率下降、请求分布变化都可能导致缓存过期。看是不是有新的隐形变量新上线的功能可能引入了时间戳、随机 ID 之类的东西。看是不是 TTL 配置问题确认缓存策略有没有被误改。我遇到过最隐蔽的一次是某个上游服务在返回知识片段时偶尔会带一个不可见的 Unicode 字符零宽空格导致同样的内容在字节层面不一致。这种问题只能靠对比原始字节才能发现。所以排查时把实际发送的 prompt 原文 dump 出来做 diff是最有效的手段。6.4 持续优化的几个方向命中率优化不是一劳永逸的业务在变请求模式也在变。我一般会定期做这几件事重新审计请求结构业务迭代后原来的稳定层可能变得不稳定了需要重新分层。调整断点位置根据命中数据把断点移到性价比更高的位置。评估新场景新上线的功能是否适合缓存能不能复用现有断点。成本复盘算清楚缓存到底省了多少钱投入的优化精力值不值。有个反直觉的点命中率不是越高越好。如果你为了追求高命中率把大量变化内容也硬塞进缓存前缀可能导致缓存频繁重建反而更贵。目标是综合成本最低而不是命中率数字最漂亮。我见过有团队把命中率刷到 95%但账单没降反升就是因为缓存创建太频繁了。7. 实操中踩过的坑与排查速查表7.1 常见问题速查问题现象可能原因排查方法解决方案命中率始终为 0断点未生效或内容每次不同dump 请求对比字节检查 cache_control 位置清理隐形变量命中率忽高忽低部分请求前缀不一致按请求来源分组统计统一内容生成路径缓存创建频繁TTL 内命中次数不足统计创建/命中比提高请求频率或延长 TTL账单不降反升缓存写入成本超过节省算总成本账减少断点或放弃缓存某断点从不命中断点位置在变化内容后检查断点前后内容调整断点位置更新 prompt 后命中率骤降缓存前缀全变对比版本差异攒批更新监控影响7.2 几个血泪教训教训一别在 system prompt 里放用户信息。我早期做的一个项目把用户昵称拼进了 system prompt想着让回复更个性化。结果每个用户的 system prompt 都不同缓存完全失效。后来改成把用户信息放到用户消息里命中率立刻上来了。教训二工具定义的顺序要固定。Agent 场景里工具列表如果是从字典遍历生成的顺序可能不稳定。我吃过这个亏同样的工具集不同进程生成的顺序不一样缓存全废。后来强制按工具名排序问题解决。教训三注意 token 计数的边界。缓存断点的最小 token 数要求是硬性的低于阈值的内容设了断点也不缓存。我曾经把断点设在一段 800 token 的内容后面怎么调都不命中后来才发现是没到最小阈值。把断点往后挪合并了更多内容才生效。教训四TTL 不是越长越好。长 TTL 的缓存写入价格更高。如果你的内容其实变化挺频繁长 TTL 反而浪费。要根据内容的实际生命周期选 TTL别一味求长。7.3 一个完整的优化前后对比最后分享一个真实案例的数据。某 RAG 问答应用优化前日均请求 50000 次平均输入 8000 token缓存命中率 18%日均输入成本约 1200 美元经过四步优化后请求结构重排稳定内容前置清理了 3 处隐形变量时间戳、随机排序、换行符不一致调整断点从 4 个减到 2 个集中在高频命中段加了缓存预热TTL 内命中次数从平均 1.8 次提升到 6.5 次优化后命中率提升到 72%日均输入成本降到约 380 美元降幅接近 70%。这个案例里最关键的其实不是技术多复杂而是把请求结构审计清楚把隐形变量找出来。很多团队卡在命中率上不去问题往往就出在这些不起眼的地方。我个人在实际操作中的体会是缓存优化这件事七分靠结构设计三分靠监控调优。结构没搭好后面怎么调都是事倍功半。所以如果你刚开始做先把请求拆开、分层、排序这三件事做扎实再去折腾断点和 TTL顺序不能反。另外别指望一次优化就到位业务在跑请求模式在变定期回头看数据、做微调才能把成本长期压住。