工程实战

响应缓存层设计与命中率:怎么把重复请求的钱省下来

LLM 应用缓存层设计 讲过三大缓存策略的全景图:语义缓存、Prompt Caching、结果缓存。这篇文章只挑其中**结果缓存(响应缓存)**这一层往深了讲——怎么设计缓存 key、精确匹配和语义匹配怎么取舍、TTL 怎么定,以及命中率这个数字到底怎么算才有意义。如果你已经上线了响应缓存但命中率一直上不去,这篇的重点在”怎么改进”,不是”要不要做”。

一、响应缓存和 Prompt Caching 不是一回事

先厘清一个新手常混淆的概念:

  • Prompt Caching(Anthropic/OpenAI 官方功能):缓存的是输入 token 的 KV 状态,省的是每次请求里重复的那段系统提示/上下文的处理成本,每次调用模型依然会跑一次推理
  • 响应缓存(本文讲的):缓存的是完整的模型输出结果,如果命中,直接返回缓存内容,完全不调用模型

两者可以叠加使用,但作用层次完全不同。响应缓存的收益上限更高(命中即零成本、零延迟),但适用范围更窄——只有”输入高度相似、输出可以复用”的场景才适合,比如 FAQ 问答、固定模板的内容生成、重复查询的数据摘要。如果每次用户输入都天差地别(比如自由对话),响应缓存基本用不上。

二、缓存 Key 设计:归一化决定命中率上限

响应缓存的命中率在 key 设计阶段就已经定了大半,后面再怎么调参数补救空间都有限。核心思路是”让语义相同但字面不同的请求,映射到同一个 key”。

精确匹配 + 归一化

最简单也最可控的方式:对输入做归一化处理后取哈希作为 key。

import hashlib
import re

def normalize_and_hash(user_input: str, system_prompt_version: str, model: str) -> str:
    # 归一化:去首尾空白、合并多余空格、统一大小写(视场景决定是否需要)
    normalized = re.sub(r'\s+', ' ', user_input.strip())
    # key 必须包含影响输出的所有维度,不只是用户输入本身
    key_material = f"{model}|{system_prompt_version}|{normalized}"
    return hashlib.sha256(key_material.encode()).hexdigest()

容易被忽略的坑:key 只哈希了用户输入,没把 model 和 system_prompt_version 编进去。等团队升级了模型版本或改了 system prompt,缓存里还是旧版本的回答,用户拿到过时甚至错误的内容,自己却毫无察觉——这是响应缓存里最隐蔽也最常见的事故。

语义匹配(向量相似度)

归一化能处理”多个空格""大小写不同”这类表面差异,但处理不了”我想退货”和”怎么申请退款”这种语义相同、字面完全不同的请求。这时候需要向量相似度匹配:

def find_cached_response(query: str, threshold: float = 0.92):
    query_vec = embed(query)
    candidates = vector_db.search(query_vec, top_k=5)
    if candidates and candidates[0].score >= threshold:
        return candidates[0].cached_response
    return None

threshold 怎么定是整个方案的核心权衡:

threshold命中率风险
过高(如 0.98+)低,接近精确匹配几乎不会误命中,但收益有限
适中(0.90-0.95)中等,能覆盖同义改写需要人工抽样验证误命中案例
过低(0.85 以下)高误命中风险陡增——“怎么退货”和”怎么退款申请延期”可能被判定为相似,返回错误答案

实操建议:新上线语义缓存时从 0.95 起步,每周抽样 50 条命中记录人工检查是否文不对题,逐步下调阈值,不要一开始就冲着高命中率把阈值设得太低。

三、TTL 与失效策略:不是所有缓存都该长期存活

响应缓存的 TTL(存活时间)要按内容的时效性分类,一刀切的固定 TTL 是命中率优化里最常见的低级错误。

内容类型建议 TTL原因
静态知识问答(如”什么是 API 中转”)数周到数月答案基本不随时间变化
产品 FAQ、政策类内容数天到 1 周政策可能调整,但不频繁
涉及价格、库存、时效性数据分钟级或不缓存缓存命中反而是负收益,返回过期数据比重新调用模型更糟
用户个性化内容不缓存或按用户 ID 隔离缓存通用回答对个性化场景没有复用价值

主动失效比被动 TTL 更重要:当 system prompt 更新、知识库文档变更、或者模型版本升级时,应该主动清空受影响的缓存,而不是干等 TTL 过期。做法是把”内容版本号”作为缓存 key 的一部分(前面例子里的 system_prompt_version),版本号一变,旧 key 自动失效,不需要遍历删除。

四、命中率怎么算才有意义

“命中率 80%“这种单一数字经常是误导性的,因为它没有说清楚”80% 是相对于什么口径算的”。至少要拆成三个维度看:

  1. 全局命中率 = 命中次数 / 总请求次数——用于看整体成本节省效果,但掩盖了场景差异
  2. 分场景命中率 = 按请求类型(FAQ / 自由对话 / 数据查询)分别统计——能看出哪类场景值得投入优化,哪类天生不适合缓存
  3. 有效命中率 = 命中且未被人工标记为”文不对题”的次数 / 命中总次数——语义缓存必须跟踪这个指标,否则命中率数字好看,但实际上在批量返回错误答案

命中率持续监控、告警阈值设计这部分,Prompt Cache 命中率监控 那篇讲得更细,这里不重复。

五、存储选型:Redis 够用,不要一上来就上向量数据库

精确匹配场景,Redis/Memcached 这类 KV 存储完全够用,延迟低、运维简单。只有确定需要语义匹配时才引入向量数据库(Pinecone、Milvus、pgvector),因为向量检索的运维和成本都比 KV 存储高一个量级。

一个务实的演进路径:先上线精确匹配缓存(归一化 + 哈希),跑一段时间统计”语义相似但字面不同导致未命中”的请求占比,如果这部分占比确实值得优化(比如超过 15%),再引入向量匹配。不要一开始就假设需要语义缓存,很多场景精确匹配 + 归一化已经能拿到大部分收益。

六、相关阅读