附录 4A:Prompt Caching
Prompt Caching 是 provider 级别的优化:如果你连续多次请求用了相同的 prompt 前缀,provider 可以跳过那段前缀的计算,直接复用缓存。对 Agent 循环来说,这意味着 system prompt + 工具定义 + few-shot 示例这些每轮不变的部分,只需要在第一轮完整计算一次。
这不是你自己在内存里做的缓存(那个第四章已经讲了)。这是 provider 在推理服务端做的 KV cache 复用。
三家机制对比
OpenAI
OpenAI 的 prompt caching 从 2024 年 10 月开始自动生效,不需要你改代码。
命中条件:
- 请求的 prompt 前缀与之前某次请求完全相同(逐 token 匹配)
- 前缀长度至少 1024 token
- 缓存有效期大约 5-10 分钟(官方未公布精确值,流量大时缓存活得更久)
价格:
- 缓存命中的 token:按输入价格的 50% 计费
- 缓存未命中:正常价格
API 响应中的识别方式:
{
"usage": {
"prompt_tokens": 2050,
"completion_tokens": 120,
"prompt_tokens_details": {
"cached_tokens": 1024
}
}
}
cached_tokens 字段告诉你有多少 token 命中了缓存。如果这个字段是 0 或不存在,就是全 miss。
适合 Agent 的点: ReAct 循环每轮都带同样的 system prompt + 工具定义。假设这部分有 1500 token,跑 6 轮,总输入量里有 9000 token 是重复前缀。缓存命中后,这 9000 token 按半价计费。
Anthropic
Anthropic 的 prompt caching 需要你显式标记哪些 block 要缓存。自动缓存也有,但显式标记更可控。
显式标记方式:
import anthropic
client = anthropic.Anthropic()
response = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
system=[
{
"type": "text",
"text": "你是杭州旅游助手。[省略 500 字的完整 system prompt...]",
"cache_control": {"type": "ephemeral"},
}
],
messages=[
{"role": "user", "content": "明天杭州天气如何?"},
],
)
cache_control: {"type": "ephemeral"} 标记这个 block 可以被缓存。
命中条件:
- 被标记的 block 内容与之前请求完全相同
- 最小缓存粒度 1024 token(Sonnet/Haiku)或 2048 token(Opus)
- 缓存有效期 5 分钟(每次命中刷新计时器)
价格(以 Claude Sonnet 为例):
| 类型 | 每百万 token 价格 | 相对比例 |
|---|---|---|
| 首次写入缓存 | $3.75(基础 $3 + 25% 写入溢价) | 1.25x |
| 缓存读取 | $0.30 | 0.1x |
| 未缓存输入 | $3.00 | 1x |
缓存读取只要基础价格的 10%。写入时多付 25%,但只付一次。
使用量反馈:
{
"usage": {
"input_tokens": 2150,
"output_tokens": 503,
"cache_creation_input_tokens": 1500,
"cache_read_input_tokens": 0
}
}
第一次请求 cache_creation_input_tokens = 1500(写入缓存)。第二次请求同样内容时,cache_read_input_tokens = 1500(从缓存读取)。
DeepSeek
DeepSeek 的缓存策略更像 OpenAI 的自动模式,但折扣更大。
命中条件:
- 前缀匹配,自动生效
- 最小前缀长度根据模型不同,通常 64 token 起(比 OpenAI 宽松很多)
- 缓存有效期未公开明确数字,实测约几分钟
价格(以 DeepSeek-V3 为例):
| 类型 | 每百万 token 价格 |
|---|---|
| 缓存命中输入 | $0.014 |
| 缓存未命中输入 | $0.14 |
| 输出 | $0.28 |
缓存命中是未命中价格的 1/10。
旅游助手的缓存收益计算
假设 ReAct 循环跑 6 轮,每轮的 messages 结构如下:
轮次1: [system(1500t)] + [user(200t)] → 输入 1700t
轮次2: [system(1500t)] + [user(200t)] + [asst(100t)] + [tool(300t)] → 输入 2100t
轮次3: [system(1500t)] + [历史(600t)] + [asst(100t)] + [tool(300t)] → 输入 2500t
轮次4: [system(1500t)] + [历史(1200t)] → 输入 2700t
轮次5: [system(1500t)] + [历史(1800t)] → 输入 3300t
轮次6: [system(1500t)] + [历史(2400t)] → 输入 3900t
6 轮总输入 token:16200
其中 system prompt 每轮重复:1500 * 6 = 9000 token
不用缓存(OpenAI GPT-4o,$2.50/M 输入 token):
total_input_tokens = 16200
cost_no_cache = total_input_tokens * 2.50 / 1_000_000
print(f"无缓存成本: ${cost_no_cache:.4f}")
无缓存成本: $0.0405
用缓存(假设轮次 2-6 全部命中 system prompt 缓存):
uncached_tokens = 16200 - (1500 * 5) # 第一轮全价,后 5 轮 system 缓存
cached_tokens = 1500 * 5
cost_uncached_part = uncached_tokens * 2.50 / 1_000_000
cost_cached_part = cached_tokens * 1.25 / 1_000_000 # 50% 折扣
cost_with_cache = cost_uncached_part + cost_cached_part
savings_pct = (1 - cost_with_cache / cost_no_cache) * 100
print(f"有缓存成本: ${cost_with_cache:.4f}")
print(f"节省: {savings_pct:.1f}%")
有缓存成本: $0.0315
节省: 22.2%
同样场景用 Anthropic Claude Sonnet($3/M 输入,缓存读取 $0.30/M):
cost_no_cache_claude = 16200 * 3.00 / 1_000_000
uncached_tokens_claude = 16200 - (1500 * 5)
cached_tokens_claude = 1500 * 5
cache_write_cost = 1500 * 3.75 / 1_000_000 # 第一轮写入
cost_uncached_claude = uncached_tokens_claude * 3.00 / 1_000_000
cost_cached_claude = cached_tokens_claude * 0.30 / 1_000_000 # 读取
cost_with_cache_claude = cost_uncached_claude + cost_cached_claude + cache_write_cost
savings_pct_claude = (1 - cost_with_cache_claude / cost_no_cache_claude) * 100
print(f"Claude 无缓存: ${cost_no_cache_claude:.4f}")
print(f"Claude 有缓存: ${cost_with_cache_claude:.4f}")
print(f"节省: {savings_pct_claude:.1f}%")
Claude 无缓存: $0.0486
Claude 有缓存: $0.0329
节省: 32.3%
Anthropic 的缓存读取折扣更大(90% off),所以循环轮次越多,省得越多。
缓存命中的实践要点
什么能命中
缓存靠前缀匹配。所以 messages 数组的前面部分越稳定,命中率越高。
✅ 容易命中:
messages = [system_prompt, fewshot_1, fewshot_2, ..., user_message]
↑ 这段前缀不变 ────────────────────────────↑ 只有这里变
❌ 难以命中:
messages = [system_prompt_with_dynamic_date, ..., user_message]
↑ 每天的日期不同,前缀就变了,缓存失效
实操建议
-
System Prompt 放最前面,内容别动态化——如果你在 system prompt 里插了当前日期、当前用户名,每次请求前缀都不同,缓存永远 miss。把动态信息放在 user message 里。
-
Few-shot 示例固定顺序——调换示例顺序就是不同前缀。
-
工具定义放在 system prompt 里且保持稳定——如果工具列表经常变,考虑把不变的工具放前面,动态工具放后面。
-
监控
cached_tokens——不看这个字段,你永远不知道缓存到底有没有生效。写一个简单的计数器:
class CacheHitTracker:
def __init__(self):
self.total_input_tokens = 0
self.total_cached_tokens = 0
def record(self, usage: dict):
self.total_input_tokens += usage.get("prompt_tokens", 0)
cached = usage.get("prompt_tokens_details", {}).get("cached_tokens", 0)
self.total_cached_tokens += cached
def hit_rate(self) -> float:
if self.total_input_tokens == 0:
return 0.0
return self.total_cached_tokens / self.total_input_tokens
tracker = CacheHitTracker()
# 模拟 6 轮 ReAct 的 usage
tracker.record({"prompt_tokens": 1700, "prompt_tokens_details": {"cached_tokens": 0}})
tracker.record({"prompt_tokens": 2100, "prompt_tokens_details": {"cached_tokens": 1500}})
tracker.record({"prompt_tokens": 2500, "prompt_tokens_details": {"cached_tokens": 1500}})
tracker.record({"prompt_tokens": 2700, "prompt_tokens_details": {"cached_tokens": 1500}})
tracker.record({"prompt_tokens": 3300, "prompt_tokens_details": {"cached_tokens": 1500}})
tracker.record({"prompt_tokens": 3900, "prompt_tokens_details": {"cached_tokens": 1500}})
print(f"总输入 token: {tracker.total_input_tokens}")
print(f"缓存命中 token: {tracker.total_cached_tokens}")
print(f"缓存命中率: {tracker.hit_rate():.1%}")
输出:
总输入 token: 16200
缓存命中 token: 7500
缓存命中率: 46.3%
46.3% 的命中率意味着接近一半的输入 token 在享受折扣。
三家对比总结
| 维度 | OpenAI | Anthropic | DeepSeek |
|---|---|---|---|
| 触发方式 | 自动 | 显式标记 + 自动 | 自动 |
| 最小前缀 | 1024 token | 1024/2048 token | ~64 token |
| 缓存折扣 | 50% off | 90% off(读取) | 90% off |
| 写入溢价 | 无 | 25% | 无 |
| 有效期 | ~5-10 分钟 | 5 分钟(命中刷新) | 未公开 |
| 识别方式 | cached_tokens |
cache_read_input_tokens |
类似 OpenAI |
选择建议:
- ReAct 循环轮次多(>5 轮)、system prompt 长(>1500 token):Anthropic 的 90% 折扣最划算
- 短前缀、高频调用:DeepSeek 的 64 token 门槛最低
- 什么都不想改:OpenAI 自动生效,零代码改动
返回 第四章。