语义缓存与 Prompt 缓存

缓存是少数能同时降成本与降延迟的手段,但它也是最容易「省了钱却丢了用户」的优化。本文区分精确缓存与语义缓存、讲清嵌入相似度阈值的设定与误命中防护,深入 Prompt Caching 与 KV Cache 前缀复用的计费与命中机制,给出缓存键设计、失效策略、多租户隔离与命中率成本测算,附可运行的阈值调参与命中率统计代码。

缓存是 LLM 工程里最诱人的优化:它同时降低成本与延迟,而且看起来实现简单——存一份问答对,下次命中直接返回。但缓存也是失败后果最隐蔽的优化:命中率从 60% 掉到 8% 时,接口成功率、延迟指标全都不变,只有账单悄悄上涨,没有任何告警会触发;而语义缓存一旦误命中,「如何开启双因素认证」被答成「如何关闭双因素认证」,用户拿到的是自信满满的错误答案。

本文聚焦缓存的机制设计,与两条相邻线索划清边界:成本侧的整体降本排序与单位经济核算见 推理成本核算与 FinOps ,KV Cache 的显存结构与 PagedAttention 内部机制见 KV Cache 管理与前缀缓存 。本文回答的是:缓存键怎么设计、阈值怎么定、误命中怎么防、多租户怎么隔离、命中率怎么算。

理解缓存要先区分两个层次:结果缓存(缓存最终答案,命中即返回,省下整条链路)与前缀缓存(缓存计算过程,命中仍需继续生成,但省下前缀的重复计算)。前者优化的是「调用次数」,后者优化的是「单次调用的成本」。两者机制、收益模型、风险都不同,必须分开设计。

目录

  1. 缓存的四个层次
  2. 精确缓存与缓存键设计
  3. 语义缓存的原理与阈值设定
  4. 误命中的风险与多层防护
  5. Prompt Caching 与前缀缓存机制
  6. KV Cache 复用与多副本一致性
  7. 缓存失效策略
  8. 命中率与成本测算
  9. 多租户隔离与隐私
  10. 生产落地清单
  11. 权衡取舍
  12. 常见坑清单
  13. 小结

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
Anthropic1.25× 基础价0.1× 基础价1024 token5 分钟(可续)
OpenAI无额外写入费0.5× 基础价1024 token5 - 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. 生产落地清单

  1. 先开前缀缓存(引擎或供应商侧),零风险、无条件开启。
  2. 再建精确缓存,键必须包含全部影响输出的因素。
  3. 精确缓存加归一化(大小写、空白、标点),提升命中率。
  4. 语义缓存从高阈值(0.97)起步,用标注数据扫描后下调。
  5. 语义缓存键里加意图分类,拆开反义问题对。
  6. 高风险领域(金额、法务、医疗)禁用语义缓存。
  7. 语义缓存命中后加轻量校验。
  8. 自建推理用前缀感知路由,提升多副本命中率。
  9. 失效靠 TTL + 版本绑定 + 内容哈希,不靠手动清理。
  10. 命中率与成本放同一看板,命中率低于 15% 时重新评估。

权衡取舍

决策点保守侧激进侧判断依据
缓存层次仅前缀 + 精确加语义缓存查询重复度与风险容忍
语义阈值0.97(少误命中)0.90(高命中)误命中的业务代价
意图入键启用不启用是否存在反义问题对
命中后校验启用不启用延迟预算
多租户隔离每租户独立实例共享实例加租户键合规要求
失效策略短 TTL长 TTL + 事件驱动数据更新频率

原则:误命中的代价越高,越往保守侧靠。缓存省的是钱,误命中丢的是信任,两者不在一个量级。

常见坑清单

  • 缓存键漏了采样参数:温度 0 与温度 1 的结果被当成同一个缓存,命中后返回不符合当前参数的结果。
  • 缓存键漏了上下文哈希:RAG 场景文档更新后仍返回旧答案。上下文哈希必须随文档版本变化。
  • 语义缓存阈值拍脑袋:用默认值 0.9 导致反义问题误命中。必须用标注数据扫描确定。
  • 不区分反义问题:开启/关闭、订阅/取消这类相似度高但意图相反的问题被缓存混淆。意图入键可拆开。
  • 高风险领域也开语义缓存:金额、法务类问题误命中后果严重。这些领域应禁用语义缓存。
  • 命中后不做校验:语义缓存的误命中直接返回给用户。加一层轻量校验把误命中率再降一个数量级。
  • 多副本随机路由:自建推理时轮询分发导致前缀缓存命中率只有 1/副本数。用前缀感知路由。
  • 缓存失效靠手动清理:发布后忘记清缓存,返回过期答案。失效必须自动化。
  • 命中率不监控:命中率暴跌但接口指标正常,账单悄悄上涨。命中率必须是一等指标。
  • 多租户共享缓存空间:租户 ID 没进键,A 租户命中 B 租户答案,属于数据泄露。
  • 缓存无限驻留:没有 TTL 与容量上限,敏感内容长期留存,内存无限膨胀。
  • 把语义缓存的节省算成线性:语义缓存是乘性降本(减少请求数),前缀缓存是线性(只省输入)。混算会高估收益。

小结

缓存的设计顺序应该从低风险到高风险:前缀缓存零风险、零误判,只要系统提示固定就该无条件开启,盈亏平衡复用次数仅约 1.3 次;精确缓存的关键在键的完整性,漏掉采样参数或上下文哈希都会导致错误命中;语义缓存收益最高但风险最大,阈值宁高勿低(0.97 起步),并且必须用意图入键、高风险禁用、命中后校验三层防护兜住误命中。

两个必须建立的纪律:第一,误命中的代价远高于未命中,所有涉及语义缓存的决策都往保守侧靠。第二,命中率必须与成本放在同一块看板上,因为缓存失效是沉默的——它不报错,只涨账单。命中率低于 15% 时,应该果断下线缓存,而不是继续调参。

最后,缓存不是万能的。当缓存命中率上不去时,真正的问题往往在别处:要么是查询本身高度多样(说明缓存不适用),要么是键设计得过于严格(说明归一化不够)。区分这两种情况,比盲目调阈值更重要。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「LLMOps」更多文章

  1. 结构化输出与函数调用
  2. 多智能体编排与工作流引擎
  3. LLM 护栏与提示注入防护