
几个月前我在给一个企业知识库问答服务升级上下文窗口时被KV Cache狠狠上了一课。原本Seq长度4K的对话一切正常系统提示词加历史记录也就刚够用当我把--max-model-len拉到32K去支持长文档问答GPU显存直接爆掉服务起不来。排查了半天发现真正的瓶颈根本不在模型权重而在那个平时不显眼的KV Cache。那次之后我把KV Cache的来龙去脉彻底捋了一遍总结出三个优化维度现在无论是多轮对话还是超长文档摘要实时部署的延迟和显存都稳住了。这篇文章把完整的思路、方案和踩坑记录写下来希望对正在被长上下文部署折磨的读者有直接帮助。KV Cache这个名词看起来高大上说白了就是自回归生成时给旧Token做的一份笔记。大模型生成每个新Token都要重新看一遍之前所有Token的Key和Value如果不缓存下来每走一步都要把整段历史重新算一遍那成本高到无法接受。真正麻烦的是这份笔记的大小会随着上下文长度和并发请求数线性疯涨尤其是当你想要实时部署、长上下文、高并发三者同时满足时KV Cache的体量能撑爆你的显存。下面这些内容就是围绕这个核心矛盾展开的先算清显存账然后给出三个维度的优化方法最后用一次从4K到32K的改造实录来落地验证。1. KV Cache是长上下文部署的第一道坎先算明白这笔显存账1.1 自回归解码与记笔记机制要理解KV Cache得先回到大模型的工作原理。现在的主流LLM基本都是Decoder-only结构生成时是一个Token一个Token往外蹦的。比方说生成今天天气真不错这句话模型先生成今然后输入今去预测天接下来输入今天去预测天气再接下来输入今天天气去预测真以此类推。问题就出在输入这两个字上每一轮都要把当前Token和前面所有Token拼在一起送去过一遍Attention而Attention计算需要用到每个Token的Key矩阵和Value矩阵。早有学者算过如果把每一轮的历史Key和Value全部重新算一遍生成100个Token的复杂度大约是原先的100倍。于是大家自然想到把已经计算过的历史Token的Key和Value缓存到显存里后面不需要重复计算只要在自注意力计算时直接读取缓存即可。这就是KV Cache的由来。类比一下就像写长论文时把查过的资料整理成小卡片放在手边写后面的章节就不用再把所有书重新翻一遍。没有这个小卡片每一次下笔都要重头检索谁也受不了。1.2 一张表看懂KV Cache占用怎么计算KV Cache的显存占用是有公式可以精确算出来的。知道这个公式你就能预判自己的GPU还能支撑多大上下文、多少并发。我们先拿一个当前主流的7B级别GQA模型举例比如Qwen2-7B或Llama3-8B层数32隐藏维4096KV头数8每个头维度128精度FP16每数值2字节。每个Token的KV Cache大小公式如下每Token KV Cache字节数 2K和V两份 × 层数 × KV头数 × 头维度 × 精度字节数代入数值2 × 32 × 8 × 128 × 2 131072字节 128KB/Token。也就是说这个模型每处理一个Token需要往显存里压进128KB的KV Cache。这还只是一个Token一个序列4K长度就要512MB32K长度就要4GB如果来了8个并发请求32K上下文的情况下就需要32GB的KV Cache显存。这已经比模型权重本身还要大了。下面这张表能更直观地感受这种增长趋势以7B模型FP16为例上下文长度单请求KV Cache占用8请求并发占用2K256 MB2 GB4K512 MB4 GB16K2 GB16 GB32K4 GB32 GB128K16 GB128 GB1.3 KV Cache才是实时部署的隐形显存黑洞很多人部署模型时习惯只估算模型权重的显存占用比如7B FP16模型大约14GB看起来一张A10/A100就够。但实际跑起来才发现长上下文场景下KV Cache的占用远高于权重。更麻烦的是它的增长速度不是线性的平方项而是和上下文长度×并发数同时挂钩——你要提高并发要拉长上下文都会成倍放大显存压力。这个特性直接决定了实时部署的形态短对话场景KV Cache压力小多塞几个请求无所谓长上下文场景必须精打细算否则到手的显存全被Cache吃光。理解了这笔账我们就有了三个明确的优化方向一是减少每个Token占用的空间量化压缩二是让重复生成的Token尽量复用已有Cache前缀缓存三是让显存分配不浪费分页管理。接下来三章分别拆解。2. 优化维度一给KV Cache精准瘦身量化与注意力头架构双管齐下2.1 KV Cache为什么可以做低精度量化KV Cache保存的是Key矩阵和Value矩阵这两者的数值分布和权重不一样。权重经过训练后通常会比较稳定而KV分布更集中有大量的数值落在一个狭窄的区间个别离群值会拉高动态范围。这意味着我们可以用更低的精度去存储它们比如从FP16降到FP8甚至INT8只要做好离群值处理和刻度缩放重建出来的数值误差对最终输出影响很小。实际工程中最常用的做法是动态逐头量化。因为不同注意力头的KV分布差异很大如果一个头用同一套缩放参数误差会被离群头拉大。所以比较稳妥的做法是按层、按头分别计算min-max或绝对值最大值得到一个缩放因子再把原始FP16数据映射到INT8或FP8区间内。K和V的敏感度也不一样通常V对量化更宽容K对量化更敏感因此有些方案会为K分配更高的精度或更精细的量化组。我用下来感觉KV Cache的INT8量化相对FP16能把显存占用直接砍一半推理速度因为有更少的显存读写反而会略微提升而困惑度一般只增加0.1~0.3%在很多场景完全可接受。具体落地时如果你用的是vLLM 0.5以后的版本可以在启动命令里加--kv-cache-dtype参数。比如在H100、L40S这类支持FP8的GPU上可以设成fp8_e5m2在只支持INT8的设备上可以尝试用int8。有些推理引擎自带KV Cache自动量化会根据硬件能力自动选择最优格式。需要注意先确认GPU是否支持对应精度否则启动时直接报错。我这边的经验是FP8 KV Cache能保留绝大部分质量但如果你做的是代码生成或逻辑推理类任务还是要跑一组你业务相关的评测集看看量化后有没有掉点。2.2 注意力头架构优化GQA如何从源头减少KV Cache量化是在每个Token的存储层面做文章但还有一个更釜底抽薪的维度从模型架构上减少KV Cache的总量。传统多头注意力MHAMulti-Head Attention中每个注意力头都有自己的独立K和V。比如一个模型有32个Query头那K和V也要有32份KV Cache自然就大。后来业界提出分组查询注意力GQAGrouped Query Attention让多个Query头共享同一组K、V头。比如Llama3-8B就是32个Query头配8个KV头KV Cache直接缩到原来的1/4。Qwen2、Llama3、Mistral这些新模型基本都用了GQA。更激进的MQAMulti-Query Attention让所有Query头共享一组K/VKV Cache更小但质量损失大现在用得不多。这件事对部署选型有直接指导意义如果你要从头训练或者微调部署自己的模型尽量选择GQA架构的基座模型。如果你在部署一个MHA架构的老模型那KV Cache占用会高很多靠量化和调度也只能适度缓解。本质上GQA是在显存和表达能力之间做了一个很好的折中这也是为什么它在长上下文时代成了标配。我在选模型时为避开这一点基本只考虑GQA架构的模型后面做32K上下文改造时省了不少力气。2.3 Token级裁剪只保留重要的历史信息除了量化格式和架构KV Cache存储的Token数量本身也有压缩空间。这类方法叫Token Eviction或Cache Pruning典型代表是H2OHeavy-Hitter Oracle。它基于一个观察Attention中真正起作用的往往是少数高注意力权重的Token这些Token被称作Heavy Hitters。生成时如果Cache满了可以扔掉那些低影响的历史Token只保留重要的从而在固定Cache容量下支持更长的有效上下文。但是这个方案在实时部署中用起来要谨慎。因为重要性是动态评估的如果丢错了Token后面生成时信息就永久丢失了而且不容易察觉。我做文档问答时试过一次发现有些关键细节恰恰藏在那些不重要的Token里导致答案缺失。这类方法更多适用于摘要、对话等容忍度较高的场景不适合对所有业务无脑启用。真要用一定先用你自己的测试集去验证答案完整性。3. 优化维度二让重复计算归零前缀缓存与跨请求复用3.1 哪些KV Cache是可以共享的细想一下KV Cache的生成流程会发现大量请求之间的前缀其实是重复的。比如你做一个知识库聊天助手每个请求都会携带相同的System Prompt角色设定、指令、知识库说明有时还有固定的Few-shot示例。又比如多轮对话里第10轮请求输入的是前9轮全部历史 当前问题这时前9轮生成的KV Cache在第10轮里也需要完全相同的版本。如果每个新请求都把前缀部分重新算一遍PreFill等于在同一条路上反复开垦浪费算力不说用户等待首个Token的延迟也会显著上升。KV Cache的正确性在于只要一个前缀的文本完全一致并且模型权重参数一样计算出来的Key和Value就是一模一样的。所以完全可以对前缀做哈希缓存请求来了先匹配哈希命中就直接复用之前算好的KV Cache不需要再重新走一遍Attention计算。这就像你写代码时把常用代码块抽成函数统一复用而不是每次复制粘贴一大段。3.2 主流实现vLLM Prefix Caching与SGLang RadixCache目前几个主流推理引擎都把前缀缓存做成了内置特性。vLLM里的实现叫Automatic Prefix Caching开启后引擎会把已经计算过的Token块Block缓存下来并给每个块算一个哈希。新请求进来时如果输入的Token序列能和缓存块匹配上就直接跳过这段的PreFill从第一个未命中的Token开始算。vLLM启动时加一个参数就能开启vllm serve Qwen/Qwen2.5-7B-Instruct-AWQ \ --max-model-len 32768 \ --enable-prefix-cachingSGLang作为一个主打低延迟的推理框架把这事做得更极致。它的RadixCache用一个基数树来组织所有前缀节点相同前缀共享支持部分命中和自动淘汰命中粒度可以到单个Token级别。如果你有大量相似前缀的动态请求SGLang的Prefix Cache命中率会更高。实际开发中如果你们团队已经用了vLLM没必要为前缀缓存单独换引擎如果是新架构选型且你的场景就是长prompt多轮、高并发可以重点评估SGLang。我在企业知识库场景里的实测数据是这样的公共System Prompt大约2000字折合约1500个Token未开启前缀缓存时一个请求的TTFT首个Token延迟约4秒开启后命中缓存的请求TTFT降到0.8秒左右。多轮对话场景收益更明显前一轮算好的缓存可以直接供下一轮使用每轮只需要算新加的那几十个Token。3.3 设计Prompt时如何让缓存命中率更高前缀缓存能不能发挥作用一半取决于你的Prompt设计是否配合。我踩过不少坑总结出几条实用的规则公共内容System Prompt、Few-shot示例永远放在Prompt最前面并且保持绝对稳定。不要给每个请求动态拼不同前缀。动态信息当前问题、时间戳、随机变量尽量放在Prompt末尾。因为前缀缓存只能命中开头连续匹配的Token任何一点前缀变化都会让整体失效。如果你需要对相似问题做多路召回可以把检索知识放在公共前缀之后、但要让知识与前缀的相对顺序固定。最好的方式是把所有知识先拼成一块固定长度并排序再让用户问题放最后。不要小看模板字符串很多人喜欢在System Prompt里插入今天是XXXX这种时间变量这会让所有请求的Prefix全部跑偏。时间信息如果必须带放到最后出现的指令里或者用占位符在业务层做替换。随着缓存命中率提高你会发现长上下文实时部署的另一个隐藏福利原先花在超长PreFill上的算力被释放出来GPU可以承接更多并发整体吞吐明显改善。4. 优化维度三从显存碎片里抢空间分页管理与连续批处理4.1 朴素实现为什么浪费显存如果你自己写过简单的推理服务可能会这样分配KV Cache根据max_model_len为每个请求预先申请一整块连续显存。比如设置最大上下文8K那就为每一个请求预留8K长度的KV Cache空间即使某个请求实际只用了1K它也占着8K的地盘。这种方式在并发起来后会产生大量空洞GPU显存实际上被虚占得七七八八真正用上的可能不到一半。更糟的是不同请求的结束时间不同释放和申请会形成显存碎片后续大块连续内存分配可能失败。这个问题很像个大学宿舍场景每个新生报到都按四人间的标准分配床位但有人只住一晚上走了床位也不能给后面的短租客用导致整栋楼空置率很高。要解决它就得把按人固定分房改成按床动态分配。4.2 PagedAttention把KV Cache变成可换页的内存vLLM给出的方案叫PagedAttention灵感来自操作系统里的虚拟内存分页。它不再为每个请求预留连续的大块空间而是把KV Cache切分成固定大小的块Block默认每个Block可以存16个Token的K、V数据。每个请求按需申请若干BlockBlock在物理显存上可以不连续通过一个Block Table来记录逻辑块和物理块的映射关系。当某个Block填满了再申请新的Block请求结束后它的Block可以立刻释放并分配给其他请求。这样做的好处是立竿见影的不按最大长度预留显存浪费率大幅下降。空闲显存真正能用来服务更多请求。计算时通过Block Table按地址读取读写效率并没有因为不连续而明显下降。不同请求的前缀Block如果哈希相同可以直接共享物理块这又和前缀缓存完美配合。我在A100上的经验是替换掉之前自研的连续缓存后有效KV Cache利用率能从不到50%提高到90%左右。显存没加一分钱支持的最大上下文长度和并发数都上了一个台阶。4.3 Continuous Batching与迭代级调度让GPU一刻不歇长上下文实时部署另一个容易忽略的问题是批处理机制。传统的静态Batching模式是凑够一批请求一起做PreFill和Decode所有请求都生成完后才释放资源下一批再进来。这个模式在长短请求混跑时特别吃亏一批里只要有一个长尾请求没生成完后面短请求就得排队GPU算力空转用户体验是突然卡一下然后又活了。vLLM这类引擎采用的Continuous Batching连续批处理则把调度粒度从请求级缩减到迭代级。前一个请求还在解码生成过程中系统就可以把新到达请求的PreFill插入当前的空闲槽位某个请求提前结束时它的显存和计算资源马上让给其他人。在长上下文场景里由于一个请求可能要解码几百上千个Token这种调度模式能让GPU始终处于满载状态。如果你还遇到超长Prompt阻塞整个批处理的问题看看引擎是否支持Chunked PreFill分块预填充。它的思路是把一个很大的PreFill任务切成多个小块让PreFill的GPU操作和正在进行生产的Decode任务交插执行。这样即使有请求带进来30K的Prompt也不会让其他低延迟请求干等。综合起来推理引擎层面的这几项技术——分页KV Cache、连续批处理、Chunked PreFill——解决的是显存怎么分配、算力怎么调度的问题。它们属于部署优化维度和前面的量化压缩、前缀复用没有冲突三者叠加效果最佳。5. 从4K到32K窗口的实战调优我的知识库问答服务改造记录5.1 改造前的状态和核心瓶颈我当时维护的服务是一个企业内部知识库问答API底层模型是Qwen2.5-7B-Instruct。最初的实现比较朴素用HuggingFace Transformers直接加载FP16权重自己管理KV Cache并发数4max_model_len4096。上线跑了一段时间还算稳定但业务方希望模型能阅读整份技术文档动辄两三万字要求把上下文窗口提升到32K。我把max_model_len改到32768后第一轮压测就崩了显存占用直接冲上30GB而那张A100只有80GB但模型权重已经占了16GB再算上其他开销直接OOM。就算侥幸不崩TTFT也涨到10秒以上根本没法实时用。拆解下来瓶颈有三层FP16的KV Cache在32K时单请求就占用4GB四个并发就是16GBTransformers自带的朴素连续缓存又要求为最坏情况预留大量空间实际浪费接近一半。所以单纯加显存不是办法要做系统性的优化。5.2 三管齐下的优化方案落地我做了三步改造依次对应前面说的三个维度第一步换用vLLM作为推理引擎。这一步同时拿到了PagedAttention、Continuous Batching和Prefix Caching三项能力。模型权重换成了AWQ 4bit量化版因为业务场景对精度要求中等量化后7B模型权重从16GB降到4GB左右给KV Cache腾出更多空间。第二步开启KV Cache低精度量化。A100支持INT8但不支持FP8的e5m2我用了vLLM提供的--kv-cache-dtype int8参数把KV Cache从FP16压缩一半。这步单独用完32K上下文的单请求KV Cache从4GB降到2GB加上PagedAttention后并发4完全没有压力。第三步统一所有的Prompt模板确保System Prompt和知识框架完全一致然后开启--enable-prefix-caching。我甚至把知识库里的FAQ列表固定顺序拼在Prompt前面只有最后留出用户问题区。这样可以最大化前缀命中率。改造后的启动命令大致如下vllm serve Qwen/Qwen2.5-7B-Instruct-AWQ \ --max-model-len 32768 \ --gpu-memory-utilization 0.92 \ --kv-cache-dtype int8 \ --enable-prefix-caching \ --max-num-seqs 8 \ --swap-space 8 \ --tensor-parallel-size 1--gpu-memory-utilization设成0.92让vLLM尽可能利用显存--swap-space预留8GBCPU内存作为备份虽然实时场景不希望触发Swap但万一极端情况不至于OOM崩溃。5.3 压测数据与对比改造前后做了同样的压测知识库文档问答每轮请求平均Prompt长度约为15K并发8总请求数100指标改造前Transformers FP16改造后vLLM AWQ int8 KV Cache Prefix Caching最大上下文窗口409632K时OOM32768请求占用KV Cache单请求15K约1.9GB FP16约0.6GB INT8TTFT无前缀命中8.2秒2.4秒TTFT有前缀命中不适用0.7秒稳定并发数48平均生成吞吐约1.5 tok/s/request约5.2 tok/s/request这几项数据有两点值得注意。第一KV Cache从FP16换成INT8后单请求占用直接减半再配合分页管理后空闲区域被利用起来并发8才把显存用满。第二前缀缓存对TTFT的影响巨大命中时TTFT从2.4秒压到0.7秒这对实时交互产品体验是质变。5.4 线上监控和迭代改造上线后我定期用vLLM暴露的Prometheus指标观察运行状态尤其是vllm:kv_cache_usageKV Cache利用率和vllm:cache_hit_tokens缓存命中Token数。建议你盯一个关键比值缓存命中Token数 / 总输入Token数。如果这个比值长期低于10%说明你的Prompt前缀设计可能有问题或者业务本身就是完全不同的短文本前缀缓存收益有限。我见过一些团队开了Prefix Caching后不调模板命中率常年为零还以为是功能坏了其实是被代码里的时间变量和随机排序坑了。另一个值得调整的参数是--max-num-seqs。这个值决定一次解码过程中最多同时存在的请求数。开太大显存会瞬间吃满而且频繁触发Preemption反而降低效率开太小又没法利用PagedAttention省下来的空间。我建议按空闲KV Cache显存 / 单请求平均KV占用来估算一个合理上限再压测微调。6. 实时部署场景的五个隐形坑与我的经验6.1 坑1KV Cache量化后在超长上下文里精度掉得比想象中多短文本测试时KV Cache INT8量化几乎看不出区别。但到了32K这种长上下文模型要多次读取缓存里的信息量化误差会被滚雪球式放大。我遇到过一个问题一份文档里的数字指标在16K上下文中能答对到了32K后模型开始含糊其辞甚至编造数据。排查了模型和Prompt都没问题最后把--kv-cache-dtype改回FP16再测错误消失。所以量化不是免费的尤其是你的业务依赖长距离精确回忆时必须用领域测试集做验收不要只看单条评测的差值为零就放心。6.2 坑2前缀缓存可能吃掉大量显存要限制缓存规模很多人以为开启Prefix Caching就是纯赚但缓存的Block也是要占显存的。当请求模式分散、命中率低的时候缓存里的Block会越堆越多挤占其他请求的KV空间。vLLM的Prefix Cache有LRU淘汰策略但你要监控它的实际命中率。如果命中率低我建议直接关掉如果命中率高再根据显存剩余情况设置合理的--max-cached-tokens上限避免缓存无限增长。6.3 坑3max-model-len千万不要盲目设大--max-model-len决定KV Cache池的最大容量。很多朋友为了让模型能力更强直接拉满到64K甚至128K结果显存被预留殆尽并发量反而掉得厉害。这里有个平衡设置越大的上下文你为单个请求预留的KV Cache上限就越高Pool越小。我实际调查后发现我们平台90%的请求都在8K以内只有个别文档问答需要32K。这就不应该把max设到64K而是设成32K再配合服务层对超长请求做截断或摘要。真正需要超长上下文时用动态长度策略或者直接上支持Prefix Cache溢出的方案别一把梭。6.4 坑4并发数调高后被Preemption反复打断Continuous Batching不是没有代价的。当你的max-num-seqs设得过高GPU的KV Cache空间不够分配调度器会强制Preemption把部分请求的KV Cache换出到CPU内存或重新计算。这个操作非常耗时会让整体吞吐不升反降。我在压测时看到过吞吐先升后降的曲线并发从4升到8时吞吐上升继续升到16反而回落。遇到这种情况先看指标里有没有大量的Preemption事件有的话就降低max-num-seqs或增加--gpu-memory-utilization给KV Cache留够空间。6.5 坑5多GPU部署时KV Cache显存分布不均匀如果模型超过单卡显存需要做Tensor Parallel多卡切分。这时要注意KV Cache也是按层切到不同GPU上的。由于长上下文请求的Cache体量大如果切分策略不均衡可能出现一张卡显存告急而另一张卡还有空闲的情况。我自己的经验是尽量通过调高--gpu-memory-utilization并统一各卡空闲显存来缓解如果模型太大优先考虑流水线并行Pipeline Parallel或直接选用KV Cache更小的模型。另外切换并行策略后一定对着nvidia-smi确认每张卡的显存均衡不要只看总显存。最后再分享一个我踩过多次坑后养成的习惯每次改KV Cache相关配置之前先跑一遍短、中、长三档上下文长度的profiling导出一份KV Cache占用随Token增长的曲线。不要只看总显存占用要看单位时间新增Token消耗的显存增量。这个数据能帮你判断前两个优化维度到底有没有生效也能提前暴露量化或缓存配置是否合理。KV Cache优化的三个维度核心思路其实就一句话让它变小一点、让它复用多一点、让它的空间利用率高一点。当三个方向同时发力时长上下文实时部署就不再是玄学了。