3.1 多级缓存设计


文档摘要

3.1 多级缓存设计 一句话带走:把对话引擎的缓存想成一道「三级漏斗」——结果缓存挡掉完全一样的请求,前缀 KV 缓存挡掉重复的 prefill,语义缓存挡掉「问法不同、意思相近」的请求。三级叠加,整体命中率能做到 30%50%,意味着近一半请求根本不用重新把 token 喂给 GPU。 缓存解决的根本问题 在第二章我们靠限流挡住「不该进来的请求」。但进来之后,每一次生成都要经过两个昂贵阶段:prefill(预填充) 把整段上下文算成 KV 状态,decode(逐 token 生成) 一个字一个字往外吐。对话越长,prefill 的上下文越长,单次成本越高,且显存占用随上下文线性增长。 缓存要做的,就是让「本来要重算的东西」被复用掉。

3.1 多级缓存设计

一句话带走:把对话引擎的缓存想成一道「三级漏斗」——结果缓存挡掉完全一样的请求,前缀 KV 缓存挡掉重复的 prefill,语义缓存挡掉「问法不同、意思相近」的请求。三级叠加,整体命中率能做到 30%~50%,意味着近一半请求根本不用重新把 token 喂给 GPU。

缓存解决的根本问题

在第二章我们靠限流挡住「不该进来的请求」。但进来之后,每一次生成都要经过两个昂贵阶段:prefill(预填充) 把整段上下文算成 KV 状态,decode(逐 token 生成) 一个字一个字往外吐。对话越长,prefill 的上下文越长,单次成本越高,且显存占用随上下文线性增长。

缓存要做的,就是让「本来要重算的东西」被复用掉。在百万级 QPS 下,哪怕只挡掉 30% 的请求,推理集群规模也能相应缩小一个可观比例——这是投入产出比最高的一笔账。

三级缓存漏斗

我们把缓存分成三级,从「最便宜、最精确」到「稍贵、更泛化」依次兜底:

  • 第一级 · 结果缓存(Exact Cache):完全一样的输入,直接返回历史答案。
  • 第二级 · 前缀 KV 缓存(Prefix KV Cache):输入的前缀(通常是系统提示词)相同,复用已经算好的 KV 状态,跳过 prefill。
  • 第三级 · 语义缓存(Semantic Cache):问题语义相近而非字面相同,返回近似结果或近似上下文。

第一级:结果缓存

最简单也最容易被低估的一级。要点是把「完全相同的请求」识别出来。

键怎么取:最常见的是对「模型 + 版本 + 系统提示词 + 用户问题 + 关键参数(temperature、top_p 等)」做稳定哈希。只有这些因素完全一致才命中——因为 temperature 不同、模型版本不同,同样的问句也可能得到不同答案,不能混用缓存。

适合什么:FAQ 类问题、固定话术、产品咨询、查天气/查汇率这类「答案与上下文无关」的请求。在客服类对话里,这类问题往往占比很高,结果缓存的命中率可以非常可观。

失效策略:答案会随知识更新而过期。给每条缓存打 TTL(如 5 分钟到 24 小时),对强时效内容(股价、天气)设短 TTL;同时当底层知识库或模型版本变更时,按命名空间批量失效。

结果缓存流程

第二级:前缀 KV 缓存(这是最容易被踩坑的一级)

这一级是降本的大头,但「踩坑率」也最高。原理是:模型推理时,系统提示词、few-shot 示例这类固定前缀在每个请求里都重复出现;如果能让推理框架复用这些前缀已经算好的 KV 状态,就不用每次都重新 prefill 整段前缀。

经典悲剧:命中率为 0。 很多团队接上 KV 缓存后效果为零,原因往往是——他们为了「让每次请求更个性化」,在前缀里插入了用户 ID、时间戳、随机 trace_id,或者每次把系统提示词的措辞微调一下。结果是:每次前缀都不同,缓存永远 miss。记住一条铁律:想被复用的前缀,必须保持字节级稳定

工程要点

  • 把稳定内容与易变内容分离:系统提示词、工具定义放最前面且保持固定;把「用户 ID、时间」这类易变信息放到上下文尾部,避免污染前缀。
  • 利用推理框架的自动前缀缓存(许多框架支持按 block/页缓存 KV,相同前缀的 block 直接命中)。
  • 监控「前缀命中率」指标,它比整体缓存命中率更能暴露问题。
前缀KV缓存复用

