🎁 新人 免费注册,送 10 次调用,最高 $1,免绑卡。
LLM Prompt Caching #5:真正能命中的 LangChain 配置

LLM Prompt Caching #5:真正能命中的 LangChain 配置

目录
  1. 先搞清楚:你要找的是哪种「缓存」?
  2. 修复方法:用 content block,别用字符串
  3. 模板变量放在哪里,决定了你的命中率
  4. 工具定义也会被缓存
  5. 多轮对话:把标记移到最后一条消息
  6. 读懂计量表,也要认识它们的名字
  7. 隐式缓存:顺序搞错时会静默失败,所以这里最需要盯紧
  8. 检查清单
  9. 免责声明
  10. 参考资料

下面这个 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}"),
])

我们用同一个 1800-token 的 system prompt 对 claude-sonnet-5 跑了两次,然后回读 usage 字段。两次调用都是:cache writes 0,cache reads 0。不是部分命中,也不是缓存碎片化,就是零。原因在于:Anthropic 只缓存你用 cache_control 标记的内容,而 ("system", ...) 元组里的纯字符串根本没地方放这个标记。LangChain 里最方便的写法,恰恰是把整份折扣全都白白丢掉的那一个,而且没有任何报错提示你。

TL;DR

  • LangChain 的 ("system", "string") 元组无法携带 cache_control,所以 Claude 什么都不会缓存:一个完全相同的 1,800-token 系统提示在 claude-sonnet-5 上实测缓存写入为 0、读取为 0。
  • 解决方法是使用带有内容块 cache_controlSystemMessage;系统块上的一个标记也会覆盖通过 bind_tools 绑定的工具。
  • langchain-anthropic 1.4.8 中,即便发生了真实的写入,input_token_details.cache_creation 仍为 0;真正的计数在 ephemeral_5m_input_tokens 里。
  • 一个顺序错误的 RAG 提示(在稳定规则之前放置变化的上下文)会在每次调用时支付约 1.25x 的缓存写入溢价:比完全不缓存还要昂贵。

这是缓存系列的第 5 篇。第 1 篇讲前缀缓存的工作原理,第 3 篇是原生 SDK 的教程。这一篇讲的是当 LangChain 替你拼装 prompt 时会发生什么变化。下面所有内容都是在 2026-07-04 通过 Synthorai 网关实测的,用的是 langchain-core 1.4.8、langchain-anthropic 1.4.8 和 langchain-openai 1.3.3。

先搞清楚:你要找的是哪种「缓存」?

有两个毫不相干的功能共用了这个词,而你搜索时点进去的那个 LangChain 文档页面,通常就是错的那个。

