这是「Nano-vLLM 源码解读」第 1 讲。整门课围绕 GeeeekExplorer/nano-vllm 这份约 1200 行的精简 vLLM 实现,把推理引擎的核心机制——KV Cache 块化、Continuous Batching、Tensor Parallel、CUDA Graph——一个一个拆开讲透。这一讲是导论:建立心智模型,看懂全景。
配套源码(路径相对 nanovllm/ 包根目录):llm.py、engine/llm_engine.py、config.py,以及仓库根目录的 example.py。
学习目标
读完这一讲,你能:
• 用一句话回答"推理引擎到底在做什么",说清楚它跟训练框架的根本差异 • 解释 prefill 与 decode 两个阶段为什么对硬件要求截然相反,以及为什么调度必须分开 • 在脑中画出 nano-vllm 的三层结构图,说出每一层的职责边界 • 知道 nano 相对生产 vLLM 砍掉了什么、保留了什么、为什么这套精简还能在小模型场景跑出对等吞吐
1. 推理引擎到底在做什么
把它浓缩成一句话:
给定一组到达时刻不同、长度各异的请求,把 GPU 的算力和显存压榨到极限,同时让每个用户感知到的延迟可控。
四个关键词:
• 到达时刻不同:请求是流式打进来的,不像训练有个静态 dataset;调度器必须不停做在线决策 • 长度各异:prompt 从 80 token 到 8000 token 都有,没办法 pad 到一样长(pad 等于直接浪费算力) • 算力 + 显存:必须同时优化两者;只有 FLOPS 没用,KV Cache 装不下就要抢占 • 延迟可控:吞吐再高,单个用户首 token 30 秒也是产品级灾难
训练框架(DeepSpeed、Megatron)解决"我有一个静态 batch,怎么让它快"。推理引擎解决"我有 N 条用户实时打进来的请求,怎么让总系统快"。这是两类完全不同的问题,需要不同的抽象。
训练像时间均匀的工厂流水线;推理像不断进出的医院急诊室。
2. Prefill 与 Decode:两个完全不同的阶段
LLM 推理拆成两个连续阶段:
两个阶段对硬件的诉求几乎相反:
• Prefill 喜欢大 batch、长序列、纯 GEMM。算子是 compute-bound 的,FLOPS 利用率轻松上 70%。 • Decode 受显存带宽限制,单条序列时算力反而吃不饱。要靠把多条序列的 decode 步聚到一起,让一次 KV 读取能服务 batch 里所有序列——这就是 continuous batching 存在的根本原因。
engine/llm_engine.py:49 的 step() 把这种"二选一"写得很直白:
defstep(self): seqs, is_prefill = self.scheduler.schedule() 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)self.scheduler.postprocess(seqs, token_ids, is_prefill)每个 step 要么全 prefill 要么全 decode,由调度器决定走哪条。这种二选一简化了内核分发,代价是 prefill 来了就要打断 decode(vLLM 后来用 chunked prefill 缓解;nano 也支持队首 chunk,详见 L8)。
3. nano-vllm 的三层架构
三层各自的"独立性测试"——换掉任何一层,其它两层不需要动:
编排层(LLMEngine)知道"用户有 prompt",不知道"模型如何前向"。它的工作就是:把 prompts 转成 Sequence、起 step 循环、把完成的序列汇总给 tokenizer 解码。换底层模型,这一层不动。
资源层(Scheduler + BlockManager)知道"显存里有 N 个 KV 块",不知道"模型里有几层 attention"。它决定哪些序列这个 step 跑、哪些块该分配 / 释放 / 复用。换 attention 实现,这一层不动。
执行层(ModelRunner + Qwen3* + layers/*)知道"hidden_size、num_heads、TP shard 怎么切",不知道"调度策略"。它接受"一组 sequences、是 prefill 还是 decode",吐出 token id。换调度策略,这一层不动。
这套划分是 vLLM 的核心设计精髓。nano 1200 行能在小模型上跟上 vLLM 几万行的吞吐,前提就是它没在边界上偷懒——每一层都对它不该知道的东西保持无知。
4. 一条 prompt 的端到端调用链
example.py:
from nanovllm import LLM, SamplingParamsllm = LLM("/YOUR/MODEL/PATH", enforce_eager=True, tensor_parallel_size=1)sampling_params = SamplingParams(temperature=0.6, max_tokens=256)prompts = ["Hello, Nano-vLLM."]outputs = llm.generate(prompts, sampling_params)generate() 内部的骨架(engine/llm_engine.py:60):
defgenerate(self, prompts, sampling_params, use_tqdm=True):for prompt, sp inzip(prompts, sampling_params):self.add_request(prompt, sp) # 1. 入队whilenotself.is_finished(): # 2. step 直到全部完成 output, num_tokens = self.step()for seq_id, token_ids in output: outputs[seq_id] = token_idsreturn [tokenizer.decode(...) for ...] # 3. 解码把这 11 步画成时序图:
对照源码逐步说明:
1. LLM.generate(prompts, sampling_params)收外部输入2. tokenizer 把 prompt 编码成 token_ids3. 包成 Sequence对象,放进Scheduler.waiting队列4. 进入 while not is_finished()主循环5. Scheduler.schedule()返回这个 step 要跑的 seqs +is_prefill标记6. ModelRunner.run(seqs, is_prefill)准备 batch tensor + 设置Context7. Qwen3ForCausalLM.forward跑前向,得到 logits8. Sampler采样下一个 token id9. Scheduler.postprocess更新序列状态、追加 token、检查 EOS10. 完成的序列汇总到 outputs 字典 11. tokenizer 解码回字符串,返回给用户
这 11 步是这门课反复展开的脚手架——每一讲都会盯着其中某一两步深入。
5. nano-vllm vs 生产 vLLM:取舍清单
nano 的"刻意省略"才是它的教学价值。 每个被砍掉的功能都对应一个工程复杂度跳跃。当你看清"少了它 1200 行就够",你也就理解了"加上它为什么要 5 万行"。
需要给一个量化对照——README 的 benchmark(RTX 4070 Laptop 8GB / Qwen3-0.6B / 256 sequences / 长度 100–1024 随机):
注意这是小模型 + 单卡场景。换成 70B + 8 卡 TP,nano 大概率不再领先(生产 vLLM 在大模型上的众多工程优化是 nano 砍掉的)。这门课的目标不是论证"nano 比 vLLM 快",而是借 nano 的简洁把"vLLM 是怎么工作的"讲透。
6. 这门课接下来讲什么
整门课分 6 个模块、17 讲,都围绕这一讲铺开的"三层架构 + 11 步链路"展开:
Sequence 这个核心数据结构在状态机中的流转 | ||
下一讲:Sequence 状态机与请求生命周期。 我们会盯着 Sequence 这个类,把它从入队到出队的所有字段变化跟一遍,看它如何同时承担"用户请求"、"调度单位"、"KV 块持有者"三重角色——这是理解后面 KV Cache 和调度章节的前提。
夜雨聆风