Prefill 和 Decode 分离:LLM 推理引擎里的两种工作负载
1. 这一篇要解决什么问题
前面几篇文章里,我们已经逐步搭出了 mini vLLM 的核心骨架。
我们先理解了一个 token 是如何被生成出来的,然后引入 KV cache,避免每一轮 decode 都重复计算完整上下文。接着,我们把一个请求抽象成 Sequence,并实现了第一版 Scheduler,让多个请求可以在同一个推理引擎里流动起来。
到现在为止,我们已经多次提到两个词:
prefill
decode
但前面的文章里,我们主要把它们当成状态来理解。
waiting 请求需要 prefill。
running 请求需要 decode。
这一篇要进一步深入。
prefill 和 decode 不只是两个阶段,它们本质上是两种完全不同的工作负载。
它们的输入形状不同。
它们对 KV cache 的操作不同。
它们的性能瓶颈不同。
它们对用户体验的影响不同。
它们在调度器里的优先级也不同。
如果我们只是粗略地把它们都看成一次模型 forward,就很难设计出一个好的推理引擎。
这一篇的目标,就是把 prefill 和 decode 真正拆开理解。
到最后,我们会看到:
prefill 负责让请求开始
decode 负责让请求持续生成
也可以说:
prefill 决定首 token 延迟
decode 决定输出流畅度和整体吞吐
这两个阶段之间的平衡,就是 LLM serving 调度的核心问题之一。
2. 从生成过程重新看 prefill 和 decode
先回到最基础的自回归生成过程。
假设用户输入 prompt:
A B C
模型要继续生成:
D E F
从用户视角看,生成过程是:
输入 A B C
输出 D
输出 E
输出 F
但从推理引擎视角看,它会被拆成两个阶段。
第一步是 prefill。
输入 A B C
计算完整 prompt 的 hidden states
生成 prompt 对应的 KV cache
根据最后一个位置的 logits 采样出 D
第二步开始才是 decode。
输入 D
读取 A B C D 对应的 KV cache
采样出 E
输入 E
读取 A B C D E 对应的 KV cache
采样出 F
注意一个容易被忽略的细节:
第一个输出 token D 是 prefill 的结果,不是 decode 的结果。
prefill 输入 prompt,并根据 prompt 最后一个位置的 logits 采样出第一个生成 token。
decode 从这个生成 token 开始继续往后推。
所以更准确的过程是:
prefill:
输入 prompt tokens
输出第一个 generated token
创建初始 KV cache
decode:
输入上一次生成的 token
输出下一个 generated token
追加新的 KV cache
这就是 prefill 和 decode 在语义上的区别。
3. 输入形状的差异
prefill 和 decode 最直观的区别是输入长度不同。
prefill 输入完整 prompt。
假设 batch 里有两个新请求:
请求 A prompt 长度 128
请求 B prompt 长度 512
prefill 阶段处理的是这些完整 prompt。
如果使用 padding 方式构造 batch,输入形状可能是:
[2, 512]
而 decode 阶段通常每个 running 请求只输入一个 token。
如果有 128 个 running 请求同时 decode,输入形状是:
[128, 1]
从表面上看,prefill 的 sequence length 大,decode 的 batch size 大。
但这只是第一层区别。
更重要的是,它们对应的计算模式不同。
prefill 是一次性处理很多 token。
decode 是反复处理单个 token。
这会导致二者在 GPU 上的行为完全不同。
4. Prefill 的计算特征
prefill 处理完整 prompt。
如果 prompt 长度是 4096,模型要在一次 forward 中处理这 4096 个 token。
在 Transformer 里,prefill 阶段会执行完整的 self attention。
对于每一层,模型会为所有 prompt token 计算 Q、K、V,并计算 token 之间的 attention。
这个阶段有几个特点。
第一,矩阵乘法规模较大。
因为输入 token 多,prefill 的矩阵计算通常更容易把 GPU 利用起来。
第二,计算量和 prompt 长度强相关。
prompt 越长,prefill 越重。
第三,prefill 会一次性创建大量 KV cache。
prompt 有多少 token,prefill 后 KV cache 就会包含多少 token 的 K 和 V。
第四,prefill 的输出决定用户什么时候能看到第一个 token。
用户发出请求之后,必须等 prefill 完成,系统才有第一个 logits 可以采样出第一个 token。
所以 prefill 直接影响首 token 延迟。
我们可以把 prefill 理解成:
一次重计算
一次大输入
一次大 cache 创建
一次首 token 产生
这就是它的系统意义。
5. Decode 的计算特征
decode 阶段每一轮只处理新生成的一个 token。
如果某个请求已经生成了很多 token,它的下一轮 decode 仍然只输入最后一个 token。
但是,这并不意味着 decode 很便宜。
因为当前 token 的 Q 需要和历史所有 K 做 attention,然后再聚合历史所有 V。
也就是说,decode 虽然输入短,但它要访问完整历史 KV cache。
这个阶段有几个特点。
第一,单个请求的计算粒度很小。
一个请求一轮只 decode 一个 token。
如果 batch size 很小,GPU 利用率会很差。
第二,decode 会频繁访问 KV cache。
上下文越长,需要读取的历史 K 和 V 越多。
第三,decode 会不断追加新的 KV cache。
每生成一个 token,每一层都会增加这个 token 对应的 K 和 V。
第四,decode 决定流式输出是否稳定。
用户已经看到模型开始输出后,后续每个 token 都依赖 decode 持续推进。
所以 decode 的体验不在于“什么时候开始”,而在于“开始以后是否顺滑”。
我们可以把 decode 理解成:
多次小计算
频繁读 cache
持续追加 cache
持续产生输出 token
这就是它和 prefill 最大的不同。
6. 为什么说 prefill 偏计算密集,decode 偏访存密集
很多文章会说:
prefill 更偏计算密集
decode 更偏访存密集
这句话很常见,但如果不解释清楚,就很容易变成口号。
先看 prefill。
prefill 一次性处理很多 token,矩阵乘法规模大。GPU 的计算单元更容易被填满,整体性能更容易受算力影响。
当然,prefill 也需要读写显存,但它的主要压力往往来自大规模计算。
再看 decode。
decode 每个请求每轮只处理一个 token。单个 token 带来的矩阵计算规模相对小,但它需要读取历史 KV cache。
历史越长,读的 K 和 V 越多。
当 batch 中有大量 running 请求时,decode 每一轮都会从显存中读取大量 KV cache。此时,性能更容易受到显存带宽、cache layout、访问模式影响。
所以这句话的含义是:
prefill 的主要矛盾是如何高效处理大量 prompt token
decode 的主要矛盾是如何高效访问大量历史 KV cache
这也是后面 PagedAttention 重要的原因。
PagedAttention 主要不是为了改变模型数学公式,而是为了让大量请求的 KV cache 能够更高效地被管理和访问。
7. KV cache 在两个阶段的角色不同
KV cache 在 prefill 和 decode 中扮演的角色也不一样。
prefill 阶段主要是创建 cache。
模型处理完整 prompt,并为每一层产生 prompt tokens 对应的 K 和 V。
可以理解成:
prefill 输入 prompt
prefill 输出初始 KV cache
decode 阶段主要是读取和追加 cache。
每一轮 decode 会读取历史 KV cache,然后把当前 token 对应的新 K 和 V 追加进去。
可以理解成:
decode 输入 last token 和历史 KV cache
decode 输出新 token 和更新后的 KV cache
所以 KV cache 的生命周期是这样展开的:
请求进入 waiting
还没有 KV cache
prefill 完成
创建 prompt 对应的 KV cache
每轮 decode
读取旧 cache
追加新 cache
请求结束
释放 cache
这条生命周期非常重要。
后面的 block manager 会围绕它展开。
它要回答的问题包括:
prefill 时如何一次性分配多个 token 的 cache 空间
decode 时如何为每个 running 请求追加一个 token 的 cache 空间
请求结束时如何释放它占用的 cache block
也就是说,prefill 和 decode 对 cache manager 的压力也不同。
prefill 是大块申请。
decode 是小步追加。
8. Prefill batch 和 Decode batch 为什么不能简单混在一起
现在我们看调度层。
在第一版 Scheduler 里,我们输出了两个列表:
prefill_sequences
decode_sequences
为什么不直接输出一个统一的 sequences 列表?
原因是这两类任务对 ModelRunner 的要求不同。
prefill sequence 需要输入完整 prompt。
decode sequence 只需要输入 last token。
prefill sequence 可能没有 past key values。
decode sequence 一定已经有 past key values。
prefill sequence 的位置编码要覆盖完整 prompt。
decode sequence 的位置通常对应当前生成位置。
prefill sequence 完成后要创建完整 prompt 的 KV cache。
decode sequence 完成后只追加一个 token 的 KV cache。
如果把它们粗暴混成一个统一 batch,ModelRunner 就要处理很多分支。
更清晰的做法是:
Scheduler 决定哪些请求 prefill,哪些请求 decode
ModelRunner 分别构造 prefill 输入和 decode 输入
Engine 分别处理两类输出
这不是为了代码好看,而是因为二者的计算语义确实不同。
当然,真实高性能系统可能会在更底层做融合和优化。但在教学版 mini vLLM 里,把这两条路径拆清楚,会更容易理解系统结构。
9. Prefill 路径应该做什么
一个 prefill 路径大致包含这些步骤:
读取 sequence 的 prompt_token_ids
构造模型输入
执行 forward
获得 logits 和 KV cache
用最后一个位置的 logits 采样第一个 token
把第一个 token 追加到 sequence.generated_token_ids
保存 KV cache
判断 sequence 是否结束
如果未结束,把 sequence 放入 running
可以用伪代码表示:
def run_prefill(sequence):
outputs = model_runner.prefill(sequence.prompt_token_ids)
sequence.kv_cache = outputs.kv_cache
logits = outputs.logits_at_last_position
next_token = sampler.sample(logits)
sequence.append_token(next_token)
if sequence.is_finished():
mark_finished(sequence)
else:
mark_running(sequence)
这里最重要的是理解顺序。
先创建 KV cache。
再采样第一个 token。
再判断是否结束。
如果 max_new_tokens 等于 1,那么 prefill 采样出第一个 token 后,请求就已经结束了,不需要进入 decode。
这类边界情况必须在状态机里处理好。
10. Decode 路径应该做什么
decode 路径大致包含这些步骤:
读取 sequence 的 last_token_id
读取 sequence 的 KV cache
构造模型输入
执行 forward
获得 logits 和更新后的 KV cache
采样下一个 token
把新 token 追加到 sequence.generated_token_ids
更新 KV cache
判断 sequence 是否结束
如果未结束,继续留在 running
伪代码可以写成:
def run_decode(sequence):
last_token = sequence.get_last_token_id()
outputs = model_runner.decode(
input_token=last_token,
kv_cache=sequence.kv_cache,
)
sequence.kv_cache = outputs.kv_cache
logits = outputs.logits
next_token = sampler.sample(logits)
sequence.append_token(next_token)
if sequence.is_finished():
mark_finished(sequence)
decode 路径看起来比 prefill 简单,但系统压力往往更大。
原因是 decode 会反复发生。
一个请求只会 prefill 一次,但可能 decode 上千次。
如果 decode 路径效率低,整个服务吞吐会很快下降。
所以推理系统的大量优化都会围绕 decode 展开。
11. 为什么首 token 延迟主要由 prefill 决定
用户发出请求后,系统要做这些事情:
排队等待调度
执行 prefill
采样第一个 token
返回第一个 token
从用户角度看,第一个 token 出来之前,模型是沉默的。
所以首 token 延迟由几个部分组成:
排队时间
prefill 执行时间
采样和返回开销
其中 prefill 执行时间通常和 prompt 长度强相关。
如果 prompt 很长,即使系统没有排队,也需要较长时间处理完整 prompt。
如果系统负载很高,waiting 请求还会排队,那么首 token 延迟会进一步增加。
这解释了为什么调度器不能一直偏向 running 请求。
如果 running 请求永远优先,decode 输出可能很顺滑,但新请求会长期得不到 prefill,用户一直看不到第一个 token。
所以 prefill 的调度影响的是:
用户什么时候感知到模型开始响应
这也是首 token 延迟的重要性。
12. 为什么输出流畅度主要由 decode 决定
当用户看到第一个 token 后,体验就从“等待开始”变成了“等待继续”。
如果后续 token 输出很稳定,用户会觉得模型在持续思考和回答。
如果输出经常停顿,用户会觉得系统卡顿。
后续每一个 token 基本都来自 decode。
因此 decode 的调度频率直接影响流式输出的流畅度。
如果调度器连续多轮都去处理长 prefill,而不调度 running 请求,那么用户会看到输出中断。
这种体验很差。
所以 decode 的调度影响的是:
模型开始输出后是否持续稳定
这就形成了一个矛盾。
prefill 需要资源,否则新请求没有首 token。
decode 也需要资源,否则老请求输出不流畅。
调度器必须在这两者之间分配每一轮的 token budget。
13. 一个具体例子:长 prefill 如何影响 decode
假设系统中有 64 个 running 请求正在流式输出。
每一轮 decode,它们各生成一个 token。
这时来了一个新请求,prompt 长度是 8192。
如果调度器直接把这个长 prefill 放进下一轮,并让它占满大部分 GPU 时间,那么那 64 个 running 请求可能要等待更久才能生成下一个 token。
用户会看到流式输出突然变慢。
如果调度器完全不处理这个长 prefill,那么新请求的用户会一直等不到第一个 token。
这就是典型冲突。
两边都有合理性。
长 prefill 不处理,新请求饿死。
长 prefill 一次性处理,running 请求卡顿。
后面我们会引入 chunked prefill,就是为了解决这个问题。
它的思路是把长 prompt 拆成多个小块,让 prefill 不再一次性占用太多预算,从而给 decode 留出空间。
但在引入 chunked prefill 之前,我们必须先把问题看清楚。
14. 另一个例子:decode 太多如何影响 prefill
再看相反情况。
假设系统中有 4096 个 running 请求。
每个请求每轮 decode 一个 token。
如果每一轮都优先把所有 running 请求调度进去,那么 sequence 数量和 token budget 可能直接被 decode 占满。
这时 waiting 队列里的新请求就没有机会做 prefill。
用户会一直等第一个 token。
这就是 waiting 饥饿。
所以即使 decode 很重要,也不能无限优先。
一个更稳妥的调度器通常需要某种平衡机制。
例如:
为 prefill 保留一部分预算
限制每轮 decode 的最大数量
给等待过久的请求提高优先级
定期插入一定数量的 prefill
不同系统会有不同实现,但背后的目标是一样的:
不能让 prefill 饿死 decode
也不能让 decode 饿死 prefill
15. Prefill 和 Decode 在 batch 构造上的差异
从 ModelRunner 的角度看,prefill 和 decode 的输入构造也不同。
prefill 需要处理变长 prompt。
如果使用普通 padding batch,需要构造:
input_ids
attention_mask
position_ids
其中 attention_mask 用来区分真实 token 和 padding token。
如果 prompt 长度差异很大,padding 会造成浪费。
decode 通常每个 sequence 只输入一个 token。
它更关心:
last_token_ids
position_ids
past_key_values
当前 sequence length
后面使用 PagedAttention 后,decode 还会关心:
block_table
slot_mapping
context_length
也就是说,prefill batch 更像“处理一批完整文本”。
decode batch 更像“为一批正在生成的请求各推进一步”。
这两种 batch 的构造方式从一开始就不同。
把这个边界拆清楚,后面改底层执行就不会乱。
16. Prefill 和 Decode 在输出处理上的差异
模型输出之后,Engine 对 prefill 和 decode 的处理也有细微差异。
prefill 输出包含完整 prompt 所有位置的 logits。
但我们只需要最后一个位置的 logits 来采样第一个生成 token。
decode 输出通常只有当前 token 对应的 logits。
所以 prefill 输出处理是:
取最后一个 prompt 位置的 logits
采样第一个 generated token
decode 输出处理是:
取当前 decode 位置的 logits
采样下一个 generated token
在单请求场景里,这个差别不明显。
在 batch 场景里,差别会更明显。
因为 prefill batch 中不同请求 prompt 长度可能不同,每个请求的“最后一个有效位置”也不同。
如果有 padding,就不能简单取 batch 的最后一个位置。
需要根据每个请求的真实 prompt 长度取对应 logits。
而 decode batch 中,每个请求通常只有一个输入 token,取 logits 会更直接。
这也是为什么 prefill 的 batch 处理更容易出错。
17. 为什么 prefill 输出的第一个 token很关键
prefill 输出的第一个 token有特殊意义。
它标志着请求从 waiting 进入 running。
在这之前,请求只是排队等待和执行 prompt 处理。
在这之后,请求进入持续 decode 阶段。
也就是说,第一个 token 是请求生命周期的转折点。
可以这样看:
没有第一个 token:
请求还没有真正开始对用户可见
有了第一个 token:
请求开始流式输出
请求进入 running
后续由 decode 推进
这也是为什么首 token 延迟是一个非常核心的指标。
它不仅是性能数字,也对应请求状态从 waiting 到 running 的转换。
18. 设计 ModelRunner 的两个入口
既然 prefill 和 decode 差异这么大,ModelRunner 可以设计成两个入口。
model_runner.run_prefill(prefill_sequences)
model_runner.run_decode(decode_sequences)
它们分别负责构造输入和执行模型。
Engine 的 step 可以变成:
scheduler_outputs = scheduler.schedule()
prefill_outputs = model_runner.run_prefill(
scheduler_outputs.prefill_sequences
)
decode_outputs = model_runner.run_decode(
scheduler_outputs.decode_sequences
)
engine.process_outputs(prefill_outputs, decode_outputs)
这样做有几个好处。
第一,职责清晰。
prefill 的输入构造和 decode 的输入构造不会混在一起。
第二,方便优化。
以后可以单独优化 decode path,而不影响 prefill path。
第三,方便调试。
如果第一个 token 不对,优先看 prefill。
如果后续 token 不对,优先看 decode。
第四,方便接入不同 backend。
例如 prefill 可以先使用框架原生 attention,decode 后面切换成 paged attention kernel。
这种拆分会让系统更容易演进。
19. 是否一定要每轮同时执行 prefill 和 decode
第一版 mini vLLM 可以允许一轮中同时有 prefill 和 decode。
但实现时,也可以先做得更简单:
如果有 prefill,就执行 prefill
如果有 decode,就执行 decode
二者可以在同一个 engine step 中先后执行
这不一定是最高性能的方式,但很适合教学版本。
后续可以继续思考更复杂的问题。
例如:
prefill 和 decode 是否应该融合成一个 batch
prefill 是否应该拆成 chunk
decode 是否应该严格每轮都执行
长 prefill 是否允许抢占
不同请求是否有优先级
这些问题都属于调度优化。
第一版不要急着解决所有问题。
先把两条路径拆清楚,再逐步引入复杂策略。
20. 对 Scheduler 的影响
在第六篇里,Scheduler 已经输出了:
prefill_sequences
decode_sequences
这一篇之后,我们可以更明确地定义它们的含义。
prefill_sequences 表示:
当前还没有完成 prefill
本轮需要输入完整 prompt
本轮会创建初始 KV cache
本轮会生成第一个输出 token
decode_sequences 表示:
已经完成 prefill
本轮只输入 last token
本轮会读取历史 KV cache
本轮会追加一个 token 的 KV cache
本轮会生成下一个输出 token
Scheduler 不负责执行这些逻辑,但它必须理解二者的资源成本。
所以调度预算中应该体现:
prefill token 成本 = prompt 长度
decode token 成本 = 每个 sequence 一个 token
这就是为什么 token budget 是比 batch size 更合理的调度单位。
21. 对 Sequence 的影响
Sequence 也需要支持 prefill 和 decode 的区分。
它至少要能回答这些问题:
我是否已经完成 prefill
我的 prompt 长度是多少
我的最后一个 token 是什么
我当前的上下文长度是多少
我是否已经结束
可以有一些概念方法:
sequence.is_waiting()
sequence.is_running()
sequence.get_prompt_len()
sequence.get_last_token_id()
sequence.get_context_len()
其中 get_context_len() 很重要。
因为 decode 时的位置编码和 KV cache 长度都依赖当前上下文长度。
如果 prompt 长度是 100,已经生成了 20 个 token,那么当前上下文长度就是 120。
下一轮 decode token 的 position 通常就是这个位置附近。
后面做 block manager 时,context length 也会用于判断应该写入哪个 cache slot。
22. 对输出对象的影响
输出对象也可以区分首 token 和后续 token。
对于流式服务来说,首 token 可能带有一些特殊指标。
例如:
time to first token
后续 token 则用于统计:
time per output token
tokens per second
Engine 可以在 Sequence 中记录:
arrival time
first_token_time
finish time
这样我们就可以计算:
首 token 延迟 = first_token_time 减 arrival_time
总延迟 = finish_time 减 arrival_time
平均输出速度 = generated_tokens 数量 除以 decode 持续时间
这些指标会在 benchmark 文章中用到。
但它们的根源就在 prefill 和 decode 的分离。
不分离这两个阶段,就很难准确理解延迟来自哪里。
23. 一个完整 step 的思考过程
现在我们把 Engine.step 的思考过程重新写一遍。
每一轮 step 开始时,系统中可能有:
waiting sequences
running sequences
finished sequences
Scheduler 先看 running,决定哪些请求继续 decode。
然后根据剩余 token budget,决定哪些 waiting 请求可以做 prefill。
接着 ModelRunner 分别执行:
prefill path
decode path
prefill path 处理完整 prompt,生成初始 cache 和第一个 token。
decode path 处理 last token,读取历史 cache,生成下一个 token。
Engine 再把输出写回 Sequence。
如果某个 Sequence 结束,就放入 finished,并释放资源。
如果还没结束,就留在 running,等待下一轮 decode。
最后,Engine 返回本轮产生的增量输出。
这个过程不断重复。
这就是一个在线推理引擎的心跳。
24. 当前阶段先不追求极致性能
这一篇的重点是拆清楚结构,而不是追求最高性能。
所以当前阶段我们可以接受一些简化。
例如:
prefill 和 decode 分开执行
prefill 使用普通 padding batch
decode 使用框架原生 past_key_values
暂时不实现 PagedAttention
暂时不实现 chunked prefill
暂时不实现复杂优先级
这些简化不会影响核心理解。
因为我们现在最重要的是把系统主干搭出来:
Sequence 管状态
Scheduler 做决策
ModelRunner 分别执行 prefill 和 decode
Engine 处理输出和状态迁移
只要这条主干清楚,后面的优化都是在这条路径上逐步替换模块。
25. 小结
这一篇我们深入拆解了 prefill 和 decode 的区别。
prefill 的特点是:
输入完整 prompt
一次性创建初始 KV cache
产生第一个输出 token
计算规模大
影响首 token 延迟
decode 的特点是:
每轮只输入 last token
读取历史 KV cache
追加当前 token 的 KV cache
反复执行很多轮
影响输出流畅度和整体吞吐
这两个阶段不仅输入不同,背后的系统含义也不同。
prefill 让请求开始。
decode 让请求持续。
prefill 太少,新请求拿不到首 token。
decode 太少,老请求输出会卡顿。
所以调度器必须在二者之间分配预算。
从工程结构上看,我们也应该把它们拆成两条清晰路径:
run_prefill
run_decode
这样,ModelRunner 的输入构造更清楚,Engine 的状态更新更清楚,后续接入 KV cache block manager 和 PagedAttention 也更自然。
下一篇文章,我们会基于这个结构实现流式输出和异步 engine。
到那时,mini vLLM 不再只是内部循环,而会开始具备服务端推理系统的形态。