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. 整体架构:三层分工

在看任何代码之前,先把 v1 的架构全貌建立起来。

下图是 vllm v1 的核心对象持有关系,用 self.xxx = ... 的方式呈现各模块之间怎么连接:

vllm v1 核心对象持有关系

图里有两条关键边界线,理解这两条线就理解了 v1 的分层逻辑:

边界一:self.engine_core = EngineCoreClientcore_client.py:83)——前后端分界

LLMEngine 之上是前端(IO 密集型:tokenize 字符串、管理请求生命周期、detokenize 输出);EngineCore 之下是后端(CPU/GPU 密集型:调度、GPU forward、采样)。

这条边界的设计依据不是 HTTP server,而是 Python GIL。前端是 asyncio 驱动的,后端是紧密的调度循环,两者放在同一进程里会互相争 GIL,谁都跑不快。分到独立进程,才能真正并行。

EngineCoreClient 是这条边界的透明代理——同进程时直接调用(InprocClient),跨进程时通过 ZMQ 传消息(AsyncMPClient),上层代码感知不到区别。

边界二:self.executor = Executorexecutor/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. 目录结构:代码在哪里

有了架构全貌,再看目录结构,就知道每个文件对应哪一层了。

顶层目录:

vllm 顶层目录结构

用户入口层(前端):

vllm/entrypoints/ 用户入口层

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

vllm/v1/engine/ 前端引擎层

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

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

执行层(GPU 执行):

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

一个关键认知: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 ExecutorWorkerGPUModelRunner(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() 初始化完整时序

沿着时序图从上往下走一遍(每个泳道对应一个模块,虚线是跨进程边界):

① LLM → LLMEngineLLM.__init__() 把所有参数打包成 VllmConfig,创建 LLMEngineInputProcessorOutputProcessor 在这一步就绪,负责前端的 tokenize / detokenize。

② LLMEngine → EngineCoreClientmake_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,KVCacheManagerScheduler 就绪。

⑧ 就绪信号逐层回传: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():后端四步初始化

EngineCore 初始化四步

图里四个方块从左到右顺序执行,每步完成后才进入下一步:

步骤一(选 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。这一步是启动最慢的地方,大模型 + 大显存可能要十几秒。

步骤四(调度器上线)KVCacheManagerScheduler 就绪,开始等待请求进来。到这步 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") 这一行干完了四件事:

  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 版本和网络配置

另外,我们团队最近出版了《动手学 AutoML:从 NAS 到大语言模型优化实战》,感兴趣的话可以看看。

动手学AutoML书籍封面

Flag Counter