课程 01 · 引擎骨架

90 行,一个推理引擎

从 LLMEngine.generate() 看清 prefill 与 decode 的分工——以及为什么整个引擎的复杂度都来自这一处分工。

约 45 分钟 源码:nanovllm/engine/llm_engine.py 前置:无

面试里问「讲讲 vLLM 的原理」,大部分人从 PagedAttention 开始背。但面试官真正想听的是:一个请求进来之后,引擎每一轮在干什么,为什么必须这样分。

这一课只做一件事:把 generate() 的主循环拆到你能在白板上默写。nano-vllm 全部代码 1450 行,其中引擎骨架只有 90 行——剩下的 1360 行,都是为了支撑这 90 行。

这一课结束时你会拿到

① 能在纸上画出 schedule → forward → postprocess 三段循环;② 能解释 num_tokens 为什么有正负号;③ 能用一句话说清 prefill 和 decode 为什么必须分两条路。

1先写一个「笨」的版本

把大模型当成一个普通函数,最直觉的生成循环长这样:

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)

它没有错,但它有三个致命问题,而且一个比一个贵:

  1. 重复计算。生成第 t 个 token 时,前面 t 个 token 的 K/V 又算了一遍。整段生成的总计算量是 O(n²),而不是 KV cache 版本里的 O(n)。
  2. 一次只服务一个请求。GPU 的算力单元要凑够一批矩阵乘才吃得饱;batch size = 1 时,大部分时间在等数据搬运。
  3. 显存不知道该怎么分。如果给每个请求按最大长度预分配 KV,一条 100 token 的短请求也要占 4096 的坑,碎片和浪费会让并发数掉到个位数。

vLLM 的论文把这三个问题的代价算得很清楚:在同样的延迟水平下,吞吐能提升 2–4 倍[SOSP'23]。而 nano-vllm 把这个提升压缩成了三个文件。

2引擎只多做三件事

把「笨版本」的三个问题反过来看,就是一个推理引擎的全部职责:

要回答的问题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 该写到哪。

3打开源码:90 行里的 7 行

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],就是完整的引擎:

① schedule挑 seqs,分配 block
→
② run拼 batch,跑 forward
→
③ postprocess采样、append、判结束

每一轮可以跑 多批 序列,但只跑 一种 模式——要么整轮都是 prefill,要么整轮都是 decode。这就是 is_prefill 这个布尔值的全部含义。

关键洞察

引擎不是「把模型跑得更快」,而是决定每一轮让哪些请求一起跑。模型的计算量由 token 数决定,引擎能做的是让这些 token 尽量填满 GPU。

4那个负号

num_tokens = sum(seq.num_scheduled_tokens for seq in seqs) if is_prefill else -len(seqs)

这一行看起来像恶作剧。为什么要用负数表示 decode?因为这两种模式下,「这一轮干了多少活」的计量单位根本不同:

负号只是为了让同一个返回值同时携带「量」和「类型」两个信息,好让 generate() 分别统计 prefill 和 decode 的吞吐[L76–83]。这不是好设计,但它把「两种模式」这件事暴露得特别清楚——你在 vLLM 里会看到同一种区分被拆成了两个独立的调度器。

5为什么一轮只能是一种模式

prefill 和 decode 的计算特征几乎相反:

prefilldecode
一次吃多少 token整段 prompt(几十到几千)每序列 1 个
主要瓶颈算力(大矩阵乘,compute-bound)显存带宽(要读完整个 KV cache,memory-bound)
KV 写入一次写一大片每步写 1 个 slot
注意力实现flash_attn_varlen_funcflash_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 的 KV2 × 28 层 × 8 头 × 128 维 × 2 字节112 KiB
一个 block(256 token)112 KiB × 25628 MiB
一条 4096 token 序列112 KiB × 4096448 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 只会让每条序列变慢,总吞吐不再上升。

6练习 A · 白板复述

合上这页,用 3 分钟在纸上画出:generate() 的循环、三个箭头的名字、以及每个箭头里发生的一件关键事。画完再展开对照。

展开:参考图与自评标准

及格:画出 waiting → running → finished 的迁移,标出 schedule / run / postprocess 三个位置。

良好:额外写出 prefill 与 decode 的瓶颈差异(compute-bound vs memory-bound),并说明 KV cache 是 decode 的主导成本。

优秀:能指出「一轮只跑一种模式」是 nano-vllm 的简化,vLLM 用 chunked prefill 把两者混在同一轮里来填满 GPU。

7练习 B · 闭卷测验

五道题,答对四道以上再往下走。答错不要紧——错的那道正好告诉你该回看哪一行。

8练习 C · 代码寻宝

打开本地 vendor/nano-vllm/,用编辑器跳转到这几行,先自己想,再展开对照。

1. scheduler.py 的 L48–51:序列从 waiting 搬到 running 的条件是什么?

seq.num_cached_tokens + seq.num_scheduled_tokens == seq.num_tokens。左边是「已经算过的 + 这一轮要算的」,等于总长度,说明这段 prompt 的 prefill 到此完成。注意在有 chunked prefill(L42–43 只允许第一条序列分块)时,可能需要多轮才满足。

2. 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 才真正分配。

3. scheduler.py 的 L89–92:一条序列在两个条件下会结束,分别是什么?

① 采样到 EOS 且没有设置 ignore_eos;② 生成的 token 数达到 max_tokens。结束后立刻 deallocate 归还 block,并从 running 队列移除——这就是 paged 分配最大的好处:释放是即时且完整的,不会留下碎片。

4. 附加:为什么 step() 里 postprocess 在 run 之后,而不是在下一轮 schedule 之前?

因为 run 产生的 logits 只在这一轮有效(GPU 上的中间张量马上会被下一轮覆盖),必须立刻采样成 token id 并写回 CPU 侧状态。引擎的状态机(num_cached_tokens、block_table)是 CPU 的真相,GPU 只是执行器。

9面试复述卡

下面这个问题几乎每场推理框架面试都会出现。先用 30 秒讲一遍,再看参考答法。

「一个请求进来之后,vLLM 这类引擎的每一步在做什么?」

展开:一个可以背的 30 秒结构

「引擎的核心循环只有三步:调度、前向、回写。调度器从 waiting 队列取请求算 prompt(prefill),从 running 队列取请求生成下一个 token(decode);前向把这一批序列拼成一个变长 batch 一次跑完;回写阶段采样、追加 token、更新 KV 的 block 表,结束的序列立刻释放显存。

关键点是 prefill 和 decode 的计算特征相反:prefill 是大矩阵乘、算力受限;decode 每步只算一个 token 却要读完整个 KV cache、带宽受限。所以调度器的大部分复杂度,都是在有限的 KV 显存里决定让谁上、让谁等、以及等不下时抢占谁。」

随时打断我。 卡在哪一行、哪个术语、或者想问「vLLM 里这段变成什么样了」,直接在对话里问——我是你的老师,不是播放器。下一课开始前,你可以先问我任何关于这一课的问题。