用过本地大模型的人应该都有这种体感:命令发出去之后,终端要么卡住不动几秒甚至更久,要么突然哗啦啦地开始吐字。我最早以为这只是“模型在思考”,直到后来上手给推理引擎做性能分析,才发现这段停顿和接下来的吐字根本不是同一件事——它们分别对应大模型推理里最基础的两个阶段:Prefill 和 Decode。
这两个名字在推理引擎的文档、论文、面试题里反复出现,但很多人的理解停留在“服务端先处理输入,然后再逐个生成输出”这个层面。真正动手写推理服务、做显存估算、或者只是想把本地部署调快一点的人,很快会发现这个划分背后藏着一整套关于自回归依赖、KV Cache、GPU 算力与内存带宽的逻辑。这篇文章就把 Prefill 和 Decode 掰开揉碎讲一遍,同时也聊聊理解这两个阶段之后,实测和调优的思路会有什么不同。
1. 一段生成,两种截然不同的工作节奏
1.1 一次推理,从输入到第一个字的完整路径
先把流程走一遍。假设我用一个 7B 模型,输入一句话“请用一句话解释什么是注意力机制”,模型正常的输出是一个以“注意力机制是……”开头的句子。
推理引擎拿到这段输入之后,第一件事是把文字切分成 token,得到一长串 token id。这些 token 会一次性全部进入模型,逐层经过 embedding、attention、FFN 等模块,最后在输出端算出一个概率分布,从这个分布里采样出整个回答的第一个 token。注意这一步:用户输入是整段一次性处理的,不是像人一样读一个字想一个字。
第一个 token 出来后,工作模式立刻变了。下一步想通过“刚才生成的这个 token”来预测第二个 token,于是把刚才那个 token 作为输入,再完整过一遍模型,得到第二个 token;再把第二个 token 塞回去,得到第三个……一直循环到遇到结束符,或者达到最大生成长度。
前面这段“整段输入 + 产出第一个 token”的过程,就是 Prefill;后面这段“一个 token 进、一个 token 出”的循环,就是 Decode。在 llama.cpp 的日志里,Prefill 对应 prompt eval,Decode 对应 eval time;在 vLLM 的调度逻辑里,前者叫 prompt processing,后者叫 token generation。名字不同,指的都是同一件事。
1.2 为什么说它是“两段式”,而非“一个循环”
有人会问:Decode 本身不就是循环吗?那 Prefill 不也可以看成 Decode 循环的第一步?
这么说在数学上没错,但工程和性能表现上完全不一样。关键差异在于:模型每一步做预测时,真正看到的不是“当前这一个 token”,而是“当前这个 token 之前的所有 token”。Decode 循环里,每一步的输入序列都在变长;而 Prefill 这一步的输入是“用户一次性给进来的、长度确定的完整 prompt”。
这个差异直接决定了计算形态。Prompt 已经是存在的内容,里面所有 token 之间的关联可以一次性算完;而回答部分是还没发生的内容,下一个词依赖前一个词的实际生成结果,只能逐个推测。所以在绝大多数主流推理引擎里,Prefill 和 Decode 是被分开设计、分开调度、甚至分开写 kernel 的。
先放一张对照表,方便建立整体印象:
| 对比维度 | Prefill | Decode |
|---|---|---|
| 处理对象 | 用户输入的整段 prompt(已知) | 逐步生成的 token(未知) |
| 并行度 | 高,prompt 里所有 token 可一起算 | 低,一次只能推进一个新 token |
| 主要计算形态 | 大矩阵乘(GEMM) | 向量乘矩阵(GEMV) |
| 对硬件的需求 | 吃算力,GPU 利用率高 | 吃访存,卡在读取权重和缓存 |
| KV Cache | 一次性写入全部历史 K/V | 每步 append 新 K/V,同时读取全部历史 |
| 用户感知 | 回车后到第一个字的停顿 | 第一个字之后逐字蹦出的速度 |
这张表后面每个点都会展开讲。先把最核心的判断记住:Prefill 是计算密集型,Decode 是访存密集型。理解这句话,基本就理解了全文的一半。
2. Prefill:一次吃进整段 Prompt 的并行阶段
2.1 Prefill 内部到底做了什么
把 Prefill 再拆细一点。输入是 S 个 token(假设 1000 个),每个 token 先被映射成一个向量,维度一般是模型隐藏层宽度,7B 级别的模型常见 H=4096。于是整个输入在模型内部就是一个形状为 [S, H] 的矩阵。
接下来每一层 Transformer 要做的事情,核心就三块:
- 计算 Q/K/V:把输入矩阵分别和三个权重矩阵相乘,得到形状为 [S, H] 的 Q、K、V。这一步对 S 个 token 来说是同一个矩阵乘,天然并行。
- Self-Attention:用 Q 和 K 计算注意力分数,再用分数加权 V 得到输出。这个过程里有一个因果掩码,后面单独说。
- 前馈网络 FFN:对每个 token 独立做两三次线性变换加激活,本质上还是矩阵乘。
可以看到,S 个 token 在一层里基本都是共享同一组权重、以矩阵形式一起参与计算的。GPU 最喜欢这种形态:矩阵越大,并行度越高,Tensor Core 越能吃饱。用行业里的说法,这个阶段是 compute-bound,瓶颈在于 FLOPs 能打多满,而不在于数据搬运。
2.2 因果掩码:为什么输入可以全部一起算
这里有一个新手容易卡住的点:Attention 不是要求“每个 token 只能看它之前的 token”吗?既然有依赖,怎么能叫并行呢?
关键在于:prompt 里的全部 token 是已知的。token 5 需要看 token 1~5(含自己)的信息,而这些 token 在输入那一刻就已经全部存在了,不存在“要先算出 token 4 才能算 token 5”的先后问题。所谓因果性,只是通过掩码把注意力矩阵的上三角位置盖住,让 token i 在 attend 的时候,只能看到 i 以及 i 之前的 key/value。
在矩阵层面,所有 token 的 Q 和所有 token 的 K 是一次性相乘的,得到 [S, S] 的分数矩阵;掩码只是把其中“未来位置互相 attend”的格子置成负无穷,让 softmax 之后变成 0。计算本身没有串行化,掩码也没有降低并行性。整段 prompt 依然可以当成一个大矩阵运算一次性完成。
这也是 Prefill 和 Decode 最本质的差别:Decode 阶段,未来的 token 还没生成出来,你想掩码都不知道该掩谁;而 Prefill 阶段,用户的完整输入就摆在眼前,所有已知信息可以一次性结算完毕。
2.3 KV Cache 在这里被“种”下去
Prefill 做完 Attention 后,每一层的 K 和 V 其实就已经按位置存下来了。为什么要存?因为后续每生成一个新的 token,它都要对“包括 prompt 在内的所有历史 token”做一次 Attention,也就是要读每个历史位置的 K 和 V。如果不存,每生成一个新 token 就得把整个 prompt 重新算一遍。
这批存下来的 K/V,就是 KV Cache。这也是 KV Cache 里只有 K 和 V、没有 Q 的原因——Q 只服务当前正在计算的 token,算完就扔;K/V 会被后续所有 token 反复读取,必须长期驻留。
KV Cache 的大小可以精确算出来:
KV Cache 字节数 = 2(K 和 V 两份)× 层数 × 序列长度 × KV head 数 × head 维度 × 每个元素字节数
以 LLaMA-2 7B 为例:32 层、32 个 KV head(MHA 结构)、head 维度 128、FP16 存储,那么每个 token 每层占 2×32×128×2 = 16KB,乘上 32 层就是约 512KB。也就是说,上下文窗口每增加 1 个 token,模型要多占约 512KB 显存。1K 上下文是 512MB,4K 是 2GB,32K 直接就是 16GB——比 7B 权重本身还大。很多人在本地跑长上下文爆显存,爆的就是这一块,不是模型权重。
2.4 长 Prompt 的 Prefill 为什么慢,为什么吃显存
Prefill 本身算得很快,因为并行度高。但长 prompt 有两个隐藏成本。
第一个是显存峰值。Prefill 时需要同时保存输入激活、中间注意力结果,以及刚写出来的完整 KV Cache。FlashAttention 这类技术把 [S,S] 的注意力矩阵放在 SRAM 里算,省掉了大量 HBM 往返,才让长序列在现代 GPU 上变得可行。没有这些优化之前,序列一长,显存和速度都很容易崩。
第二个是首 token 延迟(TTFT,Time To First Token)。用户从按下回车到看到第一个字,中间等待的时间基本就是 Prefill 的时间。prompt 越长,这个阶段越久。所以很多人本地部署后觉得“模型刚开始卡了半天”,先别急着怀疑采样参数,大概率只是 Prefill 在处理你那段几千字的上下文背景材料。
3. Decode 阶段:真正意义上的“逐字生成”
3.1 Decode 一步里发生了什么
从第一个 token 开始,模型进入 Decode 循环。每一次迭代做的事可以拆成四步:
- 把当前最新生成的 token id 转成向量,形状是 [1, H]。
- 对每一层 Transformer:算出当前 token 自己的 Q、K、V;把 K/V 追加到该层的 KV Cache 尾部;然后用这个 Q 去和 KV Cache 里所有历史位置的 K/V 做 Attention,得到这一步的输出向量,再过 FFN。
- 最后一层输出接一个映射到词表大小的线性层,得到所有候选词的概率分布,采样出下一个 token。
- 把新 token 当作输入,回到第 1 步。
这里的核心变化是:每一层 Attention 的序列维度比上一步多 1,因为 KV Cache 里多了一个新位置。但真正参与“生产新输出”的,只有当前这一个 token 的 Q。换句话说,计算形状从 [S, H] 的矩阵乘,退化成了 [1, H] 的向量乘矩阵,也就是行业里常说的 GEMV。
3.2 瓶颈从“算”变成了“读”
一次 Decode 步骤的 FLOPs 其实少得可怜。还是 7B 模型:处理一个 token 的前向计算大约需要 2×70 亿 = 140 亿次浮点运算。这在 H100 的 989 TFLOPS(FP16 稠密算力)面前不值一提。
但 GPU 要干活,得先把数据搬到计算单元里。这一步要读什么?模型全部权重——7B 参数,FP16 就是约 14GB;还要读 KV Cache——如果已经生成了 1024 个 token,KV Cache 约 512MB。H100 的 HBM 带宽约 3.35TB/s,光把 14GB 权重读一遍,理论上就要 4 毫秒以上,实际加总下来每步一二十毫秒很正常。
用“算术强度”这个概念量化更直观:一次计算需要的 FLOPs 除以需要搬运的字节数。H100 上,要让算力和带宽都恰好饱和,算术强度大约需要达到 300 FLOPs/Byte 这个量级。Decode 处理一个 token 时,14 GFLOPs / 14GB ≈ 1 FLOPs/Byte,离平衡点差了三百倍。换句话说,GPU 的计算单元大部分时间在等数据从显存搬过来,这就是 memory-bound 的含义。
这也是为什么“模型很大但推理很慢”很多时候不是算不了,而是读不完。70B 模型在同样的内存带宽下,每生成一个 token 至少要把 140GB 权重过一遍,每步时间的下限大约是 7B 模型的十倍,和它实际算得多快关系不大。
3.3 KV Cache 越大,每个字越“贵”
Decode 每步还要读完整 KV Cache,所以随着生成继续,序列越来越长,每一步的访存量也是缓慢增长的。前面算过,7B 模型差不多每 1K token 多 512MB 的 KV Cache。对带宽 3.35TB/s 的 H100 来说,1K token 的 KV 读一遍只要 0.15 毫秒,在 14GB 权重面前几乎可以忽略;但在带宽只有几十 GB/s 的 CPU 内存场景下,KV Cache 消耗的时间会被明显放大,长对话越到后面越慢的体感主要来自这里。
还有一个容易被忽略的细节:Decode 虽慢,但可以同时服务多个请求。把多个请求各自当前的 token 拼成一个 batch,一个 step 就能同时推进 B 个请求,权重仍然只需要读一遍,等于把权重搬运成本摊到了 B 个 token 头上。这也是为什么推理引擎普遍用 dynamic batching / continuous batching,宁可让一个 step 的计算形状复杂一点,也要把权重读取次数压下去。
3.4 工程上为了省“读”做了哪些事
理解 Decode 的瓶颈是访存之后,再看一系列推理优化,思路会非常清晰:
- GQA / MQA:把 KV head 数量降下来(比如从 32 个减到 8 个),KV Cache 直接缩小 4 倍,Decode 每步读 KV 的字节数也跟着缩小。LLaMA-2 70B、Mistral 等模型都用 GQA,主要动机之一就是推理端的访存和显存压力。
- KV Cache 量化:把 FP16 的 K/V 压成 INT8、INT4,存储和读取都成倍下降。代价是精度,但很多人在长上下文中实测影响可控。
- PagedAttention / vLLM 的显存管理:KV Cache 按 block 分配,避免碎片化,从而在同样的显存里塞下更多请求。它没有减少每步读取量,但把整卡利用率提上去了。
- 投机采样(Speculative Decoding):用一个更小的草稿模型先快速猜几个 token,再用主模型一次验证多个 token。它把多步串行 Decode 变成更少的验证步骤,本质上是“用算力换延迟”。
4. 为什么不能一步到位,非要拆成两段
4.1 数学层:并行边界由依赖关系决定
回到最本质的问题:为什么不能把整段回答也一次性并行生成?
因为主流大语言模型是自回归模型,数学表达式是 P(下一个 token | 前面所有 token)。生成第三个 token 时,需要知道第二个 token 是什么;而第二个 token 来自抽样分布,带有随机性,在采样之前谁也不知道它的具体值。这就决定了生成过程只能逐步推进——每一步都依赖上一步的实际输出。
而 Prefill 处理的 prompt 没有这个约束:输入是用户给定的、已经完全确定的序列,里面的因果依赖可以全部“提前结算”。所以 Prefill 能做大规模并行,Decode 不能,这不是工程偷懒,而是模型定义决定的并行边界。
用个生活化类比:读一篇已经印好的文章可以快速扫视,因为所有字都在纸上,眼睛可以并行处理;但要自己写一篇文章,只能一个字一个字往下走,因为下一句怎么写取决于上一句已经形成的语义。Prompt 是“读”,生成是“写”,两者的并行度天然不同。
4.2 系统层:KV Cache 是连接两段的桥
如果只看到“并行度不同”,还不算全貌。第二个层面是 KV Cache 让两阶段产生了明确分工。
试想如果不做这个划分:每生成一个 token,就把“prompt + 之前生成的所有 token”重新作为输入,从头算一遍。从 prompt 到生成第 N 个 token,要对前 N 个位置反复计算,总计算量是 O(N²) 量级,很快就不堪重负。
有了两阶段分工之后:Prefill 只做一次,把所有历史位置的 K/V 存下来;Decode 每步只需要算新 token 自己的 Q、K、V,然后从缓存里读历史 K/V 即可。整个序列的总计算量基本是线性增长的。KV Cache 就是这座桥——Prefill 负责建桥,Decode 负责走桥。这个设计优化的不只是“快慢”,而是整个复杂度量级。
4.3 硬件层:GEMM 和 GEMV 是两种不同的游戏
第三个层面在硬件和 kernel 设计上。Prefill 的核心计算是大矩阵乘(GEMM,M 很大);Decode 的核心计算是向量乘矩阵(GEMV,M=1)。
GPU 的 Tensor Core 针对 GEMM 做了大量优化,数据可以分块并行、权重复用充分,利用率能做到很高。但 GEMV 是把一个向量和矩阵相乘,矩阵的每一行都要读一遍,算出的结果却只有一列。在很多 GPU 上,GEMV 的算力利用率可能只有个位数百分比,因为访存带宽先顶满了。
不夸张地说,一个推理引擎如果把 Prefill 和 Decode 混用一种 kernel、一种调度策略,性能一定很难看。从 CUDA kernel 到显存分配,再到 batching 调度,业界都是分开设计和优化的。这也是为什么“两阶段”不只是一个理论概念,而是写进推理框架代码结构里的现实约束。
4.4 那有没有可能“一步到位”
严格说,确实有一类非自回归模型试图一次并行生成全部输出,但代价是输出质量、长距离一致性通常不如自回归模型,所以主流大模型很少走这条路。工程上更现实的做法是在自回归框架内“压缩串联步骤”:投机采样、Medusa、EAGLE 等思路,都是让多个 token 的验证尽量同时完成,但底层仍然遵守自回归依赖。它们优化的只是 Decode 阶段的步数,并没有推翻 Prefill/Decode 的划分。
所以结论是:在当前主流技术路线下,Prefill 和 Decode 的划分不是历史包袱,而是自回归机制、KV Cache 复杂度、以及硬件访存特性共同作用下的必然结构。
5. 理解两阶段之后,实测和调优才有章法
5.1 评测先拆开:TTFT 和 TPOT
理解了两个阶段,第一件事就是把“生成速度”这个模糊概念拆开。
- TTFT(Time To First Token):请求发出到收到第一个 token 的间隔,主要由 Prefill 决定,受 prompt 长度影响很大。
- TPOT / ITL(Time Per Output Token):相邻两个 token 的间隔,主要由 Decode 的每步耗时决定,受模型大小、内存带宽、KV Cache 长度影响。
只看总吞吐量(token/s)会掩盖很多问题:一个服务可能总吞吐很高,但长 prompt 请求的 TTFT 爆炸;另一个服务可能首字很快,但后续吐字慢如蜗牛。本地部署和在线评测时,我都建议把这两个指标分别记录。llama.cpp 启动日志里的 prompt eval time 和 eval time,一般就分别对应 Prefill 和 Decode 的耗时,跑一次就能直观看到两阶段差距有多大。
5.2 Continuous Batching:让两段工作互相填坑
在服务端推理引擎里,两阶段划分最直接的表现就是调度策略。
朴素的做法是:一批请求同时 Prefill,然后同时进入 Decode,步调完全一致。问题在于,Decode 阶段 GPU 算力严重闲置,而新来的请求只能排队等整批跑完。Continuous Batching 的思路是:把调度粒度从“请求”缩小到“token/step”。每个 step 里,有些请求在 Prefill 新来的 prompt,有些请求在 Decode 已经生成的 token,大家被编排进同一次 GPU 计算步,算力和带宽都被更充分地利用。
这也是 vLLM、TensorRT-LLM 等框架能支撑高并发在线服务的核心原因之一。如果你自己写推理服务不考虑这种调度,并发一高就会出现“要么算力空转、要么排队严重”的现象。
5.3 Chunked Prefill:别让长 Prompt 堵住所有人的嘴
Continuous Batching 解决了混跑问题,但一个新的麻烦来了:如果某个请求的 prompt 特别长,它的 Prefill 一次要算几千个 token,这个 step 的耗时会把其他请求的 Decode 全挤到一边,造成所有人体验波动。为此 vLLM 有 Chunked Prefill:把一次很长的 Prefill 拆成多个小块,穿插在多个 Decode step 之间执行。
代价是单个请求的 Prefill 被拉长,TTFT 变大;收益是全局吞吐稳定、其他请求的尾部延迟不被打爆。做在线服务的人经常要在“单请求 TTFT”和“整体吞吐”之间权衡,Chunked Prefill 就是把旋钮交到运营者手里。
5.4 部署前的显存预算和速度估算
最后落到本地部署、选型这些日常场景。算清楚两个数字就够了:
- 权重显存:参数量 × 2 字节(FP16),7B 约 14GB,13B 约 26GB,70B 约 140GB。
- KV Cache:用前面那个公式,按上下文窗口估算。7B FP16 MHA 大约每 1K token 占 512MB,想跑 8K 上下文就预留 4GB;换 GQA 模型或 INT8 KV Cache,可以再除以 4 甚至除以 8。
速度上,Decode 的下限可以粗略估算成“权重字节数 ÷ 内存带宽”。比如 RTX 4090 显存带宽约 1TB/s,跑 7B FP16,每步下限约 14ms,换算下来上限差不多 70 token/s 出头;如果跑 70B 模型,每步下限约 140ms,上限约 7 token/s。实际会再低一些,但估算思路是对的,用来选型心里会有底得多。
我自己在做推理服务排查时,判断“首字慢”和“逐字慢”用的完全是两套工具:前者查 prompt 长度、预填充调度、显卡算力;后者查内存带宽、KV Cache 量化、上下文长度。跑过几次之后就会发现,表面上都是“模型卡顿”,根因经常风马牛不相及。以后跟人聊 LLM 推理,也别用一个“每秒多少 token”概括一切,拆开说 Prefill 多少、Decode 多少,对方就知道你是真跑过推理的人。