Anthropic 的 Prompt Caching 缓存的不是“回答”,也不是“聊天记忆”,而是重复输入前缀的中间计算结果。

所以即使开启缓存,你每一轮仍然要把历史消息重新发送给 API。只不过前面一大段如果与上次完全相同,模型不用重新计算,速度更快、输入费用更低;回答仍然会重新生成。Messages API 本身依旧是无状态的。(Claude Platform)


一、Anthropic 到底缓存了什么

假设每次请求的完整输入是:
(大部分是这样子的)
第一次:
tools + system + 用户消息1

第二次:
tools + system + 用户消息1 + 助手回复1 + 用户消息2
这种叫缓存前缀

1
2
3
4
5
6
7
8
9
tools

system prompt

历史消息 1

历史消息 2

本轮用户问题

我们在某个位置放一个缓存断点:

1
2
[ tools + system + 历史消息 ]  ← 缓存前缀
[ 本轮新问题 ] ← 正常计算

第一次请求:

1
2
3
4
发现没有缓存
→ 计算整个前缀
→ 把前缀的计算结果写入缓存
→ 生成回答

第二次请求:

1
2
3
4
发现前缀完全相同
→ 从缓存读取前缀
→ 只计算新增内容
→ 重新生成回答

这里缓存的是类似模型注意力计算产生的 KV Cache,不是把 Claude 上一次回答直接拿出来。因此即使输入完全相同,输出仍可能因为采样参数等因素发生变化。(Claude Platform)
第二次请求的开头大量内容与第一次相同。Anthropic 可以直接复用已经处理过的部分,只处理新增的后缀。

cache_control 就是在告诉 Anthropic:

请把从请求开头到这里为止的内容作为一个可复用前缀。

Anthropic 的缓存顺序是:

1
tools → system → messages

并且会查找之前请求中最长的相同前缀。


二、cache_control 有两种放法

现在最容易绕晕的地方来了:

自动缓存和显式缓存都使用 cache_control,区别只是它放在哪里。

1. 顶层 cache_control:自动缓存

1
2
3
4
5
6
7
8
9
10
11
12
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,

# 放在请求顶层:自动缓存
cache_control={
"type": "ephemeral",
},

system="你是一个耐心的编程老师。",
messages=messages,
)

这里的意思是:

请自动寻找当前请求中最后一个可以缓存的内容块,并把缓存断点放在那里。

它特别适合不断增长的多轮对话。(Claude Platform)

例如第一轮:

1
2
3
4
system
user 1
assistant 1
user 2 ← 自动缓存断点

第二轮:

1
2
3
4
5
6
system
user 1
assistant 1
user 2 ← 命中旧缓存
assistant 2
user 3 ← 写入新的缓存

第三轮:

1
2
3
4
5
6
7
8
system
user 1
assistant 1
user 2
assistant 2
user 3 ← 命中旧缓存
assistant 3
user 4 ← 新缓存断点

缓存断点会随着对话自动往后移动,所以你不需要每轮手动修改消息中的标记。


2. 内容块里的 cache_control:显式缓存

假设你有一份很长、长期不变的系统规则:

1
2
3
4
5
6
7
8
9
10
11
12
13
system = [
{
"type": "text",
"text": "你是一个 Python 编程老师。",
},
{
"type": "text",
"text": very_long_project_document,
"cache_control": {
"type": "ephemeral",
},
},
]

然后正常请求:

1
2
3
4
5
6
7
8
9
10
11
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
system=system,
messages=[
{
"role": "user",
"content": "请分析这个项目的数据库模块。",
}
],
)

这里有一个非常重要的细节:

1
2
3
4
{
"text": very_long_project_document,
"cache_control": {"type": "ephemeral"},
}

不是只缓存 very_long_project_document 这一个块,而是缓存:

1
2
从整个请求开头
一直到这个内容块为止

也就是缓存的是一个累积前缀

1
2
3
tools
+ 前面的 system 块
+ 当前 system 块

Anthropic 的前缀顺序固定为:

1
tools → system → messages

所以通常应把长期稳定的内容放前面,把每次变化的内容放后面。(Claude Platform)


三、自动缓存和显式缓存怎么选

