LangChain 提示词缓存:真正能命中缓存的配置方式
目录
下面这段 LangChain system prompt 看起来完全没问题,实际上什么也缓存不了:
from langchain_core.prompts import ChatPromptTemplate
prompt = ChatPromptTemplate.from_messages([
("system", BIG_STABLE_SYSTEM_PROMPT), # the syntax every tutorial uses
("human", "{question}"),
])
我们用完全相同、包含 1,800 个 token 的 system prompt 连续请求了两次 claude-sonnet-5,然后查看 usage 字段。两次调用的缓存写入和读取都是 0。不是部分命中,也不是缓存被切碎,而是根本没有缓存。原因是 Anthropic 只缓存使用 cache_control 标记的内容,而 ("system", ...) tuple 中的普通字符串没有地方放这个标记。LangChain 最方便的语法,恰好会让你错过全部折扣,而且不会报错。
TL;DR
- LangChain 的
("system", "string")tuple 无法携带cache_control,所以 Claude 不会缓存任何内容:实测在claude-sonnet-5上重复发送相同的 1,800-token system prompt,缓存写入和读取均为 0。 - 解决办法是使用带有内容块级
cache_control的SystemMessage;system block 上的一个标记也能覆盖通过bind_tools绑定的工具。 - 使用
langchain-anthropic1.4.8 时,即使确实发生了缓存写入,input_token_details.cache_creation仍然是 0;真正的计数位于ephemeral_5m_input_tokens。 - RAG prompt 顺序错误时,如果把变化的上下文放在固定规则之前,每次调用都要支付约 1.25 倍的缓存写入溢价,甚至比完全不用缓存还贵。
系列文章:第 5 篇,共 5 篇 · 前文:第 1 篇——缓存原理 · 第 2 篇——服务商对比与评估 · 第 3 篇——可运行代码教程 · 第 4 篇——不同场景下的最佳 LLM
这是缓存系列的第 5 篇。第 1 篇介绍前缀缓存的工作原理,第 3 篇提供原生 SDK 教程,提示词缓存完整指南则横向覆盖各服务商。本文关注 LangChain 代你组装 prompt 后会发生什么。以下数据均于 2026-07-04 通过 Synthorai gateway 实测,使用 langchain-core 1.4.8、langchain-anthropic 1.4.8 和 langchain-openai 1.3.3。
先确认一下:你要找的是哪种“缓存”?
这两个毫不相关的功能都叫缓存,而搜索时最容易进入的 LangChain 文档页面,通常讲的不是你需要的那一种。
响应缓存(LangChain 的 InMemoryCache) | 提示词缓存(本系列主题) | |
|---|---|---|
| 存储内容 | 整个补全结果,保存在应用中 | prompt 前缀的 KV 状态,保存在服务商端 |
| 何时省钱 | 完全相同的请求再次出现 | 不同请求共享同一前缀 |
| 配置位置 | set_llm_cache(InMemoryCache())、SQLite、Redis | cache_control 标记或自动前缀匹配 |
| Agent 循环、RAG、聊天 | 几乎无用(每次请求都不同) | 主要优化手段,因为每轮都会重复发送 system prompt 和工具 |
这里的“完全相同”是严格意义上的完全一致:内置缓存使用(序列化 prompt、模型配置字符串)这一对值作为 key。实测中,完全相同的请求在 0 ms 内直接返回,没有发起 API 调用;prompt 多一个空格就会 miss;同一个 prompt 只把 max_tokens 改 1,也会 miss。(缓存重放还会返回原调用的 usage 数值,因此简单累加 token 会导致重复计数。)第三方集成中有语义缓存,但内置缓存只支持精确匹配。
因此,set_llm_cache 适合在测试中对完全相同的调用去重;至于 agent 每轮都要重新发送的 2,000-token system prompt,则应该交给提示词缓存,而且必须用正确的方式组装 prompt。
解决办法:使用内容块,而不是字符串
cache_control 位于内容块内部,因此 system message 必须使用内容为 block 的 SystemMessage,不能是裸字符串:
from langchain_anthropic import ChatAnthropic
from langchain_core.messages import SystemMessage
from langchain_core.prompts import ChatPromptTemplate
llm = ChatAnthropic(
model="claude-sonnet-5",
base_url="https://synthorai.io", # any Anthropic-compatible endpoint
)
prompt = ChatPromptTemplate.from_messages([
SystemMessage(content=[{
"type": "text",
"text": BIG_STABLE_SYSTEM_PROMPT,
"cache_control": {"type": "ephemeral"}, # a bare string has nowhere to put this
}]),
("human", "{question}"),
])
chain = prompt | llm
通过同一 gateway 测试相同的 1,800-token system prompt:
| 调用 | 字符串 tuple 语法 | 内容块语法 |
|---|---|---|
| 第 1 次(冷缓存) | 写入 0 / 读取 0 | 写入 1,875 / 读取 0 |
| 第 2 次,不同问题 | 写入 0 / 读取 0 | 写入 0 / 读取 1,875 |
热缓存读取的计费大约是输入价格的 10%。在 Claude 上,仅调整这一处结构,结果就从长期按全价付费,变成每次调用中固定部分都享受 90% 的折扣。具体成本计算见第 1 篇;标记机制则与 LangChain Anthropic 集成文档和 Anthropic 提示词缓存指南中的原生 SDK 用法一致。
模板变量放在哪里,决定缓存命中率
LangChain template 可以轻松地在任意位置插入变量,这也正是风险所在。缓存 key 是字节级完全一致的前缀。我们把日期放进缓存块中进行测试:
SystemMessage(content=[{
"type": "text",
"text": f"Today is {today}. " + BIG_STABLE_SYSTEM_PROMPT, # variable INSIDE the block
"cache_control": {"type": "ephemeral"},
}])
| 调用 | 结果 |
|---|---|
| 日期 A,问题 1 | 写入 1,865(该值首次出现,冷缓存) |
| 日期 A,问题 2 | 读取 1,865(值相同,命中) |
| 日期 B,问题 1 | 写入 1,865(新值,再次冷缓存) |
缓存没有失效,只是变量也进入了缓存 key。像日期这种会重复出现的值,每个值只需写入一次缓存,之后都能命中。时间戳或 request ID 这类每次调用都不同的值,会让每次请求都成为冷写入,命中率始终为零。
现实中代价最高的类似错误出现在 RAG 中。很多 chain 会把检索到的上下文放在 system prompt 顶部,位于固定指令之前。我们分别测试了两种顺序,每次查询都会更换 800-token 的检索上下文,并对 system block 加标记:
| prompt 内部顺序 | 调用 1 | 调用 2(新查询、新上下文) |
|---|---|---|
| 上下文在前,规则在后 | 写入 3,133 | 再次写入 3,133,读取 0 |
| 规则在前(已标记),上下文放在 human turn | 写入 1,852 | 读取 1,852 |
错误顺序不只是“没有折扣”:每次调用都要为全部 3,133 个 token 支付缓存写入溢价,约为正常输入价格的 1.25 倍,而且永远不会读取缓存。启用缓存但顺序错误的 RAG prompt,比完全不缓存还贵。 固定内容位于变化内容之后,因此实际上起不到任何作用。
实测可以归纳出以下规则:
- 静态文本放在最前面,并置于已标记的 block 内。 包括 system 规则、工具定义和 few-shot 示例。
- 所有变化内容都放在标记之后,最好放进 human turn:检索上下文、日期、用户问题。
- 只有当某个变量重复频率足以摊薄自身缓存写入成本时,才适合放在 block 内。
工具定义也会被缓存
Agent 每次调用都会重新发送工具 schema。在 Anthropic 的请求结构中,工具位于 system prompt 之前。标记的含义是“缓存从请求开头到此位置的全部内容”,因此需要确认两个实际问题:system block 上的标记是否也会覆盖它前面的工具?LangChain 的 bind_tools 是否能在每次调用中生成完全相同的字节?如果序列化结果有波动,前缀就会变化,每次调用都会 miss。
两个问题的实测答案都是肯定的。使用同一个带标记的 system prompt 时,不绑定工具的热缓存读取量为 1,861 个 token,绑定两个工具后则为 2,389 个 token:多出的 528 个 token 正是从缓存读取的工具 schema。连续三次调用都精确读取 2,389 个 token,说明 bind_tools 每次都能生成相同的序列化结果,framework 不会给前缀引入噪声。明确来说:只要 system block 带有标记,工具本身就不需要 cache_control;位于工具之后的这一个标记已经足够。
另一种配置适用于特定情况:工具是请求中最大的固定内容,而 system prompt 很短或者不存在。这时请求中仍然需要一个标记,可以把它放在工具上。由于 @tool 装饰的函数没有字段可以承载标记,因此这种方式只能使用原生 Anthropic 格式的 dict;bind_tools 会原样透传该 dict:
# variant: NO marked system block anywhere; the tool carries the request's only marker
llm.bind_tools([{
"name": "get_weather",
"description": LONG_TOOL_DESCRIPTION,
"input_schema": {...},
"cache_control": {"type": "ephemeral"}, # passes through bind_tools verbatim
}])
实测结果:请求中没有带标记的 system message,冷缓存写入 3,002,热缓存读取 3,002。
多轮对话:把标记移到最后一条消息
对话看起来也是一个顺序问题,实际情况正好相反:对话历史只会不断追加,所以顺序天然正确,整个 transcript 都是固定前缀。这里的问题在于覆盖范围。system block 上的标记只缓存 system block,不会缓存后面的内容。随着历史不断增长,热缓存读取量始终等于 system 的大小,所有累积的 turn 都按普通输入计费。
解决方式与原生 SDK 相同:把标记放在最新一条消息上,让断点不断向后移动,把截至当前的整段对话变成缓存前缀:
def marked(text):
return HumanMessage(content=[{
"type": "text", "text": text,
"cache_control": {"type": "ephemeral"},
}])
# each turn: history stays plain, only the newest human message carries the marker
llm.invoke([system, *history, marked(new_question)])
两轮实测结果:第 1 轮写入 1,864;第 2 轮读取 1,864,只写入新增的 15 个 token(上一轮回答加新问题),之前的前缀按约 10% 的读取价格计费。这正是 agent 循环需要的模式,而 LangChain 只需使用普通 message list 即可实现。Anthropic 每个请求最多允许四个标记,因此滑动标记可以与 system block 或工具上的固定标记组合使用。
正确读取指标,并认清字段名
LangChain 会把 usage 标准化为 usage_metadata,但这里有个容易踩的坑:在我们的所有测试中,使用 langchain-anthropic 1.4.8 时,即使实际发生了缓存写入,标准字段 input_token_details.cache_creation 仍然保持为 0。真实写入量位于一个非标准 key 中:
r = chain.invoke({"question": "..."})
det = r.usage_metadata["input_token_details"]
det["cache_read"] # correct on hits (1875 above)
det["cache_creation"] # 0 even on a cold write; do not alert on this
det["ephemeral_5m_input_tokens"] # the actual write count (1875)
服务商正确报告了写入量(原始响应中的 cache_creation_input_tokens: 1875,可通过 r.response_metadata["usage"] 查看);只是标准化映射把它放到了 TTL bucket 对应的 key 中。如果成本 dashboard 监控的是 cache_creation,它会显示缓存写入没有成本,而写入溢价其实一直在累积。要么以原始 usage 对象为准,要么明确了解各 bucket key。gateway 错报缓存字段也属于同类问题,详见你的 LLM Gateway 会谎报缓存数据吗?。
隐式缓存:顺序错误时不会报错,更需要重点监控
Claude 使用显式缓存。GPT 和大多数开源权重模型会根据前缀匹配自动缓存,不需要标记。通过 LangChain 使用时,只需更换 constructor,同一条 chain 就能工作:
llm = ChatOpenAI(model="glm-5.2", base_url="https://synthorai.io/v1")
使用普通字符串 system prompt,不加标记:GLM 5.2 第二次调用从约 1,850-token 的前缀中读取了 1,088 个 token。(不是全部:自动缓存通常按较粗的 block 粒度匹配,而不是逐字节匹配到结尾。例如 OpenAI 文档注明其粒度为 128 个 token。)目前看起来相当于白捡折扣。但前面 RAG 表格中的顺序风险在这里完全一样,而且失败方式更隐蔽。我们在自动缓存路径上重新进行了同样的顺序实验,每次调用都更换检索上下文:
| 顺序(无标记,自动缓存) | 调用 1 | 调用 2(新查询、新上下文) |
|---|---|---|
| 上下文在前,规则在后 | 读取 0 | 读取 0 |
| 规则在前,上下文放在 human turn | 读取 0 | 读取 1,088 |
顺序错误时必然是零命中:变化的上下文位于开头,任意两次调用都没有共同前缀,因此永远拿不到折扣。在显式缓存路径上,同样的错误至少会在账单中体现为每次调用的缓存写入溢价;在隐式缓存路径上,没有溢价、没有错误,也没有任何提示。prompt 只是悄悄地始终不符合缓存条件,而你可能还以为“自动”就等于“已经生效”。由于没有标记可放,prompt 顺序是隐式缓存路径唯一可调的参数。
因此,要通过指标验证生产环境中的实际命中情况,而不是只在测试中看一次:LangChain 查看 input_token_details.cache_read,原始响应查看 prompt_tokens_details.cached_tokens。OpenAI 自动缓存文档还规定了至少 1,024-token 的前缀要求;不同服务商的 TTL 和适用条件也不相同,这些内容见第 2 篇。
检查清单
- 在 Claude 上,
("system", "...")字符串 tuple 没有位置放置cache_control:不会缓存,也不会警告。可缓存的 system prompt 应使用带有内容块和标记的SystemMessage。 - 缓存 key 是字节级完全一致的前缀:静态内容放在前面,变量放在标记之后或 human turn 中。把 RAG 上下文放在规则之前,不仅会 miss,还会让每次调用都支付写入溢价。
- 缓存块内的变量会让每个值分别生成一个缓存项:重复值可以摊薄成本;每次调用都唯一的值(时间戳、request ID)永远无法命中。
- 工具位于前缀中 system prompt 之前,因此 system 标记也会缓存绑定的工具(
bind_tools的序列化是确定性的)。如果工具是最大的固定 block,可以改为在 Anthropic 格式的工具 dict 上放标记。 - 在对话中,如果标记固定在 system block 上,不断增长的历史仍会按全价计费;应把标记放到最新消息上,让每轮读取之前的前缀,只写入增量。
- 不要监控
input_token_details.cache_creation:即使发生写入,它仍然是 0,因此 dashboard 会显示缓存写入免费,而实际溢价一直在累积。真实计数位于ephemeral_5m_input_tokens,也可以读取原始response_metadata["usage"]。 - 对自动缓存模型(GPT、GLM、DeepSeek)来说,prompt 顺序是唯一可调参数,而且顺序错误时不会报错:没有溢价,没有错误,只是永远没有折扣。应通过 usage 字段验证命中情况。
set_llm_cache按精确匹配的 prompt 和模型配置存储完整响应;只有相同请求重复出现时才有价值,不适用于 agent 循环。
要养成的习惯很简单:用内容块代替字符串,把静态内容放在变量之前,让标记随对话向后移动,并读取正确的 usage 字段。实测结果的差距是:每个固定 token 享受 90% 折扣,或者完全没有折扣;在 RAG 顺序错误时,甚至还要额外付费。LangChain 并不妨碍提示词缓存,只是它让错误结构和正确结构写起来同样容易。
免责声明
测试于 2026-07-04 通过 https://synthorai.io/ 完成,使用 langchain-core 1.4.8、langchain-anthropic 1.4.8、langchain-openai 1.3.3,模型为 claude-sonnet-5 和 glm-5.2。测试使用约 1,800-token 的英文 system 前缀,样本量较小;连续调用间隔 1–2 秒,以便缓存写入完成。每组实验都使用全新生成的随机前缀,确保初始缓存为冷状态,因此不同表格中的基准 token 数量略有差异(1,852 到 1,875)。不同版本的库字段映射和服务商缓存行为可能变化;依赖这些数据之前,请在自己的技术栈上重新测试。