如果你用过ChatGPT这类生成式AI,大概率有过这种体验:把问题敲进去,界面先转个一两秒,第一个字拖半天,可一旦它开口,后面的文字就像提前背好了稿子一样往外冒。很多人的直觉是——模型太大了,算力不够。这个解释只说对了最表面的一层。真正让首token慢、后续token快的,是Transformer自注意力机制背后的一个工程级环节:KV缓存。
想弄明白这件事,得先把大模型的吐字节奏拆开看。大模型不是一次性把整段回答写好的,它是一个token一个token往外蹦的。所谓token,可以理解为文字切片,英文单词大致一两个token一个,中文则经常一个字对应一个到两个token。每生成一个新token,模型都要把之前所有内容当作上下文,再预测下一个最可能的token。这个重复"回看"的动作,就是性能差异的根源。
1. 大模型吐字这件事,为什么是两段式节奏
1.1 开场迟钝的真相:prefill阶段在消化整段上下文
当用户把问题发给大模型,模型最先经历的阶段叫prefill(预填充),也叫上下文处理阶段。这个阶段里,模型要把你输入的整个prompt所有token做一次完整前向计算:从词嵌入映射,到每一层Transformer的注意力运算,再到前馈网络,最后产出一个概率分布,挑出第一个token。
prefill阶段最大的特点是并行。GPU可以同时处理prompt里所有token的矩阵乘法,把整段上下文一次性"读完"。但并行不等于免费,你输入的文字越多,需要算的矩阵就越大。同样是问一句话,问"今天天气怎么样"和上传一篇几千字的材料,前者首token延迟可能只有几十毫秒,后者却是几秒起步。所以你会发现一个现象:短问题几乎秒回,长文档问答则卡顿明显——这个卡顿发生在第一个字出来之前,正是prefill在处理你的全部输入。
1.2 后半程丝滑的原因:decode阶段只推最新token
第一个token出来后,模型进入decode(解码)阶段。这个阶段每生成一个新token,都要基于"之前已经生成的全部内容"来决定下一个词。听起来像每走一步都要把所有历史内容重读一遍,但工程上并没有这么笨。
decode阶段的关键特征是:模型权重不会变,已经确认的历史token也不会变。唯一需要处理的,是新token如何与全部历史进行注意力交互。于是推理框架会把历史token的某些中间产物提前存起来,每走一步只需要做一次轻量级计算——把新token和已存好的历史中间结果做交互。生成速度因此从"重新处理全部历史"降为"只处理最新token",后者在工程上几乎和上下文长度解耦。
一个直观类比:prefill像是通读所有参考文献并做批注,耗时耗力;decode像是在批注之上逐字续写,每次只需要查最近的一条批注就能接着写。KV缓存就是你提前写好的批注卡片。
1.3 "算力太重"只是表象:真正的瓶颈是显存带宽和缓存命中
很多人把大模型推理慢归结为"模型太大、算力太重"。这个说法不能说错,但它解释不了"第一个token慢但后面快"的现象。同一个模型,同样参数量,为什么开头要卡一下,之后却一路丝滑?如果纯粹是算力瓶颈,每个token的耗时应该差不多才对。
真正的区别在于:prefill阶段做的是大规模并行矩阵乘法,考验的是FLOPS(每秒浮点运算次数),属于"算力密集";decode阶段每一步只生成一个token,要频繁把模型权重和KV缓存从显存搬到计算单元,属于"访存密集"。对大模型推理来说,绝大部分时间其实消耗在带宽上,而不是算力上。每生成一个token,理论上前馈网络都要把上百GB的权重过一遍,这比计算本身更耗时。
所以准确说法是:首token慢,主要是prefill阶段处理整个prompt的工程量积累;后续丝滑,是因为decode阶段借助KV缓存把每步重算量降到了最低。把锅全部甩给"模型算力",你就错过了真正可以做优化的地方——显存、缓存、内存布局。
2. KV缓存到底存了什么:拆开自注意力看重复劳动
2.1 自注意力就是一场全员互查
要理解KV缓存,得先回到Transformer的核心:自注意力(Self-Attention)。自注意力的直观含义是:序列里的每个token,都要和序列里所有token做一次相关性计算,然后根据相关性高低去融合信息。
具体一点,模型把每个token拆成三个向量。Query可以理解为"我要找什么",Key是"我被什么特征标记",Value是"我能提供什么内容"。当前token拿自己的Query,去依次和所有token(包括自己)的Key做点积,得到一系列注意力分数;再用softmax归一化;最后用这些分数对Value做加权求和,得到当前token的新表示。
也就是说,当模型生成第10个token时,它心里其实把前9个token挨个看了一遍,才知道应该接什么。问题在于,前9个token的内容从第一步起就没有变过,它们对应的Key和Value按理说也不需要变。但如果不做任何存储,模型每走一步都会把前面所有token的K、V从头再算一遍——这就是重复劳动。
2.2 没有缓存,解码会陷入O(N²)泥潭
我们算一笔账。假设当前已经生成到第t个token,如果不缓存历史K、V,模型必须把序列长度为t的所有token重新做一遍自注意力,这一步的计算量约为O(t²)。从t=1一直生成到N个token,总计算量是ΣO(t²),约等于O(N³)。
大模型日常动辄生成几百上千个token,如果每步都从头重算,早就不可用了。KV缓存出现后,每次decode只需要为最新token算一次K和V,历史token的K、V直接复用。单步计算量从"重算整个序列"降为"只新增一个位置的交互"。
有人会问:注意力分数不还是要和全部历史Key做点积吗?没错,这部分复杂度仍然随序列长度线性增长。但相比"把每个历史token的K、V都重算一遍",已经是天壤之别。这也是为什么在推理框架里,打开KV缓存和关闭KV缓存,解码速度可以差出一个数量级。试着把开源模型的生成参数里use_cache设为False,很快就能感受到什么叫一步一卡。KV缓存不是锦上添花,而是大模型能流畅对话的基石。
2.3 KV缓存的工作原理:把K和V变成"可重复使用的便签"
KV缓存的实现逻辑其实不复杂。模型在prefill阶段处理完整prompt时,会把每一层Transformer算出的Key张量和Value张量存进显存里的两块区域。到了decode阶段,每生成一个新token,模型会先算出这个token的K、V,把K、V追加进缓存,然后拿这个新token的Query与缓存里所有历史Key做点积,得到注意力分数,再对缓存里所有Value做加权求和。
一个标准推理循环大致是这样:
- 输入prompt,prefill算出第一个token,并把每一层的K、V存入缓存;
- 把第一个token作为输入,结合缓存里的历史K、V,算出第二个token,同时把第二个token的K、V追加进缓存;
- 重复第2步,直到遇到结束符或达到最大长度。
这个过程中,KV缓存的大小只和"上下文长度"与"模型结构"有关,不随生成轮次数减少——它是只增不减的。所以当你把max_new_tokens设得很大,或者上下文窗口很长,显存里的KV缓存会像滚雪球一样越滚越大。这也是长文本生成时显存容易爆掉的直接原因。
3. 工程落地课:显存里那一大块KV缓存怎么算、怎么管
3.1 用公式估算KV缓存体积
KV缓存的体积可以用公式直接估算。假设模型有L层Transformer,每层注意力头数为H,每个头维度为D,KV缓存用bf16存储(每个数占2字节),缓存总大小是:
存储字节数 = 2(K和V两份) × L × H × D × seq_len × 2(字节)
注意H×D通常等于模型的hidden_size。以常见的7B模型为例:层数L=32,hidden_size=4096,seq_len=2048,那么:
2 × 32 × 4096 × 2048 × 2 ≈ 1.07GB
也就是说,在2048上下文下,光KV缓存就要吃1GB显存。如果把上下文拉长到32768,这个数字直接变成约17GB——已经是7B模型bf16权重(约14GB)的1.2倍还多。当KV缓存逼近甚至超过权重体积时,它就成了决定"能不能跑起来"的主要因素。
如果再叠加并发请求,压力更大。一个服务部署8路并发,每个请求都得维护自己的KV缓存,显存消耗就是单路的8倍。很多推理框架在并发一高时容易OOM,原因往往不是模型权重放不下,而是KV缓存占用了大量算不到的显存。
3.2 长上下文为什么贵:缓存线性涨,注意力平方涨
KV缓存随序列长度线性增长,这个好理解。但注意力计算还有另一层开销。对于每个新token,它要和历史所有token的Key做点积,这个计算量随序列长度线性增长;而整个生成过程每一次都要扫过所有历史,累计开销就是平方级。序列越长,每生成一个token就越吃力。
这也是长上下文"贵"的本质。短prompt可能在几十token内就结束,长文档问答动辄上万token。在同样流量下,长上下文的单个请求会在decode阶段造成远高于短请求的注意力计算量。所以"上下文长度翻倍,成本不止翻倍"不是一句口号,它是KV缓存和注意力耗能双重叠加的结果。
从产品角度看,支持长上下文更像是一种能力储备。真正高频使用超长上下文的用户占比不高,但每个长请求消耗的资源可能是普通请求的几十倍。推理系统如果不在缓存管理层做优化,长上下文很容易把整台服务拖垮。
3.3 首token慢的隐藏元凶之一:解码前的缓存预分配
回到最初的问题:第一个token为什么慢。除了prefill计算量大,还有一个工程细节常被忽略——KV缓存的预分配。
KV缓存是动态增长的。如果每生成一个token就临时申请一段显存,会产生大量内存碎片和分配开销。主流推理框架通常会在prefill结束、decode开始前,根据max_new_tokens预先把整段缓存空间锁定。这意味着第一个token生成时,系统可能已经为未来数千甚至上万token的KV缓存做了最大规模的显存预留和初始化。
预留本身也有成本。在PagedAttention这类方案出现之前,显存分配还要考虑连续性,找一块足够大的连续显存可能触发额外整理或切换。所以你会发现,即使模型不大、prompt很短,输入后系统也经常卡一下——那未必是模型在"思考",而是推理引擎在为后续生成准备"草稿纸"。理解了这一点,看首token延迟时就不会只盯着参数量了。
3.4 应对生产压力的四大招:GQA、PagedAttention、前缀共享、投机解码
第一招:GQA(分组查询注意力)。既然KV缓存大头是K和V,能不能让多个Query头共享同一组K、V?GQA就是这么设计的。把原来的多个Query头分成若干组,每组用一套共享的K、V,KV缓存体积直接除以组数。现在很多新发布的模型默认采用GQA,其中一个重要原因就是省显存、降带宽。
第二招:PagedAttention。把KV缓存按固定大小的页(block)管理,类似操作系统的分页机制。需要时才分配下一页,不必一次性找一大块连续内存,大幅降低碎片率,并发吞吐常常成倍提升。我在实际部署中,把推理框架从静态缓存切到支持PagedAttention的方案后,同样的GPU能多塞好几路并发,收益非常直观。
第三招:前缀共享。聊天系统的系统提示词通常很长且固定。如果多个请求共享这段前缀,前缀部分的KV缓存也可以共享。类似"系统提示词+多轮历史"的公共前缀,只需算一次、存一份,后续请求直接复用。这也是很多推理引擎专门做前缀缓存(prefix caching)功能的原因。
第四招:投机解码。decode阶段每步串行,能不能先让一个小模型快速猜几个token,再由大模型一次性验证?投机解码就是这么干的:小额草案由轻量模型产生,大模型用一次前向批量确认或修正,因为KV缓存参与验证,批量确认的开销远小于逐个串行。这个方案能显著提升单请求吞吐,代价是需要额外维护一个小模型,适合对延迟极度敏感的场景。
这些优化有一个共同点:都没有增加模型一层算力,而是从减少重复计算、减少显存冗余下手。理解了KV缓存,就理解了这些框架为什么要花那么多心思去折腾内存管理。
4. 亲手测一测:用代码感受prefill与decode的差距
4.1 一个简单的测量实验设计
想直观感受prefill和decode的差距,不需要几十B的模型。用几B参数的开源模型就够。核心思路是分别记录首token耗时和后续每个token的耗时。
以常见的transformers库为例,大致逻辑是这样:
import time from transformers import AutoTokenizer, AutoModelForCausalLM model_name = "某开源因果语言模型路径" tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModelForCausalLM.from_pretrained(model_name, device_map="auto") prompt = "请详细解释一下什么是注意力机制,并举三个例子。" inputs = tokenizer(prompt, return_tensors="pt").to("cuda") # 先把模型预热 _ = model.generate(**inputs, max_new_tokens=4, use_cache=True) # 用一个较短的prompt测首token耗时 start = time.time() outputs = model.generate(**inputs, max_new_tokens=32, use_cache=True) first_token_time = time.time() - start严格来说,上面的计时包含整个生成过程,并不精确。更精确的做法是用流式生成接口,拿到第一个token的时间戳:
gen = model.generate(**inputs, max_new_tokens=128, stream=True, use_cache=True) start = time.time() first_token = next(gen) ttft = time.time() - start多测几次取平均,就能得到比较稳定的参考值。
然后做两组对比:第一组是固定prompt长度,看首token耗时和后续每token耗时;第二组是不断加长prompt,看首token耗时如何变化。实测下来的典型结果是:prompt从几十token涨到几千token,首token耗时可能从几十毫秒涨到几秒;而后续每token耗时变化则小得多。这个现象正好对应prefill处理prompt与decode依赖KV缓存的差异。
还有一个巧办法观察KV缓存的作用:同样的prompt,分别用use_cache=True和use_cache=False生成,再对比相同token数量下的耗时。打开缓存时速度远快于关闭缓存,差距会随着上下文长度增加而越拉越大。
4.2 从现象看本质:你的显存正在被谁吃掉
实验过程中可以同步监控显存。生成几百个token后,打开nvidia-smi观察显存曲线,你会发现显存占用在生成过程中稳步抬升,而不是一开始就恒定。抬升的这部分,几乎就是KV缓存一个token一个token累积出来的。
如果把上下文长度从2K改成8K,相同模型显存占用会明显上涨。而如果改用支持PagedAttention的推理框架,显存分配会呈现"按块增长"的阶梯状,而不是一次性大块预留。对照这些现象,你就能明白为什么总有人抱怨"上下文长度开大了就OOM"——模型权重是固定的,真正持续膨胀的是KV缓存。
从运维角度看,这类监控特别重要。如果服务上线后显存占用率缓慢爬升然后触发OOM,第一反应不应该是一味加显存,而是先看是不是单请求缓存累积、再查并发数、最后看是否需要给KV缓存做量化或压缩。
4.3 基于KV缓存的调参避坑清单
写几条我实际踩过的坑。
第一,别随便关use_cache。调试或测试时可能会手滑关闭缓存,生成速度立刻断崖式下降。如果发现模型又慢又卡,第一件事就是检查缓存是不是被关了。
第二,max_new_tokens不是越大越好。它直接影响KV缓存预分配规模。请求频繁的场景下,把max_new_tokens精确调到业务需要的上限,能省下一大块闲置显存。很多框架预分配会按这个上限来,宁小勿大。
第三,长上下文要提前做显存估算。参考前面的公式,把KV缓存量、模型权重、激活值内存加一起,再定并发数。别等线上OOM了再去减并发,那会儿通常已经被用户投诉一轮了。
第四,看延迟不能只盯着首token时间。首token快不代表整体快。要提升实际用户体验,还要关注每秒输出token数(decode吞吐)和并发下的饱和度。两个指标是一对跷跷板,要一起看,否则很容易被某个指标误导。
5. 顺着KV缓存往下走:现代推理框架还在优化什么
5.1 预填充和解码为什么要分开调度
prefill是计算密集型:长矩阵乘法把GPU算力跑满,访存量相对可控;decode是访存密集型:每生成一个token都要把大权重和长KV缓存搬一遍,算力反而闲置。这两种计算特性完全不同,硬放在同一套调度逻辑里,效率必然被拖累。
所以很多推理系统会把prefill和decode分开处理。比如用同一GPU的不同计算流,或者把prefill请求集中到高算力设备,decode请求交给高带宽设备。即使在同一设备上,也会在请求调度时把"正在prefill的长请求"和"正在decode的短请求"错开,让两种计算互补,尽量不互相阻塞。
从KV缓存的角度看,decode阶段的访存开销和KV缓存长度强相关;prefill阶段则需要为第一个token建立并分配KV缓存。拆开调度之后,两边都可以针对自己的瓶颈做优化。这也是现代推理框架吞吐高出经典实现一大截的重要原因。
5.2 KV缓存的量化、压缩与复用
KV缓存虽然省下了重复计算,但自己却成了显存和带宽的大户。于是工程师们又把矛头指向了它。
一是量化。把KV缓存从bf16或fp16压到int8甚至int4。由于K、V经过注意力计算后很多数值对精度不太敏感,量化后缓存的显存占用直接减半甚至减到四分之一,带宽压力也同步降低。代价是精度略有损失,需要在实际任务里评估影响。
二是压缩。有些方案会丢弃一部分"不那么重要"的历史K、V,只保留对后续预测影响大的token。典型做法是滑动窗口保留最近若干token,或者根据注意力分数选择top-k的token作为缓存。这相当于给KV缓存做剪枝。做得好可以极大延长有效上下文,做不好则容易丢关键信息,属于高收益高风险。
三是复用。多轮对话里,系统提示词和历史对话的前缀是重复的。如果框架支持前缀缓存,不同请求可以共享同一段KV,新请求只需要计算新增的那部分。在多用户、多轮产品的场景里,复用带来的提升通常是数倍级。
这些优化再次印证了一件事:大模型推理的战场,早就从"算法精度"转移到了"内存与带宽的经营"。谁能把KV缓存管理得更精细,谁就能在同样的显卡上服务更多请求、给用户更快的响应。
说个我个人的体会。一次压测里我盯着显存曲线看了一下午,才真正理解KV缓存对大模型推理意味着什么——它不是某一个API的优化选项,而是整个推理系统的地基。常规讨论总是习惯用"参数量大"来解释延迟,可把KV缓存那部分算清楚之后,很多反直觉的现象都能解释通了。如果你也被"首token慢"这类问题困扰,建议别急着换更大显卡,先把模型结构和缓存策略摸一遍,说不定一张老卡也能把吞吐撑上去。这就是我踩过坑之后最想分享的结论。