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. 整体架构:三层分工
在看任何代码之前,先把 v1 的架构全貌建立起来。
下图是 vllm v1 的核心对象持有关系,用 self.xxx = ... 的方式呈现各模块之间怎么连接:

图里有两条关键边界线,理解这两条线就理解了 v1 的分层逻辑:
边界一:self.engine_core = EngineCoreClient(core_client.py:83)——前后端分界
LLMEngine 之上是前端(IO 密集型:tokenize 字符串、管理请求生命周期、detokenize 输出);EngineCore 之下是后端(CPU/GPU 密集型:调度、GPU forward、采样)。
这条边界的设计依据不是 HTTP server,而是 Python GIL。前端是 asyncio 驱动的,后端是紧密的调度循环,两者放在同一进程里会互相争 GIL,谁都跑不快。分到独立进程,才能真正并行。
EngineCoreClient 是这条边界的透明代理——同进程时直接调用(InprocClient),跨进程时通过 ZMQ 传消息(AsyncMPClient),上层代码感知不到区别。
边界二:self.executor = Executor(executor/abstract.py:48)——单机/多机分界
EngineCore 只调 executor.execute_model(),不感知底层是 1 卡还是 8 卡还是 Ray 集群,具体实现由 Executor.get_class() 在启动时按配置选好。
有了这两条边界,整个系统自然分成三层:
| 层 | 模块 | 职责 |
|---|---|---|
| 前端(IO) | LLM / LLMEngine / InputProcessor / OutputProcessor | tokenize、管理请求生命周期、detokenize |
| 后端调度 | EngineCore / Scheduler / KVCacheManager | 调度决策、KV Cache 分配 |
| GPU 执行 | Executor / Worker / GPUModelRunner | model.forward()、采样 |
4. 目录结构:代码在哪里
有了架构全貌,再看目录结构,就知道每个文件对应哪一层了。
顶层目录:

用户入口层(前端):

v1 引擎层(前端 + 后端调度):

调度 + KV Cache(后端调度):

执行层(GPU 执行):

一个关键认知:vllm/v1/ 才是真正的引擎,vllm/engine/ 只是个 alias 壳(只有一行 LLMEngine = V1LLMEngine)。遇到 bug 想找源码,直接去 vllm/v1/ 里找。
5. 一次 generate() 的数据流
架构和目录都清楚了,再看一次完整的推理过程就很好理解了。
llm.generate(["给我讲个笑话"]) 从字符串进去、字符串出来,中间经过 5 步:
| 步骤 | 做了什么 | 由谁负责(对应上面哪一层) |
|---|---|---|
| ① Tokenize | 字符串 → token id 列表 | InputProcessor(前端) |
| ② Schedule | 决定这步处理哪些请求、分配 KV Cache 块 | Scheduler + KVCacheManager(后端调度) |
| ③ Execute | GPU 跑 model.forward(),得到 logits | Executor → Worker → GPUModelRunner(GPU 执行) |
| ④ Sample | 从 logits 采样出下一个 token id | Sampler(GPU 执行) |
| ⑤ Detokenize | token id 增量还原成字符串 | IncrementalDetokenizer(前端) |
生成是一个循环:每轮走完 ②③④⑤ 产出一个新 token,直到 EOS 或达到最大长度。步骤 ① 只在第一次执行,后续 decode 每轮只有那一个新 token 需要送进 ③。
有了这个全景,下面跟着 LLM.__init__() 的调用链走一遍,看这些模块是怎么一步步初始化就绪的。
6. LLM.__init__() 全链路
现在跟着 __init__ 的调用顺序,一层一层走下去。
llm = LLM(model="facebook/opt-125m")

