AI账单太贵?可能是你忽略了提示词缓存

0 阅读14分钟

开篇:那个让你心跳加速的账单

如果你正在运营一个每天处理数万次请求的AI应用,看到月度账单的那一刻,可能会有一种"AI正在面试我"的窒息感。

问题的根源在于:大模型处理输入token的算力成本(尤其是Prefill阶段的KV Cache计算)往往被低估,它可能和生成输出token一样消耗资源。更要命的是,你的系统提示词、那段5万字的企业知识库、那20个工具的定义——每次请求都在被重复计算。这些内容可能只占一次对话的10%,却贡献了90%的成本。

提示词缓存(Prompt Caching)就是为了解决这个痛点而生的。它让模型厂商可以把那些"不变的"计算结果存储起来,下次直接复用,而不用从头算起。

本质:KV Cache——模型厂商在底层做了什么

要理解提示词缓存,先要理解大模型推理的一个基本事实:Transformer的计算瓶颈在Prefill阶段(也叫初始化阶段或输入处理阶段)。

当一个5万字的文档进入模型时,模型需要逐token地通过注意力机制计算它的"表示"——这个表示在数学上是一系列矩阵,存储为K(Key)矩阵和V(Value)矩阵,这就是所谓的KV Cache。

关键问题是:这个计算的成本较高,但在特定条件下结果是可复用的。

比如你问:

  • "请总结这份财报的第三章"
  • "这份财报的毛利率是多少"

两次请求都带着同样的5万字文档,模型的Prefill阶段做的事是一样的——都要算一遍那5万token的KV矩阵。但如果第二次请求能直接复用第一次的计算结果,那Prefill就可以几乎完全跳过。

这就是提示词缓存的本质:存储KV Cache,按需复用。

从实现上看,模型厂商会把这些计算状态存储在GPU显存或高性能内存中,按缓存命中情况计费。以Gemini为例,命中缓存的读取价格可低至原价的一折

两种模式:隐式与显式

在深入聊技术实现之前,先要理清一个基础概念:提示词缓存从使用方式上分为隐式显式两种模式。

隐式模式:零配置的自动缓存

这是最简单的方式——你什么都不用做,模型厂商自动帮你搞定。

以OpenAI为代表,当输入的总Token数超过1024时,OpenAI的服务器会自动对输入前缀进行缓存。如果后续请求的前缀与已缓存的片段完全一致(即文本前缀精确匹配),即可命中缓存,享受折扣价格。

请求A: [系统提示词 1k] + [知识库 10k] + [问题1] → 服务器缓存了前11k
请求B: [系统提示词 1k] + [知识库 10k] + [问题2] → 命中!前11k直接复用

优点:零改造成本,开箱即用。

隐藏的限制:负载均衡陷阱

表面上看一切完美,但在高并发生产环境下,事情没那么简单。

OpenAI的分布式集群中,缓存是存在单台物理机的显存里的。如果负载均衡器把两个前缀完全相同的请求随机分配到了不同的服务器——请求A落在节点1,请求B落在节点2——那么即使内容一模一样,请求B依然会缓存未命中,因为节点2的显存里根本没有那份预计算状态。

换句话说:隐式缓存的命中率和负载均衡策略强相关,你无法假设"相同内容=必然命中"。

企业级破局方案:路由粘滞键(Sticky Routing Key)

在高并发场景下,有一种进阶优化思路:通过在请求中携带特定的路由标识,让网关将相同前缀的请求路由到同一台后端节点。

在支持此特性的AI网关中(如企业自建网关或部分企业级API网关),可以配置类似的路由策略:

headers = {
    "prompt_cache_key": "fin_doc_123"  # 网关基于此Key做一致性哈希
}

这种方案要求网关层具备请求级别的路由控制能力,且后端模型服务本身支持节点内的缓存复用。在OpenAI官方API中,并不原生支持客户端指定路由键,这更多是企业自建网关场景下的优化思路

如果你正在构建高并发AI服务,且使用企业级API网关,这个设计值得纳入架构考量。

对普通开发者的建议:如果你只是中小并发量,直接用官方SDK的隐式缓存即可,不用操心网关层的路由策略。但如果你有明确的长Token缓存需求(且愿意付出工程成本),往下看——显式模式可能是更可控的选择。

显式模式:开发者介入的精准控制

当你需要更精细地控制缓存行为时,就需要显式模式。目前主流的显式方案分为两类,分别代表了截然不同的设计哲学。

Anthropic的"书签模式"

Anthropic(Claude)的方案可以类比为"在阅读材料里夹书签"。

你不需要告诉模型"把这本书存起来",你只需要在某个位置放一个标记——用cache_control: {type: "ephemeral"}指明"到这里为止,请把前面的内容缓存起来"。

模型会自动计算从开头到书签位置的哈希值。下次请求时,它扫描你发送的内容,发现前缀哈希完全匹配,就直接命中缓存。

Anthropic支持在一次请求中最多设置4个书签,这意味着你可以分层缓存:

[系统提示词]      ← 第1个书签(最稳定)
[工具定义]       ← 第2个书签(偶尔变化)
[背景文档]       ← 第3个书签(较长周期不变)
[对话历史]       ← 第4个书签(持续增长)

