DAY 57 · ENGINEERING

Semantic Caching

三层缓存栈 · 非对称阈值 · 作用域键 · 双指标监控

2026-07-09 · BigCat

别为语义相同的请求付两次钱——但一次错误的命中,比一百次 miss 都贵。

// WHY THIS MATTERS

你的 LLM 账单里塞满了「语义重复」:同一个问题的不同问法、FAQ、模板化查询。精确缓存(exact-match)对这些完全失效——改一个字就 miss;前缀缓存(Day 15/16 讲的 cache_control)只省 input token、答案仍每次完整 inference。想做到「同一个问题只算一次钱」,得上语义缓存:把请求 embed,向量近邻命中就复用旧答案,省下的是整整一次推理。但语义缓存是本系列少数「越省越危险」的优化——阈值松一点,系统就会把「退货政策」的答案返回给「换货政策」的问题,而且不会报错,用户拿到的是一本正经的错答案。这一期讲的不是怎么装 GPTCache,而是怎么在省钱和 false hit 之间工程化地走钢丝。

// 01

三层缓存栈:exact → prefix → semantic

论断:这三层不是替代关系是叠加关系;命中率、省钱幅度、出错风险三者同向递增,所以查询顺序必须从最安全的一层开始。

背景与原理

把「缓存」当成一个东西是新手错误。LLM 请求有三层可缓存点,代价与风险截然不同:

关键工程决策:三层顺序查询,语义层放最后一道,只处理 exact/prefix 漏下来的请求。identical query 让零风险的 L1 先吃掉,永远不该进 L3 去赌一次相似度判断。

REQUEST "能退货吗?" │ normalize(lowercase / 去标点 / 去停用词) ▼ ┌── L1 EXACT (hash) ──────── hit ──▶ 返回答案 ✅ 0 风险 │ miss │ │ ▼ ├── L2 PREFIX (cache_control) ─────▶ 省 input token │ (仍走一次 inference,答案每次重算) ✅ 0 语义风险 │ │ 生成后 │ ▼ ├── L3 SEMANTIC (embed + ANN, dist < τ) ─ hit ──▶ 返回*旧*答案 │ miss │ ⚠️ 有 false-hit 风险 │ ▼ └────────▶ LLM ──▶ 写回 L1 + L3(带 scope key + TTL)

实战示例

def answer(q, scope):
    qn = normalize(q)                          # 归一化:表层变体先坍缩
    if (a := L1.get(hash(qn, scope))): return a   # 零风险命中
    hit = L3.search(embed(qn), scope, k=1)     # 语义近邻
    if hit and hit.dist < TAU:  return hit.answer   # ⚠️ 阈值把关
    a = llm(qn, scope)                         # L2 前缀缓存在这一层内部生效
    L1.put(hash(qn, scope), a); L3.put(embed(qn), a, scope, ttl=...)
    return a
失败模式:跳过 L1/L2 直接「全上语义缓存」。这等于把本可零风险精确命中的 identical query,也扔进相似度判断里赌一把——凭空给自己引入 false hit。语义层是补漏的,不是打头阵的。
进阶资源 · GPTCache(Zilliz,语义缓存参考实现), github.com/zilliztech/GPTCache · Anthropic Prompt Caching 文档, platform.claude.com/.../prompt-caching
// 02

阈值不是调准确率,是调「你能容忍多贵的错答案」

论断:语义缓存的相似度阈值是一个风险旋钮而不是精度旋钮——false hit 的代价远大于 miss,所以阈值该偏保守,宁可 miss。

背景与原理

两种错误的代价严重不对称miss 的代价 = 多花一次 API 调用(几毫厘 + 一点延迟),无害;false hit 的代价 = 用户拿到一个 confidently wrong 的答案,信任崩塌、甚至法律后果。既然不对称,阈值就不能按「F1 最高」来选,要按「把 false hit 压到可接受」来选——通常意味着更严的阈值、更多 miss。

Redis 用 cosine distance,区间 [0,2],0=完全相同、2=完全相反;阈值越小=越严=越少 false hit。embedding 选型的影响比阈值还大:通用句向量分不开「退货 vs 换货」这种近义但答案不同的 query,也分不开「能退吗」和「不能退吗」这种一字之差、语义相反、向量却极近的句子。所以阈值不能拍脑袋,要:拿一批人工标注的 (q1, q2, 该不该命中) pair,扫阈值画出 false-hit / false-miss 两条曲线,在「false-hit 落到红线以下」的约束里取命中率最高的点。

实战示例

# 阈值扫描:在 false-hit 上限约束下选阈值,而不是选 F1 最高
def pick_tau(pairs, embed, max_false_hit=0.01):
    best = None
    for tau in arange(0.05, 0.40, 0.01):
        fh = mean([dist(embed(a),embed(b)) < tau and not same
                   for a,b,same in pairs])     # false hit 率
        hit = mean([dist(embed(a),embed(b)) < tau and same
                   for a,b,same in pairs])
        if fh <= max_false_hit: best = (tau, hit, fh)  # 约束优先
    return best   # 严约束下命中率最高的 tau