响应缓存(LangChain 的 InMemoryCachePrompt 缓存(本系列)
存的是什么整个 completion,存在你的应用里prompt 前缀的 KV 状态,存在服务商那边
什么时候省钱完全相同的请求重复出现不同请求共享同一个前缀
存在哪set_llm_cache(InMemoryCache())、SQLite、Rediscache_control 标记或自动前缀匹配
Agent 循环、RAG、聊天几乎没用(每个请求都不一样)核心手段,因为 system + tools 每一轮都会重复

而「完全相同的请求」是字面意义上的完全相同:内置缓存以(序列化后的 prompt,model-config 字符串)这个组合作为 key。实测:完全一样的重复请求会在 0 ms 内返回、不发起任何 API 调用;给 prompt 加一个空格就会 miss;同样的 prompt 只把 max_tokens 改动 1 也会 miss。(缓存重放还会返回原始调用的 usage 数字,所以简单粗暴地统计 token 会重复计数。)语义缓存以第三方集成的形式存在;内置缓存只支持精确匹配。

所以 set_llm_cache 用来在测试里对相同调用去重是没问题的;但你每一轮 agent 都要重发的那份 2000-token 的 system prompt,是 prompt 缓存该管的事,而它需要 prompt 用正确的方式拼装出来。

修复方法:用 content block,别用字符串

cache_control 是写在 content block 里的,所以 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

还是同一个 1,800 token 的 system prompt,走同一个网关测出来的结果:

调用字符串元组写法content block 写法
第 1 次(冷)write 0 / read 0write 1,875 / read 0
第 2 次,换个问题write 0 / read 0write 0 / read 1,875

命中的读取只按输入价格的约 10% 计费,所以在 Claude 上,这一个结构上的改动决定了你是永远付全价,还是每次调用里稳定的那一半能拿到 90% 折扣。费用账在 第一部分里;标记的具体机制和 LangChain Anthropic integration docsAnthropic 的 prompt caching guide 里裸 SDK 的用法一致。

模板变量放在哪里,决定了你的命中率

LangChain 模板让你可以毫不费力地在任意位置插值变量,问题恰恰就出在这里。cache key 是逐字节精确的前缀。我们把一个日期放进被缓存的 block 里,然后测量:

SystemMessage(content=[{
    "type": "text",
    "text": f"Today is {today}. " + BIG_STABLE_SYSTEM_PROMPT,   # variable INSIDE the block
    "cache_control": {"type": "ephemeral"},
}])
调用结果
A 天,问题 1write 1,865(对这个值来说是冷的)
A 天,问题 2read 1,865(同一个值,命中)
B 天,问题 1write 1,865(新值,又冷了)

缓存并没有失效,只是被这个变量当成了 key 的一部分。像日期这种会重复的值,每个值付一次 cache write,之后就能命中。而每次调用都唯一的值,比如时间戳或者 request ID,会让每次调用都变成冷写入,命中率正好是零。

这个错误在真实场景里最烧钱的版本是 RAG。很多 chain 顺手用的那种模板,会把检索到的 context 放在 system prompt 的最上面,排在静态指令前面。我们用一段每次查询都会变的 800 token 检索 context 加上一个带标记的 system block,测了两种顺序:

prompt 里的顺序第 1 次调用第 2 次调用(新查询,新 context)
context 在前,规则在后write 3,133又是 write 3,133,read 0
规则在前(带标记),context 放在 human 轮次write 1,852read 1,852

错误的那一行不只是「没折扣」这么简单:每次调用都要对全部 3,133 token 付 cache write 的溢价,大约是普通输入价格的 1.25 倍,而且一次都读不回来。一个顺序放错、又开了缓存的 RAG prompt,比完全不缓存还贵。 固定内容排在可变内容后面,等于压根不存在。

从测量结果里能得出的规则:

  • 静态文本放最前面,放进带标记的 block 里。 system 规则、工具定义、few-shot 示例。
  • 任何会变的内容都放在标记之后,最好放到 human 轮次里:检索到的 context、日期、用户问题。
  • 变量放在 block 里只有一种情况能接受:它重复得足够频繁,能把自己那次 cache write 的成本摊平。

工具定义也会被缓存

Agent 每次调用都会重新发送工具 schema。在 Anthropic 的请求布局里,工具排在 system prompt 前面。缓存标记的含义是「从请求开头到这里的所有内容都缓存」,于是有两个实际问题。system 块上的标记,能不能也覆盖到它前面的工具?还有,LangChain 的 bind_tools 每次调用是否都会把工具序列化成完全相同的字节?因为一旦序列化有波动,前缀就变了,每次调用都会 miss。

两个问题都有实测答案。用同一个带标记的 system prompt,warm cache 读取在不带工具时是 1,861 个 token,绑定两个工具时是 2,389 个 token:多出来的 528 个 token 就是从缓存里取回的工具 schema。而且这个 2,389 在连续三次调用里完全一致,说明 bind_tools 每次的序列化结果都相同,框架不会往前缀里掺入噪声。所以说清楚:只要 system 块带着标记,工具本身就不需要 cache_control,它后面那一个标记就把活全干了。

反过来的布局适用于一种特定情况:工具是你发送的内容里最大、最稳定的部分,而 system prompt 很小甚至没有。这时请求仍然需要在某处放一个标记,那就把它放到工具上。这只对原生 Anthropic 格式的 dict 有效,因为 @tool 装饰的函数没有字段能承载它;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
}])

实测:cold 写入 3,002,warm 读取 3,002,请求里没有任何带标记的 system 消息。

多轮对话:把标记移到最后一条消息

对话看上去也是个排序问题,其实正好相反:顺序本来就是完美的,因为历史只会往后追加,所以整段记录都是稳定前缀。这里的问题是覆盖范围。system 块上的标记只缓存 system 块,后面的都不缓存:随着历史增长,warm 读取始终停在 system 的大小,而每一轮累积的对话都按普通 input 计费。

解决办法和原生 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 用一个普通的消息列表就能表达出来。Anthropic 每个请求最多允许四个标记,所以这个滑动标记可以和 system 块或工具上的固定标记组合使用。

读懂计量表,也要认识它们的名字

LangChain 把用量统一收进 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)

Provider 自己上报的写入是对的(原始响应里 cache_creation_input_tokens: 1875,通过 r.response_metadata["usage"] 就能看到),只是标准化映射把它归到了 TTL 分桶的 key 下。一个盯着 cache_creation 的成本看板会告诉你缓存是免费的,而写入的溢价却在悄悄累积。要么信任原始的 usage 对象,要么记住这些分桶 key。这跟网关误报缓存字段是同一类问题,我们在 Does Your LLM Gateway Lie About Cache? 里做过审计。

隐式缓存:顺序搞错时会静默失败,所以这里最需要盯紧