在代码实现上,这个书签(cache_control)是挂载在具体Message对象的内部末尾的。它的作用域是"向后覆盖"——告诉模型:"请把当前消息块及之前的所有上下文打个快照。"

举一个具体的JSON结构:

{
  "system": [
    {
      "type": "text",
      "text": "你是一个财务分析助手...",
      "cache_control": {"type": "ephemeral"}  // ← 挂载在这里
    }
  ],
  "messages": [
    {
      "role": "user",
      "content": [
        {
          "type": "text",
          "text": "这是5万字的财报...",
          "cache_control": {"type": "ephemeral"}  // ← 第2个书签
        }
      ]
    }
  ]
}

即使对话在不断增长,前面的层级依然可以命中缓存。

这种方案的优点是零额外资源管理成本,模型自动处理匹配和生命周期。但它的TTL机制需要了解清楚:Anthropic提供两种缓存时长——默认5分钟(写入价格1.25倍),和可选的1小时(写入价格2倍)。5分钟缓存在每次命中后会重置TTL,因此持续对话场景下实际可用时间可能更长。

Gemini的"资源模式"

Gemini(Google)采用了完全不同的思路:先把内容存好,拿到一个ID,以后凭ID调用。

这更像是"把文档上传到云端,拿到一个提取码"。

# 第一步:创建缓存实体
cached_content = genai.create_cached_content(
    model="gemini-1.5-pro",
    contents=[...],  # 5万字的文档
    display_name="financial_report_2026",
    ttl=3600  # 1小时
)
# 返回: projects/123/.../cachedContents/abc-456

# 第二步:凭ID引用
response = model.generate_content(
    contents=[{"text""第三章的关键数据是什么?"}],
    cached_content="projects/123/.../cachedContents/abc-456"
)

这里的核心概念是CachedContent——它是Google云端的一个持久化资源,有自己的生命周期、存储计费、和唯一标识。

这意味着:

  1. 计费模型不同:缓存本身按存储时长收费(通常是每百万token每小时),读取时享受大幅折扣
  2. TTL可以很长:可以设置数小时甚至数天
  3. 跨请求共享:同一个ID可以被多个并发请求引用

代价是你需要管理这套"ID映射"的生命周期。

成本模型对比

维度OpenAI(隐式)Anthropic(书签模式)Gemini(资源模式)
写入成本无额外费用按专门的缓存写入费率计费(略高)按普通输入计费
读取成本原价的50%左右原价的10%左右可低至原价的10%
存储计费无(TTL内免费)按存储时长计费
典型TTL10-15分钟(厂商决定)分钟级小时级到天级
控制粒度无(厂商决定)最多4个书签位完全可控(ID引用)
适用场景大量重复前缀高频短对话、多轮推理低频长文档、跨用户共享

免责声明:以上折扣比例为基于各厂商公开定价的典型值,实际计费以官方最新定价为准。不同模型、区域可能存在差异。

LangChain的实现:中间件与分层抽象

上面我们聊了两种缓存范式的底层原理,那在实际工程中,如何用LangChain来落地?

Anthropic:中间件模式

LangChain官方提供了AnthropicPromptCachingMiddleware,你需要手动把它挂载到模型上:

from langchain_anthropic import ChatAnthropic
from langchain_anthropic.middleware import AnthropicPromptCachingMiddleware

model = ChatAnthropic(model="claude-3-5-sonnet-20241022")
model.bind(middleware=[AnthropicPromptCachingMiddleware()])

注意这里有个关键点:框架不会自动判断"在4个最佳位置打标签" 。你需要根据业务逻辑,手动决定在哪些消息后面挂载缓存Middleware。4个断点的最优分配,依然是架构师需要思考的问题。

Gemini:资源管理模式

Gemini的CachedContent是独立资源,需要先创建、再引用:

from langchain_google_genai import ChatGoogleGenerativeAI

# 实际使用时,你需要自己管理 Key -> ID 的映射
# LangChain 不会自动帮你做这个关联
cached_content = genai.cached_content_create(
    model="gemini-1.5-pro",
    contents=[...],
    display_name="financial_doc_v1",
    ttl=3600
)

model = ChatGoogleGenerativeAI(model="gemini-1.5-pro")
model.invoke(messages, cached_content=cached_content.name)

关于"统一抽象"的实话

LangChain目前并没有一个完美的、prompt_cache_key级别的统一抽象,能让你写一套代码自动适配所有厂商。

真实的工程现状是:

  • Anthropic:通过Middleware拦截消息,插入cache_control标记
  • Gemini:需要自己管理CachedContent资源的创建和引用
  • OpenAI:依赖隐式前缀匹配,无太多可编程空间

如果你在企业级项目中需要真正的"统一缓存层",大概率需要自己封装一层CacheManager,来处理不同厂商的差异。这不是LangChain的bug,而是AI SDK生态尚未成熟的真实写照。

理解这一点,有助于你在选型时做出更务实的决策。

工程实践:如何用好缓存

黄金法则:静态在前,动态在后

