去年有一段时间,我在给一家做客服系统的公司调模型推理。同一个7B模型,接在聊天窗口上表现还行,一旦换成企业文档问答,用户就开始抱怨“等太久”。奇怪的是,GPU利用率并不低,显存也够,输出速度反而挺快——也就是说,模型“说起来”不慢,但“开口前”很慢。
这个现象背后,就是大模型推理里最容易被忽略的两个阶段:Prefill和Decode。Prefill负责一次性读完Prompt、算出第一个token的概率分布;Decode则负责在后面一个token一个token地续写。它们是同一份权重上的两种计算形态,一个像批量盖章,一个像逐字抄写,性能瓶颈完全不同。这篇文章我从原理讲到实测,适合正在做推理服务部署、压测和性能优化的人参考,希望你看完能少走我走过的弯路。
1. 从一次线上卡顿说起:长文档问答为何比短对话多等好几秒
1.1 同一个模型,为什么体验天差地别
那次排查的起点很简单。同一套客服系统里,用户问“你们的退款政策是什么”,模型几乎秒回;但一旦把一份几千字的合同丢进去问“第三条和第十七条有没有冲突”,界面上要转好几秒才开始吐字。我一开始怀疑是显存不够、上下文太长导致OOM,看了一眼监控,显存占用正常,GPU利用率反而很高,显存带宽也打得很满。
真正让我困惑的是:模型没有换,权重没有动,为什么只是输入变长,体验就天差地别?后来我才理解,大模型生成一段回复,在GPU上并不是一个均匀的“边读边写”过程,而是被硬生生拆成两个计算形态完全不同的阶段。很多性能优化方案,本质上都是在处理这两个阶段之间的矛盾。
这两个阶段在不同框架里的叫法不太一样,有的叫prompt processing和generation,有的直接叫首token阶段和续写阶段,但最通用的名字还是Prefill和Decode。搞清楚它们,你才能真正看懂为什么GPU推理服务会有“高延迟、高吞吐”同时存在的现象,也才能看懂为什么有些加速手段换到另一个场景就完全失效。
1.2 一次回复在GPU上实际发生的两步
先从直觉上建立一个画面。想象你让一个助手读一份很长的合同,然后回答你的问题。助手要做的事分两步:先把合同从头到尾读一遍,理解整体内容;然后基于读到的内容,一个字一个字地组织回答。读合同这一步,时间取决于合同有多长;写回答这一步,时间取决于回答有多长,但每个字都必须等前面一个字写出来之后才能写。
在GPU上,第一步就是Prefill。模型拿到一整段Prompt,包括系统提示、历史消息、当前问题,一次性做前向计算,把所有输入token的信息加工成注意力计算需要的形式,并输出第一个token的概率分布。这个阶段的特点是:输入数量大,计算密集,GPU的矩阵乘法单元能被充分喂饱。
第二步是Decode。模型已经生成了第一个token,接下来每个step只输入“刚才生成的那一个token”,然后结合之前保存的所有上下文信息,算出下一个token。这个阶段的特点恰好相反:每一步的计算量很小,但权重还得全量参与,GPU更多时间花在等数据从显存里搬出来,而不是在真正计算。
两句话总结:Prefill是一次性并行读完所有的字,Decode是逐个串行写出每一个字。后面的所有优化策略,都是围绕这个区别展开的。
2. 两种计算形态的数学本质:一批算完的矩阵与一步一验的续写
2.1 自回归的本质:每一步都依赖前一步
要理解Prefill和Decode为什么长成现在这样,得回到大模型的基本生成方式——自回归。绝大多数生成式大模型在训练时做的事情,就是最大化这样一个条件概率:
p(x₁, x₂, …, xₜ) = ∏ p(xₜ | x₁, x₂, …, xₜ₋₁)
翻译成人话就是:每生成一个新token,都要以“之前所有token”为条件。这个链式依赖决定了模型不可能一次性把所有输出token全部并行算出来,因为第5个token的概率分布要等第4个token确定之后才知道。这是Decode阶段必须逐个生成的数学原因。
但这里有个容易被忽略的点:虽然输出token之间是串行依赖的,输入token之间却没有这种依赖。Prompt里那几百几千个token,它们相互之间不需要等来等去,可以在一次计算里全部并行参与矩阵乘法。这就给Prefill阶段的并行化留出了巨大的空间,也为后面的KV Cache、投机采样等一系优化埋下了伏笔。
2.2 计算图视角:大矩阵乘法与单token串行链
如果把一次完整推理画成计算图,Prefill和Decode的形状差异非常直观。Prefill阶段的输入形状是[batch, seq_len],seq_len是Prompt长度。在做注意力计算时,Query、Key、Value都是整段输入一起算的,也就是说,一个巨大的矩阵乘法发生在所有输入token之间。矩阵乘法的并行规模直接和seq_len成正比,seq_len越长,单次计算越“肥”,GPU越容易吃饱。
Decode阶段则完全不同。每个step的输入形状是[batch, 1],只有一个新token。虽然模型参数和Prefill完全一样,但此时做矩阵乘法时,序列维度只有1。如果只有一个请求在生成,那么每个step就是一次batch=1的小矩阵乘法,然后得到下一个token的分布。这个计算很小,但问题是——整个模型的权重仍然需要完整地读取一遍。
打个比方:Prefill就像让你一次批阅一百份试卷,CPU(GPU算力)忙得很充实;Decode则是每写一个字都要把整本教材翻一遍,翻书时间远远多于动笔时间。同一个模型,两种形态,瓶颈完全不同。
2.3 一个关键推论:两者的瓶颈必然不同
从上面的计算形态能推出一个核心判断:Prefill是计算密集型(compute-bound),Decode是访存密集型(memory-bound)。这两个词是推理优化的分水岭。
所谓计算密集型,意思是GPU的瓶颈在于“算不过来”,矩阵乘法的算力被耗尽,但显存带宽还有富余。所谓访存密集型,意思是GPU的瓶颈在于“搬数据搬不过来”,权重从显存到计算单元的那条路堵得死死的,算力实际上在等待数据。这两个结论看起来简单,实际影响非常大:同一个量化方案,对Prefill和Decode的收益完全不同;同一个批调度策略,可能优化了其中一段却对另一段毫无帮助。后面第3、4节我会把两条路径掰开来看。
3. Prefill的算力画像:大矩阵全速前进,KV Cache同时在膨胀
3.1 Prefill到底算了什么:一次请求的完整计算路径
Prefill阶段的计算路径并不复杂,但每一步都分量十足。假设你输入了2000个token,模型首先把每个token映射成向量,然后经过若干层Transformer。每一层里要做的事情包括:把输入向量分别乘上三组权重,得到Query、Key、Value;用Query和Key计算注意力分数;根据分数从Value里加权取出信息;最后经过前馈网络。
整个过程的计算量大致可以用一个公式估算:FLOPs ≈ 2 × 参数量 × 输入token数。拿一个7B模型举例,输入2000个token,算力消耗大约是2 × 7e9 × 2000 = 28 TFLOPs,也就是28万亿次浮点运算。如果在一块算力约989 TFLOPs的H100上跑,理论时间只要28毫秒左右;但实际推理时算子间的拼接、内存布局、注意力计算的中间步骤都会拖后腿,利用率通常只有30%到50%,所以实际耗时大概在60到90毫秒。
这就是为什么长Prompt场景下首token延迟会明显变高——不是GPU偷懒,而是它真的在短时间内做了海量计算。如果同时有8个请求、每个请求2000 token,Prefill阶段的理论计算量直接乘8,首token前的计算时间会成倍增长。这也是Prefill优化方向的核心:要么缩短计算路径,要么让多张卡并行分担。
3.2 KV Cache是“算完不丢”的缓存策略
Prefill阶段还有一个副产品,就是我前面提到的KV Cache。它的存在本身就是为了解决“Decode阶段重复计算”的问题。
详细展开:在Prefill阶段,模型已经为输入的所有token算好了Key和Value。如果没有KV Cache,那么在Decode阶段的每一步,模型都需要回过头去重新计算所有之前token的Key和Value——每个step都重算一遍,成本根本无法接受。所以推理框架都会把算好的Key和Value缓存到显存里,之后每个step只计算新token自己的Key和Value,再跟缓存拼起来用。
为什么只缓存Key和Value,不缓存Query?因为Query用完即弃——它在计算当步的注意力分数之后就完成了使命,不再有后续依赖;而Key和Value在之后的每一步都要被新token的Query重新查询一遍。这个设计是自回归注意力机制下最自然的缓存选择。
KV Cache的显存占用不能忽视。以7B模型常见的配置来算:32层、64个注意力头、每个头128维,用BF16(每个数占2字节)存储。每个token每层要存Key和Value两份矩阵,每份大小是64×128,两个矩阵共16K个数,乘上2字节是32KB;再乘32层,每个token大约占用1MB显存。8000个token的上下文,就是8GB。这还只是一个请求,如果并发32个请求,KV Cache就要吃掉256GB,比模型权重本身大得多。这也是长上下文服务普遍吃显存的根源,后面第6节我会专门说这个坑。
3.3 Prefill阶段的优化主线:并行与瘦身
针对Prefill的优化,核心思路是让它“更快地算完”。常用的手段包括:
- 张量并行:把模型权重切到多张卡上,每个Prefill step的多头注意力、大矩阵乘法天然可以拆分,这是最直接的加速方式。
- 序列并行:当序列特别长时,把序列维度切给不同设备来做注意力计算,主要服务于超长上下文的场景。
- 算子融合:把多个小算子合并成一个大算子,减少显存读写次数和kernel启动开销。Prefill阶段算子里动辄几万行矩阵乘,减少中间落地能显著提高利用率。
- 页式显存管理:把KV Cache像操作系统分页一样分配,减少碎片浪费,让有限的显存装下更多请求,间接提升整个Prefill阶段的吞吐。
顺带一提,很多推理框架现在都在做“Prefill和Decode分别调度”,原因就在于这两个阶段的最优执行方式不同,混在一起会互相干扰。这个我在第5节细讲。
4. Decode的访存画像:每写一个字,都要把整本书翻一遍
4.1 一个反直觉的数字:计算只占等待时间的零头
现在把镜头转向Decode。很多人第一次看到Decode阶段的性能数据都会觉得不可思议:每一步明明只生成一个token,为什么GPU的时间都花在这里?
问题出在权重读取上。一个7B模型,BF16精度下权重占用约14GB。Decode的每一步,理论上都要把这14GB权重从显存读一遍,即使当前只需要为1个token做计算。以H100的3.35TB/s显存带宽来算,读完14GB权重需要大约4.2毫秒。而这一步的实际计算量只有2 × 7e9 = 14 GFLOPs,以990 TFLOPs算力折算,纯计算只需要大约14微秒。
4.2毫秒和0.014毫秒,差了300倍。这就是Decode的真相:GPU不是算得慢,而是在等数据从显存搬到计算核心的路上。你把GPU想象成一个阅读速度极快的人,但每读一个字都要从仓库拖一整本书出来翻一遍。单请求场景下,Decode每步的时延基本由显存带宽决定,而不是算力。
这也是为什么单条流式输出很难跑得非常快——理论极限就摆在那里。要让硬件利用率上去,唯一的办法就是提高并发度,让更多人一起分摊“搬书”的成本。
4.2 为什么批量能把“读权重”的成本摊薄
Decode阶段优化最核心的手段就是batch。道理很简单:权重反正要读一遍,读一遍供1个token用,和读一遍供20个token用,显存带宽的消耗几乎不变。batch=20时,每个token摊到的访存成本只有原来的1/20,等效的token生成速率可以提升接近20倍。
所以你会发现,大模型的推理服务几乎都强调“高并发”。一批请求在Decode阶段互相“拼车”,每多塞一个请求进来,整个系统的吞吐就往上走一大截,直到算力本身成为瓶颈。这也是为什么静态批处理、动态插队、连续批处理这些调度策略如此重要:目的都是尽量让一批请求在Decode阶段待在一起,别让GPU闲着等下一个请求。
但batch不是越大越好。每个请求的KV Cache都在占显存,batch增大后显存压力跟着增大,同时每个step的时延也会轻微上升。实际工程里要算一本账:显存允许放多少并发序列,带宽允许支撑多高的token吞吐,算力在哪一步开始成为新的瓶颈。
4.3 量化和架构优化为什么在Decode更立竿见影
Decode对权重体积极度敏感,这件事在工程上的直接推论就是:量化对Decode的影响远大于Prefill。
举个例子,把7B模型从BF16量化到INT4,权重体积从14GB降到3.5GB,Decode每步的理论延迟就能从4.2毫秒降到1毫秒出头,生成速度几乎线性提升。而Prefill阶段虽然量化后也能减少显存占用和部分带宽压力,但它的瓶颈是算力,权重变瘦并不会让矩阵乘法变快太多,收益自然没那么大。
同样的逻辑也适用于稀疏化架构和混合专家模型。专家模型每次只有一部分专家被激活,参数读取量大幅减少,Decode阶段能享受到结构性的带宽红利。这也是现在很多高吞吐推理服务选择专家模型的根本原因之一。理解了Decode的访存瓶颈,你就很容易看懂市场上的各种加速方案到底在设计什么——它们多半都是在帮你少读几页书。
5. 优化手段为什么必须分阶段:调度、并行与投机采样三种思路
5.1 连续批处理:别让Prefill堵住Decode的路
先看一个现实问题:线上请求的Prompt长度差异非常大。有人只发了10个token的短query,有人丢进来5000字的文档。如果按照传统的静态批处理,一批请求必须等最慢的那个完成才能整体释放;一个长Prompt的Prefill请求会拖着整条船,短query只能在后面排队,体验极差。
现在的推理框架普遍改用连续批处理(continuous batching)。核心思路是:每个step结束时检查哪些请求已经生成完毕,一旦有请求完成,立刻从等待队列里拉一个新的请求进来,做它的Prefill。这样GPU永远不会因为“等最慢的人”而空转,短query和长文档也能公平地共享算力。
但连续批处理会引入一个新的矛盾:Prefill和Decode对资源的需求形态完全不同。一个正在做Prefill的长请求,会瞬间吃掉大量算力和带宽;旁边正在Decode的几个短请求,可能因此被拖慢一拍。成熟的调度器会把两个阶段分开排队、动态分配GPU资源,甚至让Prefill和Decode分别跑在不同的计算流上。理解这个调度逻辑,是读懂很多推理框架源码的钥匙。
5.2 分布式并行:同一套并行方案,两阶段的受益不同
分布式推理里最常用的张量并行,在Prefill和Decode两个阶段的表现也截然不同。Prefill阶段单步计算量大,矩阵乘法占绝对主导,并行拆分后通信时间相对计算时间占比很小,扩展效果接近线性。Decode阶段正好相反,每个step的计算量小,但张量并行意味着每个step都要做多次AllReduce通信,通信时间占比高得多。
换句话说,用4卡张量并行跑一个7B模型,Prefill阶段几乎能得到3.5倍以上的加速;而Decode阶段可能只有2倍不到的提升,因为通信开销抵消了部分收益。不少团队在压测时只看整体吞吐数字,换到长输出场景就发现“多卡并行没想象中快”,本质就是没把两个阶段分开看。
实际调优时,我会更倾向于为长上下文场景做序列并行,为高吞吐场景做张量并行,再配合通信与计算重叠的异步实现,尽量让两个阶段都能吃到并行化的红利。但这些都是工程取舍,没有银弹。
5.3 投机采样:把串行“伪装”成并行的思路
提到Decode加速,不得不聊投机采样(speculative decoding)。它的思路很聪明:既然Decode慢的根源是串行和访存,那能不能让一个更小的模型先快速“打草稿”,生成几个候选token,然后让大模型一次性验证这些候选,把串行的Decode步骤“合并”成一个更像Prefill的并行计算?
具体流程大致是:小模型依次生成4个候选token,大模型把这4个token和原上下文一起做一次前向计算,一次性得到它们对应的概率分布。如果某个候选token的概率大于等于小模型给出的概率,就接受;不满足的,用拒绝采样的方式修正。数学上可以证明,最终采样出的token分布与直接使用大模型逐个生成的分布完全一致。这是投机采样能落地的关键保障——加速但不变味。
实际运行中,投机采样能把Decode阶段的每一步从“读一遍大模型权重生成1个token”变成“读一遍大模型权重验证4个token”,等效把权重读取次数摊薄。加速效果取决于小模型的接受率,通常会有1.5到3倍的提升。代价是小模型本身也要占显存,且长Prefill场景下的收益有限——所以它本质上又是为Decode量身定制的方案。你看,优化手段总是要分阶段的。
6. 实测数字与我的调优习惯:先打点,再判断该优化哪一段
6.1 一组有参考价值的实测数字
为了让你更直观地感受两阶段的差异,我放一组某次压测里的数据。硬件是单张80GB显存的旗舰加速卡,模型是7B级别,BF16权重,推理框架启用了连续批处理和页式KV Cache管理。以下数字按场景区分,仅供量级参考,具体值会随硬件和实现浮动。
| 场景 | 平均Prompt长度 | 平均输出长度 | Prefill平均耗时 | Decode每token耗时 | 首token延迟P95 | 稳态生成速度 |
|---|---|---|---|---|---|---|
| 客服短对话 | 120 token | 300 token | 约30 ms | 约3.5 ms | 约80 ms | 约280 token/s |
| 文档问答/RAG | 3500 token | 200 token | 约950 ms | 约4.0 ms | 约1.2 s | 约250 token/s |
看这张表,你能立刻读出两个信息。短对话场景里,300个token的Decode总耗时大约1秒,Prefill的30毫秒几乎可以忽略——这类服务的优化重点是Decode和调度。文档问答场景里,Prefill用了近1秒,而Decode总耗时也就0.8秒,首token延迟占总响应时间的一半以上——这时候如果不优化Prefill,做再多Decode加速,用户体验都不会有质的提升。
6.2 怎么判断该优化哪一段:按场景对号入座
我自己的调优习惯是,拿到一个推理服务的性能问题,先看三个维度:Prompt长度分布、输出长度分布、并发请求量,然后对号入座:
- 短输入、长输出的场景(聊天、写作、代码生成):总耗时以Decode为主,优先优化Decode。手段包括增大动态batch、接入投机采样、做权重量化、减少不必要的采样开销。
- 长输入、短输出的场景(文档问答、RAG、以文搜文):首token延迟主要由Prefill决定,优先优化Prefill。手段包括张量并行、序列并行、算子融合,以及尽量减少重复的系统提示词。
- 高并发混合场景:两阶段都要照顾,但调度策略优先级更高。先检查连续批处理是否生效、Prefill会不会周期性堵住Decode,再考虑扩大batch上限。
- 流式输出体验优化:用户关注的是首token延迟和每token间隔的稳定性,这时候两阶段的P95都要打点监控,不能只看平均值。
如果你没有现成的trace工具,可以临时在推理服务的日志里加上两个时间戳:请求进入时间和第一个token出来的时间,这两个时间差就是Prefill耗时(加上排队时间);再从首token到最后一个token的时间除以token数,就是Decode每token耗时。先做这个除法,优化方向基本就清楚了。
6.3 三个我踩过的坑,以及分阶段打点的排查思路
坑我踩了不少,挑三个最典型的说。
第一个坑:拿Prefill的吞吐去估算线上容量。之前某次容量评估,我用一批长文档请求做了压测,算出一个看起来很漂亮的“系统吞吐”,结果上线后短query并发一多,P95延迟直接超标。问题在于短query场景下系统吞吐虽然高,但请求数量暴涨后排队时间也涨,我这个容量模型根本没把“Prefill和Decode混合调度下的排队效应”算进去。后来我改成分别统计Prefill阶段的处理能力和Decode阶段的并发生成能力,再把调度排队时间算成独立变量,才把容量模型校准。
第二个坑:KV Cache显存只按max_seq_len估算。我一开始只算“单条最长对话”的KV Cache,忽略了并发数。结果线上跑长对话时,几十个并发请求的KV Cache叠加,显存瞬间被打爆,服务直接OOM重启。后来我改用这个公式估算:KV Cache显存 ≈ 2 × 层数 × 头数 × 头维度 × 字节数 × 平均序列长度 × 并发数,再额外预留20%的余量。这个公式救了我好几次。
第三个坑:多卡并行后只看单卡利用率,忽略通信代价。某次上4卡张量并行,看平均GPU利用率挺高,但每个token的生成延迟却从3.5毫秒涨到了5毫秒。后来打点发现,Decode阶段每步的AllReduce通信和计算没有重叠,卡的算力在等通信。把通信操作改成异步、和下一层计算重叠之后,每token延迟才回到4毫秒左右。这类问题不做分阶段打点根本发现不了。
现在我做性能分析,习惯先做一道简单除法:首token延迟占总响应时间的比例。如果这个比例超过一半,重点就去查Prefill相关的计算路径和显存分配;如果不到一成,说明瓶颈在Decode或调度,去查batch策略和带宽利用率。这个习惯帮我避免了很多盲目优化。Prefill和Decode不是两个需要背下来的术语,它们更像是两把尺子,把“生成一个回答”这个模糊的过程量成了两段清晰可算的工程问题,剩下的事情,无非是找到对应的螺丝钉去拧而已。