BUZZ AI Gateway
首页博客 › Anthropic Prompt Caching 生产实践

Anthropic Prompt Caching 生产实践:一份能省钱的工程手册

Prompt Cache 是 Claude API 上单点收益最大的成本杠杆,但大多数团队还把它扔在地上没捡。不是因为它难开,而是让它真正生效的写法,和写一次普通 LLM 调用的写法不太一样。这份手册是我们希望第一次结账之前就有人塞给我们的版本。

工程手册 · Anthropic Messages API · cache_control / TTL / 网关透明转发

为什么 Prompt Cache 是最大的成本杠杆

看一份真实的 Claude 生产负载,输入这边的账单几乎都被"跨调用不变的内容"占据。带策略和语气说明的 system prompt、写了十几个函数定义的 tools schema、few-shot 示例、用户在追问三轮的同一段检索文档——这些每次都是一模一样地传上去。请求最底下的 user turn 往往是整个 payload 里最小的一部分,但只要不开缓存,你就要为它上方的每一个字节、每一次调用,按全价输入付钱。

Prompt Cache 把这件事翻过来。它不再把每次请求当独立的算一遍,而是把你标记为可缓存的前缀存到服务端,后续命中的请求直接跳过重算。账面差距非常硬:cache read 是基础输入价格的 0.1 倍。这不是"打个折",这是数量级差异。一个高频 agent 从不开缓存切到合理放置 marker,输入开销通常下降 70% 到 90%——模型不用换、prompt 内容不用改、输出质量不变。

杠杆是真实存在的。它没被用上的原因是:Prompt Cache 想真的省钱,前缀必须跨调用字节级完全一致,而大多数应用代码自然写出来不会保证这件事。一旦你内化了这个约束,后面的章节就是机械活了。

Prompt Cache 实际做了什么

Anthropic Messages API 暴露 Prompt Cache 的方式只有一个机制:在某个 content block 上挂一个 cache_control 字段。请求到服务端后,系统把这个 block 之前(含本身)的所有内容做哈希,然后:

cache_control 当前只有一种结构:

{"type": "ephemeral"}             # 默认 5 分钟 TTL
{"type": "ephemeral", "ttl": "1h"} # 1 小时 TTL

marker 可以放三个区域:system 数组、tools 数组里的某个条目、messages 里的某个 content block。一个请求最多带 4 个 marker,每个都是一个独立的 breakpoint。下一次调用,服务端会自动选最长匹配的那个 breakpoint,所以放 marker 时可以大胆一点——最长稳定前缀总会赢。

下面所有决策都建立在两个不那么直观的事实上:

  1. 前缀必须字节级精确匹配。多一个空格、字段顺序变了、system prompt 里换了一个浮点数字面量、模型 ID 换了,任何一个都会 miss。生成可缓存前缀的代码必须是确定性的。
  2. 有最小前缀长度。大约 1024 token 以下(Haiku 是 2048),marker 会被静默忽略,按"没开缓存"计费。给小 system prompt 打 marker 是空操作。

价格算账:5 万 token system prompt,100 次调用

把节省感算清楚最快的办法是用一个真实形状跑一遍。假设一段 5 万 token 的 system prompt(策略、语气、schema 描述、few-shot)在 Claude Sonnet 上跑,5 分钟内被调用 100 次。Anthropic 公布的价格如下:

模型基础输入5 分钟写入(1.25x)1 小时写入(2x)Cache 读取(0.1x)
claude-opus-4-8$5.00$6.25$10.00$0.50
claude-sonnet-4-6$3.00$3.75$6.00$0.30
claude-haiku-4-5$1.00$1.25$2.00$0.10

所有数字都是每百万输入 token。实时价格(包含网关折扣)在 /api/pricing;本节用的是 Anthropic 一级价目,这样倍数关系最直观。

不开缓存,Sonnet,100 次,每次 5 万 token。

100 次 x 50,000 token = 5,000,000 输入 token
5,000,000 / 1,000,000 x $3.00 = $15.00

5 分钟缓存,Sonnet,100 次。第一次写入,后续 99 次读取。

写入: 50,000 / 1,000,000 x $3.75 = $0.1875
读取: 99 x 50,000 / 1,000,000 x $0.30 = $1.4850
合计: $1.6725

