ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

AI 应用怎么做缓存?一个省下 40% 调用量的实现方案

2026/9/23 9:26:54 拓冰建站 浏览量
AI 应用怎么做缓存?一个省下 40% 调用量的实现方案 先交代边界本文所有数据来自我维护的一个多语言客服消息处理服务月调用量约 80 万次任务覆盖意图分类、语言识别、摘要、回复生成四类。对比维度只有三个——调用量降幅、错误答案率、P95 延迟。缓存方案是我在生产环境里迭代了两轮才稳定的第一轮上线时只降了 9%第二轮才做到 40%——中间踩的坑比方案本身更值钱这篇按踩坑文的顺序写先写现象再写排查最后给完整实现。如果你只想看代码直接跳到「完整实现」那节。现象缓存上线一周调用量只降了 9%第一版缓存方案很朴素对请求做哈希命中直接返回缓存答案。设计目标定的是砍掉 30% 调用量——依据是抽样统计里 35% 的请求语义高度相似。结果上线一周命中率曲线躺平在 9% 附近远低于预期。更糟的是第二天就出了一次线上事故一位西语客户问退款时效收到的却是三天前另一位客户问「发票怎么开」的答案。两个问题在 embedding 空间里相似度 0.87——不算特别高但当时阈值设在 0.85照样命中了。调用量没降 错误答案漏出这两个现象放在一起说明问题不在「要不要缓存」而在缓存键的设计和命中条件上。排查把未命中的请求拉出来逐个归因我导出了三天内全部未命中请求约 4.2 万条按「本应命中却没命中」人工标注了 500 条归因结果集中在这三类第一类归一化没做对占未命中的 41%。「怎么退款」和「怎么 退款 」在哈希后是两个完全不同的 key。全角问号、多余空格、大小写差异每一个都在把同一种提问拆成无数个 key。这在多语言场景里更严重——西语的开头问句大写习惯、阿拉伯语的方向字符都会让朴素哈希失效。第二类缓存键没带任务上下文占 33%。同一句「Please send me the price list」在分类任务里期望输出是inquiry在回复生成任务里期望输出是一段话。第一版方案只哈希了输入文本两个任务互相污染——分类任务先缓存了inquiry回复生成任务命中后直接把一个单词当回答吐给了客户。这个问题没有报错纯粹是业务上错了是靠客服抽检发现的。第三类语义缓存阈值一刀切占 26%。意图分类任务期望的输出粒度很粗十几个标签之一0.85 的相似度下答案混用几乎无害但回复生成是开放式输出任何相似度下的答案混用都有风险。用同一个阈值管所有任务要么保守到没收益要么激进到出事故。解法一缓存键 归一化输入 任务上下文 模型版本三个归因对应三条修正。键的设计上最终定稿的公式是key sha256( NFKC归一化(输入) 任务类型 模型名 模型参数指纹 prompt版本 )归一化这一步用 Unicode NFKC 折叠打底再做小写化、空白折叠、去标点变体。多语言场景额外要做的是剥离零宽字符和双向控制字符——这些不可见字符在中东客户的真实输入里出现频率比想象中高。模型参数指纹指的是 temperature、max_tokens 这类影响输出的参数拼接串。很多人缓存键里只放输入不放参数一旦某天把 temperature 从 0.2 调到 0.7旧缓存还在往外吐旧风格的答案排查起来非常费劲。带上 prompt 版本号同理系统提示词改版是缓存失效的天然时机显式版本号比「改版后手动清缓存」可靠得多。解法二语义缓存的三道闸语义缓存用 embedding 相似度找近邻是命中率的主要增量来源但也是事故来源。第二版方案里我给它加了三道闸缺一不可第一道闸答案类型白名单。只允许「事实型、低时效」的答案进语义缓存——退款流程说明、产品参数、营业时间。凡是涉及订单状态、账户余额、物流单号的答案一律精确缓存甚至不缓存。西语客户收到发票答案那起事故根源就是把回复生成类答案放进了语义缓存这道闸加上后再没发生过跨问题污染。第二道闸按任务分区设阈值。意图分类这类粗粒度任务阈值可以放到 0.88命中容错大摘要类 0.90一切开放式生成任务语义缓存直接关闭。阈值不是全局参数是每个任务类型的独立参数。第三道闸缓存答案带时效字段。每条缓存答案写入时带一个valid_until由调用方按业务语义指定——退款流程说明可以存 7 天促销政策只存 4 小时。产品改了退款流程时运营在后台把该类目答案的 TTL 强制刷新不需要等自然过期。解法三防击穿与负缓存——命中率上 30% 之后的新问题命中率上来之后出现了两个此前不存在的毛刺问题缓存击穿。某个热点问题比如大促期间的「怎么改收货地址」缓存过期瞬间几百个并发请求同时未命中、同时打到模型P95 延迟瞬间从 800ms 飙到 9 秒。解法是 singleflight同一个 key 的并发未命中请求只放一个去调模型其余挂起等结果。负缓存缺失。大量不存在的订单号、乱码输入反复穿透缓存打到模型。解法是负缓存模型判定「无法回答」的输入也写入缓存TTL 短一些比如 30 分钟相同的无效输入直接返回兜底话术。这一项又贡献了约 6% 的调用量下降是意外收获。顺带一提重复请求的识别和去重跟 Webhook 幂等是同一类问题——都是「同一个逻辑请求可能到达多次第二次必须走确定性路径」。上一波我在《Webhook 幂等设计AI 消息通道同步的三类重复问题附代码》里把幂等键的设计拆过一遍那套思路直接搬过来用在缓存键上少走了很多弯路。三种缓存策略的横向对比策略实测命中率贡献主要限制更适合先试的场景精确缓存~14%归一化做不好命中率虚低无法处理同义改写任何应用的第一步零风险语义缓存~22%相似≠可用需白名单分区阈值时效字段三道闸提问重复率高、答案偏事实型的客服/FAQ负缓存~6%兜底话术写不好会显得敷衍TTL 要短无效输入占比高的开放入口注意三者是叠加关系精确缓存先行、语义缓存吃增量、负缓存兜底穿透。我的 40% 是三者合计且前提是任务结构里分类/摘要类占比高——如果你的应用全是开放式长文本生成语义缓存那 22% 基本拿不到40% 对你不成立。完整实现可直接抄走下面是两级缓存 负缓存 singleflight 的精简实现生产环境把内存 dict 换成 Redis 即可importhashlibimportthreadingimporttimeimportunicodedataimportre# ---------- 归一化命中率的第一决定因素 ----------defnormalize(text:str)-str:tunicodedata.normalize(NFKC,text)tre.sub(r[\u200b-\u200f\u202a-\u202e],,t)# 零宽与双向控制字符tre.sub(r\s, ,t.strip().lower())tre.sub(r[?!。,.]$,,t)# 抹平结尾标点变体returntdefcache_key(task:str,text:str,model:str,prompt_ver:str,temp:float,max_tokens:int)-str:rawf{task}|{normalize(text)}|{model}|{prompt_ver}|{temp}|{max_tokens}returnhashlib.sha256(raw.encode()).hexdigest()# ---------- 缓存条目值 过期时间 是否负缓存 ----------_store:dict[str,tuple[str,float,bool]]{}_locks:dict[str,threading.Lock]{}_locks_guardthreading.Lock()def_ttl_for(task:str,negative:bool)-int:ifnegative:return1800# 负缓存一律 30 分钟return{classify:86400,summarize:21600,reply:0}.get(task,3600)# reply 不缓存0 不写入def_lock_for(key:str)-threading.Lock:with_locks_guard:return_locks.setdefault(key,threading.Lock())# ---------- 主入口两级查找 singleflight 防击穿 ----------defcached_infer(task:str,text:str,model:str,prompt_ver:str,temp:float,max_tokens:int,infer_fn,whitelist_ok:boolTrue)-str:keycache_key(task,text,model,prompt_ver,temp,max_tokens)entry_store.get(key)ifentryandentry[1]time.time():val,_,negentryreturnFALLBACKifnegelsevalwith_lock_for(key):# singleflight同 key 只放一个进模型entry_store.get(key)# 双重检查等待期间可能已被别人填上ifentryandentry[1]time.time():val,_,negentryreturnFALLBACKifnegelseval answerinfer_fn(task,text)# 真正的模型调用只发生一次neganswer.startswith(UNANSWERABLE)ttl_ttl_for(task,neg)iftask!replyand(whitelist_okorneg):_store[key](answer,time.time()ttl,neg)returnFALLBACKifnegelseanswer三点使用说明infer_fn里约定「无法回答」的输出以UNANSWERABLE开头用于触发负缓存reply类任务在示例里直接不写缓存——要不要对回复生成开语义缓存请先按你自己的抽检错误率决策别照抄_store换 Redis 时注意把过期判断交给 Redis 的 TTL别在应用层再判一次。适用边界什么情况下 40% 对你无效最后把边界说透。这个 40% 成立的三个前提一是任务结构里结构化任务分类、识别、摘要占比过半二是提问重复率高——客服、FAQ、工单类场景天然满足长文档分析类天然不满足三是答案以事实型为主语义缓存的三道闸才立得住。如果你的应用三条里只占一条现实预期是 10%~15%这个数字也值得拿但别按 40% 做汇报。另外缓存不是免费的多了一层存储运维、多了一类「旧答案还在吐」的失效事故面团队的失效机制要跟得上否则省下的调用费会以客诉的形式还回去。FAQQ1语义缓存的 embedding 模型怎么选和业务语言分布强相关。多语言场景先看 embedding 模型在你主要语种上的检索召回质量中英混合可以接受泰语、越南语这类低资源语言要单独验证。向量库用轻量方案如 SQLite 向量扩展起步就够几万条缓存量级用不上重型向量数据库。Q2缓存命中率应该怎么设监控告警分任务类型监控命中率而不是只看全局均值——全局均值会被高命中任务掩盖低命中任务的异常。另外错误答案率抽检 客诉回溯是比命中率更重要的指标命中率涨、错误率也涨说明闸门没设对。Q3模型升级后旧缓存要全部清掉吗要。缓存键里带模型版本号升级后新版本自然走新 key旧 key 等 TTL 自然过期即可不需要手动清理。这也是为什么强烈建议把模型名和 prompt 版本编进缓存键。Q4精确缓存会不会导致用户永远收到一成不变的回答对事实型答案是特性不是缺陷对生成型答案确实需要考虑多样性。我的做法是生成类任务温度参数编进缓存键且 TTL 设短平衡一致性缓存收益和回答多样性。Q5负缓存的兜底话术怎么写才不显得敷衍给出路不给结论说明「这个输入我们暂时处理不了」同时给一个明确的替代路径换个问法、联系人工、附上输入格式示例。纯「无法回答」四个字的兜底话术是负缓存被客诉的最主要原因。这个方向的前置篇是《LLM 应用如何省钱我把 token 成本砍掉 70% 的 5 个手段》链接那篇里两级缓存只占了三段篇幅这篇算是把它单独拆开讲透。缓存的答案质量兜底思路和《AI Agent 自动跟进客户从需求到落地的完整架构》链接里 Agent 护栏的设计是相通的——都是「自动化程度越高确定性路径越重要」。如果你在纠结知识库该走检索还是微调《RAG 还是微调用一个真实的多语言客服场景说清楚》链接给了一棵可以直接套用的决策树。这个系列还剩最后一篇会按计划更完关注不迷路。你们线上的 LLM 缓存目前是精确哈希还是带语义相似度的命中率的真实数字是多少评论区说一个数我看看大家的应用结构差异有多大。专注 AI 工程化实践与出海外贸技术