沿着时序图从上往下走一遍(每个泳道对应一个模块,虚线是跨进程边界):
① LLM → LLMEngine:LLM.__init__() 把所有参数打包成 VllmConfig,创建 LLMEngine。InputProcessor 和 OutputProcessor 在这一步就绪,负责前端的 tokenize / detokenize。
② LLMEngine → EngineCoreClient:make_client() 根据部署场景选择 InprocClient(离线推理)或 AsyncMPClient(HTTP serving)。这是前后端的分界线——之后 LLMEngine 只通过 EngineCoreClient 和后端通信。
③ EngineCoreClient → EngineCore:EngineCore 进入初始化(后台进程模式时,此步跨进程边界)。
④ Executor 选型:Executor.get_class() 按 world_size 和 Ray 配置选好具体实现(UniProc / Multiproc / RayDistributed),EngineCore 之后只调 executor.xxx(),不感知底层差异。
⑤ collective_rpc(“init_worker”):广播到所有 Worker 进程,触发 CUDA context 初始化和 NCCL 通信初始化。多 GPU 时所有 rank 同时执行,主进程等全部完成才继续。
⑥ collective_rpc(“load_model”):权重加载进 GPU,GPUModelRunner.load_model() 执行,self.model(FlashAttention 版本的 nn.Module)就绪。
⑦ _initialize_kv_caches():跑 profiling dummy forward 量一下峰值显存,算出可用块数,分配 KV Cache tensor,KVCacheManager 和 Scheduler 就绪。
⑧ 就绪信号逐层回传:EngineCore ready → EngineCoreClient ready → LLMEngine ready → LLM 对象就绪,llm.generate() 可以被调用。
6.1 LLM.init():把参数打包,创建 LLMEngine
# LLM.__init__() — vllm/entrypoints/llm.py:176
class LLM:
def __init__(self, model, tensor_parallel_size=1, ...):
engine_args = EngineArgs(model=model, tensor_parallel_size=tp_size, ...)
vllm_config = engine_args.create_engine_config() # 所有配置归一为一个对象
self.llm_engine = LLMEngine(vllm_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 |
为什么 worker_cls 是字符串?
ParallelConfig.worker_cls = "vllm.v1.worker.gpu_worker.Worker",由vllm/platforms/cuda.py:307注入。不直接存类引用,是因为import gpu_worker会触发 CUDA context 初始化——而主进程不应该持有 CUDA context,那是 worker 子进程的事。字符串延迟到子进程里通过resolve_obj_by_qualname()动态 import,做到零副作用。不同平台只需注入不同字符串,上层代码完全不感知。
6.2 LLMEngine.init():前端三件套 + 前后端分界
# LLMEngine.__init__() — vllm/v1/engine/llm_engine.py
class LLMEngine:
def __init__(self, vllm_config):
self.input_processor = InputProcessor(vllm_config) # tokenize 请求
self.output_processor = OutputProcessor(vllm_config) # 组装输出(含 detokenize)
self.engine_core = EngineCoreClient.make_client( # ← 前后端分界线
vllm_config, asyncio_mode=False, ...
)
make_client() 根据部署场景选择三种实现:
| 实现类 | 场景 | 通信方式 |
|---|---|---|
InprocClient(:276) | 离线推理(LLM 类) | 直接调用 EngineCore 方法,零序列化 |
SyncMPClient(:779) | 多进程离线推理 | ZMQ + msgpack,同步 |
AsyncMPClient(:950) | HTTP serving(AsyncLLM) | ZMQ + asyncio,后台独立进程 |
为什么 HTTP server 必须用独立进程? Python GIL 让同一进程里的 asyncio 事件循环和 EngineCore 调度循环互相争 CPU 时间。分到独立进程,两边才能真正并行——EngineCore 在跑 GPU forward 的同时,HTTP server 可以同步接收新请求。
EngineCoreClient 不只是初始化时的路由,而是前端对后端的唯一通信界面,贯穿整个推理生命周期。 LLMEngine 的所有代码永远只调 self.engine_core.xxx(),不直接接触 EngineCore:
# 前端代码——无论底层走哪种通信,写法完全一样
self.engine_core.add_request(request) # 送入新请求
self.engine_core.get_output() # 取回这步的输出
self.engine_core.abort_requests(req_ids) # 中止请求
self.engine_core.reset_prefix_cache() # 清空前缀缓存
self.engine_core.collective_rpc("method") # 广播 RPC 给所有 Worker
self.engine_core.profile(is_start=True) # 开关 GPU profiler
InprocClient 就是简单的直接转发(core_client.py:297);SyncMPClient/AsyncMPClient 的同一个方法变成 ZMQ 消息——接口完全一样,前端感知不到差异。
6.3 EngineCore.init():后端四步初始化

图里四个方块从左到右顺序执行,每步完成后才进入下一步:
步骤一(选 Executor):Executor.get_class() 按 world_size 和是否用 Ray 选好实现类。选好之后 EngineCore 就和具体实现解耦了——后续全部通过 self.executor.xxx() 调用。
步骤二(初始化 Worker):collective_rpc("init_worker") 广播,Worker 进程(或线程)启动,做 CUDA context 初始化和 NCCL 通信握手。多卡时所有 rank 并行初始化,主进程在这步可能阻塞几秒。
步骤三(加载模型 + KV Cache):collective_rpc("load_model") 把权重加载进 GPU;随后 _initialize_kv_caches() 跑 profiling dummy forward 量峰值显存,算出可用 KV Cache 块数,分配 tensor。这一步是启动最慢的地方,大模型 + 大显存可能要十几秒。
步骤四(调度器上线):KVCacheManager 和 Scheduler 就绪,开始等待请求进来。到这步 EngineCore 才算真正”活了”。
# EngineCore.__init__() — vllm/v1/engine/core.py:99
class EngineCore:
def __init__(self, vllm_config):
# 步骤一:根据配置选择 Executor,初始化 Worker
executor_class = Executor.get_class(vllm_config) # abstract.py:48
# world_size==1 → UniProcExecutor (uniproc_executor.py:45)
# world_size>1 → MultiprocExecutor (multiproc_executor.py:103)
# ray 集群 → RayDistributedExecutor (ray_executor.py:64)
self.executor = executor_class(vllm_config)
# 步骤二:广播 RPC 给所有 Worker,初始化 CUDA + 加载模型权重
self.executor.collective_rpc("init_worker") # CUDA init, NCCL init
self.executor.collective_rpc("load_model") # 权重加载进 GPU
# 步骤三:KV Cache 初始化(三小步,见下)
self._initialize_kv_caches(vllm_config)
# 步骤四:调度器上线,等待请求
self.scheduler = Scheduler(vllm_config, self.kv_cache_manager)
collective_rpc 是广播调用——单 GPU 直接调 Worker 方法,多 GPU 时所有 rank 同时执行,主进程等所有 rank 完成才继续。
6.4 Worker.init() / GPUModelRunner.load_model():权重进 GPU
load_model RPC 最终落到:
# Worker.__init__() — vllm/v1/worker/gpu_worker.py:126
class Worker:
def __init__(self, vllm_config):
self.model_runner = GPUModelRunner(vllm_config) # gpu_model_runner.py:438
# GPUModelRunner.load_model() — vllm/v1/worker/gpu_model_runner.py:5176
class GPUModelRunner:
def load_model(self):
self.model = get_model_loader(load_config).load_model(vllm_config)
# self.model 是标准 torch.nn.Module
# attention 层已被 vllm 替换为调用 FlashAttention kernel 的实现
FlashAttention 是一种 IO-aware 注意力算法,通过 tiling 减少 HBM 读写次数,比标准 softmax(QKᵀ/√d)V 快 2-4×。权重加载完后,self.model 就是这样一个 FlashAttention 版本的 nn.Module,可以直接 forward()。
6.5 KV Cache 初始化:量一下,算一下,分一下
# EngineCore._initialize_kv_caches() — vllm/v1/engine/core.py(精简)
def _initialize_kv_caches(self, vllm_config):
# 1. 查询每层 KV Cache 的块规格(head 数、head dim、dtype)
kv_cache_specs = self.executor.get_kv_cache_specs()
# 2. 跑一次 profiling dummy forward,测量峰值显存
# available = gpu_memory_utilization × total_vram - peak_forward
num_gpu_blocks = self.executor.determine_available_memory(kv_cache_specs)
# 3. 在 GPU 上分配 KV Cache tensor,初始化 BlockPool
self.executor.initialize_cache(num_gpu_blocks)
self.kv_cache_manager = KVCacheManager( # kv_cache_manager.py:110
num_gpu_blocks, kv_cache_specs
)
为什么要 profiling?vllm 不知道推理时 activation 峰值是多少——它取决于 batch size 和序列长度,无法静态估算。直接跑一次”最坏情况”的 dummy forward 量一下,剩下的显存全给 KV Cache。代价是启动多花几秒,换来分配精准。
至此,LLM() 完成:模型在 GPU 上就绪,KV Cache 分配好,调度器等待请求。
7. 小结:初始化完成时,系统是什么状态
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 版本和网络配置 |
另外,我们团队最近出版了《动手学 AutoML:从 NAS 到大语言模型优化实战》,感兴趣的话可以看看。
