别为语义相同的请求付两次钱——但一次错误的命中,比一百次 miss 都贵。
你的 LLM 账单里塞满了「语义重复」:同一个问题的不同问法、FAQ、模板化查询。精确缓存(exact-match)对这些完全失效——改一个字就 miss;前缀缓存(Day 15/16 讲的 cache_control)只省 input token、答案仍每次完整 inference。想做到「同一个问题只算一次钱」,得上语义缓存:把请求 embed,向量近邻命中就复用旧答案,省下的是整整一次推理。但语义缓存是本系列少数「越省越危险」的优化——阈值松一点,系统就会把「退货政策」的答案返回给「换货政策」的问题,而且不会报错,用户拿到的是一本正经的错答案。这一期讲的不是怎么装 GPTCache,而是怎么在省钱和 false hit 之间工程化地走钢丝。
把「缓存」当成一个东西是新手错误。LLM 请求有三层可缓存点,代价与风险截然不同:
cache_control 把稳定前缀(system prompt、few-shot、长文档)缓存在服务端。省的是 input token,但每次仍走一次完整 inference——答案照算,所以也是零语义风险。注意 Anthropic 默认 5 分钟 TTL、可选 1 小时,最多 4 个 breakpoint,breakpoint 之前任何一个字节变了都会让缓存从该点失效,所以 breakpoint 要卡在「稳定前缀」和「易变尾部」的边界上。关键工程决策:三层顺序查询,语义层放最后一道,只处理 exact/prefix 漏下来的请求。identical query 让零风险的 L1 先吃掉,永远不该进 L3 去赌一次相似度判断。
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
两种错误的代价严重不对称: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
就算相似度判断完美,语义缓存还有一类更隐蔽的错:答案本身不再成立。三种中毒源:
解法是作用域键(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()}) # 版本号一升,全体旧缓存失配
先说安全地提升命中率的手段(不动阈值、不加风险):
但命中率是个危险的单指标:松阈值 → 命中率涨 → 账单好看 → 但 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)
把四点串成一个周末项目:给你现有的 RAG 或客服 agent 加语义缓存,目标不是命中率最高,是零可感知 false hit。
cache_control,把 system prompt + 检索文档卡在一个 breakpoint 前。tenant / user(仅个性化时) / lang / data_version;给源数据的写路径挂一个 bump data_version 的钩子。hit_rate 和 false_hit_rate,只对后者配报警。做完这套,你以后看任何「我们上了语义缓存省了 X%」的分享,都会先问一句:你的 false-hit 率是多少、怎么测的?——答不上来的省钱数字都是耍流氓。