一点诚实的提醒:前缀 KV 缓存的收益取决于「前缀占比」。如果你的系统提示词很短、用户问题才是大头,那这级收益有限;反之,若你用了很长很精细的系统提示词(很多 Agent 场景正是如此),这级能省下的 prefill 成本非常可观。

第三级:语义缓存

前两级都要求「字面一致」,但真实对话里,用户换种问法问同一件事太常见了:「怎么退款」和「我想把钱退回来」意思一样,却字面不同。语义缓存就是为此而生。

原理:把用户问题做向量化(embedding),与缓存库里的历史问题向量比对余弦相似度;相似度超过阈值(如 0.92)就认为命中,返回对应的答案或上下文片段。

关键设计

  • 阈值取舍:阈值太高(如 0.98)漏召回,缓存形同虚设;太低(如 0.80)会返回「答非所问」的错误答案,这是语义缓存最危险的副作用。建议按业务可接受的「错误率红线」反推阈值,并保留兜底——相似度在「灰色区间」时,宁可放过、交给模型重新算,也不要硬返回可能错误的缓存。
  • 写回机制:未命中时,算完的结果连同其向量一并写回缓存库,让缓存「越用越聪明」。
  • 与结果缓存的关系:语义缓存命中后,常先去结果缓存里查有没有精确答案;二者是互补而非互斥。
语义缓存命中

三级如何串成漏斗&一致性

三级缓存不是「选一个用」,而是层层兜底:先查结果缓存(精确、零成本),miss 了查前缀 KV(省 prefill),再 miss 查语义缓存(省部分计算)。每一级都替下一级削掉一部分流量,最终整体命中率 = 1 − (1−P1)(1−P2)(1−P3)。

一致性是底线:所有缓存都必须绑定「模型版本 + 知识版本」作为命名空间。一旦模型升级或知识库更新,旧命名空间的缓存必须能一键失效,否则用户会拿到「用旧知识算出来的过时答案」,这种隐性错误比慢更可怕。

几个实战建议

  • 先上结果缓存(实现简单、零风险),再上前缀 KV(盯紧命中率),最后上语义缓存(要调阈值、要防错误命中)。
  • 给每级缓存都上监控:命中率、节省的 token 数、错误命中导致的「用户退回/重问」比例。
  • 语义缓存建议从「只读不写」的保守模式起步,验证错误率可控后再开写回。
  • 别为了追求命中率而把系统提示词写死成无法迭代——把「可迭代内容」和「可缓存前缀」分开管理,才是长久之计。

把三级缓存做扎实,你会在账单上直接看到推理集群用量下降;这比任何「换更快的框架」都来得实在。

缓存的存储拓扑与多副本一致性

前面讲的是「缓存逻辑上分几级」,但落到生产,你还得决定「缓存放哪」。这直接决定命中率和延迟:

  • 本地进程内缓存(LRU):命中延迟最低(微秒级),但只对本机有效。多实例部署时,每台机器各存各的,命中率被实例数稀释。适合做「前缀 KV 缓存」这种本机复用的场景。
  • 集中式缓存(如 Redis 集群):所有实例共享,命中率不被实例数稀释,但多一跳网络(毫秒级)。适合结果缓存和语义缓存这种需要跨实例共享的场景。
  • 推理框架内置 KV 池:前缀 KV 缓存通常由推理框架管理在显存或内存里,对业务透明,但跨推理节点不共享。这又回到「请求黏性」问题:同一会话最好路由到同一推理节点,才能复用它的前缀 KV。

我的取舍建议:结果缓存和语义缓存放集中式(共享优先),前缀 KV 依赖推理框架本机池加会话黏性路由。别把所有缓存都堆到 Redis,那样前缀 KV 的复用链路会被拉长、收益归零。

容量规划与失效策略

缓存不是「无限大就最好」。内存和显存都是有限资源,必须做容量规划和淘汰:

  • 淘汰策略:精确结果缓存用 LRU 最自然;语义缓存因为要按相似度检索,更适合放在带向量索引的存储里,按 TTL 加访问频次淘汰。
  • 容量估算:以结果缓存为例,若单条平均 2KB,目标存一千万条热点答案,约需 20GB,这对 Redis 是合理的预算;若想存更多,就要上 TTL 自动过期,只保近期热点。
  • 命名空间失效:所有缓存都按「模型版本加知识版本」分桶。模型或知识一升级,旧桶整体失效,避免「用旧知识答新问题」的隐性错误。
  • 防雪崩:缓存集中过期会造成瞬时回源风暴。给 TTL 加随机抖动,让失效时间打散;同时前置限流保护回源路径,避免缓存击穿直接打垮模型。