缓存前缀这部分省了 89%。每次请求底部的 user turn 不受影响,仍按标准输入价计费,但在大多数负载里 user turn 只占总输入 token 的一小部分。

1 小时缓存,同样形状。

写入: 50,000 / 1,000,000 x $6.00 = $0.30
读取: 99 x 50,000 / 1,000,000 x $0.30 = $1.485
合计: $1.785

在 5 分钟窗口内,1 小时 TTL 严格更贵——写入倍数高。它的价值完全来自把成本摊薄到更长的时间间隔上。这就直接引出下一个问题。

5 分钟还是 1 小时:盈亏点在哪

选哪种 TTL,本质是两个变量的函数:这段前缀在过期前会被读多少次,以及读取之间的间隔通常多长。

把两次写入的成本差,跟"一次读取 vs 一次按全价输入计费"的成本差对照一下:

所以,只要超过 5 分钟窗口后还能多命中 1 次(这次是 5 分钟版本接不住的),1 小时 TTL 就回本。还是 50K Sonnet 的例子,多付的写入是 50,000 / 1,000,000 x ($6.00 - $3.75) = $0.1125。一次 1 小时命中的成本 $0.015,对应一次按全价的 $0.15,净省 $0.135。多一次命中 1 小时 TTL 就转正了。

大多数负载可以直接套用的默认值:

要避免的反模式是在高频负载上"为了保险"选 1 小时 TTL。每次新建条目都要付高写入倍数,对那种本来就经常 cache-cold 的前缀(用户级状态、请求级数据),反而把账单拉高了。

能跑通的代码模式

Anthropic 的 Python SDK 接受在结构化 content block 上挂 cache_control。下面所有例子假设 client 已经把 base_url="https://buzzai.cc" 设好;API 表面和 Anthropic 一级完全一致,所有缓存指令字节透传。

1. 单 session 的 system prompt + tools

最常见的入门姿势。system prompt 是单个 text block,tools 数组短而稳定,在两边各放一个 marker 收尾。两个 breakpoint,在 agent 整个生命周期都稳定。

from anthropic import Anthropic

client = Anthropic(
    base_url="https://buzzai.cc",
    api_key="sk-...",  # BUZZ key
)

SYSTEM_PROMPT = """You are an internal compliance assistant.
[... 50K tokens of policies, tone rules, schema definitions ...]"""

TOOLS = [
    {
        "name": "lookup_customer",
        "description": "Fetch customer record by id.",
        "input_schema": {
            "type": "object",
            "properties": {"id": {"type": "string"}},
            "required": ["id"],
        },
    },
    # ... more tool definitions ...
]

# 在最后一个 tool 上挂 cache_control,把 tools 数组整段封顶
TOOLS[-1] = {**TOOLS[-1], "cache_control": {"type": "ephemeral"}}

resp = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=1024,
    system=[
        {
            "type": "text",
            "text": SYSTEM_PROMPT,
            "cache_control": {"type": "ephemeral"},
        }
    ],
    tools=TOOLS,
    messages=[
        {"role": "user", "content": "Look up customer 12345 and summarize their last 3 tickets."}
    ],
)

print(resp.usage)
# Usage(input_tokens=42, cache_creation_input_tokens=51234,
#       cache_read_input_tokens=0, output_tokens=187)

TTL 内第二次调用、保持同一段 system prompt 和 tools 数组,你会看到 cache_creation_input_tokens=0cache_read_input_tokens=51234。底部的 user turn 仍按标准输入价计费。

2. 多轮对话

聊天 agent 想缓存的前缀会随时间增长:第 1 轮在第 2 轮时已稳定,第 1-3 轮在第 4 轮时已稳定,以此类推。正确做法是把 cache_control 挂在上一轮的 assistant 回复上,这样到这里为止的所有内容都成为可缓存前缀。

def build_messages(history, new_user_input):
    messages = []
    for i, turn in enumerate(history):
        block = {"type": "text", "text": turn["text"]}
        # 把最近一条 assistant 回复标为 cache breakpoint
        if i == len(history) - 1 and turn["role"] == "assistant":
            block["cache_control"] = {"type": "ephemeral"}
        messages.append({"role": turn["role"], "content": [block]})
    messages.append({"role": "user", "content": new_user_input})
    return messages