Claude 的缓存是显式的。GPT 和大多数开源权重 provider 会在前缀匹配时自动缓存,不需要任何标记。在 LangChain 里,同一条链只改一处构造函数就能跑:

llm = ChatOpenAI(model="glm-5.2", base_url="https://synthorai.io/v1")

纯字符串 system prompt,没有标记:GLM 5.2 第二次调用读取了约 1850 个 token 前缀中的 1088 个。(不是全部:自动缓存按粗粒度的块增量匹配,而不是逐字节匹配到末尾;比如 OpenAI 文档里写的是 128-token 粒度。)到目前为止都是白赚的钱。但上面 RAG 表里那个顺序搞错的隐患在这里同样成立,而且失败模式更难缠。我们在自动路径上重跑了同样的顺序实验,每次调用都换新的检索上下文:

顺序(无标记,自动缓存)第 1 次调用第 2 次调用(新 query,新上下文)
先上下文,再规则read 0read 0
先规则,上下文放在 human turnread 0read 1,088

错误的顺序会无条件归零:变化的上下文放在最前面,任意两次调用都不共享前缀,折扣永远不会到。在显式路径上,同样的错误至少会体现在账单里,表现为每次调用都有缓存写入溢价;而在隐式路径上没有溢价、没有报错、也没有任何信号。prompt 就是静默地永远不达标,而你以为「自动」就等于「生效」。而且既然没有标记可放,prompt 顺序是隐式路径给你的唯一旋钮。

所以要靠计量表来验证,而且要在生产环境里持续验证,别只在测试里跑一次:input_token_details.cache_read(LangChain)或 prompt_tokens_details.cached_tokens(原始)。OpenAI 的自动缓存另外还规定了 1024-token 的最小前缀,而各 provider 的 TTL 和适用条件各不相同,这是第二部分要讲的内容。

检查清单

  • 在 Claude 上,("system", "...") 这种字符串元组没地方放 cache_control:什么都不会被缓存,也不会有任何提示。要让 system prompt 可缓存,得用带 content block 和标记的 SystemMessage
  • 缓存 key 是逐字节精确匹配的前缀:静态内容放最前面,变量放在标记之后或者放到 human turn 里。把 RAG context 放在规则前面,不仅命中不了缓存,每次调用还要额外付一次写入费。
  • 缓存块里放一个变量,每个取值就会产生一条独立的缓存项:重复出现的值可以摊薄成本,而每次调用都不一样的值(时间戳、请求 ID)永远命中不了。
  • Tools 在前缀里排在 system prompt 之前,所以 system 标记也会把绑定的 tools 一起缓存(bind_tools 的序列化是确定性的)。如果 tools 是你最大的稳定块,可以把标记放到 Anthropic 格式的 tool dict 上。
  • 在多轮对话里,把标记固定在 system 块上,不断增长的历史记录仍然按全价计费;应该把标记放在最新的消息上,这样每一轮都读取之前的前缀,只写入增量部分。
  • 别去监控 input_token_details.cache_creation:即使发生了写入,它也一直是 0,dashboard 会据此得出缓存免费的结论,可实际上写入费用一直在累积。真正的计数在 ephemeral_5m_input_tokens 里,或者直接读原始的 response_metadata["usage"]
  • 在自动缓存的模型上(GPT、GLM、DeepSeek),prompt 顺序是唯一能调的旋钮,顺序错了也是静默失败:没有额外费用,没有报错,只是那个折扣永远不来。命中情况要从 usage 字段里确认。
  • set_llm_cache 存的是完整的响应,key 是精确的 prompt 加模型配置;只有在完全相同的请求重复出现时才划算,agent 循环里根本用不上。

这些习惯都很小:用 content block 而不是字符串、静态内容放在变量前面、让标记随对话一起滑动、正确读一个 usage 字段。实测的差别是:每个稳定 token 拿到 90% 折扣,或者什么都拿不到;而在 RAG 顺序放错的情况下,是拿到折扣还是反过来多付钱。LangChain 并不会妨碍 prompt caching,它只是让写错 prompt 结构和写对一样容易。


免责声明

本文数据于 2026-07-04 针对 https://synthorai.io/ 测得,使用 langchain-core 1.4.8、langchain-anthropic 1.4.8、langchain-openai 1.3.3,模型为 claude-sonnet-5glm-5.2,system 前缀约 1,800 个英文 token,样本量较小,连续两次调用之间留了 1–2 秒的间隔以便缓存写入落地。每个实验都用一个全新的随机前缀来保证冷缓存,所以各表之间的基线 token 数会略有出入(1,852 到 1,875)。库的字段映射和厂商的缓存行为在不同版本间会变;在依赖这些数字之前,先在你自己的技术栈上重新测一遍。

参考资料

← 返回博客