一个量化的账:三级叠加能省多少

假设某业务 100 万 QPS,单次生成平均消耗 2000 token 的算力当量,三级缓存命中率分别为:结果缓存 15%、前缀 KV 40%(在结果未命中后的请求上)、语义缓存 25%(在前两者都未命中后)。则整体命中率约等于 1 减 (1 减 0.15)(1 减 0.40)(1 减 0.25) 约等于 1 减 0.85 乘 0.60 乘 0.75 约等于 1 减 0.3825 约等于 61.75%。

这意味着近 62% 的请求被缓存兜住、不必重新生成,推理集群规模可据此大幅压缩。即便现实里语义缓存调不高(保守取 15%),整体也能到约 53%。这是架构层面「算得起」的关键,而非靠堆 GPU。当然数字会因业务差异很大,这里只是演示「三级叠加的乘数效应」,真实值要靠你的流量画像反推。

需要监控的核心指标

  • 各级命中率:尤其是前缀 KV 命中率,为 0 是红色警报(几乎一定是前缀不固定)。
  • 节省 token 总量:直接换算成成本,向上汇报最直观。
  • 语义缓存错误命中率:返回了「看似相关实则答错」的比例,超过红线必须收紧阈值。
  • 回源 QPS 与缓存写回延迟:衡量缓存本身是否成了瓶颈。

把这几项接进监控大盘,缓存策略就从一个「玄学优化」变成了「可度量、可迭代的工程模块」。

语义缓存的向量库选型与召回工程

语义缓存好不好用,七分看向量检索。几个常被忽略的工程细节:

  • embedding 模型要和业务问题分布对齐:用通用中文 embedding 去检索英文技术问答,效果会塌。最好用和你的语料同域的模型,必要时微调。这条很多人偷懒跳过,结果语义缓存命中率低得可怜。
  • 近似检索要控召回率:向量库默认为了快会用近似最近邻(ANN),但召回率可能只有 90%。对语义缓存而言,漏召回只是少省一次钱,代价可接受;过度追求召回率会拖慢检索本身。建议把检索耗时也纳入预算,ANN 的候选集大小按延迟红线反推。
  • 冷启动怎么办:刚上线时缓存库是空的,命中率为 0,全靠回源。别因此关掉它——让流量慢慢灌,配合写回机制,几天内命中率就会长起来。上线前也可以拿历史日志做一次「预热灌库」,把高频问题先填进去。

结果缓存的键设计陷阱

结果缓存看似简单,键设计却有三个高频坑:

  • 浮点参数未归一化:temperature 写成 0.8 和 0.80、top_p 写成 0.9 和 0.900,字符串不同导致哈希不同、缓存互不命中。必须先把所有生成参数标准化(统一位数、统一顺序)再哈希。
  • 把易变上下文塞进键:有人把「当前时间」拼进问题一起哈希,结果同一个问题每分钟都算新键。时间类信息要么不入键,要么按时间粒度(如按分钟桶)归并。
  • 忽略模型版本:模型一升级,旧答案可能失效。键里必须含模型版本号,否则会长期返回旧模型时代的答案。

把键设计成「确定性函数」——同样的语义输入永远映射到同一个键——是结果缓存正确性的根基。

缓存与限流的边界

一个容易混淆的点:缓存和限流都「拦请求」,但目的不同。限流是「保护系统不被压垮」(第二章),缓存是「省算力、降延迟」(本章)。二者可以叠加:限流挡掉超额流量后,剩下的流量里再由缓存兜掉一部分计算。在容量规划时,建议先把「缓存能兜掉多少」算进去,再去定限流阈值和集群规模——否则你会为「本来就不该算的请求」多买一堆 GPU。

渐进落地路线(给团队的执行顺序)

  1. 第一周:上线结果缓存 + 前缀 KV 缓存,盯命中率,尤其修掉前缀不固定的坑。这一步风险最低、收益立竿见影。
  2. 第二周:接入集中式缓存做跨实例共享,把单机的精确结果缓存升级为共享结果缓存,命中率立刻上一个量级。
  3. 第三周起:灰度语义缓存,阈值从严到宽,配错误命中监控,确认红线可控后再开写回。

别一上来就三件套全推,那样一旦语义缓存答错,你很难定位是哪一级出的问题。一级一级加,每级都能单独验证,才是稳的打法。


发布者: 作者: 转发
评论区 (0)
U