resp = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=1024,
    system=[{
        "type": "text",
        "text": SYSTEM_PROMPT,
        "cache_control": {"type": "ephemeral"},
    }],
    messages=build_messages(history, "follow-up question..."),
)

这样最多用掉 4 个 marker 中的 3 个(system、上一轮 assistant、当前 user 如果需要),让服务端每次自动选最长匹配。会话越长,节省越多——每加一轮,都是在延长上一次已经缓存好的前缀。

3. 长文档,整段缓存

文档问答和代码审阅类负载里,文档才是要缓存的资产,变化的是 user turn。把文档作为 user message 第一个 content block 单独放,直接挂 marker。

resp = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=2048,
    system=[{"type": "text", "text": "You are a careful technical reviewer."}],
    messages=[
        {
            "role": "user",
            "content": [
                {
                    "type": "text",
                    "text": large_document_text,  # 80K tokens
                    "cache_control": {"type": "ephemeral", "ttl": "1h"},
                },
                {
                    "type": "text",
                    "text": "Summarize the security implications in section 4.",
                },
            ],
        }
    ],
)

第一轮按 1 小时写入价把文档存进去。一小时之内对同一份文档的所有追问都按 0.1x 读取。这是长 context 负载里最能省钱的形态——一次写入摊到整个阅读 session。

走网关:什么会变,什么不变

如果你把请求打到 BUZZ AI Gateway 而不是 api.anthropic.com,从应用代码角度看 Prompt Cache 行为完全一致。BUZZ 是一个透明转发器,请求体的每一个字节,包括 cache_control marker、system block 顺序、tool 定义顺序、message 内容,都原样上行。响应,包括带 cache_creation_input_tokenscache_read_input_tokensusage 对象,也原样下行。

具体的契约是:

值得反复强调一句:重写请求的网关会破坏缓存,字节透传的网关不会。完整的可用模型清单,包括本文涉及的所有支持缓存的型号,在 /models;实时价格包括各种 cache 倍数在 /api/pricing

Claude Code 用户可以用一行命令把 CLI 配置成走网关:

curl -fsSL https://buzzai.cc/sh/claudecode.sh | bash

Claude Code 自己对 system prompt 和 tools 的缓存逻辑会继续生效,因为它发出去的请求形态从头到尾不变。

悄悄破坏缓存的常见错误

大多数缓存相关的 bug 是看不见的:请求成功、响应正确,只有账单比该有的多。下面这些是最常见的失败模式。

system prompt 里有非确定性内容

如果你的 system prompt 通过字符串拼接把当前日期、request id、user id 或任何按请求变化的值放在 marker 之前,每次调用都会落到一条新的 cache 条目上。把这些易变值挪到 marker 之后,放到 user turn 里,或者塞进单独的非缓存 block。一个有用的体检方法是每次调用记一下缓存前缀的哈希:本应复用缓存的两次调用如果哈希不同,泄漏点就找到了。

tools 数组或 system block 顺序不稳定

tools 经常从 registry 加载,而 dict 顺序在不同 Python 版本或不同进程里不一定稳定。发出去前用确定性方式排序,marker 永远挂在最后一个元素上。多 block 的 system 数组同理。

后续调用漏带 marker

缓存是逐次 opt-in 的。如果只有第一次请求带了 cache_control、后面都没带,服务端就没有可查的 key,即使条目还在也按全价计费。把 marker 写在共享代码里,不要放在第一次调用点。

前缀低于最小阈值

1024 token 以下(Haiku 是 2048)的 marker 会被忽略。开了缓存之后,如果第一次调用看不到 cache_creation_input_tokens,基本是前缀太短了。要么把更多内容合并到 marker 之前,要么接受这段 prompt 不够大、用不上缓存。

block 顺序放反

marker 缓存的是它上方的内容,不是下方。把 cache_control 放到当前用户问题上,等于让服务端去缓存每次都变化的内容,永远不会命中。marker 应该挂在稳定 block 上:system、tools、历史轮次、长文档的早期部分。

TTL 漂移

用一个声明 5 分钟 TTL 的请求去命中已经存在的 1 小时条目,是可以读到的;但你不能通过混用 TTL 创建新的 1 小时条目。每个 breakpoint 选一种 TTL,在缓存的整个生命周期里保持一致。

