1. 多轮对话越聊越慢:重复 prefill 才是真正的成本黑洞
做 LLM 推理服务的人应该都有这个体感:单轮请求的时延很好控,但一进入多轮对话,会话越聊越长,响应越来越慢,显存也越来越紧张。用户反馈里最典型的一句话就是"前面几轮还好,聊到十几轮就开始转圈圈了"。
这个问题的根源不在模型本身,而在推理服务对历史上下文的处理方式。大模型推理通常分成 prefill 和 decode 两个阶段:prefill 阶段要把用户输入的所有 token 并行计算一遍,生成每个 token 对应的 K、V 向量(也就是 KV Cache),并缓存下来;decode 阶段则基于 KV Cache 逐个生成新 token。理论上,只要服务端支持把历史轮次的 KV Cache 保留在显存里,新一轮请求就不需要把整个会话从头再算一遍。
但真实生产环境没有那么理想。很多业务场景的服务端并不做严格的逐轮 KV Cache 维护:要么把完整对话历史重新拼接后整段传给模型,要么因为部署架构的原因导致前缀缓存无法命中。这种情况下,每一轮新请求都会把之前所有轮次的 token 重新做一遍 prefill。会话越长,重复计算的量越大。
1.1 KV Cache 的显存账本:为什么长会话会拖垮资源
先算一笔显存账。以一个 7B 规模、隐藏维度 4096、32 层、每层 32 个注意力头的模型为例,在 FP16 精度下,每个 token 的 KV Cache 大约占 512KB 显存(32 层乘以 32 个头乘以 128 维再乘以 2 份 K/V 和 2 字节精度)。这意味着:
| 上下文长度 | 单会话 KV Cache 显存占用(估算) | 100 个并发会话 |
|---|---|---|
| 512 token | 约 256MB | 约 25GB |
| 2048 token | 约 1GB | 约 100GB |
| 8192 token | 约 4GB | 约 400GB |
这个量级说明两个问题:第一,长会话的 KV Cache 本身就昂贵;第二,如果每一轮都重新 prefill 全部历史,那不只是浪费显存,而是在成倍地浪费算力。假设一个会话平均 2000 token,用户每轮新输入只有 100 token,那么模型实际有效计算量中的 95% 都花在历史 token 的重复计算上。
我之前在内部做过一次简单的 trace 统计,在一个模拟的助手型产品里,大约有 23% 的请求在语义上和历史轮次高度近似——用户换了个措辞问同一个问题,或者把之前的请求微调后重发,甚至直接复制粘贴追问。这些请求本不该重新消耗一次完整推理。
1.2 精确缓存为什么救不了多轮场景
最直观的办法是做精确匹配:把整个对话上下文的文本拼起来,算一个哈希,再存一个 KV Cache 索引。下一次请求如果哈希值完全一致,就直接复用缓存。
这个方案在单元测试里看起来很合理,一上线就露馅。因为真实用户不会原封不动地重复一句话,哪怕只是把"谢谢"改成"非常感谢",把标点从半角变成全角,哈希值就完全变了。更不用说多轮对话里大量出现的指代和改写,比如用户上一轮说"帮我对比这三款手机",下一轮补充"顺便加上那个红色那款",这里的"那款"需要结合历史上下文才能理解成"某具体型号"。字符串层面完全匹配是不可能的。
精确前缀缓存则更脆弱:它不仅要求当前轮文本一致,还要求之前所有轮次逐字一致。只要用户在第三轮把问题改了一个字,第四轮开始之前共享的上下文就无法复用了。
这也是我决定做语义缓存这个方向的原因:KV Cache 解决的是 token 级的重复计算,但语言表达的多样性让 token 级复用永远存在盲区。真正想降低多轮推理的成本和时延,需要在更抽象的语义层去做匹配。
2. SmartCache 的缓存键设计:从 token 哈希到分层语义指纹
明确了目标之后,第一个要解决的问题是:缓存键到底应该是什么。
在单轮语义缓存的常见做法里,直接用当前用户问题文本的 embedding 做检索,命中就返回缓存答案。这个思路搬到多轮对话上会立刻出问题——多轮请求的语义不在当前这一句话里,而在"系统提示词 + 历史对话 + 当前问题"的组合中。
2.1 为什么缓存键不能只包含当前问题
举一个我在实测中遇到的真实案例。用户先问"周末去杭州玩三天,预算五千以内,帮我排一个行程",助手给出行程后,用户追问"那家酒店的早餐值不值得吃"。单独对"那家酒店的早餐值不值得吃"做 embedding,模型根本不知道"那家酒店"指哪个,向量也会落在完全无关的空间里。
如果把整个历史对话拼在一起做 embedding,又会出现另一个问题:历史越长,语义越被稀释。前五轮都在聊旅行,第六轮突然问"明天的会议纪要帮我整理一下",全量拼接后的 embedding 大概率会漂移到"旅行"主题附近,检索结果完全不可用。
我在 SmartCache 里采用的做法是分层语义指纹:把多轮上下文拆成粗粒度的结构化标签和细粒度的向量两个层级,分别承担"范围过滤"和"语义近似"的职责。
2.2 粗粒度标签:可解释的硬过滤条件
粗粒度标签是从上下文中抽取的结构化信息,通常是 JSON 形式,包括几个核心字段:
- 意图域:比如"旅行规划""代码生成""文档总结""售后咨询"。
- 任务骨架:描述整个多轮会话在围绕什么主线进行,例如"用户要求推荐杭州市区酒店并对比价格"。
- 活跃实体集合:当前会话中仍然生效的地名、人名、商品名、数字约束等。
- 变更指令标志:这一轮是否包含"不要""改成""加上""去掉"这类会让旧答案失效的变更指令。
- 话题切换标记:判断当前问题和最近历史是否属于同一主题,如果是突然切换,旧缓存必须全部失效。
标签的作用是做硬过滤。向量相似度再高,如果意图域不同,或者活跃实体集合冲突,就不该命中。比如用户问"帮我写一封请假邮件"和"帮我写一封投诉邮件",两者的 embedding 距离很近,但意图域和应用结果完全不同,必须通过标签提前排除。
2.3 细粒度向量:裁剪后的上下文编码
细粒度向量负责在同一个主题范围内做近似匹配。这里的关键不是对"全部历史"做 embedding,而是构造一个上下文编码视图,规则很简单:
- 系统提示词单独截断到最近 512 字符参与向量化,避免模型指令的冗长描述干扰语义;
- 历史对话按轮次压缩,每轮只保留"说话人身份 + 核心要点",发言人标记(user/assistant)会作为前缀拼进文本;
- 当前用户的原始输入全量参与,并且在组合时抬高权重——我可以把当前问题在文本里重复一遍,比如按"当前问题 + 当前问题 + 截断历史"的拼接方式,让模型注意力天然偏向最新意图;
- 超过 embedding 模型最大长度时,优先丢弃最老的、与当前主题标签无关的轮次,而不是简单从头截断。
2.4 embedding 模型的选型与归一化
多轮对话场景对 embedding 模型的要求和通用检索不太一样。通用 embedding 擅长捕捉单句语义,但对"谁在什么时候说了什么"这种带角色的结构不敏感。我在实践中的处理是:不追求超大模型,选一个百 MB 量级、支持较长输入序列的通用 encoder 就够,关键是做好文本归一化。
我用的归一化包括:统一说话人分隔符、把系统消息里频繁变化的指令模板做脱敏、将数字和单位统一格式、对用户消息不做任何改写直接在原文两侧加"user:"标记。这套操作比换一个更大更贵的 embedding 模型带来的收益稳定得多。
3. 命中判定与三层安全网:整体相似度并不等于答案可复用
语义缓存最大的风险不是"缓存 miss",而是"错误命中"——返回了一个看似相关但实质错误的旧答案。这个风险在单轮缓存里已经存在,在多轮上下文下会被放大,因为多轮请求可以因为一个微小的变化就要求完全不同的答案。
所以 SmartCache 把命中判定拆成三层:候选召回、变更指令检测、答案校验。
3.1 第一层:标签预筛与向量召回
查询先走粗粒度标签过滤。标签不匹配的候选直接丢弃,然后在剩余候选中用向量检索取 top-k。这里我使用的是一个基于 HNSW 图索引的向量检索组件,单个节点就能支撑百万量级指纹的召回,P99 查询时延在 10 毫秒左右。
相似度分数采用余弦相似度,但分数本身不会直接当作命中依据,而是映射成三个区间:
| 区间 | 行为 |
|---|---|
| 分数高于接受阈值 | 直接命中缓存,返回缓存答案 |
| 分数高于候选阈值但低于接受阈值 | 进入变更指令检测,判定为"半命中" |
| 分数低于候选阈值 | 判定为 miss,走完整模型推理 |
阈值具体取多少不能拍脑袋,我会在第五部分专门讲调参时踩的坑和离线回放评估方法。
3.2 第二层:变更指令检测,这是多轮语义缓存和其他缓存的核心差异
假设用户上一轮问"帮我推荐一款适合编程的轻薄本,预算八千以内",助手给出了答案。下一轮用户说"预算提高到一万,重量再轻一点"。此时上下文整体相似度可能高达 0.90,如果直接命中缓存,返回的老答案完全无效。
SmartCache 的做法是:当候选分数落在半命中区间时,先对当前用户输入做一轮轻量级变更意图分析。检测逻辑包括:
- 是否出现"不要、取消、去掉、排除"等否定型指令;
- 是否出现"加上、补充、还有"等新增型指令;
- 是否出现新实体、新数字约束,并且和历史参数冲突;
- 是否出现话题切换信号(比如突然从"旅行"跳到"会议")。
如果确认没有任何变更指令,走直接命中;如果检测到变更,走"半命中"路径:保留历史上下文的 token 级 KV Cache 复用,让模型只对新增和变化的片段做增量 prefill 和 decode。这个路径仍然能省掉大量重复计算。
我在实现里甚至把变更指令检测做成了一个小模型分类器,输入当前用户消息和候选缓存的上下文摘要,输出一个"是否修改历史上下文"的三分类结果。实测比纯规则引擎的准确率高出不少。
3.3 第三层:答案级校验
即使相似度分数很高、也没有明显的变更指令,我仍然不直接信任缓存。答案级校验会抽取缓存答案中的关键信息点(数字、名单、日期、清单项),和当前请求的约束做一一比对。校验逻辑是硬规则:如果用户当前请求里明确提到"预算八千",但缓存答案中推荐的产品价格区间超出这个范围,校验一定失败。
校验失败时的策略是降级:不返回缓存,直接走模型推理,并把这条缓存的权重下调。这个设计很关键,它可以保证语义缓存作为一个优化层不会把错误扩散到用户侧。
4. 落地实现:从缓存写入到召回验证的完整链路
理论设计讲完,说说工程落地。我把 SmartCache 做成一个独立的语义缓存服务,挂在 LLM 推理网关旁路,对业务侧透明。
4.1 核心组件与请求流程
服务内部有四个模块:上下文签名器、向量索引、缓存存储、答案校验器。请求进来后的完整链路是:
- 网关收到多轮请求,把当前会话的完整上下文交给上下文签名器;
- 签名器生成粗粒度标签 + 细粒度向量;
- 查询向量索引,按标签过滤、向量召回 top-k 候选;
- 对候选做相似度区间判断和变更指令检测;
- 命中则通过答案校验器做关键信息比对,通过后直接返回缓存答案;
- 未命中或校验失败则进入模型推理,推理完成后把新结果写入缓存。
缓存写入也有讲究。我在设计中采用"延迟写入"机制:模型推理完成后先写入一个 pending 状态,此时该记录不参与召回;只有当用户没有发起"重新生成"或对结果做负面反馈时,才在下一个时间窗口把状态置为 ready。这么做的原因是,很多产品里用户会反复点击重新生成,第一个结果未必是最终采纳的答案,如果急着写缓存,后续就会把垃圾答案反复曝光。
4.2 缓存记录结构
每条缓存记录大概长这样:
{ "cache_id": "fc3a8b...", "context_fingerprint": "sha256(system_version + coarse_tags)", "coarse_tags": { "intent_domain": "travel_planning", "active_entities": ["hangzhou", "3_days", "budget_8000"], "task_branch": "hotel_recommendation", "context_switch": false }, "dense_vector_id": "vec_7781f2", "answer": { "text": "...", "key_facts": {"budget": 7500, "check_in_date": "2025-06-20"} }, "meta": { "system_prompt_version": "v3.1.0", "ttl": 3600, "hit_count": 128, "feedback_score": 0.93, "state": "ready" } }这里 context_fingerprint 不是全文本哈希,而是"系统版本 + 粗粒度标签"的稳定哈希,用来在系统升级或会话主题切换时快速失效整批缓存。dense_vector_id 指向向量索引中的浮点向量。key_facts 是一个很关键的结构化字段,它让答案校验不再依赖重新解析自然语言,而是直接比对结构化的关键约束。
4.3 淘汰与失效策略
缓存一定会脏,关键是脏得可控。我用的淘汰策略有三条:
- LRU 淘汰:按命中率加权的 LRU,长期不被命中的冷门上下文优先踢出;
- TTL 失效:不同意图域设置不同的生命周期,比如天气和政策类缓存 TTL 很短,攻略类和代码模板类可以长一些;
- 版本号失效:系统提示词模板一旦升级,所有旧记录通过 context_fingerprint 前缀全部失效,避免模型行为变更后旧答案继续污染线上。
另外,用户在会话中明确说"重新开始""忘了之前的吧"时,网关会发送一个会话重置信号,语义缓存层直接把该会话相关的记录全量标记为 invalid。这个信号比任何相似度算法都可靠,因为它是我能把握的用户真实意图。
4.4 监控指标:别只盯着命中率
语义缓存上线后,最容易犯的错误是整天盯着命中率。我后来把监控面板换成四个指标组合:
- hit_rate:请求命中缓存的比例;
- precision@hit:抽样评估命中结果是否真的被用户采纳(后续是否继续追问、是否修改),而不是只看离线标注;
- saved_tokens:因为命中而避免重复 prefill 的 token 总量,这是成本收益的核心指标;
- cache_pollution:一段时间内出现"缓存返回后被用户纠正"的比例,这个指标比 hit_rate 更能在早期反映阈值失控。
只用 hit_rate 做绩效考核,团队一定会为了指标好看把阈值无限调低,最后换来一堆错误复用的事故。
5. 实测效果与三个典型调参踩坑
SmartCache 在模拟项目 X 的离线会话集上做了回放测试,又在两个内部业务场景里跑了小流量 A/B。先说结果,再说踩过的坑。
5.1 基线对比数据
测试集包含 4 类多轮会话:旅行规划、售后咨询、代码问答、文档改写,每类各约 5000 条完整会话链。三组对照组是:无缓存、精确字符串前缀缓存、SmartCache 语义缓存。
| 方案 | 端到端 P50 时延 | 端到端 P95 时延 | 缓存命中率 | 答案采纳率(抽样) |
|---|---|---|---|---|
| 无缓存 | 1.85s | 4.2s | 0% | 基准 |
| 精确前缀缓存 | 1.74s | 4.0s | 8.2% | 接近基准 |
| SmartCache 语义缓存 | 0.91s | 2.1s | 26.7% | 略低于基准但差异不显著 |
时延降低主要来自两处:直接命中时省掉了完整 prefill 和大部分 decode;半命中时虽然仍要走模型,但历史上下文只做增量计算。P95 的改善比 P50 更明显,因为长会话原本是时延拖尾的重灾区,而语义缓存恰好最容易在长会话中命中。
5.2 踩坑一:阈值拍脑袋,换来看似合理但实际错误的高相似度命中
第一个版本里,我把接受阈值定在 0.85,理由是"看起来差不多"。上线一天就出现了一次严重事故:用户先问"客服电话为什么打不通",得到一段关于客服时间表的回答后,紧接着问"退款申请多久能到账",系统直接命中了上一轮的缓存,返回了客服时间表。
事后分析发现,这两个问题在通用 embedding 模型里的余弦相似度竟然高达 0.87。原因是"客服"和"退款"这两个词在训练语料里频繁共现,模型把它们映射到了相近的语义区域。这给我两个教训:第一,阈值永远不能用感觉定,要用离线回放数据分布来定;第二,向量相似度在自然语言层面有多义词和共现偏差,粗粒度标签的硬过滤不是可选项,而是必修课。
后来我把接受阈值改成"离线回放时误命中率低于万分之一"来校准。不同业务场景的值差异很大,代码生成类通常要 0.92 以上,闲聊类可以放宽到 0.78。
5.3 踩坑二:全量历史做 embedding,新意图被历史稀释
这是多轮语义缓存最典型的问题。有一次测试里,前五轮用户都在规划旅行,第六轮突然问"明天上午的会我不去了,帮我写一封请假邮件"。这个请求的正确答案应该是一封邮件,但全量上下文 embedding 做出来的向量仍然离旅行规划主题非常近,召回排在前面的全是酒店和行程相关记录。
解决办法就是我在第二章节提到的上下文编码视图:历史参与但必须压缩,当前问题全量参与并提高权重,同时用粗粒度标签里的 context_switch 信号强行切断旧缓存。
我还会对话题切换加了一个额外保护:当标签分类器认为当前问题和最近 3 轮历史不属于同一话题时,直接跳过向量相似度评估,把整条请求当作全新会话处理,不给任何历史缓存机会。
5.4 踩坑三:embedding 模型长度限制下,截断位置决定成败
第一次实现里,上下文超过 embedding 模型最大长度时,简单地从开头截断。结果长会话缓存几乎全部失效,因为在超过长度后,最早的指令和目标约束是全被截掉的,留下的只有最近几轮闲聊和用户的操作记录,编码视图的语义完全变了。
改成"分块-加权-汇总"之后好很多:把长上下文按轮次切成块,每个块单独编码,再按"离当前时间越近权重越高"做加权平均。系统提示词里真正影响行为的关键段落单独编码并保留最高权重。这个方案规避了 embedding 模型长度限制,而且对旧轮次偶尔被挤掉的情况更鲁棒。
5.5 语义缓存适用的风险分级
经过这几次踩坑,我总结了一张调参参考表:
| 任务类型 | 接受阈值参考 | 是否允许直接命中 | 半命中策略 |
|---|---|---|---|
| 闲聊、生活建议 | 0.78 - 0.82 | 允许 | 增量复用 |
| 旅行攻略、产品推荐 | 0.86 - 0.90 | 允许,但校验关键约束 | 增量复用 |
| 代码生成、合同审查 | 0.92 以上或关闭缓存 | 谨慎,需人工确认 | 只复用上下文,不返回缓存答案 |
这份表不是金科玉律,但可以作为一个起步参考。核心原则是:误判的后果越严重,阈值就要越严,甚至可以直接关掉缓存;宁可让缓存命中率难看,也不能牺牲正确率。
6. 把两层缓存真正叠起来:语义缓存与 Token 级 KV Cache 的协同
语义缓存落地之后,团队的注意力又回到了一个老问题:语义缓存 miss 的时候,多轮推理的性能依然会有长尾劣化。这两个层次的缓存不应该是对立关系,而是接力关系。
6.1 语义缓存的边界,正好是 KV Cache 的主场
语义缓存能直接返回答案,但只有高度近似的场景才能命中。更多的情况是用户"在同一个话题下改变一个条件",比如"预算从八千提到一万"——这个请求在语义上不应该直接复用旧答案,但它的历史上下文(城市、景点、主要诉求)对新请求仍然有用。
这种场景正是 token 级 KV Cache 的用武之地。服务端如果能维护好会话级的 KV Cache,并在每轮新请求时只对增量部分做 prefill,模型推理的耗时可以从"完整上下文 prefill"降为"增量 prefill + 全部 decode",整体时延收益也非常可观。
6.2 前缀重建与半命中路径的配合
我在上线 SmartCache 后,把语义缓存的"半命中"路径和前缀缓存彻底打通。流程变成:
- 语义缓存层判定当前请求属于"半命中",也就是历史上下文基本成立,但用户新增了变更条件;
- 把变更后的请求交给推理服务,推理服务检查会话的 KV Cache 是否仍然有效;
- 如果 KV Cache 里的历史前缀与当前请求一致,直接复用并增量 prefill;
- 模型只针对变更部分生成新的回答。
这个组合链路带来的收益比我预想的大。旅行规划场景中,完整上下文 1200 token,用户每轮新增只有 80 token,半命中路径下的模型 prefill 计算量大约只有原来的 6.7%。
6.3 缓存预热和反馈闭环
语义缓存有一个冷启动期:新会话模板上线前,头几天命中率都很低。我在离线侧做了一个预热管道,把历史会话库里高频出现的多轮场景先批量打标、编码、写入缓存,等线上流量进来时已经有一部分候选可用。
反馈闭环也很重要。我会把用户对缓存答案的后续行为纳入权重:如果用户紧接着追问"不对,我不是这个意思",或者点击了重新生成,这条缓存记录的 feedback_score 就会下调;下调到一定程度后不再参与召回。这个机制比定期人工抽检及时得多,能自动捕捉语义漂移和模型行为变化带来的缓存污染。
6.4 一些没有绕过去的边界问题
语义缓存不是万能的。我做的这个版本里,依旧有几个场景完全不建议开启缓存:
- 强时效性内容:实时行情、天气、库存数量,任何带"现在"含义的请求都不该走缓存;
- 强正确性要求:医疗建议、合同数字、法律条款,这类场景除非有人工确认环节,否则收益远小于风险;
- 个性化强烈的内容:同样是旅游攻略,用户 A 和用户 B 的偏好差异很大,跨用户复用容易产生"看似合理但不适合"的错误答案。
我的处理是给每个缓存记录加一个 scope 字段,标识它是"全局可复用"还是"仅限单会话复用"。默认严格按会话维度隔离缓存,只有那些被证明跨用户安全的系统级回答才允许进入全局池。
做完整套 SmartCache,我最深的体会是:语义缓存本质上是"在正确性可容忍的范围内,用近似匹配换取延迟和算力"。它不是存储组件,而是一个语义路由决策层。上线之前,先花时间统计自己业务里的语义重复请求占比,如果不足 5%,这个方向带来的收益会非常有限;如果超过 20%,那它大概率会是继 KV Cache 之后又一个值得投入的性价比高地。离线回放评估阈值这一步尤其不要省,拿历史会话先跑一遍,比上线后反复调参省力得多,也安全得多。