无论选择哪种缓存方案,"内容排列顺序"都是决定缓存命中率的核心因素。

核心原则:把最稳定的内容放在最前面,最易变的内容放在最后面。

好的实践:

[系统提示词]           ← 几乎不变,最适合缓存
[工具定义/函数签名]    ← 相对稳定
[长文档/知识库]        ← 周期性强,缓存价值高
[用户当前问题]         ← 每次必变,缓存不到

反模式:

# 灾难案例:时间戳放在开头
now = datetime.now()
system_prompt = f"""当前时间:{now}
你是财务分析助手...
"""
# 结果:整个5万字文档都无法命中缓存

# 正确做法:时间戳放在最后或动态注入
system_prompt = """你是财务分析助手...
"""
user_question = f"""请分析这份报表,重点关注{datetime.now()}以来的变化..."""

避坑指南:这些操作会摧毁缓存

  1. 在缓存前缀中使用动态变量:时间戳、随机数、用户实时数据放在Prompt前面,会导致整段哈希失配
  2. 忽略TTL机制:Gemini的缓存不会永久存在,当缓存过期后,ID依然存在但内容已销毁,此时会收到NOT_FOUND错误。需要在代码中处理这种"悬挂引用"
  3. 忽视长度门槛:各厂商通常要求缓存内容达到一定Token量才生效(通常1024-32768 Token不等)。太短的Prompt不值得缓存,因为哈希计算本身也有开销
  4. 跨请求携带状态:如果两个请求的内容"看起来差不多但差一个空格",哈希会完全不同

本地状态管理:Gemini场景下的真实工程成本

如果你选择了Gemini的资源模式,有一个绕不开的工程复杂度:你需要自己维护"业务Key到物理ID"的映射关系

LangChain不会帮你自动处理这个。实际项目中,你通常需要自己实现类似这样的逻辑:

# 伪代码:需要自己维护的映射表
class CacheManager:
    def __init__(self):
        self._local_map: dict[strstr] = {}  # business_key -> resource_id

    def get_or_create(self, business_key: str, content: str) -> str:
        # 1. 先查本地映射
        if resource_id := self._local_map.get(business_key):
            # 2. 先验证缓存是否有效,再发起业务请求
            if self._is_cache_valid(resource_id):
                return self._invoke_with_cached_content(resource_id)
            else:
                # 3. 缓存已失效,清除本地记录
                del self._local_map[business_key]

        # 4. 创建新缓存
        resource_id = genai.create_cached_content(...)
        self._local_map[business_key] = resource_id
        return resource_id

    def _is_cache_valid(self, resource_id: str) -> bool:
        """验证缓存是否仍然有效(不触发业务请求)"""
        # 实际实现:通过Gemini API查询该CachedContent的元数据
        # 返回True/False,不抛出异常
        ...

工程建议:将"缓存有效性校验"与"业务请求"分离。上面代码中,_invoke_with_cached_content只管发请求,不捕获异常;_is_cache_valid负责预检。如果混在一起,网络超时等非缓存相关的异常也会被误判为缓存失效。

此外,你还需要考虑:

  • 进程重启后本地映射丢失:第一个请求会触发重建,有额外延迟
  • 分布式场景:多个Pod之间需要共享映射(Redis或数据库)
  • TTL预判:不能等收到NOT_FOUND才重建,要提前主动刷新

这不是LangChain的缺陷,而是Gemini资源模式本身的设计决定的。选择这种模式,就要接受这套工程成本。

选型决策树

说明:以下选型建议仅从缓存特性出发。实际选型时,还需综合考虑模型能力、价格、生态支持、合规等因素。

什么场景选什么方案?

选择OpenAI(隐式模式)如果:

  • 你的业务天然有大量重复前缀(比如固定系统提示词+相似文档)
  • 你不想改动任何代码,只想"开箱即用"
  • 接受缓存行为由厂商黑盒控制

选择Anthropic(书签模式)如果:

  • 你做的是多轮对话或Agent推理,交互在秒级到分钟级完成
  • 你不想管理额外的缓存资源,只想专注业务逻辑
  • 你的Prompt结构相对简单,层级不超过4层

选择Gemini(资源模式)如果:

  • 你有超长文档(10万token以上),需要跨小时反复查询
  • 你需要在多个用户或多个服务之间共享同一份缓存
  • 你对成本极度敏感,愿意花工程成本换取更低单价

写在最后:缓存不是银弹

提示词缓存解决的是"重复计算"的问题,但它不是万能药。

它的适用前提是:你的业务确实存在足够多的重复输入

如果你的每个请求都是独一无二的(比如完全不同的用户问题),缓存的价值接近于零,反而浪费了工程的复杂度,得不偿失。

另一个需要警惕的是成本幻觉:缓存的存储和读取虽然单价低,但当你的缓存策略设计不当导致大量写入(比如TTL太短导致频繁重建),实际账单可能比不用缓存还贵。

真正优秀的AI架构师,不是"用了缓存",而是 "知道什么时候用、怎么用、怎么衡量ROI"


如果你在这个领域有更多的工程实践经验,欢迎交流。