切模型

缓存条目按模型隔离。把"难一点"的请求从 claude-sonnet-4-6 换到 claude-opus-4-8,对那批请求来说就是完整 miss——更高的单价打在没缓存的前缀上。如果你在做模型 A/B,要预期两边各自维护各自的缓存。

一份观测清单

看不到的缓存就调不动。最少有用的埋点是每次调用三个计数器,全部能从响应的 usage 对象里拿到:

按路由或功能聚合,然后画 cache_read / (cache_read + input + cache_creation) 这个比例随时间的曲线。一个健康的生产 agent 预热后会稳定在 0.7 以上。高频路由低于 0.3 就是调优空间,几乎一定能追溯到上一节的某个错误。

BUZZ 在响应体里原样暴露这些 usage 字段,所以你为 Anthropic 直连写的指标管道,把 base_url 切到 网关之后照样能用。

FAQ

BUZZ 这边需要做什么特殊配置才能开缓存吗?

不需要。缓存是上游特性,完全由请求体决定。cache_control 写对了,网关原样转发,Anthropic 处理后续。

开缓存会改变模型行为吗?

不会。编码后的前缀重放出来跟新算一遍是等价的,模型看到的 context 完全一样。如果开了缓存之后行为漂移,大概率是同时改了别的东西导致的。

如果 Anthropic 提前淘汰了缓存条目会怎样?

下一次本来该命中的请求会按全新写入计费。没有报错也没有 warning,只是那一次账单稍微高一点。在公布的 TTL 内淘汰很少见,但不保证一定不发生。

能跨用户共享缓存吗?

可以,只要前缀字节一致并且调用走的是同一个账号。被所有用户共用的 system prompt 和 tools 数组只对应一个缓存条目,不管是哪个用户触发了写入。

缓存会改变 rate limit 吗?

cache 读取仍然计入 TPM(每分钟 token 数),只是按更便宜的价格计入。RPM(每分钟请求数)和缓存无关。

所有 Claude 模型都支持缓存吗?

当前 Sonnet、Opus、Haiku 系列都支持,包括 claude-opus-4-8claude-sonnet-4-6claude-haiku-4-5。最小前缀长度不同,Haiku 需要 2048 token 才会让 marker 生效。

OpenAI 兼容路径能用 Prompt Cache 吗?

缓存是 Anthropic Messages API 的特性。要用它必须走 Claude 原生接口(BUZZ 上是 https://buzzai.cc 配 Anthropic SDK)。https://buzzai.cc/v1 上的 OpenAI 兼容接口适合迁移和复用代码,但不暴露 cache_control

few-shot 示例值得缓存吗?

几乎一定值得。few-shot 通常是 prompt 里最长的稳定段、又跨调用一字不差地复用。把它们放在 system block 里、marker 之上。

如果 system prompt 刚好差一点点不到最小长度怎么办?

用真正有用的内容把它补足:显式格式示例、边界情况说明、术语表。为了凑长度而凑长度对质量没好处,经常还会让模型表现变差。

结论

Prompt Cache 是少有的"开起来几乎不花成本、不用换模型、动辄把输入开销砍掉一个数量级"的优化。它没被用上的原因是工程层面的而不是技术层面的:跨调用产生字节一致的前缀,需要在 prompt 拼装上稍微讲一点纪律,而大多数代码一开始不是这么写的。

手册其实很短。给 system prompt 和 tools 数组挂上 marker。根据前缀复用频率选 TTL。把易变值放到 marker 之下。盯紧 cache_read_input_tokens,调到它占主导为止。如果你通过 BUZZ 调 Claude,所有 cache 指令原样转发,所有 usage 字段原样回来,网关账单上看到的省钱效果和直连一模一样,价格在 /api/pricing。支持的模型清单(包括所有可缓存型号)在 /models;Claude Code 用户用 /sh/claudecode.sh 一行命令就能切到网关。

读完这篇文章如果只做一件事,就是给最高频的 Claude 路由埋好那三个 usage 计数器。一小时之内,你的账单和它"应该是多少"之间的差距就会浮出来。

发表时间:2026-05-22

最近审阅:2026-05-22