课程 01 · 引擎骨架
从 LLMEngine.generate() 看清 prefill 与 decode 的分工——以及为什么整个引擎的复杂度都来自这一处分工。
面试里问「讲讲 vLLM 的原理」,大部分人从 PagedAttention 开始背。但面试官真正想听的是:一个请求进来之后,引擎每一轮在干什么,为什么必须这样分。
这一课只做一件事:把 generate() 的主循环拆到你能在白板上默写。nano-vllm 全部代码 1450 行,其中引擎骨架只有 90 行——剩下的 1360 行,都是为了支撑这 90 行。
① 能在纸上画出 schedule → forward → postprocess 三段循环;② 能解释 num_tokens 为什么有正负号;③ 能用一句话说清 prefill 和 decode 为什么必须分两条路。
把大模型当成一个普通函数,最直觉的生成循环长这样:
def generate_naive(model, tokenizer, prompt, max_tokens=64):
ids = tokenizer.encode(prompt)
for _ in range(max_tokens):
logits = model(torch.tensor([ids], device="cuda")) # 每次都重算整个前缀
next_id = logits[0, -1].argmax().item()
ids.append(next_id)
return tokenizer.decode(ids)
它没有错,但它有三个致命问题,而且一个比一个贵:
t 个 token 时,前面 t 个 token 的 K/V 又算了一遍。整段生成的总计算量是 O(n²),而不是 KV cache 版本里的 O(n)。vLLM 的论文把这三个问题的代价算得很清楚:在同样的延迟水平下,吞吐能提升 2–4 倍[SOSP'23]。而 nano-vllm 把这个提升压缩成了三个文件。
把「笨版本」的三个问题反过来看,就是一个推理引擎的全部职责:
| 要回答的问题 | nano-vllm 的答案 | 代码位置 |
|---|---|---|
| 这一轮让谁上 GPU? | waiting / running 双队列,装不下就抢占 | engine/scheduler.py |
| 算出来的 K/V 存在哪? | 按 block 分页 + 引用计数 + 前缀哈希 | engine/block_manager.py |
| 多个请求怎么缝进一次前向? | 变长 batch、slot_mapping、FlashAttention varlen |
engine/model_runner.py |
至于「模型本身」,在 nano-vllm 里只是 models/qwen3.py 的 216 行——引擎不关心模型结构,它只关心这个 batch 有几个序列、每个序列多长、KV 该写到哪。
llm_engine.py 全部 90 行,真正定义引擎行为的是 step()[L49–55]:
nanovllm/engine/llm_engine.py · step() def step(self):
seqs, is_prefill = self.scheduler.schedule() # ① 调度:谁上 GPU
num_tokens = sum(seq.num_scheduled_tokens for seq in seqs) if is_prefill else -len(seqs)
token_ids = self.model_runner.call("run", seqs, is_prefill) # ② 前向:GPU 干活
self.scheduler.postprocess(seqs, token_ids, is_prefill) # ③ 记账:写回、判死
outputs = [(seq.seq_id, seq.completion_token_ids) for seq in seqs if seq.is_finished]
return outputs, num_tokens
外层再套一个 while not self.is_finished():[L73],就是完整的引擎:
每一轮可以跑 多批 序列,但只跑 一种 模式——要么整轮都是 prefill,要么整轮都是 decode。这就是 is_prefill 这个布尔值的全部含义。
引擎不是「把模型跑得更快」,而是决定每一轮让哪些请求一起跑。模型的计算量由 token 数决定,引擎能做的是让这些 token 尽量填满 GPU。
num_tokens = sum(seq.num_scheduled_tokens for seq in seqs) if is_prefill else -len(seqs)
这一行看起来像恶作剧。为什么要用负数表示 decode?因为这两种模式下,「这一轮干了多少活」的计量单位根本不同:
-len(seqs) 来记——负数。负号只是为了让同一个返回值同时携带「量」和「类型」两个信息,好让 generate() 分别统计 prefill 和 decode 的吞吐[L76–83]。这不是好设计,但它把「两种模式」这件事暴露得特别清楚——你在 vLLM 里会看到同一种区分被拆成了两个独立的调度器。
prefill 和 decode 的计算特征几乎相反:
| prefill | decode | |
|---|---|---|
| 一次吃多少 token | 整段 prompt(几十到几千) | 每序列 1 个 |
| 主要瓶颈 | 算力(大矩阵乘,compute-bound) | 显存带宽(要读完整个 KV cache,memory-bound) |
| KV 写入 | 一次写一大片 | 每步写 1 个 slot |
| 注意力实现 | flash_attn_varlen_func | flash_attn_with_kvcache |
把两者混在一轮里,GPU 既要算大矩阵又要读 KV,两种优化会互相打架。所以 nano-vllm 的选择很直接:先尽量把 waiting 队列里的 prefill 做完,没有 prefill 了才进入 decode[scheduler.py L54–55]。
现在算一笔账,你就知道 KV cache 为什么是整个系统的心脏。Qwen3-0.6B 的配置是 28 层、8 个 KV head、head_dim 128、bf16[config.json]:
| 量 | 算式 | 结果 |
|---|---|---|
| 每个 token 的 KV | 2 × 28 层 × 8 头 × 128 维 × 2 字节 | 112 KiB |
| 一个 block(256 token) | 112 KiB × 256 | 28 MiB |
| 一条 4096 token 序列 | 112 KiB × 4096 | 448 MiB |
| 12 GB 卡能装多少(粗算) | (12×0.9 − 1.2 GB 权重) ÷ 28 MiB | 约 340 个 block |
340 个 block ≈ 8.7 万个 token ≈ 21 条满长度序列。注意 max_num_seqs 默认是 512[config.py L10]——能排队的请求数和显存能容纳的序列数根本不是一回事。调度器真正在做的,是在这两者之间做取舍。
「batch 越大吞吐越高」只在显存够用时成立。decode 阶段每多一条序列,就要多读一份 KV——超过带宽极限后,加 batch 只会让每条序列变慢,总吞吐不再上升。
合上这页,用 3 分钟在纸上画出:generate() 的循环、三个箭头的名字、以及每个箭头里发生的一件关键事。画完再展开对照。
及格:画出 waiting → running → finished 的迁移,标出 schedule / run / postprocess 三个位置。
良好:额外写出 prefill 与 decode 的瓶颈差异(compute-bound vs memory-bound),并说明 KV cache 是 decode 的主导成本。
优秀:能指出「一轮只跑一种模式」是 nano-vllm 的简化,vLLM 用 chunked prefill 把两者混在同一轮里来填满 GPU。
五道题,答对四道以上再往下走。答错不要紧——错的那道正好告诉你该回看哪一行。
打开本地 vendor/nano-vllm/,用编辑器跳转到这几行,先自己想,再展开对照。
scheduler.py 的 L48–51:序列从 waiting 搬到 running 的条件是什么?seq.num_cached_tokens + seq.num_scheduled_tokens == seq.num_tokens。左边是「已经算过的 + 这一轮要算的」,等于总长度,说明这段 prompt 的 prefill 到此完成。注意在有 chunked prefill(L42–43 只允许第一条序列分块)时,可能需要多轮才满足。
block_manager.py 的 L104:为什么判断新块的条件是 len(seq) % block_size == 1?decode 这一步要写入的是「最后一个 token」的 KV,它位于 len(seq) - 1 号位置。当 len(seq) % block_size == 1 时,这个位置恰好是某个新 block 的第 0 格,所以要提前分配一个 block。can_append 只检查空闲块够不够,may_append 才真正分配。
scheduler.py 的 L89–92:一条序列在两个条件下会结束,分别是什么?① 采样到 EOS 且没有设置 ignore_eos;② 生成的 token 数达到 max_tokens。结束后立刻 deallocate 归还 block,并从 running 队列移除——这就是 paged 分配最大的好处:释放是即时且完整的,不会留下碎片。
step() 里 postprocess 在 run 之后,而不是在下一轮 schedule 之前?因为 run 产生的 logits 只在这一轮有效(GPU 上的中间张量马上会被下一轮覆盖),必须立刻采样成 token id 并写回 CPU 侧状态。引擎的状态机(num_cached_tokens、block_table)是 CPU 的真相,GPU 只是执行器。
下面这个问题几乎每场推理框架面试都会出现。先用 30 秒讲一遍,再看参考答法。
「一个请求进来之后,vLLM 这类引擎的每一步在做什么?」
「引擎的核心循环只有三步:调度、前向、回写。调度器从 waiting 队列取请求算 prompt(prefill),从 running 队列取请求生成下一个 token(decode);前向把这一批序列拼成一个变长 batch 一次跑完;回写阶段采样、追加 token、更新 KV 的 block 表,结束的序列立刻释放显存。
关键点是 prefill 和 decode 的计算特征相反:prefill 是大矩阵乘、算力受限;decode 每步只算一个 token 却要读完整个 KV cache、带宽受限。所以调度器的大部分复杂度,都是在有限的 KV 显存里决定让谁上、让谁等、以及等不下时抢占谁。」
随时打断我。 卡在哪一行、哪个术语、或者想问「vLLM 里这段变成什么样了」,直接在对话里问——我是你的老师,不是播放器。下一课开始前,你可以先问我任何关于这一课的问题。