← 返回资讯

LLM 应用层缓存策略:降成本与提速的系统设计

2026-06-29

上周排查一个客服机器人的账单,发现同一批用户问的问题翻来覆去就那几十种变体,但每次都老老实实地重新调一次 GPT-4o——一个月光这一个场景就烧掉小几千块。这就是没做缓存的典型代价:LLM 调用是按 token 计费的,同样的问题(哪怕只是换了个说法)如果每次都从头推理,钱和延迟都是白花的。

LLM 应用的缓存和传统缓存有根本区别:传统缓存要求 key 完全一致才命中,而 LLM 场景里输入不完全相同但语义相近的问题,也值得复用回答——用户问”退货要多久”和”退款几天到账”,措辞不同但答案可以是同一份。合理的三层缓存体系可将实际 API 调用量减少 40%70%,同时把命中请求的延迟从几百毫秒几秒压到几十毫秒以内。下面这套架构不是纸上谈兵,是按”先上最简单能落地的、再逐步加复杂度”的顺序设计的,你可以只上第一层就见效,也可以三层全部叠加。

三层缓存架构

用户请求

  ▼ 层1:精确匹配缓存(Redis / 内存)
  │  命中率:5~20%,延迟:<1ms
  ▼ 层2:语义相似缓存(向量检索)
  │  命中率:20~50%,延迟:10~50ms
  ▼ 层3:Prompt Caching API(供应商侧)
  │  命中率:取决于 prompt 重叠度,折扣:50~90%
  ▼ 实际 LLM 调用(缓存未命中)

每层各有适用场景,组合使用效果最佳。判断该上哪一层,别凭感觉,看两个指标:一是”这个请求会不会被大量用户问到几乎一样的内容”(决定要不要上层1/层2),二是”这个请求的 prompt 前缀是否固定不变”(决定值不值得用层3)。客服 FAQ 两个条件都满足,三层全上;代码生成两个条件都不满足,缓存基本白搭,别硬凑。

第一层:精确匹配缓存

对完全相同的输入直接返回缓存结果,适合 FAQ、固定报表等确定性场景:

import hashlib
import json
import redis

cache = redis.Redis(host="localhost", port=6379, decode_responses=True)

def get_cache_key(messages: list, model: str) -> str:
    """对 messages 和 model 做 hash,作为缓存 key"""
    content = json.dumps({"messages": messages, "model": model}, sort_keys=True)
    return f"llm:exact:{hashlib.sha256(content.encode()).hexdigest()[:16]}"

async def cached_llm_call(messages: list, model: str, ttl: int = 3600) -> str:
    key = get_cache_key(messages, model)

    # 查缓存
    cached = cache.get(key)
    if cached:
        return cached  # 缓存命中,直接返回

    # 缓存未命中,调用 LLM
    response = await call_llm(messages, model)
    result = response.choices[0].message.content

    # 写缓存,设置 TTL
    cache.setex(key, ttl, result)
    return result

TTL 设置建议:事实性问答 24h、价格/库存等实时数据 5min、固定文案 7天。

这段代码里有两个容易被忽略、但线上真的会踩的细节:

  1. json.dumps(..., sort_keys=True) 里的 sort_keys=True 千万别删。如果不排序,Python 字典 {"a": 1, "b": 2}{"b": 2, "a": 1} 序列化出来的字符串不一样,hash 出来的 key 自然也不一样——同样的 messages 只是字段顺序不同(比如上游服务重构后字典构造顺序变了),缓存就再也命中不了,你还看不出哪里错了,只会发现”缓存命中率突然掉到 0 但代码没改”。我在一次上游微服务升级后就中过这个招,排查了小半天才发现是字典顺序变了。
  2. sha256(...).hexdigest()[:16],只取前 16 位而不是完整的 64 位,是为了让 key 短一些方便在 Redis 里存和查,16 位十六进制等于 64 位信息熵,实际业务量级下(哪怕日调用百万级)hash 碰撞概率也是天文数字级别的低,不用担心。但如果你的场景是金融、医疗这类对”绝对不能返回不该返回的答案”要求极高的场景,建议保留完整 64 位或直接把原始 messages 存成 Redis 的 field 名(牺牲一点存储换绝对准确)。