场景 推荐方式
普通多轮聊天 顶层自动缓存
长 system prompt 显式缓存
固定知识文档 + 不同问题 显式缓存
大量固定工具定义 显式缓存
对话历史不断增长 自动缓存
不同部分更新频率不同 多个显式断点
系统提示固定,同时还想缓存对话 显式 + 自动组合

还可以同时使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,

# 自动处理不断增长的对话
cache_control={
"type": "ephemeral",
},

# 显式缓存固定系统提示
system=[
{
"type": "text",
"text": very_long_system_prompt,
"cache_control": {
"type": "ephemeral",
},
}
],

messages=messages,
)

不过每次请求最多可以使用四个缓存断点,顶层自动缓存也会占用其中一个。(Claude Platform)


四、为什么叫 ephemeral

目前支持的缓存类型是:

1
{"type": "ephemeral"}

ephemeral 的意思是:

1
临时的、短暂存在的

默认有效期是五分钟:

1
2
3
cache_control={
"type": "ephemeral",
}

等价于:

1
2
3
4
cache_control={
"type": "ephemeral",
"ttl": "5m",
}

也可以设置为一小时:

1
2
3
4
cache_control={
"type": "ephemeral",
"ttl": "1h",
}

TTL 是从最近一次命中或写入附近开始维持的;在有效期内再次命中,会延长缓存的可用时间,而不需要重新支付缓存写入费用,但仍会按缓存读取 token 计费。(Claude Platform)


五、缓存为什么能省钱

Anthropic 当前的价格倍率大致是:

1
2
3
4
5
6
普通输入:1 倍

写入 5 分钟缓存:1.25 倍
写入 1 小时缓存:2 倍

读取缓存:0.1 倍

也就是第一次缓存稍贵,但之后读取特别便宜。(Claude Platform)

假设固定前缀有 10,000 tokens。

没有缓存,调用三次:

1
2
10,000 × 3
= 30,000 个普通输入 token

五分钟缓存:

1
2
3
4
5
第一次写入:10,000 × 1.25
第二次读取:10,000 × 0.1
第三次读取:10,000 × 0.1

合计相当于:14,500 个普通输入 token

调用次数越多,收益越明显。

因此它特别适合:

1
2
3
4
5
6
长系统提示
大段项目代码
长文档问答
大量 few-shot 示例
固定工具定义
长时间多轮对话

六、怎样判断到底有没有命中

返回值的 usage 中主要看三个字段:

1
2
3
response.usage.input_tokens
response.usage.cache_creation_input_tokens
response.usage.cache_read_input_tokens

含义分别是:

1
2
3
4
5
6
7
8
input_tokens
本次没有被缓存覆盖、正常处理的输入

cache_creation_input_tokens
本次用于创建缓存的 token

cache_read_input_tokens
本次从缓存读取的 token

总输入量是:

1
2
3
4
5
total_input_tokens = (
response.usage.input_tokens
+ response.usage.cache_creation_input_tokens
+ response.usage.cache_read_input_tokens
)

官方 API 也是按照这三个字段之和表示完整输入 token。(Claude Platform)

第一次请求可能是:

1
2
cache_creation_input_tokens = 5000
cache_read_input_tokens = 0

第二次相同前缀:

1
2
cache_creation_input_tokens = 0
cache_read_input_tokens = 5000

自动缓存的多轮聊天里,第二次通常可能同时出现:

1
2
cache_read_input_tokens > 0
cache_creation_input_tokens > 0

因为旧对话从缓存读取,而新增对话尾部又被写入新缓存。


七、流式请求怎么查看 usage

你的程序使用的是:

1
with client.messages.stream(...) as stream:

在真正的 Anthropic API 中,可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
with client.messages.stream(
model="claude-sonnet-4-6",
max_tokens=4096,
cache_control={
"type": "ephemeral",
},
system="你是一个聪明、耐心、友好的 AI 助手。",
messages=messages,
) as stream:

for event in stream:
if event.type == "thinking":
print(event.thinking, end="", flush=True)

elif event.type == "text":
print(event.text, end="", flush=True)

final_message = stream.get_final_message()

print("\n缓存使用情况:")
print(final_message.usage.model_dump_json(indent=2))

get_final_message() 会返回 SDK 在流式过程中累计完成的最终 Message 对象,其中包含 usage。(GitHub)


八、缓存命中的严格条件

1. 前缀必须完全一致

这些区别都可能破坏缓存:

1
2
3
4
5
6
多一个空格
工具顺序变化
system prompt 改了一个字
图片发生变化
消息顺序变化
在前缀中加入当前时间

缓存命中要求断点之前的内容完全一致。输出不会被缓存,只会重新生成。(Claude Platform)

例如这种设计不太好:

1
2
3
4
5
system = f"""
你是编程助手。
当前时间:{datetime.now()}
这里还有五万字项目文档……
"""

因为每次请求时间都不同:

1
2
第一次:当前时间 21:30:01
第二次:当前时间 21:30:08

整个后续前缀都会变化。

应改成:

1
2
3
4
5
6
7
8
9
10
11
12
13
system = [
{
"type": "text",
"text": very_long_static_document,
"cache_control": {
"type": "ephemeral",
},
},
{
"type": "text",
"text": f"当前时间:{datetime.now()}",
},
]

这样长文档留在稳定前缀里,动态时间放在缓存断点后面。


2. 内容太短可能不会缓存

不同模型都有最小可缓存 token 数量。

如果没达到阈值,API 通常不会报错,而是静默地不缓存:

1
2
cache_creation_input_tokens = 0
cache_read_input_tokens = 0

因此不要拿一句:

1
你好呀

测试缓存,然后疑惑为什么始终没有命中,小笨瓜会被缓存机制气鼓鼓的。应当用足够长的 system prompt、文档或对话测试。(Claude Platform)


3. 第一个请求必须开始返回后,缓存才可用

缓存不是在请求刚发送出去时就立即可用,而是在第一个响应开始后才可以被后续请求命中。

因此同时并发发送十个完全相同的新请求:

1
await asyncio.gather(*tasks)

不一定都能命中第一个请求建立的缓存。需要先让“预热请求”开始响应或完成,再发后续并发请求。(Claude Platform)


4. 有一个 20 内容块回溯窗口

Anthropic 会从缓存断点向前寻找以前写入过的缓存,但最多向前检查 20 个内容块。

普通聊天每轮增加两三个块通常没问题;如果一次 Agent 循环加入大量工具调用和工具结果,旧断点可能超出回溯范围,这时可以增加显式缓存断点。(Claude Platform)

这个属于进阶优化,现在知道有这么个机制就够啦。


九、最重要:你当前程序用的其实不是 Anthropic 后端

你文件里配置的是:

1
2
3
4
5
6
BASE_URL = "https://api.deepseek.com/anthropic"

client = Anthropic(
api_key=API_KEY,
base_url=BASE_URL,
)

所以你只是使用 Anthropic Python SDK 和 Anthropic 请求格式,实际请求发给的是 DeepSeek。

DeepSeek 官方兼容性文档明确写了:消息块、工具定义、工具结果里的 Anthropic cache_control 会被忽略。(DeepSeek API Docs)

DeepSeek 使用的是自己的自动上下文缓存:

1
2
3
4
默认自动开启
自动识别重复的输入前缀
不需要手动添加 cache_control
缓存属于 best-effort,不保证每次都命中

它同样要求前缀重复,而且只缓存输入计算,回答依然重新生成。(DeepSeek API Docs)

所以要把两种机制分开:

1
2
3
4
5
6
7
8
9
真正的 Anthropic API
→ 顶层 cache_control:自动缓存
→ 块级 cache_control:显式缓存断点
→ 可以设置 5m / 1h TTL

你现在的 DeepSeek Anthropic 兼容接口
→ Anthropic 块级 cache_control 被忽略
→ DeepSeek 自己自动做前缀缓存
→ 你主要保证历史消息前缀一致即可

也就是说,在你当前这份聊天程序里:

1
messages.append(...)

每轮保持原历史消息完全不变,只在结尾追加新消息,本身就很适合 DeepSeek 的自动前缀缓存。你不需要给每个消息手动塞 cache_control

真正理解成一句话就是:

自动缓存帮你决定“断点放哪里”;块级 cache_control 让你自己决定“稳定前缀到哪里结束”。而你目前使用的 DeepSeek 后端,又是另一套默认自动缓存机制。

例子

假设用户要求:“读取 app.py,告诉我入口在哪里。”

