上个月排一个线上问题我们的对外AI问答服务上线第三周GPU没有扩容但模型调用账单涨了快三倍。查了一圈下来最扎眼的还不是成本平均每天十几万请求里差不多一半是“语义重复”——“附近有什么川菜馆”“川菜馆哪家好”“推荐一家川菜馆”这三个问题在我们的服务里各自走了一次完整推理谁也没占到谁的便宜。当时第一反应是加内存缓存按文本哈希存请求。结果上线后命中率只有百分之十几因为人的问法太飘完全一模一样的文本远没有想象中多。后来把方案收敛成一套从 KV Cache 到语义缓存的三层缓存架构成本才压下去p95 延迟也从 1.9 秒降到 400 毫秒左右。这篇文章把这套架构每层的原理、参数、踩坑点和协同方式原原本本写一遍适合正在用大模型做线上功能的团队参考。1. KV Cache 先想明白它是显存换算力不是传统意义的缓存1.1 生成过程里最大的重复劳动在哪先看底层。大语言模型做文本生成的时候需要把输入序列逐 token 过一遍 Transformer。真正耗算力的地方是自注意力每个新 token 的输出都要和前面所有 token 算一遍相关性。不缓存 K/V 的情况是这样的生成第 1 个 token要把注意力算到第 1 个 token生成第 2 个 token理论上又要把注意力算到第 2 个 token生成第 N 个 token就要把从 1 到 N 的注意力全部重新算。整段生成的复杂度是 O(N²)而实际上前 N-1 个 token 的 Key 和 Value 矩阵在算第 N-1 个 token 时就已经算出来了丢掉它们就是在重复造轮子。KV Cache 做的事情简单粗暴把这些已经算好的 Key 和 Value 矩阵放到显存里下一个 token 做注意力时直接把缓存里的 K/V 取出来只计算新 token 自己的 Query 和它们的点积。这一改decode 阶段从反复计算历史变成查表同样的长文本生成速度能差出好几倍。这也是为什么 vLLM 这类推理框架敢说自己吞吐量翻倍的原因之一。1.2 显存账怎么算一条长上下文就要吃几百兆有了缓存就得还显存的债。KV Cache 的占用公式可以粗略写为单条序列显存 ≈ 序列长度 × 隐藏层维度 × 层数 × 2Key 和 Value 各一份× 精度字节数拿一个隐藏层维度 4096、32 层的 7B 级别模型举例上下文长度 4096使用 BF16 精度4096 × 4096 × 32 × 2 × 2 1,073,741,824 字节 1GB一长条对话就吃掉 1GB 显存。如果线上并发 32 条这样的长序列32GB 显存直接没了一半。很多团队说“大模型显存不够”其实模型权重只是一个部分KV Cache 往往才是偷偷消耗显存的大头。这也是为什么 KV Cache 不能无限堆。它本质是拿显存换算力给生成过程提速但显存本身有物理边界。1.3 三个主流手段分页管理、GQA、量化既然显存有限工程上从三方面下手。第一个是分页管理。早期推理框架给每条序列预分配一整块连续显存不管实际用多少先按最大长度预留。长上下文场景下预留空间往往一半以上是空的物理碎片也很多。后来主流的推理框架改成按块分配每个块放固定数量的 token序列长度变化时可以按块扩展不连续的内存块也能通过一张映射表串起来。这其实就是把操作系统的分页思想搬到了 KV Cache 管理上。它解决的问题很直接缓存利用率大幅上升还能把暂不用的块换出到 CPU 内存需要时再换回来。第二个是 GQA也就是分组查询注意力。标准的多头注意力里每个注意力头都有一份独立的 K/V缓存量跟着 Q 头数量线性涨。GQA 的做法是让一组 Q 头共享同一组 K/V 头KV Cache 体积可以缩小到原来的 1/8 甚至 1/16。现在主流的大模型基本都用了这个结构不只是为了推理快也是为了省显存。第三个是 KV Cache 量化。把 Key 和 Value 从 BF16 压到 INT8 或者 FP8显存直接减半再激进一点用 INT4减到四分之一。实测下来INT8 在绝大多数任务上跟 BF16 的差异不明显INT4 就需要谨慎最好是先在自己的评测集上跑一遍确认关键任务没有劣化再上线。1.4 为什么 KV Cache 管不了跨会话的重复回到架构层面KV Cache 有天然的边界它跟着会话走用户开一个新会话前面的 KV Cache 全部失效所有 prompt token 都要做一次完整的 prefill。这意味着没有任何办法用 KV Cache 去解决“两个不同用户问同一个问题”的重复计算。就算同一用户把同一个问题换了一种说法重新提交KV Cache 也帮不上忙因为输入序列已经变了之前算过的 K/V 跟新序列没有关系。所以AI 应用的缓存不能只靠推理引擎内部那一层必须往应用栈上层走。缓存的目标从“加速单次推理”变成了“避免整次推理”这才引出后面的请求级缓存和语义缓存。2. 第二层请求级精确缓存——确定性是最便宜的防错2.1 哪些流量该在第一道缓存就被拦住请求级精确缓存说白了就是拿请求文本当 key在进入模型之前先查一遍内存存储完全一致就直接返回。很多人觉得这层技术含量低但它其实是整套缓存架构里性价比最高的一层因为逻辑简单、零语义误判、实现成本极低。我建议先做这层再考虑语义缓存。理由很实际语义缓存要引入向量化、相似度检索和阈值判断哪一个环节出问题都可能返回错误答案而精确缓存不会。它只认完全一致的内容最多带上一些正则和规范化处理。哪些流量适合在这一层拦截我列几个高发场景用户点击“重试”命令没变直接重发前端超时但实际请求已成功客户端自动重放客服入口里被 FAQ 引导过的固定问题内部工具链里定时跑的同一条分析指令。这些请求没有语义变化只是同一条命令反复进来第一层缓存拦住它们能省掉后续向量检索和模型推理的完整链路延迟最少省几倍。2.2 Cache Key 设计里的隐藏变量请求级缓存最容易被低估的是 Cache Key 设计。很多人直接把用户输入的字符串拿来当 key上线后就会发现缓存错答案。原因在于大模型应用的最终输出不仅取决于用户文本还取决于系统提示词、模型版本、采样参数、上下文状态。同一个问题“请用一句话介绍你自己”和加了长系统提示词之后的“请用一句话介绍你自己”模型得到的上下文完全不同答案也可能不同。如果都用原始文本做 key第二个请求就会命中第一个请求的缓存返回一个缺少系统提示词约束的答案。我实际维护的 key 结构大概是这样的规范化后的用户文本系统提示词的哈希值模型版本号温度、核采样参数如果服务包含多轮上下文还要加上上下文快照的哈希或显式标记“该请求不走缓存”。规范化也不是简单去空格。中文场景下我一般会做全半角统一、去掉首尾空白和多余换行数字格式统一但不要做过度的分词或同义词替换那会破坏“精确”的本意。2.3 TTL 与命中率精确缓存的上限TTL 设计上我一般不会用一个全局值。默认短 TTL几分钟到一小时视业务而定。对于确认会出现高频问题的模板型请求单独建一个热点表把它们标记为长 TTL比如 24 小时甚至一周只要热点不被淘汰就一直复用。但精确缓存的天花板很快会露出来。我见过不少客服、知识库问答类的线上项目文本级精确缓存的命中率普遍在 10% 到 25% 之间。用户会用不同语序、不同词汇问同一个意思文本完全一致的请求并没有想象中多。命中率到这个水平整体成本下降不明显p95 延迟也只改善了一部分。要再往上一层走就得让缓存识别“话不一样意思一样”语义缓存该上场了。3. 第三层语义缓存——把“意思相同”当命中条件3.1 一条请求在语义缓存里的完整旅程语义缓存的基本思想和搜索引擎做召回一致把用户请求编码成一个向量在向量空间里找跟它最接近的历史请求再用相似度阈值决定是否命中。我落地时的链路一般是这样的请求规范化用开源的向量模型把请求文本编码成向量在向量索引里做 TopK 检索取相似度最高的 1 条或 3 条候选计算候选与当前请求的余弦相似度超过阈值将候选对应的缓存响应返回未超过走模型真实推理模型返回后做脱敏、模板化、可缓存性判断异步写入向量索引。组件选型上我会劝很多团队不要一上来就上重型分布式向量库。语义缓存的请求量通常没有大到需要单独一个集群的程度用 PostgreSQL 的向量插件或者内存数据库自带的向量检索能力就够了。能少一个组件就少一个组件部署和运维成本同样是成本。向量模型的维度也是够用就好。768 维和 1024 维是大多数场景的甜点区再往上维度翻倍检索延迟涨得比收益快。3.2 阈值怎么定宁漏勿错先抓同义改写阈值是整个语义缓存里最重要的参数也是调参时最容易踩坑的地方。开个玩笑说阈值调得好不好直接决定你的缓存是帮你省钱还是帮你惹事。我按余弦相似度给几个经验区间相似度区间含义建议0.95 以上近似复读基本就是同一条话大胆命中0.90 ~ 0.95同义改写安全度较高可以命中但要抽检0.85 ~ 0.90开始出现意图漂移谨慎尽量不命中0.85 以下高概率是不同的意思不要命中0.85 到 0.90 这个区间最危险。我曾经碰到过一组真实案例“我想订明天的房间”和“明天我想退房”向量相似度超过 0.85。从字面上看都是明天和房间但一个订一个退语义完全相反。如果阈值设在 0.85这类请求会被缓存直接命中返回一个完全错误的答案。我的建议是上线初期把阈值设得保守一点比如 0.92 或 0.90宁可漏掉一部分可命中请求也不要因为误命中出脏数据。跑两周之后每天抽查相似度在阈值附近的真实请求确认没有反义、换意图的情况再一点一点往下探。这个下探速度要很慢每次降 0.01 到 0.02 就够了。3.3 缓存响应要模板化别把别人的订单答案发给你比“误判同类请求”更危险的是“命中正确的相似请求却返回了不该复用的响应”。假设 A 用户问“我订的酒店几点退房”系统回了“您预订的酒店 12 点前退房”。B 用户问“我订的酒店几点退房”从语义上几乎完全一致如果缓存直接复用 A 的响应B 就拿着一句写着他自己名字和订单号的话里的错误信息。这个问题的解法不是靠阈值而是在写入缓存前做响应模板化。把响应里的动态内容抽出来比如用户名、日期、金额、订单号、城市名全部替换成占位符再把模板存入缓存。命中缓存时根据当前请求上下文把占位符填回去。如果做不好模板化干脆就限制语义缓存只对静态知识型问答生效任何涉及个性化信息的请求直接跳过缓存。结合业务实际我会把请求分成三类静态知识类可以缓存带少量动态实体但能模板化的谨慎缓存强个性化的一律不缓存。3.4 失效与更新语义缓存不能粗暴 TTL语义缓存的失效比精确缓存复杂。原因在于精确缓存里同一条文本只对应一种答案语义缓存里一个向量簇可能覆盖几十种问法并且所有问法都命中共用同一个响应。一旦这个响应过期影响面会被放大几十倍。语义缓存非常适合业务侧显式失效后台有“知识库已更新”“课程已下架”这类信号时把对应的请求模板和它的向量记录一起删掉。如果只靠系统自动 TTL最怕出现的是业务方上午改了商品价格用户下午问“现在多少钱”缓存里还是上午的旧价而且所有同义问法都命中了旧答案用户和客服都会被误导。所以语义缓存我会设置相对保守的 TTL同时对可以标记实时性的请求天气、价格、库存、榜单直接绕开缓存。另外要定期清理冷向量。向量库里大量长期没命中过的旧向量不仅占存储还会拉低检索精度因为它们会让一些相似但已经没意义的请求“碰巧中奖”。4. 三层协同请求流向、单飞合并与成本收益4.1 一次请求从进入到返回的完整路径三层缓存不是各干各的它们在一条完整链路里按顺序工作。把请求进来到回源模型的过程展开请求进入服务先做业务层面的脱敏和可缓存性判断第一道精确缓存文本完全一致直接返回第二道语义缓存向量相似度达标直接返回两道都没命中进入推理引擎模型在 decode 阶段靠 KV Cache 加速生成返回响应后做脱敏和模板化异步写入语义缓存。这里有个顺序细节为什么精确缓存一定要放在语义缓存前面因为它的成本更低、零误判、还省一次 embedding 计算。如果每一条请求都先跑一遍向量化再查语义缓存哪怕最终命中整个过程也会拖长。精确匹配挡掉一部分重复请求后进入语义层的量会小很多。4.2 写入侧设计异步回填、脱敏、可缓存性判断缓存不是“模型返回了就往库里写”这么简单写入侧比读取侧更容易被忽略。模型返回后我强烈建议别在响应路径上同步写向量库。向量化、建索引、写存储都有耗时用户还等着拿结果同步写会把本来已经省下来的延迟又还回去。正确做法是先把响应返回给客户端然后把响应放到本地队列或者消息队列里由后台任务做脱敏、模板化、判可缓存性、向量化再写入语义缓存。脱敏这一步尤其重要它不只是合规要求也是缓存本身的要求。把任何带用户身份、联系方式、地址的响应写进共享缓存都是埋雷后面任何其他用户命中这条缓存都会拿到不该拿的信息。我会在缓存之前做一层强制过滤包含手机号、微信号、住址、订单号但无法模板化的响应一律不写缓存。4.3 缓存击穿时刻的合并回源三层缓存架构上线后最容易被忽视的故障点是缓存击穿。精确缓存和语义缓存同时未命中紧接着同一时刻又有大量同义请求进来系统会怎样如果每个请求都独自去调模型缓存层在那一刻就形同虚设后端可能被打出大量重复推理。尤其当某个运营活动把高频问题顶上热搜时几百个用户同时问“怎么参与活动”几乎一模一样的请求会同时到达。解法是单飞合并。基本思路是同一个缓存 key 或同一个语义候选集合在第一个请求开始回源模型时把后续相同请求挂起等待第一个请求返回后把结果同时分发给所有挂起的请求。这样一万个并发同义请求模型只跑一次剩下的全部从这个返回结果里拿。很多语言的基础库里都有现成的 singleflight 库直接用就行不要自己用锁拼一不小心会把死锁问题带进线上。4.4 简单算一笔账三层叠加到底能省多少用真实场景粗算一下收益。假设单次模型调用的成本按输入输出 token 累计计费均值约 X 元无缓存时 100 个请求有 100 次要落到模型。加入三层缓存后假设精确缓存命中率 12%语义缓存命中率 28%总计约 40% 的请求不需要调用模型成本端省下约 40% 的模型调用费用延迟端缓存命中的 p95 在几百毫秒量级模型回源请求的 p95 通常在 2 秒左右整体显著拉低容量端相同 GPU/服务配额下模型推理并发压力减少排队概率下降又反过来让回源请求的延迟更稳定。这里我不给一个具体金额因为各家模型单价差异大但“命中率直接换算成成本下降比例”这个逻辑是通用的。三层缓存的收益是可以量化出来的这也是我建议每个团队在上缓存前先把指标基线打好的原因。5. 实战中绕不开的坑穿透、版本升级、实时性冲突5.1 语义层同样会被穿透多数人聊缓存穿透时只聊精确缓存但语义缓存同样会被穿透而且更隐蔽。有人用脚本改了请求里的一个数字或一个地名比如把“推荐附近 10 人聚餐的餐厅”分别改成“11 人”“12 人”“13 人”再重复提交。这些请求在语义层面的相似度已经超过了阈值却无法命中任何缓存因为每一条都是新向量都会触发一次完整模型推理。比普通穿透更头疼的是每一条都还要先过一遍 embedding向量计算本身也白白消耗资源。我的应对手段是请求级频率限制、对高频来源打请求指纹、把一段时间内相似度很高的未命中请求聚成一个簇簇内做一个共享的“最近已回源”标记防止同簇请求反复穿。语义缓存不是搜索引擎它不应该为恶意吞吐的行为买单。5.2 模型升级后旧缓存怎么回流模型版本升级是缓存架构最容易翻车又最容易被遗忘的事。模型从 V1 升到 V2 后同一个问题大概率会得到不同的答案哪怕只是 prompt 微调、安全对齐调整都会改变输出。如果缓存还在按旧 key 复用 V1 的答案用户就会觉得模型升级了个寂寞甚至看到更差的结果因为旧的错误答案被缓存和向量索引长期固化。我在升级时都会把两步做成流程规范第一步Cache Key 里带上模型版本号使精确缓存自然隔离第二步语义缓存按模型版本做分区升级后流量切到 V2 的空分区新分区冷启动预热。预热可以把高频 FAQ 提前用 V2 模型跑一遍写入 V2 的语义缓存避免升级当天大面积缓存穿透。旧分区保留足够长的观察期确认稳定后再清理。5.3 实时性请求该绕缓存就绕缓存缓存天然的敌人是实时性。越灵活的缓存越容易把旧数据以“语义正确”的方式送到用户面前。比如“今天天气怎么样”和“明天天气怎么样”字面只差一天语义相似度相当高但答案完全不同。价格、库存、比分、榜单、股价都属于强实时数据。它们就算被缓存写进去了命中一次旧数据就会出一次事故。我的做法是在业务层提前打标记。请求解析阶段发布一个“实时性”标签带了这个标签的请求直接跳过精确缓存和语义缓存走实时链路只有确认是慢变或静态的内容才允许进入缓存。缓存架构不能只考虑命中敏感数据的实时性底线比缓存收益重要得多。5.4 需要盯的指标命中率、相似度分布、误判抽样缓存上线后我通常会拉一套专属监控不看这套监控等于没上缓存。四个指标最核心每层缓存命中率分精确层和语义层单独统计出问题时能定位是哪一层失效相似度分布直方图看阈值附近的请求有多少评估调阈值空间缓存误判抽样定期人工检查被命中但事实上不该命中的请求这是兜住底线的手段命中请求的响应年龄也就是命中的是多少分钟前生成的旧答案对比业务可接受范围。在刚开始跑的阶段误判抽样比命中率更重要。命中率高但误判率也在涨说明阈值定低了你每省下一分钱都在埋一颗雷。缓存系统的质量不是看它能拦多少请求而是看它能安全地拦多少请求。最后说下我个人盯监控的习惯。我每上线一套缓存架构前两周会每天把阈值附近的所有命中请求拉出来人工扫一遍哪怕慢一点也要做。很多团队把缓存当成一个一劳永逸的优化其实它更像一套需要持续校准的过滤装置真正稳定的状态是在上线后不断调出来的。先把精确缓存跑稳再上语义缓存阈值从保守往下探这层循序渐进的安全感值得每一个线上 AI 服务拥有。