缓存系统的三个经典坑,LLM 场景一样会中招:

  • 缓存穿透:大量请求查询一个根本不存在的(或者故意构造的边界)key,缓存和数据源都没有结果,每次都要真的调一次 LLM。防法:对确定不存在的结果也缓存一个空值(短 TTL,比如 60s),避免同一个恶意/异常请求反复穿透到后端。
  • 缓存击穿:某个爆款 key(比如首页大促文案生成)过期的瞬间,成千上万个并发请求同时发现缓存失效,一拥而上全部去调用 LLM,直接把配额和延迟打爆。防法:用分布式锁或者 asyncio.Lock 做 “single flight”——同一个 key 只放一个请求去真正调用 LLM,其他并发请求等这个结果写回缓存后直接读缓存,而不是每个都自己去调一次。
  • 缓存雪崩:大量 key 在同一时刻集中过期(比如你在代码里统一写死了 ttl=3600,一批 key 是同一批时间写入的,就会同一时刻集中失效)。防法很简单:写入时给 TTL 加一点随机抖动,比如 ttl=3600 + random.randint(0, 300),把过期时间打散开,避免同一秒钟内缓存集体清零导致请求洪峰全部打到 LLM 上。

这三个问题在传统 Web 缓存里是基本功,但很多团队第一次做 LLM 缓存时会忘记——因为”调用 LLM 很贵、很慢”这件事把问题放大了:普通缓存击穿最多是数据库扛不住,LLM 缓存击穿是账单和延迟同时爆炸。

第二层:语义相似缓存

对语义相近(但措辞不同)的问题复用历史回答:

import numpy as np
from openai import OpenAI

client = OpenAI()

class SemanticCache:
    def __init__(self, similarity_threshold: float = 0.92):
        self.threshold = similarity_threshold
        self.embeddings: list[np.ndarray] = []
        self.responses: list[str] = []
        self.queries: list[str] = []

    def _embed(self, text: str) -> np.ndarray:
        resp = client.embeddings.create(
            model="text-embedding-3-small", input=text
        )
        return np.array(resp.data[0].embedding)

    def get(self, query: str) -> str | None:
        if not self.embeddings:
            return None
        q_emb = self._embed(query)
        # 计算余弦相似度
        similarities = [
            np.dot(q_emb, e) / (np.linalg.norm(q_emb) * np.linalg.norm(e))
            for e in self.embeddings
        ]
        best_idx = int(np.argmax(similarities))
        if similarities[best_idx] >= self.threshold:
            return self.responses[best_idx]
        return None

    def set(self, query: str, response: str):
        self.embeddings.append(self._embed(query))
        self.responses.append(response)
        self.queries.append(query)

相似度阈值选择:0.95+ 很保守(几乎只命中近似重复);0.88~0.92 适合 FAQ 场景;低于 0.85 容易返回不相关缓存,慎用。

这套实现有个明显的扩展性上限,你得知道它在哪: 上面代码里 self.embeddings 是个 Python list,get() 里对每一条历史 embedding 做一次余弦相似度计算,是 O(n) 的线性扫描。缓存条目在几千条以内没什么问题,实测几百到一千条时单次查询也就几毫秒;但如果你的 FAQ 库涨到几万条、几十万条,线性扫描的耗时会随条目数线性增长,语义缓存本来是为了”降延迟”,结果自己反而成了延迟大户,这就本末倒置了。到了这个规模该换成向量数据库或本地 ANN(近似最近邻)索引,比如用 FAISS 建 HNSW 索引,或者直接上 pgvector / Qdrant / Milvus 这类专门的向量库,把 O(n) 降到近似 O(log n)。判断标准很简单:缓存条目预计会不会超过 5000~10000 这个量级,超过就别用 list 硬扛。

真实踩坑案例:阈值设错,缓存返回了”看起来对但其实不对”的答案。 有次我们把阈值设到 0.85,上线后有用户反馈”问的是退货政策,机器人却回答了退款政策”——两句话在语义空间里离得确实很近(都在讲”钱/货”相关的售后流程),但业务含义完全不同。排查思路:先打日志记录每次命中时的 query、命中的历史 query、以及相似度分数,出问题后拉出日志一看,命中分数刚好卡在 0.85~0.88 之间——这段区间就是”像但不完全对”的重灾区。后来把阈值提到 0.91,同时把 FAQ 场景里容易混淆的问题(退货 vs 退款、注册 vs 登录这类)做了专门的关键词兜底判断,双保险后才把误命中压下去。教训是:阈值不是拍脑袋定的,要拿真实历史查询日志跑一遍相似度分布,看清楚”容易混淆的语义边界”卡在哪个分数段,再决定阈值往哪边偏。

第三层:Prompt Caching API(供应商原生)

OpenAI、Anthropic 等供应商提供 Prompt Caching:对重复的系统 prompt 或长上下文前缀,只需首次计费,后续命中折扣 50%~90%:

# OpenAI 自动缓存:系统 prompt 超过 1024 token 时自动生效
# 无需额外配置,在 usage 中体现 cached_tokens

response = client.chat.completions.create(
    model="gpt-4o",
    messages=[
        {
            "role": "system",
            # 长系统 prompt(>1024 token)自动触发缓存
            "content": LONG_KNOWLEDGE_BASE_CONTENT
        },
        {"role": "user", "content": user_question}
    ]
)
print(response.usage.prompt_tokens_details)
# {'cached_tokens': 8192, 'audio_tokens': 0}
# cached_tokens 部分按 50% 折扣计费

