4.3 缓存污染:错误答案的复用放大与防御 4.2 把阈值定在了 0.70,误命中率压到了 5%,数字看起来漂亮。可语义缓存最阴险的地方在于:就算阈值分毫不差,被命中的那个答案本身可能就是错的——而一旦错的答案进了缓存,它会被成千上万次地问、成千上万次地吐。精确匹配不会放大错误,因为错答案几乎没有机会被再次命中;语义缓存恰恰相反,它专门把"意思相近"的请求导向同一个答案。这就是污染,也是这一层风险最特殊的地方。下一节的多级架构,正是为了给这种放大装一道闸。 三类会被复用放大的错误来源 我把污染归成三类,它们的成因不同,防线也不同,混为一谈会防不住。 第一类,模型本身答错被缓存。 大模型不是永远对的。某个罕见问题第一次被问到,模型生成了一个自信但错误的答案,语义缓存不知道对错,照单全收写进库。
4.2 把阈值定在了 0.70,误命中率压到了 5%,数字看起来漂亮。可语义缓存最阴险的地方在于:就算阈值分毫不差,被命中的那个答案本身可能就是错的——而一旦错的答案进了缓存,它会被成千上万次地问、成千上万次地吐。精确匹配不会放大错误,因为错答案几乎没有机会被再次命中;语义缓存恰恰相反,它专门把"意思相近"的请求导向同一个答案。这就是污染,也是这一层风险最特殊的地方。下一节的多级架构,正是为了给这种放大装一道闸。
我把污染归成三类,它们的成因不同,防线也不同,混为一谈会防不住。
第一类,模型本身答错被缓存。 大模型不是永远对的。某个罕见问题第一次被问到,模型生成了一个自信但错误的答案,语义缓存不知道对错,照单全收写进库。此后所有相似问题都命中它,错误被复制成一片。这类污染的根本难题是:缓存层看不到"真相",它只看到"曾经生成过"。举个具体场景:某知识库问答里,"公司年假天数"被模型答成"10 天",其实是旧制度;这条错误答案写进语义缓存后,所有"年假怎么算""还剩几天年假"都命中它,HR 每天要手动纠几十次。问题不在缓存逻辑,而在它忠实地复用了一个生成错误。
第二类,上下文相关的答案被去上下文复用。 这是对话系统里最容易被忽视的一种。用户说"我上一步该干嘛",模型结合前面三轮对话给出"请先完成支付"。语义缓存若只用当前这句话做键,会把"我上一步该干嘛"直接命中成某次历史对话的答案,可那位用户的上下文早变了。去上下文复用就像一个仓库把 A 客户的提货单发给 B 客户——单看字面都对,合起来全错。比如用户 A 问"我上一步该干嘛",上下文是"支付刚失败",答案应是"重试支付或换卡";用户 B 同一句话,上下文是"正在填收货地址",答案应是"补全地址后提交"。若缓存键只用这句话,B 会命中 A 的答案、被指引去"重试支付"——典型的上下文串味,单看每句都没错,合起来出事。
第三类,时效性答案过期。 价格、库存、政策、活动规则,这些数值是会变的。一条"本商品现价 99 元"的答案,在语义缓存里能活很久;可大促半夜调价后,旧答案仍在被命中返回。这类污染不是答案生成错了,而是"对过的东西变错了",而且变错的时刻缓存层毫无察觉。这类污染最隐蔽,因为它被缓存的那一刻是对的。活动规则"满 300 减 50"在活动期内正确,活动结束当天若没失效,仍会被命中返回,用户拿着过期规则去下单,纠纷立刻产生。和前两类不同,它考验的是"缓存知不知道世界已经变了"。
光说类型太抽象,讲一个我见过的真实翻车。
背景:某电商在大促期间上线了语义缓存,目的是扛住暴涨的售前咨询。价格类问题占据咨询量三成,团队认为这类问题"标准答案稳定",放心地让语义缓存接管,阈值沿用通用的 0.68,没做额外时效处理。
经过:大促第三天凌晨,运营把一款爆款的价格从 199 元调到 149 元,并更新了商品详情页。但语义缓存里还躺着几百条"这个多少钱""最低价多少"的旧答案(199 元)。凌晨调价后的流量虽低,却持续命中这些旧答案——因为新问题向量和旧问题向量语义足够近,阈值照常放行。到了上午高峰,大量用户按 199 元的旧价咨询并下单,系统按页面新价 149 元履约,每单净亏 50 元。
损失:上午约四小时高峰,该爆款售出近八千单,按价差估算直接亏损约四十万元,外加一波"说好的 199 怎么变 149"的客诉和投诉。更麻烦的是,事故被发现后,团队一时不敢清缓存,怕误伤正常命中,导致旧答案又多活了半小时。
根因:三条防线同时缺失。其一,时效性答案没有 TTL,旧价格长期有效;其二,没有把"价格时效/活动批次"维度纳入缓存键,调价后旧键依旧匹配;其三,缺乏主动失效机制,运营调价动作没有触发对应缓存条目的失效。一句话:把"会变的事实"当成了"不变的答案"来缓存。事故后的修法是三管齐下:给价格类答案加 10 分钟 TTL;把"活动批次号"纳入缓存键,调价即换键、自然失效;运营调价动作通过消息总线广播到缓存层触发主动失效。修完后再没出现类似客诉,而缓存命中率只掉了不到三个点——说明防线没怎么伤到收益,关键在"失效要跟着业务变更走",而不是等 TTL 慢慢过期。
针对三类污染,业界沉淀出五道可叠加的防线。它们不是单选,而是从便宜到贵依次布防。
第一道,TTL 与主动失效。 给每条缓存答案设存活时间,到期自动失效重算。对时效敏感类(价格、库存、政策)设短 TTL,甚至接到业务变更事件时主动批量失效。这是成本最低的一道,代价是偶尔返回一次"稍旧"的答案,但能挡住绝大多数过期污染。
第二道,命中白名单与审核层。 不是所有问题都进语义缓存。把"高后果"问题(涉及钱、合同、医疗建议)排除在自动命中之外,或让命中结果先过一层规则/人工审核再返回。代价是这部分流量享受不到缓存红利,但把错误代价锁在了可控范围。
第三道,答案置信度门槛。 写入缓存前,先看这次生成答案的置信度(来自模型 logits、自检打分或 reranker 评分)。低置信度的答案不写入,从源头减少"错答案进库"。代价是丢掉一些本可命中的长尾问题,命中率会略降。
第四道,对话上下文入键。 针对第二类污染,把会话 ID、最近若干轮上下文摘要、用户状态哈希进缓存键,让"我上一步该干嘛"在不同对话里命中不同的键。代价是键空间变大、跨会话复用率下降,但彻底隔离了上下文串味。
第五道,灰度命中。 新缓存条目先对小比例流量生效,监控其被后续反馈(点赞、纠正、人工标记)打脸的比例;确认无误再全量。这是最后一道兜底,代价是上线慢半拍,却能在污染扩散前按住它。
五道防线的布设顺序也很关键:TTL 和主动失效是地基,先铺;白名单和置信度门槛按错误后果逐步加;灰度作为最后一道保险。别一上来就把五道全上,那样命中率会被压得没收益,反而让人得出"缓存没用"的错误结论——很多团队就是被自己过度防御劝退的。先用一两道把最贵的风险按住,再看监控决定要不要加厚。
五道防线各有取舍,没有哪道是免费的。下面这张表把"防什么"和"付出什么"并排摆出来,方便你按业务后果选型。
| 防线 | 主要防御的污染类型 | 直接代价 / 副作用 | 适用优先级 |
|---|---|---|---|
| TTL 与主动失效 | 时效性过期 | 偶发返回略旧答案;需接入变更事件 | 高,必做 |
| 命中白名单 / 审核层 | 高后果答错 | 高后果流量放弃缓存红利 | 高,涉钱涉医必做 |
| 答案置信度门槛 | 模型本身答错 | 命中率略降,长尾丢失 | 中,按错误成本定 |
| 对话上下文入键 | 上下文去语境复用 | 键空间膨胀,跨会话复用下降 | 中,对话系统必做 |
| 灰度命中 | 各类未知污染 | 上线慢半拍,监控成本高 | 低,作为兜底 |
防线布好不等于高枕无忧,生产里还要能"看见"污染。最实用的信号是命中答案的后续反馈率:如果被命中的答案之后频繁触发"用户追问、人工转接、点踩",说明这批命中里混了错的。我们给语义缓存的每个命中打上标记,下游的转人工率、点踩率一旦对某个缓存键异常升高,就自动把它降权或拉出命中池。污染早发现,放大面就小。
我的经验口诀是:会变的先管时效,涉钱的先管后果,对话的先管上下文,剩下的再用置信度和灰度兜底。别想用一道防线包打天下——污染从来是组合拳打进来的。
这一节的结论很硬:语义缓存的收益和污染的风险是一体两面,命中率越高,放大错误的潜在面越大。单级语义缓存把全部鸡蛋放在一个会放大的篮子里,下一节就用多级架构把风险拆开分级。