失败模式:拿默认阈值(很多库给 0.8 相似度)+ 通用 embedding 直接上生产。近义反义句、否定句、带数字的 query(「订单 1234」vs「订单 1235」)embedding 极近,会稳定 false hit。MeanCache 论文(arXiv 2403.02694)直接点名:现有缓存方法找不到真正的语义相似,导致不可接受的 false hit/miss 率——这不是调参小问题,是语义缓存的核心难题。
进阶资源 · Redis《What is Semantic Caching》(阈值与 distance 单位), redis.io/blog/how-to-cache-semantic-search · MeanCache: User-Centric Semantic Caching, arXiv:2403.02694
// 03

缓存中毒:正确性不止取决于「问题像不像」

论断:两个字面相同的问题,答案也可能不同——决定缓存能不能共享的是 scope key,不是 query 相似度。

背景与原理

就算相似度判断完美,语义缓存还有一类更隐蔽的错:答案本身不再成立。三种中毒源:

解法是作用域键(scope key):缓存键不能只是 query 的 embedding,要拼上 user_id / tenant / 数据版本 / 语言 / 上下文摘要——只有同一 scope 内才允许命中。TTL 按内容时效分级:静态知识可以 1 天,价格/库存这类可能只给 60 秒甚至不缓存。MeanCache 的做法是给每条缓存 query 编码一段 context chain,用来区分「带上下文依赖的」和「独立自足的」query——后者才安全跨会话复用。

实战示例

# scope key:相似度只是命中的必要条件,scope 一致才是充分条件
def scope_key(req):
    return {
      "tenant": req.tenant_id,          # 隔离多租户
      "user":   req.user_id if req.personalized else "*",
      "lang":   req.lang,
      "dv":     req.data_version,        # 数据一变,旧缓存自动失配
    }
# 数据写入路径挂失效钩子:源数据变 → 按 scope 清缓存
def on_price_update(sku):
    cache.invalidate(scope={"dv": bump_version()})   # 版本号一升,全体旧缓存失配
失败模式:全局共享缓存 + 没有失效钩子。数据库更新了、但缓存没人通知,agent 拿着旧数据一本正经地答,越权限、越时效地错下去。失效必须由 write-path 主动触发——用数据版本号做 scope 的一部分是最省事的实现:源一变就 bump 版本,所有旧缓存瞬间失配,不用逐条删。
进阶资源 · MeanCache(context chain 区分上下文依赖 query), arXiv:2403.02694 · Redis LLM Cache(scope / TTL 用法), docs.redisvl.com/.../llmcache
// 04

命中率工程 + 双指标监控

论断:只盯命中率会诱导你把阈值越调越松,直接把系统调坏——必须同时监控命中率和 false-hit 率。

背景与原理

先说安全地提升命中率的手段(不动阈值、不加风险):

但命中率是个危险的单指标:松阈值 → 命中率涨 → 账单好看 → 但 false hit 同时悄悄涨。所以必须配一个 shadow eval抽样把已经 cache-hit 的请求也真打一次 LLM,比对新旧答案是否等价(用 LLM-as-judge 或 embedding 相似度判等),据此估算线上 false-hit 率。这和 Day 42 的影子测试、Day 56 用分布而非单点稳住 eval 是同一套思路——用一个便宜的旁路把静默错误显影出来

实战示例

# shadow sampling:1% 的命中请求偷偷真算一遍,量化 false-hit 率
def answer_monitored(q, scope):
    hit = semantic_lookup(q, scope)
    if hit and sample(0.01):              # 1% 命中走影子对账
        truth = llm(q, scope)
        if not equivalent(hit.answer, truth):  # judge / 相似度
            metrics.incr("cache.false_hit")   # 报警看这个,不是命中率
    return hit.answer if hit else llm(q, scope)
失败模式:把命中率当成 KPI 汇报给团队。人会优化被考核的指标——工程师会把阈值越调越松让命中率好看,false hit 在没人监控的暗处涨,直到某天客诉集中爆发才发现。命中率是收益指标,false-hit 率才是护栏指标,两个必须一起看;护栏破线时优先回收命中率。
进阶资源 · Redis《What is Semantic Caching》(命中率与评估), redis.io/blog/how-to-cache-semantic-search · GPTCache eval 模块, github.com/zilliztech/GPTCache

// 综合实战 · 给自己的 RAG/agent 加一层「安全」语义缓存

