做 LLM 推理优化的朋友,多半在多轮对话上吃过亏。表面上看,对话每轮只是把历史消息原样拼起来再喂给模型,可用户一旦愿意陪你聊下去,显存占用和首 token 延迟就会像滚雪球一样往上翻。我第一次看到 2025_NIPS_SmartCache 这个题目时,心里想的是:这不就是把相似问题缓存一下嘛,有什么好发论文的。等自己按这个思路复刻了一版之后才明白,标题里 Context-aware(上下文感知)四个字才是真正的门槛。
这里我想聊的是自己从“看到标题”到“按思路做了个简易版本”的完整过程,包括多轮推理真正卡在哪、为什么精确前缀缓存救不了场、上下文感知的语义表示怎么设计、KV Cache 怎么复用、以及那些文档里不会写但实际必然会踩的坑。适合正在做推理服务优化、RAG 系统,或者单纯在研究 LLM 缓存策略的工程师和学生。文章里不会贴完整代码库,但会把关键的设计取舍和调参方法讲透,保证你能照着搭一个能跑的版本。
1. 语义缓存要解决的多轮推理难题
1.1 多轮对话的推理瓶颈在 Prefill
LLM 服务里,一个请求从进入引擎到吐字,分两大阶段。Prefill 把输入序列一次性过完,产出 KV Cache;Decode 阶段逐个蹦 token,每蹦一个,就把新 token 对应的 KV 追加进缓存。对单轮短请求,Prefill 通常只占整体延迟的一小会儿。多轮对话的区别在于:它每次进来的不是一个短请求,而是把系统提示、之前所有用户输入、之前所有模型回复,再加上这一轮的新消息,全部拼在一起。也就是说,前一轮我已经算过的历史 token,这一轮原封不动地再算一遍。
举个数字例子:假设平均一轮对答各 500 token,第 1 轮输入可能还只是系统提示加几百 token 上下文,第 10 轮输入就逼近 5000 token,第 20 轮接近 10000 token。Prefill 阶段的计算量随输入长度增长,序列越长,同样一次请求消耗的 GPU 计算就越多,用户感受到的首 token 延迟也越明显。同时 KV Cache 还在那儿越堆越高,7B 模型按常见配置每个 token 大约需要 1KB 上下的 KV 存储,20 轮的会话就能攒出好几个 GB。
更狠的是线上服务不可能只有一个会话,几十上百路并发同时沟通,Prefill 重复计算的浪费被放大得非常惊人。我习惯用一句话跟别人讲这个痛点:每次回复短消息前,都要把整本聊天记录从头朗读一遍,哪怕你只是想回一句“好的”。语义缓存想省掉的,正是这种重复朗读。
1.2 精确前缀缓存为什么救不了多轮场景
很多人第一反应是:前缀缓存不是已经很成熟了吗?确实,业界先普及的是前缀树(Radix Tree)缓存、PagedAttention 这类方案。做法很好理解:把已经算好的 KV Cache 按 token 前缀组织成树,某个输入序列的前缀如果全都算过,新请求只要这部分一模一样,就能直接加载对应的 KV,只计算变化之后的部分。这套设计在公共提示词场景效果很好,比如大家都在用同一段系统提示、同一个固定指令模板、同一份长文档引用。
但多轮对话恰恰从头到尾都是“活的”。用户上一轮说“用 A 方案试试”,下一轮改口“算了,还是用 B 方案”,或者中间插一句“再补充一个条件”。只要任意一个字符被改动,前缀就从改动点断开,后面所有内容的 KV 都得重算。想让用户按照你缓存的原文一字不落地提问,根本不现实。所以,精确前缀缓存是个好东西,它和后面要讲的语义缓存不冲突,但它对手指型、纠错型、补充型的多轮请求几乎无能为力。
1.3 语义缓存的价值边界
语义缓存换了一个赛道:不再要求 token 级别一模一样,而是判断两个请求在多轮上下文层面是否语义等价。如果等价,就把当时的回答或者 KV 状态拿过来复用。它想抓住的是“问法不同、意思相同”的那部分流量。
不过价值边界得先讲清楚。语义缓存最适合的是答案稳定的场景:客服问答、产品咨询、系统配置、代码解释、常见手册类知识。这类请求重复率高,而且经常是同义改写式重复。不适合的是高度个性化的生成——让模型写一段营销文案,同样是“给秋季新品写个宣传语”,完全不该因为有人问过就返回同一段话;也不适合强时效场景,天气、股价、活动价之类的内容,命中旧答案等于坑用户。
落地前我建议先做一件事:抽样一批真实对话,人工标注哪些请求在历史上能找到语义等价对象。如果这个比例不到 30%,语义缓存基本没有发挥空间,不如把精力拿去做前缀缓存和更好的调度;如果比例到 60% 以上,这就是一个立竿见影的优化点。先判断业务有没有缓存红利,再去设计缓存,顺序不能反。
2. 上下文感知的语义表示怎么设计
2.1 多轮的“语义”不等于当前一句话
很多人第一次实现时容易犯一个错:只对当前这条用户消息做 embedding,然后拿它和历史请求的 embedding 比相似度。真实多轮对话里,后一句往往自带省略和指代。用户先问“怎么给 Python 程序加结构化日志”,再问“能不能只记 ERROR 级别以上的”。单独看第二句,它几乎就是一个独立小问题,但没有第一轮它根本不完整。更复杂的是,用户可能在第三轮说“跟刚才那个方案比呢”。这里的“刚才那个方案”到底指第一轮还是第二轮,取决于前两轮里哪一条被确立成了“方案”。
这些信息全部落在上下文里,只取当前一句话做向量,信息是不完整的。Context-aware 指的就是要把“当前消息 + 它依赖的对话历史”打包成一个可比较的上下文表示。我见过不少自研缓存方案,第一版只对当前用户消息做向量化,结果命中率低得可怜,因为真实对话里过半的后续轮次都在省略主语、指代前文。这不是 embedding 模型不行,而是输入构造就没把上下文装进去。
2.2 上下文表示的构造方案:窗口、摘要与双通道
把上下文变成向量,业界大致有四种做法,各有取舍:
| 方案 | 优点 | 问题 |
|---|---|---|
| 全量拼接后 embedding | 信息最完整 | 文本过长导致向量质量下降,计算耗时也高 |
| 最近 N 轮窗口 | 实现简单,对指代型请求友好 | 早期关键信息会丢失 |
| 历史摘要 + 当前轮 | 兼顾信息量与长度 | 摘要生成有额外成本,并且摘要本身有损 |
| 语义向量 + 结构化指纹 | 召回与防误判分开处理 | 需要额外维护结构化字段 |
我的建议是第一版先用最近窗口,别急着上摘要。等看到误判样本确实来自早期信息丢失,再加摘要补位。很多早期信息其实用不着模型去理解,你只要把第一轮的“问题主题”作为结构化字段抽出来,就已经能解决大半。所谓上下文感知,落到工程上是一套“上下文表示策略”,不是某个单一模型能搞定的。
2.3 相似度计算与阈值标定
拿到 embedding 之后,最常用的是余弦相似度。但相似度值域跟 embedding 模型、业务分布都有关系,所以标定阈值必须拿自己数据的正负样本对来做。我自己的做法是:整理一批正样本(问法不同但语义等价的请求)和一批负样本(看起来近但意图不同的请求),全部算相似度、画出分布,然后以负样本的 95 分位作为初始阈值。意思就是,先让 5% 以内的近似但不等价请求混进来,保证不瞎命中。
上线之后再看误判 case 往下调,用准确率倒逼召回。另外双阈值非常实用:高阈值直接命中;中阈值再比一下结构化指纹,比如关键实体、动作类型,一样才命中;低阈值直接不命中。这相当于给语义比较加了第二道保险。提醒一句,不同业务领域、不同 embedding 模型,相似度分布差异非常大。照搬论文里的 0.9、0.95,大概率是要出事的。
3. KV Cache 复用与缓存一致性的实现路径
3.1 命中之后,到底在复用什么东西
从请求处理层面看,语义缓存命中可以分两种复用等级。第一级是答案级,命中后直接把历史回复作为结果返回,完全不走模型,延迟最低,适合问答一致型业务。第二级是 KV 缓存级,命中后把历史上下文的 KV 状态加载进引擎,模型只对新消息做续写,适合用户继续追问的场景。还有些实现会折中:语义命中后不直接返回旧答案,而是把历史里命中的那段上下文作为前缀,让模型基于新的表达把答案再生成一遍,避免旧回答的语气和当前请求不匹配。
这里必须讲一个硬约束:KV Cache 复用对 tokenizer 和模型权重版本极其敏感。语义上等价的文本,token 序列很可能不同,同一个位置对应的语义单元根本不对齐,强行拼接很容易生成跑偏。所以工程上普遍是“语义机制负责找素材,token 前缀机制负责对接”:先用语义命中确定哪段历史值得复用,再用是否精确前缀命中决定能不能直连。语义命中的价值,是大幅度抬高了“能找到可复用上下文”的概率。
3.2 缓存条目管理与存储设计
缓存条目应该按会话维度管理,而不是把不同会话的历史样本全倒进一个全局池。原因很简单:不同用户、不同知识库版本、不同系统提示下的上下文,语义再接近也不能通用。一个典型的缓存条目大概长这样:
{ "session_id": "sess_0001", "context_vector": [0.001, -0.002, 0.003], "struct_fingerprint": { "intent": "howto", "entity": "python_logging", "negation": false }, "model_version": "llm_v3", "tokenizer_version": "tok_v3", "ttl": 3600, "state": "active", "hit_count": 3, "payload": { "type": "kv_cache_ref", "cache_id": "kv_0001" } }其中 model_version 和 tokenizer_version 一定要作为缓存键的一部分,否则模型一升级,旧缓存继续命中,线上立刻出问题。state 字段建议至少区分三种:active 表示可正常命中,suspect 表示命中后需要复核,frozen 表示会话已结束、等待淘汰。写缓存时机也重要,我的习惯是每轮生成完再写,推理中别写,避免把中间态写进去。
3.3 一致性维护:命中并不等于正确
语义缓存最让人后怕的是:命中了旧上下文,系统浑然不觉地返回一个显然不符合现状的答案。比如用户上一轮把方案从 A 改成 B,缓存没有立刻失效,下一轮问“那就按方案实施吧”,系统命中旧缓存,回答还在讲 A。用户会立刻觉得整个系统坏了。
所以我在判定流程里加了几条硬规则。第一,检测到否定、纠正、条件重新表述,直接失效本会话缓存,比如“不是”“算了”“不对,我是说”这类信号。第二,时敏内容设短 TTL,甚至直接禁止缓存。第三,模型权重、tokenizer、系统提示词、知识库版本任何一项变化,都按版本号维度做缓存清理。一致性维护没有花活,但它才是线上不掉链子的底线。缓存命中率再高,只要命中错一次,用户信任就崩了。
4. 工程实现细节与被验证的收益
4.1 技术栈选型与存储结构
整体上没有特别高深的东西,关键是别把业务逻辑搞丢。下面是适合快速落地的组件选择:
| 组件 | 推荐做法 | 备注 |
|---|---|---|
| Embedding 模型 | 通用句子编码器,可量化到 FP16/INT8 | 注意最大输入长度,超长要截断 |
| 向量索引 | 条目少于百万直接暴力扫描 | 数据量大了再考虑 HNSW,别一上来上索引 |
| 缓存条目存储 | 进程内字典 + LRU,多副本共享选 Redis | 版本号、TTL、状态字段不能丢 |
| 多轮状态 | 推理引擎进程内维护 | 和 KV Cache 生命周期绑定 |
容易踩的大坑反而是:为了检索性能引入一整套向量数据库,却忽略了缓存条目本身的版本、TTL、状态字段。语义缓存首先是个业务逻辑问题,不是纯检索工程。索引再快,命中了不该命中的条目,代价远大于省下的那点毫秒。
4.2 关键参数与调优记录
相似度阈值是第一个要调的参数。下面是某内部评估集上的趋势数据,只代表变化规律,不代表通用结论:
| 相似度阈值 | 命中率 | 准确率 |
|---|---|---|
| 0.80 | 36% | 92% |
| 0.85 | 28% | 96% |
| 0.90 | 18% | 98% |
阈值越低,命中率越高,但误判也越多。准确率低的时候,用户体感是“AI 答非所问”,这比省下的计算成本严重得多。所以我的选择是:初始阈值定在准确率能到 95% 以上的位置,再通过误判样本慢慢往低压。
窗口轮数也要试。窗口设 3 轮时,指代型请求经常丢掉前文信息;设到 10 轮,向量又被太多无关历史稀释,误判率明显上升。最后我保留 5 轮,并把第一轮的问题主题单独做成结构化字段。另外加了一条兜底逻辑:相似度落在临界区域的请求,不直接返回历史答案,而是走一次正常推理,但 KV Cache 可以省着用。
4.3 收益评估:延迟、显存与吞吐
从结果看,命中的请求 Prefill 延迟能降一个数量级,从几百毫秒压到几十毫秒;如果走答案级缓存,甚至到个位数毫秒。吞吐方面,大量请求跳过 Prefill,同样的 GPU 能同时接更多会话,并发上限明显抬高。
但有一个关键认知必须反复强调:语义缓存省的主要是 Prefill 计算量,不是 KV Cache 的显存。命中的请求,如果走 KV 级复用,历史 KV 照样躺在显存里;如果走答案级缓存,只是不用重新建 KV 了。指望语义缓存去救显存 OOM,基本方向就错了。收益的天花板是前面说的可缓存请求比例,它决定了理论上最多能省多少 Prefill,后面所有优化都只是在逼近这个天花板。
5. 落地过程中的典型坑与排查实录
5.1 语义误判的经典案例
误判一般集中在这三类。第一类是实体差异被忽略,比如“2 个并发实例”和“2 个并发请求”,向量距离很近,但实际需求一个要开两台机器,一个只是调一个接口。第二类是正反意图翻转,“开启 A 功能”和“别开启 A 功能”,embedding 模型很容易把两者看得很相似。第三类是相关但不等价,“怎么部署系统”和“怎么迁移系统”,相似度不低,但在业务里是两件完全不同的事。
排查方式跟做数据科学很像:把所有误判样本捞出来,聚类,看是实体冲突、意图翻转,还是相关但不等价。大部分情况下,加一个结构化指纹字段做二次校验就够了。别一上来换更大的 embedding 模型,很多时候不是模型不够好,而是输入上下文本身没把关键信息表达进去。
5.2 缓存污染与淘汰策略踩坑
有次排查很有意思。用户问“A 方案怎么实施”,命中了答案,后面几轮一直正常。到第五轮用户说“不对,A 方案不适用,改走 B 方案”,结果新的请求还是命中 A 方案缓存,系统连续三句话都在讲 A 方案。原因就是否定语义没有接入缓存失效逻辑。
修复办法很直接:检测到否定或纠正信号后,立即清空当前会话缓存;被用户修改过的上下文条目,标记成“只读不写”,可以命中但不再写入新结果;再加 TTL 兜底,会话结束后批量清场。淘汰策略不能只靠 LRU。LRU 保证的是空间,保证不了语义。用户主动修正上下文,优先级比任何被动淘汰都高。
5.3 版本切换带来的缓存失效
某团队复盘线上问题时,模型从旧版切到新版,缓存没清。结果用户问同一个问题,一部分请求命中旧缓存,返回旧模型风格的回答,另一部分正常走新模型,两边答案甚至打架。从用户视角看,就是模型忽好忽坏,过了一个多小时才恢复。
这类问题的根源都一样:版本号没有纳入缓存键。模型权重、tokenizer、系统提示词、知识库版本,任何一个发生变化,相关缓存都必须立刻不可命中。版本切换时的缓存清理,要当作发布流程的一部分来设计,不能指望运维手动清。
5.4 评测怎么做才不自我感动
单轮同义改写的命中率再高,也不代表线上效果好。真实多轮对话里,用户会指代、补充、否定、改条件。评测集至少要覆盖这样几类:同义改写(期望命中)、指代提问(期望命中)、补充背景但原问题没变(期望命中)、实体换掉(期望拒绝)、意图反转(期望拒绝)、相关但不等价(期望拒绝)。如果只在单轮改写集上测,命中率很容易虚高,一上线就被真实请求打脸。
有条件的话,用 shadow 流量做线上回放。把真实请求同时喂给带缓存和不带缓存两条链路,比较生成质量和用户后续行为。用户是否立刻编辑、是否继续纠正,这些行为数据比离线准确率硬气得多。缓存命中率不是唯一指标,命中得准比命中得多重要得多。
做完这套东西,我最大的体会是:语义缓存的上限由业务数据决定,下限由一致性保障决定。你花很多心思调的阈值,到头来可能不如把“用户否定检测”和“版本失效”做好来得值钱。最后分享一个小技巧:拿不准这个请求该不该缓存时,把它丢进“可疑”状态,允许命中,但同时在后台做一次完整推理做对比,等观察够了再决定是否放行。这套“灰度缓存”的思路,在真实业务上比任何离线调优都管用。