
去年有一段时间我在给一家做客服系统的公司调模型推理。同一个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就是一次batch1的小矩阵乘法然后得到下一个token的分布。这个计算很小但问题是——整个模型的权重仍然需要完整地读取一遍。打个比方Prefill就像让你一次批阅一百份试卷CPUGPU算力忙得很充实Decode则是每写一个字都要把整本教材翻一遍翻书时间远远多于动笔时间。同一个模型两种形态瓶颈完全不同。2.3 一个关键推论两者的瓶颈必然不同从上面的计算形态能推出一个核心判断Prefill是计算密集型compute-boundDecode是访存密集型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 tokenPrefill阶段的理论计算量直接乘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用显存带宽的消耗几乎不变。batch20时每个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.5GBDecode每步的理论延迟就能从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 token300 token约30 ms约3.5 ms约80 ms约280 token/s文档问答/RAG3500 token200 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不是两个需要背下来的术语它们更像是两把尺子把“生成一个回答”这个模糊的过程量成了两段清晰可算的工程问题剩下的事情无非是找到对应的螺丝钉去拧而已。