把四点串成一个周末项目:给你现有的 RAG 或客服 agent 加语义缓存,目标不是命中率最高,是零可感知 false hit

  1. 先搭三层:normalize → L1 exact(Redis hash)→ L3 semantic(GPTCache / RedisVL)→ LLM。L2 前缀缓存交给 Anthropic cache_control,把 system prompt + 检索文档卡在一个 breakpoint 前。
  2. 标 50 对 query:从真实日志挑 50 对语义近的问题,人工标「该不该共享答案」,跑 §2 的阈值扫描,在 false-hit ≤ 1% 约束下取阈值。
  3. 加 scope key:拼上 tenant / user(仅个性化时) / lang / data_version;给源数据的写路径挂一个 bump data_version 的钩子。
  4. 上 shadow 对账:1% 命中请求真算一遍,metrics 里同时打 hit_ratefalse_hit_rate,只对后者配报警。
  5. 算账:一周后看省了多少 inference、false-hit 率多少。你会发现——把阈值收严、命中率从 40% 掉到 25%,但 false hit 归零,才是生产该要的点,而不是反过来。

做完这套,你以后看任何「我们上了语义缓存省了 X%」的分享,都会先问一句:你的 false-hit 率是多少、怎么测的?——答不上来的省钱数字都是耍流氓。

// ENGLISH GLOSSARY

Semantic Cache
把请求 embed、用向量近邻命中就复用旧答案的缓存,省下整次 inference。本期主角。
Exact / Prefix Cache
精确缓存(文本 hash)与前缀缓存(如 Anthropic cache_control 省 input token),两者均零语义风险。
False Hit
相似度命中了、但返回的旧答案其实不对——语义缓存最贵的错误。
Similarity Threshold (τ)
判定「够像可复用」的距离阈值。是风险旋钮不是精度旋钮,宜偏严。
Cosine Distance
Redis 用的距离度量,区间 [0,2],0=相同、2=相反。
Scope Key
缓存键里除 embedding 外的隔离维度:tenant / user / data_version / lang。决定能否跨请求共享。
Canonicalization
请求归一化(lowercase、去标点、去礼貌噪声),让精确层先坍缩表层变体。
Negative Cache
缓存「无结果 / 不知道」,避免空查询反复打 LLM。
Cache Poisoning
因个性化 / 时效 / 上下文依赖,缓存的答案已不再成立却仍被命中。
Shadow Eval
抽样把命中请求也真算一遍、比对答案,用来量化线上 false-hit 率。

// 深入思考

语义缓存用同一个 embedding 判「够像可复用」,而 RAG 也用 embedding 判「够像可召回」。两者的相似度阈值能共用一套吗?
不能,方向相反。RAG 召回要:多召回几个候选,交给 reranker/LLM 去筛,false positive 无害(下游会丢)。语义缓存命中要:一旦命中就直接返回、没有下游把关,false positive 直接变错答案。同一对 query 在 RAG 里该召回、在缓存里未必该命中。所以缓存阈值要显著严于同 embedding 的检索阈值,且要单独用「答案是否等价」而非「话题是否相关」来标注调参。
推理模型(o1/R1,Day 49)越贵,缓存收益越大。但它们同一 prompt 每次思考路径都不同,这对语义缓存意味着什么?
收益诱惑最大、风险也最大。贵,所以命中一次省得多;但输出是长推理链,两个语义相近的问题「正确答案」也许一致,中间推理却可能不该复用——缓存整条 trace 会把 A 问题的推理硬套给 B。务实做法:只缓存最终答案不缓存 reasoning trace,且阈值收得比对普通模型更严(答案更长、更容易在细节上分叉)。对高价值场景,宁可只让 exact 层命中、放弃语义命中。
为什么「命中率」这个看起来最自然的 KPI,反而是语义缓存里最危险的考核指标?
因为它和 false-hit 率被同一个旋钮(阈值)反向耦合:松阈值同时抬高两者。一旦命中率进了考核,人会优化它——把阈值调松,命中率的涨立刻可见、可汇报,而 false hit 的涨是静默的、要专门 shadow 采样才看得到。于是系统被稳定地推向「省钱数字漂亮但错答案变多」。正确做法是把 false-hit 率设成不可逾越的护栏指标,命中率只在护栏内优化——收益指标服从护栏指标。这与 Day 34/56 的「用护栏约束优化」是同一条工程哲学。
如果 embedding 模型升级了(换了更强的句向量),已经建好的语义缓存库会发生什么?
整个向量库作废——旧向量和新 query 的向量在不同空间,距离不可比,阈值也失去意义。这跟 Day 40 讲的「embedding 迁移要重建索引」同源。所以 embedding 版本必须进 scope key(或直接做成独立命名空间),升级时新旧库并存、灰度切换,不能原地混用。这也提醒:语义缓存把「省钱」和「embedding 选型」绑死了——换 embedding 的成本包含缓存库重建,选型要一次尽量选对。

// 延伸阅读