vllm v1 源码精读(一):为什么要重写,以及 LLM() 这行代码背后发生了什么
vllm v1 源码精读(一):为什么要重写,以及 LLM() 这行代码背后发生了什么
本文基于 vllm
ba22152(2026-07-06),源码链接均指向该 commit 的固定行号。 代码浏览入口:github.com/vllm-project/vllm/tree/ba22152
1. 接口没变,但内部几乎全换了——为什么?
如果你最近更新了 vllm,会发现同样这两行代码:
llm = LLM(model="facebook/opt-125m")
outputs = llm.generate(prompts, sampling_params)
行为看起来和以前没什么两样,但打开源码就会发现,vllm/engine/llm_engine.py 这个原来的核心文件,现在只剩一行:
LLMEngine = V1LLMEngine
整个引擎几乎被完整替换掉了。做这么大规模的重构,肯定不是为了重构而重构——v0 一定有什么本质问题,撑不住了。
所以在看新代码之前,先搞清楚 v0 到底出了什么问题,是理解这次重写的前提。
2. v0 的三个问题
问题一:调度和 GPU forward 串行,GPU 在等 CPU
v0 的每个推理步骤,大致是这样跑的:Python 做调度决策 → tokenization → KV 块分配 → 打包数据 → GPU forward → 回收结果。这一串全在同一个 Python 进程里顺序执行。由于 Python GIL 的存在,CPU 侧的调度逻辑和 GPU forward 无法真正并行,GPU 经常在等 Python 干完那堆”杂活”才能开始计算。
问题二:prefill 和 decode 阶段割裂
v0 把 prefill 和 decode 当成两个独立的 batch 来跑:先把所有等待中的请求 prefill 一遍,再切换到 decode 阶段。这两个阶段的计算特性完全不同——prefill 是 compute-bound(算力瓶颈),decode 是 memory-bound(带宽瓶颈)——来回切换导致 GPU 利用率不稳定,而且 decode 中的请求还会被 prefill 阻塞,TTFT(Time to First Token,首个 token 的延迟)受到明显影响。
问题三:每步都传完整请求对象
每次调度都要把完整的 request 对象序列化传给 worker,包括整个 token 序列、所有 sampling 参数等,开销随请求变长而线性增大。
有了这三个问题,v1 的设计思路就很清晰了:
- EngineCore 独立进程 + ZMQ 通信:把调度器和 GPU worker 放到独立后台进程,前端只做 tokenization 和 detokenization,两边真正并行。ZMQ(ZeroMQ)是一个高性能消息队列库,专门用于进程间高效传递数据,延迟极低。
- 统一 prefill/decode 调度:用
num_computed_tokens跟踪每个请求的进度,prefill 和 decode 同框处理,chunked prefill 自然支持 - 差量传输:第一次调度发完整数据,后续步骤只发 diff(新分配的块 id + 新 token)
解决思路明确了,下一步是看 v1 到底怎么组织这套新架构。
3. 一次 generation,经历了哪些步骤?
在看代码之前,先用最简单的语言把整个过程走一遍。你调用 llm.generate(["给我讲个笑话"]) 之后,从字符串进去,到字符串出来,中间发生了 5 件事:
| 步骤 | 做了什么 | 由谁负责 |
|---|---|---|
| ① Tokenize | 把 “给我讲个笑话” 变成 token id 列表 | InputProcessor |
| ② Schedule | 决定这步处理哪些请求、分配 KV Cache 块 | Scheduler + KVCacheManager |
| ③ Execute | GPU 跑 model.forward(),得到 logits | Executor → Worker → GPUModelRunner |
| ④ Sample | 从 logits 采样出下一个 token id | Sampler |
| ⑤ Detokenize | 把 token id 增量还原成字符串 | IncrementalDetokenizer |
生成过程是一个循环:每次循环走完 ②③④⑤,产出一个新 token,追加到输出里;下一轮循环再来,直到遇到 EOS token 或达到最大长度,请求结束。步骤 ① 只在第一次执行一次,后续 decode 每轮只有那一个新 token 需要送进 ③。
有了这张地图,再看后面每个模块,就能对应到”它在哪个步骤干什么”了。
4. 为什么要这么分层?
5 个步骤有了,但为什么要拆成这么多模块,而不是一个大函数从头跑到尾?
主线一:关注点分离
每个模块只做一件事,接口干净,可以独立替换:
- 调度(Scheduler)和执行(Executor)分开:调度决策需要对全局 KV 内存可见,必须集中管理;GPU 执行只需要拿调度结果去跑,不关心调度逻辑
- 前端和后端分开:前端(
LLMEngine/AsyncLLM)处理用户交互——tokenize、管理请求生命周期、返回结果;后端(EngineCore)只负责调度和 GPU 执行
“前端/后端”的划分依据不是 HTTP server,而是 Python GIL。 LLMEngine 这一层是 IO 密集型——处理请求进出、detokenize 输出;EngineCore 是 CPU/GPU 密集型——紧密循环做调度和 forward。把两者放在同一个进程里,asyncio 事件循环和 EngineCore 调度会互相争 GIL,谁都跑不快。分到独立进程,才能真正并行。
EngineCoreClient(vllm/v1/engine/core_client.py:83)就是这条边界的代理:同一进程时直接调用(InprocClient),不同进程时通过 ZMQ 传消息(AsyncMPClient)——上层代码感知不到区别。
主线二:屏蔽变化点
-
Executor抽象屏蔽了单机/多机的差异:不管 1 卡还是 8 卡还是 Ray 集群,EngineCore只调executor.execute_model(),不感知底层是什么 - 字符串注册(
worker_cls、attention backend 等)让平台替换(CUDA / ROCm / 自定义芯片)不影响上层代码
理解了这两条主线,后面看到”为什么不直接调,要封装一层”,大概率就是其中一条的体现。
5. 整体目录结构
先建立全局视角,后面看具体流程才不会迷路。
顶层目录:

用户入口层展开:

v1 引擎层展开:

调度 + KV Cache 层展开:

执行层展开:

一个关键认知:vllm/v1/ 才是真正的引擎,vllm/engine/ 只是个 alias 壳,遇到 bug 想找源码,直接去 vllm/v1/ 里找。
6. 核心对象关系
把 5 个步骤映射到具体的类和文件:
LLM vllm/entrypoints/llm.py
└─► LLMEngine vllm/v1/engine/llm_engine.py
├─► InputProcessor vllm/v1/engine/input_processor.py ← 步骤①
├─► OutputProcessor vllm/v1/engine/output_processor.py:417
│ └─► IncrementalDetokenizer
│ vllm/v1/engine/detokenizer.py:30 ← 步骤⑤
└─► EngineCoreClient vllm/v1/engine/core_client.py:83
│ ↑ 前端/后端的边界:同进程直接调用 / 跨进程走 ZMQ
EngineCore vllm/v1/engine/core.py:99
├─► Scheduler vllm/v1/core/sched/scheduler.py:68 ← 步骤②
│ └─► KVCacheManager
│ vllm/v1/core/kv_cache_manager.py:110
└─► Executor vllm/v1/executor/abstract.py:48 ← 步骤③
└─► Worker vllm/v1/worker/gpu_worker.py:126
└─► GPUModelRunner
vllm/v1/worker/gpu_model_runner.py:438
├─► self.model (torch.nn.Module)
└─► Sampler vllm/v1/sample/sampler.py ← 步骤④
用代码验证这个关系,看 LLMEngine.__init__() 的核心三行:
# vllm/v1/engine/llm_engine.py(精简)
class LLMEngine:
def __init__(self, vllm_config):
self.input_processor = InputProcessor(vllm_config) # 步骤①的负责人
self.output_processor = OutputProcessor(vllm_config) # 步骤⑤的负责人
self.engine_core = EngineCoreClient.make_client(vllm_config, ...) # 前/后端分界线
make_client() 这一行是整个架构最关键的分叉点——它根据配置决定返回 InprocClient(同进程直接调用)还是 AsyncMPClient(独立进程 + ZMQ)。这条线之上是前端,之下是后端。
后端 EngineCore 初始化时持有调度器和执行器:
# vllm/v1/engine/core.py:99(精简)
class EngineCore:
def __init__(self, vllm_config):
executor_class = Executor.get_class(vllm_config) # 选 UniProc/MultiProc/Ray
self.executor = executor_class(vllm_config)
self.scheduler = Scheduler(vllm_config, kv_cache_manager)
Executor.get_class()(vllm/v1/executor/abstract.py:48)根据 world_size 和是否配置 Ray 选择具体实现。EngineCore 只看 Executor 接口,不感知底层是单卡还是多卡——这就是”屏蔽变化点”的直接体现。
有了对象关系,接下来看 LLM() 这一行是怎么把这整套机器跑起来的。
7. LLM.__init__() 全链路
好,现在来拆这一行代码:
llm = LLM(model="facebook/opt-125m")
完整的初始化调用链如下:

