1. 为什么大模型推理这么慢:自回归生成中的算力陷阱
前阵子一个做后端的朋友跟我吐槽:他把一个7B量级的开源模型部署到内部测试服务上,一开始觉得响应还挺快,可一旦上下文拉长、多轮对话聊下去,每个字的生成速度肉眼可见地变慢。他一度怀疑是不是显卡散热不行,还琢磨着要不要加一张卡。我告诉他,先别急着掏钱,你撞上的大概率不是算力不足,而是推理框架和KV Cache处理方式的问题。
这里的关键,在于目前主流大模型都是自回归结构:一次推理只生成一个token(可以粗略理解成一个字词片段),生成完之后把它拼回输入,再预测下一个token。这个过程串行且不可跳过,模型没法一口气把一句话的所有token全部并发算完。你喂给它的Prompt越长,它在每一步要"回忆"的信息就越多,如果实现得不好,每多生成一个token都要把前面所有token的中间状态从头算一遍,计算量随序列长度平方级增长。这也就是为什么很多人在长对话场景下感觉模型"越用越慢"。
我见过不少刚接触推理优化的同学,一上来就盯着算子优化、量化这些词,却忽略了一个朴素但影响极大的事实:模型推理时消耗的计算量,很大一部分是被"重复计算历史token"浪费掉的。想真正把推理速度提上去,第一件事不是换卡,而是把注意力机制里每一步都在重复计算的KV部分缓存起来。这也是本篇文章的核心主线——先讲清KV Cache的动机与原理,再展开聊vLLM这类推理框架如何利用它做工程化优化。
1.1 一次只产出一个token的逐字游戏
大模型生成文本的模式,本质上是在"逐字接龙"。比如你输入"中国的首都是",模型内部会先把这个Prompt切分成token序列,然后一次性做一轮前向计算,预测出第一个输出token最可能是"北"。接着,模型把"北"追加到Prompt末尾,再整体做一次前向,预测下一个token"京"。
你可能会想:第二次前向时,"中国的首都是"这些token的中间计算结果和第一次应该完全一样,为什么还要重算一遍?这就是自回归推理的低效根源。如果推理框架把注意力层里每个历史token的Key(键)和Value(值)向量重新计算一遍,相当于每次生成一个新token,都要把整个对话历史"重读"一次。
以序列长度L为例,一次解码的时间复杂度是O(L²),整个生成过程的复杂度则是O(L³)级别的累积。换成工程上的说法:当你把上下文从1K翻到4K时,如果框架没有缓存机制,推理速度不会慢4倍,而是可能慢一个数量级。这就是我反复跟朋友强调"先看KV Cache,再谈推理加速"的原因。
1.2 每一步都在重读历史的数学账
为了更直观地理解这个"重复计算"的代价,可以把Transformer每一层注意力简化为一个公式:
Attention(Q, K, V) = softmax(Q · K^T / sqrt(d)) · V
这里,Q是当前token的查询向量,K和V是序列中每个token对应的键向量和值向量。模型要预测下一个token时,当前token充当Q,它需要跟上下文里所有token的K逐一做点积,算完注意力权重后再加权V。如果「当前token之前的那些K和V」每次都被重新计算一遍,那么序列越长,单步开销就越大。
我在实际调服务时测过一个对比:6B规模模型、无KV Cache缓存时,生成长对话到第200个token左右,速度衰减已经非常明显;而到了400个token以后,基本处于"打字机卡壳"的状态。同样一台机器、同一个模型,接入KV Cache之后,单token生成速度在长上下文下趋于平稳。这个差距不是靠推理框架微调能弥补的,它是算法结构决定的。
1.3 prefill阶段与decode阶段:两种节奏的差别
理解了"逐字接龙"之后,还有一个工程上很重要的区分:注意力计算其实分两个阶段,业界一般叫prefill(预填充)和decode(解码)。
- prefill阶段:整段Prompt一次性输入,模型并行计算Prompt里所有token对应的中间状态。这个阶段是纯矩阵乘法,计算量在GPU眼里更像一个"大一点的矩阵乘",整体算力利用率可以打得很高。
- decode阶段:逐token串行生成。每一步只产生一个新的Q,要与历史上所有K、V做attention。这个阶段矩阵的某个维度很小,GPU的并行度很难拉满,而且显存访问次数和上下文长度强相关。
所以,做推理加速最忌讳的就是"一刀切"地看待性能。你会发现很多框架会把prefill和decode拆开调度,因为前者是计算密集,后者更接近访存密集。KV Cache的作用,正是在decode阶段把历史上所有token的K、V提前算好、存到显存里,每生成一个新token,只需要对这个token计算一次K、V并追加到缓存尾部,而不再把整个历史重新算一遍。
2. KV Cache到底缓存了什么:从QKV分工说起
KV Cache说起来简单,就是把Key和Value存下来。但为什么偏偏是Cache K和V,而不是缓存整个中间结果?这就得把Q、K、V在注意力机制里的真实分工弄明白。
之前有同事跟我说,他看了一堆资料,知道要缓存KV,但总感觉"K、V和Q长得完全一样,都是个向量,凭啥只缓存两个"。我打了个比方:你可以把Q理解成"我当前想查什么问题",K是"资料库里每份文档的目录条目",V则是"每份文档的正文内容"。注意力机制做的事情,是先拿Q去比对所有K,找出哪些文档和自己想查的问题最相关,然后取出对应文档的正文(V)来做加权汇总。
在生成下一个token时,当前token的Q是"新产生"的,没法提前缓存;但它要去比对的所有历史token的K,以及要根据权重取出来的历史token的V,都是固定不变的——至少在当前生成步内不会变化。既然如此,把这两个东西重新算一遍纯属浪费,它们正是KV Cache要存储的对象。
2.1 Q是当前问题,K是索引目录,V是正文内容
再往深一层说,K和V不是单独一个数,而是张量。模型有多层注意力层,每层都有自己的K和V。每生成一个token,每一层都会为这个token计算一份K向量和一份V向量,并往缓存里追加。所谓"Cache",本质上就是把这些K、V按序列位置、按层、按注意力头整齐地排放在显存里。
这里有一点值得注意:缓存的不是模型的权重,而是"某个输入序列在若干层注意力中产生的中间激活值"。模型权重是固定的,只有几十GB,训练好之后不动;而KV Cache是跟每次请求动态绑定的,请求长度越长、并发数越高,缓存占的显存就越大。我在真实服务里见过一种典型的"显存意外打满":模型权重只占了6GB,结果KV Cache悄悄吃掉了十几个GB,最后OOM。
2.2 缓存是如何被建立和更新的
KV Cache的构建过程,跟随上面前面说的prefill和decode分阶段展开:
- prefill阶段:模型处理完整Prompt时,会把Prompt里所有token的K、V算出来,一次性写入缓存。这一步相当于"初始化"了一份完整的KV Cache,长度等于Prompt长度。
- decode阶段:每生成一个新token,模型会为这个token算出新的一份K、V,追加到缓存末尾,同时用更新后的缓存去计算attention。这个过程在每一层都会发生,所以缓存是逐层、逐token不断增长的。
用伪代码描述单步decode是这样的:
# 伪代码:单步解码时 KV Cache 的工作方式 for layer in model.layers: # 当前token经过线性变换得到 q, k, v q = layer.q_proj(hidden_state) k = layer.k_proj(hidden_state) v = layer.v_proj(hidden_state) # 把新的 k, v 追加到本层缓存尾部 layer.kv_cache_k[layer.cache_len] = k layer.kv_cache_v[layer.cache_len] = v # 使用完整缓存参与注意力计算 attn_output = attention(q, layer.kv_cache_k[:layer.cache_len+1], layer.kv_cache_v[:layer.cache_len+1])也就是说,每步decode只需要做"追加"和"读取",历史K、V不再重新计算。这也解释了为什么有KV Cache的推理服务,单token生成速度能保持相对平稳,不会因为对话变长而急剧恶化。
2.3 一张公式看懂缓存到底占多少显存
说完原理,来看点实际的。KV Cache占显存这件事,很多新手会低估。计算单个token的KV Cache占用可以用一个不算复杂的公式:
每个token的KV缓存字节数 = 2 × 层数 × KV头数 × 每个头维度 × 字节数
乘以2是因为K和V各一份。以常见的7B开源模型配置来举例:假设32层、8个KV头、每个头维度128、权重用FP16(2字节),那么单个token的KV缓存为:
2 × 32 × 8 × 128 × 2 = 131072字节 = 128KB
单个token就要128KB,看起来好像不多。但当你把序列长度和并发batch乘上去,数字就非常可观了。
| 序列长度 | batch数 | KV Cache总占用(估算) |
|---|---|---|
| 1024 | 8 | 128KB × 1024 × 8 = 1GB |
| 2048 | 8 | 128KB × 2048 × 8 = 2GB |
| 4096 | 16 | 128KB × 4096 × 16 = 8GB |
| 8192 | 16 | 128KB × 8192 × 16 = 16GB |
上面这张表用的是估算值,真实显存占用还会因为padding、对齐方式有些出入,但量级是差不多的。你如果用的模型KV头更多、层数更深,比如32层直接配32个KV头,那么上表中的数字还要再翻几倍。显存规划时,KV Cache往往比想象中更能吃显存。
2.4 缓存思路简单,为什么框架实现差异这么大
听到这里你可能觉得KV Cache原理太简单了——不就是把算过的K、V存下来吗?确实,原理门槛很低,真正的差异全在"怎么存、怎么管理"。同一个模型,复用一份KV Cache思路,不同推理框架做出来的实际吞吐差距可以拉到两三倍,核心差别就在存储布局、内存复用和调度策略。
这也是为什么有了朴素KV Cache之后,还会出现vLLM、PagedAttention这类工程方案。就像你有一个书架,理论上每一本书都摆在书架上,但书的摆放方式、你找书的方式、书架满了之后如何腾挪空间,直接决定了你实际看书的速度。
3. vLLM做了什么改变:从"存下来"到"存得聪明"
vLLM是我接触过的推理框架里,在工程层面把KV Cache管理做到很精细的一个。它最核心的贡献是PagedAttention,它解决的不再是"要不要缓存"的问题,而是"缓存怎么分配才不浪费"。要理解它为什么重要,得先看看朴素KV Cache在真实服务里会踩什么坑。
假设一个推理服务同时处理多个请求,为了给每个请求预留缓存空间,传统做法是在显存里按最大序列长度一次性分配一块连续的缓冲区。很多框架还会按一个"最大并发数×最大长度"的池子统一分配。问题在于:用户的请求长度差异极大,有的只问一句"你好",有的塞进一篇几千字的长文。按最大值预分配,短请求占着的多余空间永远用不上,显存利用率奇低。
3.1 连续分配导致的碎片化与浪费
我实际搭服务时观察过一个很典型的案例:一台24GB显存的卡,模型权重占了大约8GB,剩下的16GB按理说全都可以给KV Cache用。但采用传统的预分配方案后,同时并发20个请求,每个请求预留4096长度的缓存,最终实际用到的总长度可能连30%都不到。换句话说,一大半显存被"预留"浪费掉了,而浪费的结果就是不敢继续接更多请求,服务吞吐上不去。
另一个问题是碎片化。即使显存里还有很多空闲空间,但这些空间不连续时,传统方案依然没法继续为大请求分配一整块缓存。这和操作系统里老式的连续内存分配会遇到外部碎片是一个道理。很多开发者遇到这种问题第一反应是"显存不够了,加卡",但其实是"显存管理方式不对,浪费了"。
3.2 PagedAttention:像虚拟内存一样管理KV Cache
PagedAttention的思路很直接:既然一次性分配一大块连续显存不现实,那就把KV Cache切成固定大小的块,按需分配,同时用一张映射表记录"逻辑位置"和"物理位置"的对应关系。每个块内部是一小段连续的KV数据,但块与块之间不需要物理连续。
这个设计几乎就是操作系统中虚拟内存分页的翻版。操作系统允许一个进程的逻辑地址空间远大于物理内存,并通过页表把逻辑页映射到分散的物理页;PagedAttention也是类似的,它把一个请求的KV Cache拆成多个block,每个block默认可以承载若干个token(常见设定是一块16个token),然后通过block table维护从逻辑block到物理block的映射。解码时GPU侧只需要按block table的顺序遍历,而不需要把散落的物理块拷贝成连续块。
这个转换带来的直接收益,是显存利用率的大幅提升。物理块按需申请,请求结束了立刻释放,别的请求马上复用;没有大块连续内存也能正常运作。我在同一台24GB显卡上,把传统方案换成PagedAttention方案之后,并发请求数差不多翻了一倍,靠的不是加显存,而是把之前被浪费的"预留给未来长序列"的存储空间真正用了起来。
3.3 连续批处理带来的额外收益
PagedAttention还顺带解锁了一个重要能力:连续批处理。传统批处理会等整个batch的所有请求全部完成之后,才一起释放资源、载入下一批请求。如果batch里正好有一个超长请求在慢慢生成,其他已经生成完的短请求也只能干等,白白浪费算力。
连续批处理(continuous batching)打破了这种"同进同出"的模式:每解码一个token,框架都会检查当前batch里哪些请求已经结束,结束的立刻从batch中移除,释放占用的block,并把等待队列里的新请求补进来。这样一来,GPU在任何时刻都在尽可能多地处理有效token,而不是空转等待最慢的那个请求。
这个机制我之前在传统框架里很难做到,因为KV Cache的物理空间是预分配死的,想动态插一个新请求进去,往往要找一大块空闲的连续空间。有了PagedAttention之后,新增请求只需要按block粒度分配物理块,逻辑上还是连续的一块"虚拟KV Cache",实现成本低很多。
3.4 为什么说它主要提升吞吐而不是单请求延迟
要给原理做个总结的话,vLLM这类优化方案的直接效果,更多体现在"系统吞吐"而非"单次请求延迟"。PagedAttention没有减少某个请求生成单个token所需的flops,它减少的是显存浪费、排队等资源碎片问题。单位时间内能完成的请求数量提升了,用户排队等待的时间自然也就下降了,但单请求的decode速度不会因为换框架就产生质的飞跃。
这也是我经常跟人说的:如果你的应用场景是"每次只有一个用户、一个请求、跑一个很长很长的生成任务",那PagedAttention带来的直观感受可能不明显;但如果你的业务是API服务、多人并发、长短请求混合,那它的价值大得多。选推理优化方案之前,先想清楚自己的请求模式。
4. KV Cache进阶:量化、GQA、前缀复用这些搭配怎么用
理解了核心原理后,真正的工程问题来了:KV Cache虽然省了重复计算,但它占显存是真的多。我在前面表格里算过,一个中等规模的模型配上16并发、8K上下文,KV Cache能吃掉十几个GB。怎么在省显存、提速和保质量之间找平衡,这里分享几个我实际用过的搭配方案。
4.1 KV Cache量化:显存立刻省一半以上
KV Cache里面的数值要参与后续attention计算,当然不能乱压精度。但工程实践发现,KV Cache对低比特的容忍度比权重量化高一些。常见做法是给K、V单独做8bit或4bit量化,并配合分组scale因子,保证每一小段缓存都能被还原到大致正确的数值范围。
我试过对某个7B模型做KV Cache 8bit量化,显存占用直接砍半,困惑度(perplexity)的劣化几乎可以忽略。4bit量化也能跑,但长文本生成时偶尔会出现"上下文漂移"的问题,模型开始说着说着忘记前面聊过什么,或者回答变得有点飘。这说明4bit量化对某些模型和数据集并不完全无损,需要有校准集配合。
如果显存吃紧,我一般建议先从8bit开始,无损收益大;如果还想继续压缩,再考虑4bit,但上线前一定要做长上下文的回归测试。把KV Cache量化比作"压缩聊天记录"是很贴切的——压缩了历史记录,整体对话占的存储小了,但细节可能有所丢失。至于丢多少,取决于你的压缩算法和聊天内容的复杂程度。
4.2 GQA会让缓存占用大幅变化
还有一个从模型结构层面减少KV Cache的手段,叫GQA(分组查询注意力)。传统多头注意力MHA里,每个query头都有自己独立的K、V头,KV头数与query头数相等,缓存量自然大。而GQA让多个query头共享同一组K、V头,比如32个query头共享8个KV头,KV Cache的理论占用直接降到原来的四分之一。
前面公式里的"KV头数"这项,就是GQA起作用的变量。这也是为什么现在很多开源模型的配置里会专门标出KV heads的数量。如果同一个模型有MHA和GQA两个版本,部署时优先选GQA的,因为它的长上下文生成成本和显存占用都友好得多。MQA(Multi-Query Attention)是更极端的一种,所有query头共用一个KV头,缓存最小,但质量上不如GQA平衡。
4.3 前缀复用,跑聊天系统容易被忽略的优化
还有一个我初期忽略、后来真香的功能是前缀缓存复用(prefix caching)。在聊天或多轮对话场景里,很多请求共享同一段前缀,比如系统提示词、固定的开场白、人设设定。这些内容对应的KV Cache如果不复用,每个新请求都要重新计算一遍,浪费非常可观。
具体做法是:框架把KV Cache按块缓存下来,并记录块之间的前缀关系。如果新请求的前缀与之前某个请求一致,直接沿用已有的cache块,只计算新增部分。比如某内部聊天机器人,系统提示词固定,开头又有一大段设定说明,开启前缀复用之后,请求的首token延迟大幅下降,因为前面一大段KV计算结果都被直接"继承"了。
不过前缀复用也有它自己的复杂度:缓存什么时候淘汰、多长的前缀值得复用、跨请求共享会不会被显存打满,都需要调。我在生产环境里的经验是,当固定系统提示词超过几百token时,这个优化性价比极高;如果只是几十token的前缀,收益就不明显。
4.4 不同请求模式下的优化侧重点
说了这么多,最后还是要强调:没有一套配置能吃遍所有场景。我自己会按请求模式粗略分三类来取舍:
- 短问题、高并发:比如客服、简单问答。KV Cache总量不大,瓶颈更多在并发调度和batch管理,PagedAttention带来的收益最大。
- 长文档、多轮对话:比如RAG、长文总结。KV Cache随上下文快速膨胀,优先做KV Cache量化、用带GQA的模型,必要时配合前缀复用。
- 单请求、超长生成:比如离线写长文。此时连续批处理的价值有限,反而是KV Cache的绝对容量和decode单步速度更关键。
这张表不是标准答案,但能帮你快速定位自己该优先试哪个方向。我经常看到有人拿着人家发的benchmark就照搬参数,结果跟自己的场景根本不匹配,然后跑出一堆"优化了个寂寞"的数据。
5. 实践记录:一次从卡顿到平稳的推理优化过程
原理讲再多,不如拿一次真实调优的过程收尾。这里不涉及具体公司和项目,我按当时在内部测试环境里的做法,把大致的排查和优化链路写出来,给同样在折腾推理加速的朋友一个参考。
5.1 评测前先把三个指标搞清楚
很多人调推理速度,嘴上说"很慢",但根本说不清慢在哪。我当时习惯先盯三个指标:
- TTFT(Time To First Token):从请求发出到返回第一个token的时间。它主要受prefill阶段和排队情况影响,也就是"模型要读完你的整个Prompt才开始说话"。
- TPOT(Time Per Output Token):生成每个token的平均耗时,对应decode阶段的单步速度。
- 吞吐量:单位时间内整个系统能生成的token总数,反映的是一个服务同时扛多个请求的能力。
如果TTFT高,先查prefill或排队长度;如果TPOT高,就要看decode的单步计算效率和KV Cache读取带宽;如果整体吞吐上不去,多半是显存管理或者batch策略的问题。那次测试里,我最开始只盯总耗时,结果怎么调都有点"打地鼠",后来拆开指标,才定位到问题主要在TTFT而非decode速度。
5.2 我踩过的两个坑
第一个坑是KV Cache上量之后OOM。当时给一个服务开了较大的并发窗口,想着batch越大吞吐越高,结果请求一上来,KV Cache涨得飞快,显存撑爆直接OOM。后来才意识到,模型权重只是显存占用的"固定部分",KV Cache是随并发和上下文长度动态增长的"弹性部分"。如果不给KV Cache设置量上限,再大的卡也有被填满的一天。正确做法是给缓存池设一个可接受的上限,超出的请求排队等待,而不是无脑扩大batch。
第二个坑是量化后长上下文质量劣化不明显但确实在掉。8bit量化跑短文本很稳,几乎看不出差别;但有一段时间做长文档摘要,发现输出开始出现"前言不搭后语"的情况,排查了很久最后才回归测试到KV Cache量化精度上。调整了量化分组大小、做了一个简单的校准集之后才恢复。这件事给我最大的教训是:显存优化和生成质量的关系,往往是"短期无感、长期明显",一定不能省掉长上下文的回归测试。
5.3 个人体会
如果让我给刚开始做推理优化的朋友一句建议,我会说:先别沉迷于各种花式算子优化,先把KV Cache这条主线吃透。你把重复计算这个事想清楚了,再回头看推理框架的设计,会发现很多方案的出发点都很朴素,只是工程实现里藏着大量细节。
真正常见的瓶颈并不神秘——要么是重复计算,要么是显存浪费,要么是调度不灵活。KV Cache把第一个问题解决了一部分,PagedAttention把第二个问题解决了一部分,连续批处理把第三个问题解决了一部分。剩下的大多是在这些方案之间做取舍和调参。
再分享一个小技巧:做性能测试时,别只测一个固定长度的请求。混合长度、混合并发才是真实世界的形态。跑一组长短混合的压测,你会对KV Cache和框架调度策略的价值有更直观的感受。我自己那边把这套链路整理完之后,同样的显存下并发能力提升了一倍多,生成速度也从"长对话后期明显卡顿"变成了"全程相对平稳",这个结果基本符合这套原理的预期。