第一次请求大概是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
{
model: "claude-sonnet-4-6",
max_tokens: 8192,

tools: [
{
name: "ReadFile",
description: "读取文件",
input_schema: {...},
cache_control: {"type": "ephemeral"}
}
],

system: [
{
type: "text",
text: "你是一个终端编程助手……",
cache_control: {"type": "ephemeral"}
}
],

messages: [
{
role: "user",
content: [
{
type: "text",
text: "读取 app.py,告诉我入口在哪里",
cache_control: {"type": "ephemeral"}
}
]
}
]
}

这里写入了三个缓存点:

1
2
3
工具定义末尾
系统提示词末尾
用户消息末尾

Claude 判断需要读取文件,返回:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
role: "assistant",
content: [
{
type: "thinking",
thinking: "我需要先读取 app.py。",
signature: "encrypted-signature..."
},
{
type: "tool_use",
id: "tool_001",
name: "ReadFile",
input: {
file_path: "app.py"
}
}
]
}

MewCode 执行 ReadFile,拿到文件内容。此时会发起第二次请求。

第二次请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
{
model: "claude-sonnet-4-6",
max_tokens: 8192,

tools: [
{
name: "ReadFile",
description: "读取文件",
input_schema: {...},
cache_control: {"type": "ephemeral"}
}
],

system: [
{
type: "text",
text: "你是一个终端编程助手……",
cache_control: {"type": "ephemeral"}
}
],

messages: [
{
role: "user",
content: "读取 app.py,告诉我入口在哪里"
},

{
role: "assistant",
content: [
{
type: "thinking",
thinking: "我需要先读取 app.py。",
signature: "encrypted-signature..."
},
{
type: "tool_use",
id: "tool_001",
name: "ReadFile",
input: {
file_path: "app.py"
}
}
]
},

{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool_001",
content: "1 from textual.app import App\n2 class MewCodeApp...",
cache_control: {"type": "ephemeral"}
}
]
}
]
}

注意:每次请求都会携带完整对话历史,不是只发送新增的工具结果。

但第二次请求的前半部分和第一次相同:

1
2
3
相同 tools
+ 相同 system
+ 相同第一条 user 消息

因此 Anthropic 可以复用缓存,只处理新增部分:

1
2
assistant 的 tool_use
+ user 的 tool_result

然后 Claude 返回最终答案:

1
2
3
4
5
6
7
8
9
{
role: "assistant",
content: [
{
type: "text",
text: "程序入口位于 app.py 中的 MewCodeApp……"
}
]
}

如果用户继续问“那 Agent 在哪里创建?”,第三次请求还会携带前面的全部历史:

1
2
3
4
5
6
7
tools
system
用户问题1
assistant 工具调用
工具结果
assistant 最终回答
用户问题2 ← 新的 cache breakpoint

所以完整对话会越来越长,而 Prompt Cache 的作用就是复用前面没有变化的部分。ConversationManager 负责保存这份完整历史,serialization.py 把它转换成上面的 Anthropic 格式。

如果设置三个缓存点

用户消息末尾的缓存点包含:

1
tools + system + messages

三个缓存点实际上是逐层扩大的:

1
2
3
缓存点 1:tools
缓存点 2:tools + system
缓存点 3:tools + system + messages

Anthropic 会优先寻找“最长的可命中前缀”。前面的缓存点主要充当后备。

例如完整缓存没有命中:

1
2
3
tools              相同
system 相同
messages 被压缩或发生变化

此时:

  • tools + system + messages 未命中
  • tools + system 仍然可以命中

再例如 system prompt 变化:

1
2
3
tools              相同
system 变化
messages 自然也无法复用

此时还能命中最前面的 tools 缓存。

可以把它理解成三个存档点:

1
2
tools ──●──────── system ──●──────── messages ──●
点1 点2 点3
发生的变化 最长可能命中
只新增消息 点3中的旧消息前缀
消息历史被压缩/改写 点2:tools + system
system prompt 改变 点1:tools
工具定义改变 全部失效

所以你说得对:在一个非常简单、历史永远只追加且不超过回看范围的聊天程序中,只标记最后一条 user 消息通常就够了。

MewCode 设置三个缓存点,是因为它还会:

  • 压缩或重写对话历史
  • 截断工具结果
  • 动态发现、启用工具
  • 改变 Plan/Coordinator 等提示内容

多个缓存点不是为了重复缓存三遍,而是为了在后半段变化时,尽可能保住前面仍然稳定的部分。最终计费和复用通常以“最长命中的那个前缀”为主。