这条调用链分四层,每一层解决一个具体问题,逐层看会更清楚。
7.1 配置层:EngineArgs → VllmConfig
LLM.__init__()(vllm/entrypoints/llm.py:176)做的第一件事,是把所有参数打包成 VllmConfig:
# vllm/entrypoints/llm.py:176
engine_args = EngineArgs(model=model, tensor_parallel_size=tp_size, ...)
vllm_config = engine_args.create_engine_config()
VllmConfig 是一个”大容器”,把所有子配置聚合在一起:
| 子配置 | 包含什么 |
|---|---|
ModelConfig | 模型路径、dtype、max_model_len |
CacheConfig | block_size、gpu_memory_utilization |
ParallelConfig | tp_size、pp_size、worker_cls(字符串!) |
SchedulerConfig | max_num_seqs、max_num_batched_tokens、enable_chunked_prefill |
这一层的意义在于:把散落在用户侧的各种参数,在最早期统一做合法性校验和依赖解析,后续所有模块都只看 VllmConfig,不再直接接触原始参数。越早发现配置错误,越好调试。
一个值得注意的设计细节:worker_cls 是字符串
ParallelConfig.worker_cls保存的不是类对象,而是字符串"vllm.v1.worker.gpu_worker.Worker"。这个字符串在vllm/platforms/cuda.py:307里被 CUDA 平台层注入,后面通过resolve_obj_by_qualname()动态 import。为什么不在 import 时直接引用类对象?因为 import
gpu_worker会触发 CUDA 相关代码加载,导致 CUDA context 提前初始化。多进程场景下,主进程不应该初始化 CUDA(那是 worker 子进程的事),所以必须推迟到真正需要时才 import。字符串注册 = 零 import、零副作用。副作用是,不同平台(CUDA / ROCm / TPU)只需注入不同字符串,就能替换 Worker 实现,上层代码完全不感知。
7.2 后端选择:三种 Executor
配置确定之后,下一步是选择执行后端:
# vllm/v1/executor/abstract.py:48
executor_class = Executor.get_class(vllm_config)
| 配置 | 选择 | 场景 |
|---|---|---|
world_size == 1 | UniProcExecutor | 单 GPU,同进程,最简单 |
world_size > 1 | MultiprocExecutor | 多 GPU,多进程 |
| 配置了 ray | RayDistributedExecutor | 多节点集群 |
这层抽象的价值在于:上层代码(EngineCore)只跟 Executor 接口打交道,不关心底下是单卡还是多卡。
7.3 通信模式:InprocClient vs ZMQ
Executor 决定了如何管理 Worker,接下来要决定前端和后端之间用什么方式通信:
# vllm/v1/engine/core_client.py:83
client = EngineCoreClient.make_client(vllm_config, ...)
| 模式 | 触发条件 | 通信方式 |
|---|---|---|
InprocClient | 默认(单进程 LLM) | 直接函数调用,零序列化 |
SyncMPClient | VLLM_ENABLE_V1_MULTIPROCESSING=1 | ZMQ + msgpack,后台进程 |
AsyncMPClient | AsyncLLM(HTTP server) | ZMQ + asyncio,后台进程 |
离线推理默认走 InprocClient——EngineCore 和前端在同一进程,调度和执行完全同步,没有任何序列化开销。只有上 HTTP server(AsyncLLM)时才会启动独立后台进程通过 ZMQ 通信。
为什么 HTTP server 要用独立进程?
HTTP server 是 asyncio 驱动的 IO 密集型任务,EngineCore 是 CPU/GPU 密集型任务,同一进程里两边会互相抢 GIL,谁都跑不快。独立进程 + ZMQ 绕开 GIL,两边真正并行——多线程共 GIL,多进程才真并行。
7.4 EngineCore 初始化三步走
前面三层都是”决策层”。EngineCore.__init__()(vllm/v1/engine/core.py:99)才是真正做事的地方,它做三件事:
第一步:init_device() + load_model()
executor.collective_rpc("init_worker") # CUDA init, dist init
executor.collective_rpc("load_model") # 加载权重到 GPU
collective_rpc 是广播调用——单 GPU 直接调 Worker 方法,多 GPU 时所有 rank 同时执行。最终落到:
# vllm/v1/worker/gpu_model_runner.py:5176
self.model = get_model_loader(load_config).load_model(vllm_config, ...)
# self.model 是标准的 torch.nn.Module,attention 层被 vllm 替换为 FlashAttention 版本
FlashAttention 是一种 IO-aware 的注意力算法,能显著减少 HBM 读写次数,加速 attention 计算。权重加载完后,self.model 就是这样一个 FlashAttention 版本的 torch.nn.Module。
第二步:KV Cache 初始化(三小步)
这是 vllm 启动时”Capturing CUDA graph…“日志的来源。显存有限,vllm 需要知道在保留模型推理所需显存后,还剩多少可以给 KV Cache——分三步做:
# Step 1: 查询每层需要多大的 KV Cache 块
kv_cache_specs = executor.get_kv_cache_specs()
# Step 2: 做一次 profiling forward pass,测量峰值显存
available_memory = executor.determine_available_memory()
# available_memory = gpu_memory_utilization × total - peak_forward - cuda_graph_memory
# Step 3: 在 GPU 上分配 KV Cache tensor,初始化块池
executor.initialize_cache(num_gpu_blocks)
kv_cache_manager = KVCacheManager(num_gpu_blocks, kv_cache_specs)
为什么要 profiling forward pass?vllm 不知道推理时峰值会用多少显存——activation、中间 tensor 的大小取决于 batch size 和序列长度,没法静态估算。直接跑一次”最坏情况”的 dummy forward 量一下峰值,剩下的才给 KV Cache。代价是启动时多花几秒,但比估算准得多。
有了 KV Cache,调度器就有了分配和回收的对象,可以进行第三步了。
第三步:调度器初始化
scheduler = Scheduler(vllm_config, kv_cache_manager)
调度器持有两个队列(waiting 和 running)以及 KV Cache 管理器的引用,等待请求进来。至此,LLM() 这一行的工作全部完成。
8. 小结:初始化完成时,系统是什么状态
LLM("facebook/opt-125m") 这一行干完了四件事:
- 解析配置,确定执行后端和通信模式
- Worker 初始化,CUDA init,HuggingFace 权重加载到 GPU(
torch.nn.Module就绪) - Profiling forward pass → 计算可用 KV Cache 块数 → 分配 KV Cache tensor
- 调度器就绪,等待请求
常见报错对应的模块:
| 报错场景 | 大概位置 |
|---|---|
| 多 GPU 启动失败 / NCCL 报错 | vllm/v1/executor/multiproc_executor.py |
| 模型加载失败 / 权重格式不支持 | vllm/model_executor/model_loader.py |
| 显存不够 / KV Cache 分配失败 | EngineCore._initialize_kv_caches() → determine_available_memory() |
| 平台不兼容 / worker_cls 找不到 | vllm/platforms/cuda.py(或对应平台文件) |
| TP/PP 通信超时 | MultiprocExecutor,检查 NCCL 版本和网络配置 |
做 LLM 推理效率研究,一直绕不开 vllm 这类框架——既要用它,也要改它。如果你对 LLM 效率优化、NAS、AutoML 有兴趣,可以看看我之前出版的《动手学 AutoML:从 NAS 到大语言模型优化实战》,书里 LLM 推理效率那章和本文的工程背景有直接关联。
