缓存是 LLM 工程里最诱人的优化:它同时降低成本与延迟,而且看起来实现简单——存一份问答对,下次命中直接返回。但缓存也是失败后果最隐蔽的优化:命中率从 60% 掉到 8% 时,接口成功率、延迟指标全都不变,只有账单悄悄上涨,没有任何告警会触发;而语义缓存一旦误命中,「如何开启双因素认证」被答成「如何关闭双因素认证」,用户拿到的是自信满满的错误答案。
本文聚焦缓存的机制设计,与两条相邻线索划清边界:成本侧的整体降本排序与单位经济核算见 推理成本核算与 FinOps ,KV Cache 的显存结构与 PagedAttention 内部机制见 KV Cache 管理与前缀缓存 。本文回答的是:缓存键怎么设计、阈值怎么定、误命中怎么防、多租户怎么隔离、命中率怎么算。
理解缓存要先区分两个层次:结果缓存(缓存最终答案,命中即返回,省下整条链路)与前缀缓存(缓存计算过程,命中仍需继续生成,但省下前缀的重复计算)。前者优化的是「调用次数」,后者优化的是「单次调用的成本」。两者机制、收益模型、风险都不同,必须分开设计。
目录
- 缓存的四个层次
- 精确缓存与缓存键设计
- 语义缓存的原理与阈值设定
- 误命中的风险与多层防护
- Prompt Caching 与前缀缓存机制
- KV Cache 复用与多副本一致性
- 缓存失效策略
- 命中率与成本测算
- 多租户隔离与隐私
- 生产落地清单
- 权衡取舍
- 常见坑清单
- 小结
1. 缓存的四个层次
从「最精确」到「最模糊」,缓存可以分四层,收益与风险同步递增:
| 层次 | 匹配方式 | 命中收益 | 误命中风险 | 实现成本 |
|---|---|---|---|---|
| 前缀缓存 | 前缀 token 完全相同 | 省前缀计算 | 无 | 低(引擎内置) |
| 精确缓存 | 请求完全相同 | 省整次调用 | 无 | 低 |
| 归一化缓存 | 归一化后相同 | 省整次调用 | 低 | 低 |
| 语义缓存 | 嵌入相似度超阈值 | 省整次调用 | 高 | 中高 |
四层的适用场景不同:
- 前缀缓存几乎零风险、零误判,只要系统提示固定且长,就应该无条件开启。它是投入产出比最高的一层。
- 精确缓存适合「完全相同的问题反复出现」的场景,如 FAQ、状态查询。键的构造必须包含所有影响输出的因素。
- 归一化缓存在精确缓存基础上做大小写、空白、标点归一化,命中率提升明显且风险可控。
- 语义缓存命中率最高,但必须接受误命中风险,需要额外的防护层。
设计原则是:从低风险层往高风险层做。先把前缀缓存与精确缓存做满,再考虑语义缓存。很多团队直接上语义缓存,结果误命中频发,反而要把阈值调高到几乎不命中,白白增加了复杂度。
2. 精确缓存与缓存键设计
精确缓存的成败几乎完全取决于键的设计。一个不完整的键会导致「命中不该命中的缓存」,这比不命中更糟。
影响输出的全部因素都必须进键:
| 因素 | 是否必须进键 | 原因 |
|---|---|---|
| 用户输入 | 是 | 最基本 |
| 系统提示版本 | 是 | 提示改了答案就该变 |
| 模型与版本 | 是 | 不同模型输出不同 |
| 温度等采样参数 | 是 | 温度 0 与 1 结果分布不同 |
| 检索上下文哈希 | 是 | RAG 场景上下文变了答案就变 |
| 租户 ID | 是 | 隔离与安全要求 |
| 工具结果 | 视情况 | 若注入上下文则必须 |
import hashlib
import json
def cache_key(payload: dict) -> str:
"""构造精确缓存键:所有影响输出的因素都要进键"""
canonical = json.dumps({
"input": payload["input"].strip(),
"system_prompt_version": payload["system_prompt_version"],
"model": payload["model"],
"temperature": payload["temperature"],
"top_p": payload["top_p"],
"context_hash": payload.get("context_hash", ""), # 检索上下文指纹
"tenant": payload["tenant_id"],
}, sort_keys=True, ensure_ascii=False)
return "llm:exact:" + hashlib.sha256(canonical.encode()).hexdigest()
三个容易漏的因素:采样参数(温度不同结果不同,但很多实现忘了进键)、上下文哈希(RAG 场景下同一个问题在不同文档集下答案不同)、系统提示版本(提示迭代后旧缓存必须失效)。
上下文哈希的构造是 RAG 场景的关键:把检索返回的文档 ID 列表排序后哈希,任何一篇文档变化都会让哈希变化,从而让缓存自动失效。
def context_hash(doc_ids: list[str], doc_versions: dict[str, str]) -> str:
"""把文档 ID 与版本拼起来哈希,任一变化即失效"""
payload = "|".join(f"{d}:{doc_versions.get(d, 'v0')}" for d in sorted(doc_ids))
return hashlib.sha256(payload.encode()).hexdigest()[:16]
3. 语义缓存的原理与阈值设定
语义缓存用嵌入向量的相似度代替字符串相等:把用户问题编码成向量,在缓存库里做最近邻检索,如果最近的历史问题相似度超过阈值,就返回它的答案。它本质上是把缓存问题转化成了向量检索问题,因此其工程细节与向量索引的调优密切相关;语义缓存与语义路由共用同一套嵌入与检索设施,可以放在一起设计,参考 语义缓存与路由 。
import numpy as np
def semantic_lookup(query_vec: np.ndarray, index, threshold: float = 0.95):
"""在语义缓存里找相似问题,返回 (命中的答案, 相似度) 或 None"""
hits = index.search(query_vec, limit=1) # 最近邻
if not hits:
return None
cached_vec, answer = hits[0]
sim = float(np.dot(query_vec, cached_vec) /
(np.linalg.norm(query_vec) * np.linalg.norm(cached_vec)))
if sim >= threshold:
return answer, sim
return None
阈值是语义缓存唯一的核心旋钮,它直接决定「命中率」与「误命中率」的权衡:
| 阈值 | 命中率 | 误命中率 | 适用场景 |
|---|---|---|---|
| 0.90 | 高 | 高 | 宽松场景(闲聊、常识) |
| 0.95 | 中 | 中 | 通用客服 |
| 0.97 | 中低 | 低 | 事实性问答 |
| 0.99 | 低 | 极低 | 强合规场景 |
阈值不能拍脑袋定,必须用标注数据扫出来。做法是构造一批「问题对」,标注每对是「同一意图」还是「不同意图」,然后扫描阈值,观察「正确命中率」与「错误命中率」随阈值的变化曲线,找拐点。
def sweep_threshold(pairs, embed, threshold_values):
"""pairs: [(q1, q2, is_same_intent)],返回每个阈值的准确率与错误命中率"""
rows = []
for th in threshold_values:
tp = fp = tn = fn = 0
for q1, q2, same in pairs:
v1, v2 = embed(q1), embed(q2)
sim = float(np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2)))
hit = sim >= th
if same and hit:
tp += 1
elif not same and hit:
fp += 1
elif same and not hit:
fn += 1
else:
tn += 1
precision = tp / max(tp + fp, 1) # 命中的里面有多少是对的
recall = tp / max(tp + fn, 1) # 该命中的里面命中了多少
rows.append({"threshold": th, "precision": round(precision, 4),
"recall": round(recall, 4), "fp": fp})
return rows
if __name__ == "__main__":
for row in sweep_threshold([], None, [0.90, 0.93, 0.95, 0.97, 0.99]):
print(row)
经验数据:在中文客服数据集上,阈值 0.95 通常能到约 0.92 的精确率与 0.55 的召回率;提到 0.97,精确率升到 0.98 但召回率降到 0.30。精确率(不误命中)比召回率(多命中)重要得多,因为一次误命中的用户伤害远大于十次未命中的成本节省。因此阈值宁高勿低。
一个必须记住的陷阱:语义相似不等于意图相同。以下三对问题向量相似度都极高,但答案完全相反:
| 问题 A | 问题 B | 相似度 | 意图关系 |
|---|---|---|---|
| 如何开启双因素认证 | 如何关闭双因素认证 | 0.95+ | 相反 |
| 怎么取消订阅 | 怎么续订 | 0.93+ | 相反 |
| 密码忘了怎么办 | 密码怎么改 | 0.94+ | 相近但不同 |
这类「否定词/反义词」是语义缓存的天然盲区,因为嵌入模型对否定与反义的区分能力有限。
4. 误命中的风险与多层防护
既然误命中无法从阈值上完全消除,就必须用多层防护兜住。
防护一:意图分类入键
在缓存键里加入意图分类的结果,让不同意图的问题落在不同的缓存空间:
def guarded_key(query_vec, intent: str, threshold: float) -> str:
"""把意图与阈值纳入缓存命名空间,避免跨意图误命中"""
ns = f"sem:{intent}:{threshold:.2f}"
return ns + ":" + hashlib.sha256(query_vec.tobytes()).hexdigest()[:16]
意图由一个小分类器或关键词规则给出(如「开启/关闭」「取消/续订」这类反义对直接判为不同意图)。这一步能把上面表格里的三对反义问题全部拆开。
防护二:高风险领域禁用
对金额、法律、医疗、账户安全等高风险领域,直接关闭语义缓存,只保留精确缓存。这些领域的误命中后果不可接受,省下的钱不值得。
防护三:命中后轻量校验
缓存命中后,用一个小模型判断「缓存答案是否真的适配当前问题」:
def verify_hit(query: str, cached_query: str, answer: str, judge) -> bool:
"""轻量校验:缓存问题与原问题是否同意图"""
verdict = judge.chat(
system="判断两个问题是否在问同一件事,只回答 SAME 或 DIFFERENT。",
user=f"问题1:{query}\n问题2:{cached_query}",
temperature=0.0,
).strip().upper()
return verdict == "SAME"
这层校验增加一次小模型调用(约 5 到 20 毫秒),但仍远快于一次完整的主模型调用。它把误命中率再降一个数量级,是「语义缓存用于生产」的关键一步。
| 防护层 | 额外延迟 | 误命中降幅 | 实施成本 |
|---|---|---|---|
| 阈值调高 | 无 | 中 | 无 |
| 意图分类入键 | 5 - 15 ms | 大 | 低 |
| 高风险禁用 | 无 | 完全 | 无 |
| 命中后校验 | 5 - 20 ms | 大 | 低 |
推荐组合:阈值 0.95 到 0.97、意图入键、高风险禁用、命中后校验。四层叠加后,误命中率能压到千分之一以下。
5. Prompt Caching 与前缀缓存机制
Prompt Caching(前缀缓存)是另一种缓存,它缓存的是计算过程而非结果。当多次请求的前缀(通常是系统提示加少样本示例)完全相同时,服务端可以复用这段前缀的 KV Cache,跳过重复的 prefill 计算。
它的机制是:把输入按「缓存断点」切分,断点之前的 token 序列被计算一次并缓存,后续请求若前缀匹配则直接复用,只对断点之后的部分做 prefill。
请求 1: [系统提示 3000 token][用户问题 A]
请求 2: [系统提示 3000 token][用户问题 B]
└──── 相同前缀,命中缓存 ────┘
命中后:只对 [用户问题 B] 做 prefill,前 3000 token 直接读缓存
主流供应商的 Prompt Caching 计费口径:
| 供应商 | 缓存写入 | 缓存命中 | 最短可缓存长度 | TTL |
|---|---|---|---|---|
| Anthropic | 1.25× 基础价 | 0.1× 基础价 | 1024 token | 5 分钟(可续) |
| OpenAI | 无额外写入费 | 0.5× 基础价 | 1024 token | 5 - 10 分钟 |
| Google Gemini | 按存储时长计费 | 0.25× 基础价 | 32768 token | 可配置 |
命中价通常是基础价的 0.1 到 0.5 倍,写入价在 1.0 到 1.25 倍之间。这意味着:前缀缓存的收益取决于「同一前缀被复用的次数」。复用 2 次,写入成本被摊薄,净收益为正;复用 1 次(写完就没再用),反而比不缓存更贵。
收益的盈亏平衡计算:
def prompt_cache_breakeven(hit_price_ratio: float = 0.1,
write_price_ratio: float = 1.25) -> float:
"""返回前缀缓存的盈亏平衡复用次数"""
# 不缓存:n 次都按 1.0 计费 -> n
# 缓存: 1 次写入 1.25 + (n-1) 次命中 0.1
# 平衡: n = 1.25 + 0.1 * (n - 1)
# n * 0.9 = 1.15 -> n = 1.28
return (write_price_ratio - hit_price_ratio) / (1 - hit_price_ratio)
if __name__ == "__main__":
print(f"盈亏平衡复用次数: {prompt_cache_breakeven():.2f}") # 约 1.28 次
结论是:只要同一前缀在 TTL 内被复用超过约 1.3 次,前缀缓存就划算。绝大多数「系统提示固定且长」的场景都远超这个门槛,因此前缀缓存应该无条件开启。
6. KV Cache 复用与多副本一致性
自建推理时,前缀缓存由引擎实现(vLLM 的 Prefix Caching / RadixAttention、SGLang 的 RadixAttention)。它与云服务的 Prompt Caching 机制相同,但有两个工程差异需要注意。
多副本下的缓存命中
生产环境通常有多个推理副本(Pod),每个副本的 KV Cache 是独立的。如果负载均衡把请求随机分发,同一个前缀可能落到不同副本,导致缓存不命中。
解决手段是前缀感知路由(prefix-aware routing):网关按请求前缀的哈希做一致性哈希,把同一前缀的请求路由到同一副本。这样能把前缀缓存命中率从「1/副本数」提升到接近 100%。
| 路由策略 | 命中率(4 副本) | 实现复杂度 | 风险 |
|---|---|---|---|
| 轮询 | 约 25% | 低 | 副本重启后缓存全丢 |
| 一致性哈希 | 约 90%+ | 中 | 热点前缀压垮单副本 |
| 会话粘性 | 约 80% | 低 | 会话切换时失效 |
一致性哈希的副作用是热点前缀(如所有请求共享的系统提示)会把流量集中到少数副本,需要配合负载上限与副本自动扩缩容。
缓存与显存的关系
KV Cache 是显存里最紧张的资源,前缀缓存会长期占用显存。一个 3000 token 的前缀缓存,在 GQA 模型上约占用几十 MB,几百个不同前缀就会吃掉大量显存,挤压并发容量。因此自建场景要监控「缓存占用的显存比例」,并设置缓存条目上限与 LRU 淘汰。KV Cache 的显存公式与容量规划细节见 KV Cache 管理与前缀缓存 。
7. 缓存失效策略
缓存失效是「沉默的故障源」:不失效的缓存会返回过期答案,而系统不会有任何异常信号。
| 失效触发 | 机制 | 适用 |
|---|---|---|
| TTL 过期 | 到时间自动失效 | 通用兜底 |
| 版本绑定 | 键含提示/模型版本 | 提示或模型变更 |
| 内容哈希 | 键含上下文哈希 | RAG 场景 |
| 主动清除 | 数据变更时删除相关键 | 强一致要求 |
| 容量淘汰 | LRU 淘汰旧条目 | 内存受限 |
组合策略推荐:TTL 兜底 + 版本绑定 + 内容哈希。TTL 防止永远不失效,版本绑定让提示与模型变更自动失效,内容哈希让文档更新自动失效。三者叠加后,只有「同一版本、同一上下文、TTL 内」的请求才会命中。
一个必须避免的反模式是「手动清缓存」:靠运维记得在发布后清缓存,迟早会忘。失效必须自动化、由数据驱动。
版本化缓存键的实现
把版本与内容哈希显式编进键,是自动化失效的具体落地方式:
import hashlib
import json
import time
def versioned_key(payload: dict, ttl_seconds: int = 3600) -> str:
"""版本化缓存键:把提示版本、模型、上下文哈希都编进去"""
fingerprint = json.dumps({
"pv": payload["prompt_version"], # 提示版本,变更即失效
"mv": payload["model_version"], # 模型版本
"ctx": payload.get("context_hash", ""),# 检索上下文指纹
"q": payload["input"].strip().lower(), # 归一化后的输入
"t": payload.get("temperature", 0.0),
"tenant": payload["tenant_id"],
}, sort_keys=True, ensure_ascii=False)
digest = hashlib.sha256(fingerprint.encode()).hexdigest()
return f"llm:cache:{payload['tenant_id']}:{digest}"
def store_with_ttl(redis, key: str, value: dict, ttl: int = 3600) -> None:
"""写入时带 TTL,TTL 是永不失效的最后一道兜底"""
redis.setex(key, ttl, json.dumps(value, ensure_ascii=False))
这套键的价值在于:提示改一次,pv 变化,旧键自然不再被命中;文档更新一次,ctx 变化,旧答案自动失效;即使这些都漏了,TTL 到点也会兜底清掉。三层防护叠加后,过期答案几乎不可能被返回。
8. 命中率与成本测算
命中率是缓存的核心指标,必须与成本指标放在同一块看板上监控。
class CacheMetrics:
def __init__(self):
self.hits = 0
self.misses = 0
self.saved_tokens = 0
def record(self, hit: bool, tokens: int) -> None:
if hit:
self.hits += 1
self.saved_tokens += tokens
else:
self.misses += 1
@property
def hit_rate(self) -> float:
total = self.hits + self.misses
return self.hits / total if total else 0.0
def monthly_saving(self, in_price: float, out_price: float,
in_out_ratio: float = 7.0) -> float:
"""估算月度节省(美元):命中省下整次调用"""
per_req = (in_out_ratio * in_price + out_price) / (1 + in_out_ratio)
return self.saved_tokens / 1_000_000 * per_req
命中率与降本的对应关系(以 RAG 客服为例,输入 2400、输出 350 token,GPT-4o 定价):
| 命中率 | 月请求 2160 万 | 月节省 | 降幅 |
|---|---|---|---|
| 10% | — | 约 1.37 万美元 | 10% |
| 20% | — | 约 2.74 万美元 | 20% |
| 40% | — | 约 5.48 万美元 | 40% |
| 60% | — | 约 8.22 万美元 | 60% |
注意这里的节省是乘性的:语义缓存直接减少请求数,命中 40% 就省 40% 的成本。而前缀缓存是线性的,只影响输入侧。因此当语义缓存命中率能稳定超过 20% 时,它的降本效果优于前缀缓存。整体降本手段的排序见 LLM 成本优化 。
命中率低于 15% 时,缓存的复杂度与误命中风险往往覆盖不了收益,应该考虑下线或重新设计键。命中率的健康区间因场景而异:FAQ 类能到 40% 以上,开放式问答可能只有 5% 到 10%。
9. 多租户隔离与隐私
多租户场景下缓存引入两个新风险:跨租户命中(A 租户的问题命中了 B 租户的答案,数据泄露)与敏感内容驻留(缓存里存了 PII 或商业机密)。
三条纪律:
- 租户必须进键。无论是精确缓存的键还是语义缓存的命名空间,租户 ID 都必须在最外层。跨租户命中是严重的安全事故。
- 敏感领域不缓存。涉及个人数据、财务、法务的问答,直接跳过缓存,或缓存时做 PII 脱敏。
- 缓存条目设 TTL 与容量上限。缓存不是持久存储,长期驻留会放大泄露风险。
def tenant_scoped_key(tenant_id: str, base_key: str) -> str:
"""把租户放在缓存键最外层,物理隔离不同租户的缓存空间"""
return f"llm:{tenant_id}:{base_key}"
更进一步的做法是「每租户独立缓存实例」:不同租户用不同的 Redis DB 或不同的缓存服务,从物理上杜绝跨租户命中。代价是缓存命中率下降(每个租户各自冷启动),需要在隔离强度与命中率之间权衡。
语义缓存的隐私问题更微妙:缓存的是「问题向量 + 答案」,而问题向量本身可能包含敏感信息。合规要求高的场景,应该对缓存的问题向量做加密存储,或使用不可逆的向量化(无法反推原文的嵌入)。
10. 生产落地清单
- 先开前缀缓存(引擎或供应商侧),零风险、无条件开启。
- 再建精确缓存,键必须包含全部影响输出的因素。
- 精确缓存加归一化(大小写、空白、标点),提升命中率。
- 语义缓存从高阈值(0.97)起步,用标注数据扫描后下调。
- 语义缓存键里加意图分类,拆开反义问题对。
- 高风险领域(金额、法务、医疗)禁用语义缓存。
- 语义缓存命中后加轻量校验。
- 自建推理用前缀感知路由,提升多副本命中率。
- 失效靠 TTL + 版本绑定 + 内容哈希,不靠手动清理。
- 命中率与成本放同一看板,命中率低于 15% 时重新评估。
权衡取舍
| 决策点 | 保守侧 | 激进侧 | 判断依据 |
|---|---|---|---|
| 缓存层次 | 仅前缀 + 精确 | 加语义缓存 | 查询重复度与风险容忍 |
| 语义阈值 | 0.97(少误命中) | 0.90(高命中) | 误命中的业务代价 |
| 意图入键 | 启用 | 不启用 | 是否存在反义问题对 |
| 命中后校验 | 启用 | 不启用 | 延迟预算 |
| 多租户隔离 | 每租户独立实例 | 共享实例加租户键 | 合规要求 |
| 失效策略 | 短 TTL | 长 TTL + 事件驱动 | 数据更新频率 |
原则:误命中的代价越高,越往保守侧靠。缓存省的是钱,误命中丢的是信任,两者不在一个量级。
常见坑清单
- 缓存键漏了采样参数:温度 0 与温度 1 的结果被当成同一个缓存,命中后返回不符合当前参数的结果。
- 缓存键漏了上下文哈希:RAG 场景文档更新后仍返回旧答案。上下文哈希必须随文档版本变化。
- 语义缓存阈值拍脑袋:用默认值 0.9 导致反义问题误命中。必须用标注数据扫描确定。
- 不区分反义问题:开启/关闭、订阅/取消这类相似度高但意图相反的问题被缓存混淆。意图入键可拆开。
- 高风险领域也开语义缓存:金额、法务类问题误命中后果严重。这些领域应禁用语义缓存。
- 命中后不做校验:语义缓存的误命中直接返回给用户。加一层轻量校验把误命中率再降一个数量级。
- 多副本随机路由:自建推理时轮询分发导致前缀缓存命中率只有 1/副本数。用前缀感知路由。
- 缓存失效靠手动清理:发布后忘记清缓存,返回过期答案。失效必须自动化。
- 命中率不监控:命中率暴跌但接口指标正常,账单悄悄上涨。命中率必须是一等指标。
- 多租户共享缓存空间:租户 ID 没进键,A 租户命中 B 租户答案,属于数据泄露。
- 缓存无限驻留:没有 TTL 与容量上限,敏感内容长期留存,内存无限膨胀。
- 把语义缓存的节省算成线性:语义缓存是乘性降本(减少请求数),前缀缓存是线性(只省输入)。混算会高估收益。
小结
缓存的设计顺序应该从低风险到高风险:前缀缓存零风险、零误判,只要系统提示固定就该无条件开启,盈亏平衡复用次数仅约 1.3 次;精确缓存的关键在键的完整性,漏掉采样参数或上下文哈希都会导致错误命中;语义缓存收益最高但风险最大,阈值宁高勿低(0.97 起步),并且必须用意图入键、高风险禁用、命中后校验三层防护兜住误命中。
两个必须建立的纪律:第一,误命中的代价远高于未命中,所有涉及语义缓存的决策都往保守侧靠。第二,命中率必须与成本放在同一块看板上,因为缓存失效是沉默的——它不报错,只涨账单。命中率低于 15% 时,应该果断下线缓存,而不是继续调参。
最后,缓存不是万能的。当缓存命中率上不去时,真正的问题往往在别处:要么是查询本身高度多样(说明缓存不适用),要么是键设计得过于严格(说明归一化不够)。区分这两种情况,比盲目调阈值更重要。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。