# Anthropic Claude:需在请求中显式标记缓存点
messages = [
    {
        "role": "user",
        "content": [
            {
                "type": "text",
                "text": LONG_DOCUMENT,
                "cache_control": {"type": "ephemeral"}  # 标记缓存点
            },
            {"type": "text", "text": user_question}
        ]
    }
]

最大化 Prompt Caching 命中率的技巧:把稳定内容(系统 prompt、知识库、工具定义)放在消息列表最前面;把变化内容(用户问题)放在最后。缓存是前缀匹配,前缀越稳定命中率越高。

这里有个反直觉但很关键的取舍点:Prompt Caching 不是免费的,写入缓存本身要多付钱。 以 Anthropic 的机制为例,首次把内容写入缓存(cache write)的价格通常比不缓存的正常输入价格更贵(官方文档里给出的量级大约是基础输入价的 1.25 倍左右,具体倍率以官方定价页为准),只有后续命中(cache read/hit)才享受大幅折扣。这意味着:如果你的系统 prompt 只会被调用一两次就再也不会复用(比如每个用户的系统 prompt 都是动态拼接、几乎不重复),那开缓存反而更贵,因为你多付了写入的溢价却没机会吃到命中折扣。Prompt Caching 划算的前提是这段前缀会被高频复用——至少几十次以上的复用次数,才能把写入的溢价摊薄。

另一个真实踩过的坑:缓存点标记对了,但命中率还是 0。 现象是代码里明明写了 cache_control: {"type": "ephemeral"},但 usagecache_read_input_tokens 一直是 0、cache_creation_input_tokens 却每次都不为 0(说明每次都在”重新创建”缓存,从没命中过)。排查下来最常见的原因是:前缀内容不是逐字节完全一致。哪怕只是知识库文本里多了一个空格、当前日期被拼接进了本该稳定的系统 prompt、或者工具定义的 JSON 序列化顺序变了,都会导致前缀”看起来一样、实际不一样”,缓存直接判定为未命中,重新创建。修法:把会变化的内容(时间戳、用户 ID、随机数)严格放到消息列表最后、缓存点之后;缓存点之前的内容做一次哈希对比测试,确保两次请求真的字节级完全相同。另外要注意 Anthropic 的缓存有效期是有窗口的(有 5 分钟和 1 小时两档可选,具体以官方文档为准),如果两次请求间隔超过缓存窗口,自然也不会命中,这种情况不是 bug,是过期了。

并发场景下怎么进一步压榨缓存收益? 如果你的应用是高并发的 API 网关(比如同时服务几十上百个用户请求),单纯靠用户自然的请求节奏去命中 Prompt Caching 效率有限,因为缓存窗口是分钟级的,请求间隔一长就失效。这时候一个实用技巧是”预热”:在缓存窗口快到期前(比如提前 30 秒),主动发一个轻量的心跳请求把系统 prompt 的缓存续上,避免真实用户请求撞上缓存刚好过期的那个瞬间。这个技巧只在你的 QPS 和缓存价值都比较高时才划算,日调用量小的场景不值得为此增加复杂度。

缓存策略选型矩阵

场景推荐层次预期收益
FAQ / 客服标准问题精确 + 语义命中率 60%+,成本降 50%+
RAG 问答(系统 prompt 固定)Prompt Caching输入 token 成本降 40~60%
代码生成(高随机性)不缓存或 TTL 极短收益有限,缓存过期成本高
报表/批量处理精确缓存相同查询直接命中,成本接近零

常见问题

缓存了过时的回答怎么办? 精确缓存设合理 TTL,并提供”强制刷新”接口(在请求中加 force_refresh=true 跳过缓存);语义缓存维护版本号,知识库更新时清空对应版本的缓存。

语义缓存的 embedding 调用本身也要成本,值得吗? text-embedding-3-small 单次调用约 $0.00002,而 gpt-4o 一次对话平均 $0.01~0.05。命中率达到 30% 时语义缓存已开始盈利。建议先上精确缓存(零额外成本),语义缓存在请求量达到日均 1000+ 时再评估引入。

多用户场景下缓存隔离怎么做? 在 cache key 中加入隔离维度:全局共享内容(FAQ)不加用户 ID;个人定制内容(个人偏好、私有知识库)在 key 中加 user_id。避免 A 用户的个人数据被 B 用户缓存命中。


← 返回 应用模式总览:从 Prompt 到 Agent | 应用模式专题

相关阅读:调用失败兜底设计 · 应用可观测与日志

各供应商 Prompt Caching 规则不同、难以统一管理?力达云聚合 API 抹平各家缓存策略差异,统一接口自动最大化缓存命中,降低接入复杂度。