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 的设计思路就很清晰了:

  1. EngineCore 独立进程 + ZMQ 通信:把调度器和 GPU worker 放到独立后台进程,前端只做 tokenization 和 detokenization,两边真正并行。ZMQ(ZeroMQ)是一个高性能消息队列库,专门用于进程间高效传递数据,延迟极低。
  2. 统一 prefill/decode 调度:用 num_computed_tokens 跟踪每个请求的进度,prefill 和 decode 同框处理,chunked prefill 自然支持
  3. 差量传输:第一次调度发完整数据,后续步骤只发 diff(新分配的块 id + 新 token)

解决思路明确了,下一步是看 v1 到底怎么组织这套新架构。


3. 一次 generation,经历了哪些步骤?

在看代码之前,先用最简单的语言把整个过程走一遍。你调用 llm.generate(["给我讲个笑话"]) 之后,从字符串进去,到字符串出来,中间发生了 5 件事:

步骤 做了什么 由谁负责
① Tokenize 把 “给我讲个笑话” 变成 token id 列表 InputProcessor
② Schedule 决定这步处理哪些请求、分配 KV Cache 块 Scheduler + KVCacheManager
③ Execute GPU 跑 model.forward(),得到 logits ExecutorWorkerGPUModelRunner
④ 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,谁都跑不快。分到独立进程,才能真正并行。

EngineCoreClientvllm/v1/engine/core_client.py:83)就是这条边界的代理:同一进程时直接调用(InprocClient),不同进程时通过 ZMQ 传消息(AsyncMPClient)——上层代码感知不到区别。

主线二:屏蔽变化点

  • Executor 抽象屏蔽了单机/多机的差异:不管 1 卡还是 8 卡还是 Ray 集群,EngineCore 只调 executor.execute_model(),不感知底层是什么
  • 字符串注册(worker_cls、attention backend 等)让平台替换(CUDA / ROCm / 自定义芯片)不影响上层代码

理解了这两条主线,后面看到”为什么不直接调,要封装一层”,大概率就是其中一条的体现。


5. 整体目录结构

先建立全局视角,后面看具体流程才不会迷路。

顶层目录:

vllm 顶层目录结构

用户入口层展开:

vllm/entrypoints/ 用户入口层

v1 引擎层展开:

vllm/v1/engine/ 前端引擎层

调度 + KV Cache 层展开:

vllm/v1/core/ 调度与KV Cache层

执行层展开:

vllm/v1/executor/ + worker/ 执行层

一个关键认知: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")

完整的初始化调用链如下:

LLM 初始化调用链

这条调用链分四层,每一层解决一个具体问题,逐层看会更清楚。

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)

调度器持有两个队列(waitingrunning)以及 KV Cache 管理器的引用,等待请求进来。至此,LLM() 这一行的工作全部完成。


8. 小结:初始化完成时,系统是什么状态

LLM("facebook/opt-125m") 这一行干完了四件事:

  1. 解析配置,确定执行后端和通信模式
  2. Worker 初始化,CUDA init,HuggingFace 权重加载到 GPU(torch.nn.Module 就绪)
  3. Profiling forward pass → 计算可用 KV Cache 块数 → 分配 KV Cache tensor
  4. 调度器就绪,等待请求

常见报错对应的模块:

报错场景 大概位置
多 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 推理效率那章和本文的工程背景有直接关联。

动手学AutoML书籍封面

Flag Counter