系列 《大模型推理系统揭秘:从 vLLM 看 LLM Serving Infra 核心技术》 第 3 / 16 篇 深入
系列总览 — 为什么这样组织、读它需要什么、读完能做什么 · 幻灯片 — 整个系列的精华,一份可分享的 deck
学习地图 — AI-Infra 工程师学习地图 第 9 / 14 个系列
- 为什么 LLM Serving 比传统 DL 推理难?
- 如何衡量一个 LLM Serving 系统?
- 鸟瞰 vLLM:一个请求如何穿过整个推理系统?
- Scheduler:GPU 这一轮到底给谁用?
- KV Cache:LLM Serving 的第一号内存问题
- GPU 执行:如何让每个 Token 算得更快?
- 解码的扩展:采样、投机解码与结构化输出
- Multi-GPU:一张卡不够时如何扩展?
- PD 分离:从资源混部走向计算解耦
- 模型适配:如何跟上变化极快的模型世界?
- 请求形态的扩展:multi-LoRA 与多模态
- 硬件解耦:如何不让芯片差异污染 Serving 核心?
- Serving Infra 的下一站:从模型执行器到分布式智能操作系统
- 回到源码:一次请求在 vLLM 内部的真实旅程
- vLLM 与 SGLang:同一个请求穿过两套 Serving 系统
- 系列总结与通关自测
NOTE 本文基于 vLLM v0.27.1(tag
6e448d0,commit6e448d0ea9bf3d88d898b65449ca6dc2aec170ac,2026-08-11)源码剖析。文中文件路径、类名和函数名均以该版本为准;vLLM 迭代很快,阅读时请以你手上的版本对照。
前两篇分别定义了问题(LLM Serving 为什么难)和尺子(用什么指标衡量)。在深入调度、KV Cache、GPU 执行等单个战场之前,需要先有一张全景图:一个请求进入 vLLM 之后,会经过哪些模块,以什么形式被传递,最后如何变成 token 返回给用户。
本篇的核心问题是:
一个请求如何穿过 vLLM 的整个推理系统——哪个模块负责决策、哪个模块负责执行,它们之间传递的到底是什么?1
一、总览:用 4+1 视图看同一个系统
1. Kruchten 4+1:五个视角看同一个系统
借用软件架构里的 Kruchten 4+1 视图模型来看 vLLM V1。逻辑视图说明模块分工(第二章的静态拓扑);开发视图把模块放回源码目录,说明去哪读代码(第三章);进程视图说明模块在哪个进程、怎样交互(第四章);部署/物理视图说明进程怎么落到 GPU 和节点上(第五章)。最后的 +1 场景视图沿时间轴追踪一次请求(第六章,含请求流与数据流两个子场景)——它把前四个视图串成一条线。
这样可以分清谁保存请求状态、谁决定这一步跑什么,以及 token 和调度结果怎样跨进程移动。
2. 本文的章节安排
| 章 | 视图 | 内容 |
|---|---|---|
| 二 | 逻辑视图 | 静态系统拓扑,自顶向下:API Server 与 AsyncLLM、EngineCore、Scheduler 与 KVCacheManager、Worker 与 Executor |
| 三 | 开发视图 | 模块 ↔ 源码目录 ↔ 所在进程的对照表 |
| 四 | 进程视图 | API Server、EngineCoreProc、WorkerProc 的职责与 IPC |
| 五 | 部署/物理视图 | 进程到 GPU、节点的映射;会改变进程图的部署方式 |
| 六 | 场景视图(+1) | 两个子场景:1. 请求流(一次请求的完整生命周期,HTTP → SSE [DONE]);2. 数据流(Token 如何穿过整个 Serving 栈) |
| 七 | 本文小结 | 模块分工,以及与第一篇“四问”的对应 |
| 八 | 自测 | 7 道题 |
二、逻辑视图:静态系统拓扑(自顶向下)
vLLM V1 的整体架构遵循控制面/数据面分离的经典设计哲学。自顶向下,逐层解剖其系统拓扑。
%% 图:vLLM V1 的静态系统拓扑:API 层 → 引擎层 → 调度层 → 执行层,控制面与数据面分离
graph TB
subgraph "API Layer (控制面入口)"
Client[Client / OpenAI SDK]
API[API Server<br/>FastAPI + uvicorn]
end
subgraph "Engine Layer (异步引擎)"
AsyncLLM[AsyncLLM<br/>异步请求管理]
EngineCore[EngineCore<br/>核心调度循环]
end
subgraph "Scheduling Layer (调度层)"
Scheduler[Scheduler<br/>请求调度 + 资源管理]
KVCacheManager[KVCacheManager<br/>显存块管理]
BlockPool[BlockPool<br/>物理块池]
SOManager[StructuredOutputManager<br/>约束输出]
end
subgraph "Execution Layer (执行层)"
Executor[Executor<br/>分布式执行抽象]
Worker0[GPU Worker 0]
Worker1[GPU Worker 1]
WorkerN[GPU Worker N]
end
subgraph "Model Layer (模型层)"
MR0[ModelRunner<br/>模型前向 + 采样]
Attn[Attention Backend<br/>FlashAttn / FlashInfer]
KVCache[GPU KV Cache<br/>物理显存]
end
Client -->|HTTP/gRPC| API
API -->|add_request| AsyncLLM
AsyncLLM -->|EngineCoreRequest| EngineCore
EngineCore --> Scheduler
Scheduler --> KVCacheManager
KVCacheManager --> BlockPool
EngineCore --> SOManager
EngineCore -->|SchedulerOutput| Executor
Executor --> Worker0
Executor --> Worker1
Executor --> WorkerN
Worker0 --> MR0
MR0 --> Attn
Attn --> KVCache
MR0 -->|ModelRunnerOutput| EngineCore
从这张图看,从 Client 到 EngineCore 是请求入队路径,传递的是”生成什么”;EngineCore 到 Worker 再到 ModelRunner 是执行路径,携带的是这一轮调度出来的 batch 和 block 布局;而 ModelRunner 返回的只是采样结果,不是下一次 forward 的完整状态。调度状态留在 EngineCore,模型侧负责的是单次前向计算。
1. API Server 与 AsyncLLM:异步并发处理的桥头堡
入口位于 vllm/entrypoints/openai/api_server.py,基于 FastAPI 构建的 HTTP 服务器,实现了 OpenAI 兼容的 /v1/chat/completions、/v1/completions 等 REST API。
AsyncLLM(vllm/v1/engine/async_llm.py)是面向外部的异步引擎接口。它自己不做重活,而是把工作交给三个同目录下的协作者:
InputProcessor(input_processor.py):把文本 / 多模态输入 tokenize、校验采样参数,产出EngineCoreRequest;EngineCoreClient(core_client.py):与EngineCore通信的客户端。默认部署下EngineCore运行在独立进程中,两者通过 ZMQ + msgspec 收发EngineCoreRequest/EngineCoreOutputs(AsyncMPClient);单进程调试时用InprocClient。这条进程边界是 vLLM V1 让 HTTP 处理、tokenize / detokenize 与 GPU 调度循环互不阻塞的关键;OutputProcessor(output_processor.py):接收EngineCoreOutputs,做 detokenize、stop 条件检查、logprobs 整理,产出RequestOutput交给 API 层流式返回。
因此 AsyncLLM 的职责是:管理请求的异步生命周期、串起上述三者、支持 SSE 流式响应,以及数据并行(DP)场景下的多引擎协调。
2. EngineCore:推理系统的中央调度大脑
EngineCore(vllm/v1/engine/core.py)是 vLLM V1 引擎的核心,它的 step() 方法驱动整个推理循环:
# vllm/v1/engine/core.py (简化)
class EngineCore:
"""Inner loop of vLLM's Engine."""
def __init__(self, vllm_config, executor_class, ...):
# 1. 初始化模型执行器
self.model_executor = executor_class(vllm_config)
# 2. 初始化 KV Cache
kv_cache_config = self._initialize_kv_caches(vllm_config)
# 3. 初始化调度器
Scheduler = vllm_config.scheduler_config.get_scheduler_cls()
self.scheduler = Scheduler(
vllm_config=vllm_config,
kv_cache_config=kv_cache_config,
...
)
3. Scheduler 与 KVCacheManager:资源管理双核
Scheduler(vllm/v1/core/sched/scheduler.py,约 3000 行)是调度的大脑,决定每一轮迭代中哪些请求参与推理、分配多少 token 预算。
KVCacheManager(vllm/v1/core/kv_cache_manager.py)管理 GPU 显存上的 KV Cache 物理块——分配、释放、前缀缓存复用、驱逐。
4. Worker 与 Model Executor:模型执行的设备抽象
%% 图:Executor 与 Worker:一次 execute_model() 分发给多个 GPU Worker,各自的 ModelRunner 持有分片权重与 KV Cache
graph TB
EX["Executor<br/><i>执行抽象:单设备或多设备模型调用</i>"]
EX --> W0 & W1 & WN
subgraph W0["Worker 0 (GPU 0)"]
M0["ModelRunner"] --- S0["Model Weights(分片)<br/>KV Cache"]
end
subgraph W1["Worker 1 (GPU 1)"]
M1["ModelRunner"] --- S1["Model Weights(分片)<br/>KV Cache"]
end
subgraph WN["Worker N (GPU N)"]
MN["ModelRunner"] --- SN["Model Weights(分片)<br/>KV Cache"]
end
S0 <-.->|NCCL| S1
S1 <-.->|NCCL| SN
常见 GPU 部署中,Executor 会把一次 execute_model() 调用分发给一个或多个 Worker;Worker 再调用本地的 ModelRunner 执行模型前向、采样和 KV Cache 读写。单卡、同机多进程、Ray 分布式和 external launcher 的进程/设备映射并不完全相同,因此不应把“一个 GPU 固定绑定一个 Worker 进程”写成绝对规则。
每个 Worker 通常负责:
GPUModelRunner(vllm/v1/worker/gpu_model_runner.py):负责输入准备(_prepare_inputs())、模型前向(execute_model())、采样(sample_tokens())。本系列图中简写为 ModelRunner。v0.27.1 另有一份新实现vllm/v1/worker/gpu/model_runner.py,通过VLLM_USE_V2_MODEL_RUNNER=1启用,文中以默认实现为主线- 模型权重的分片(Tensor Parallel 下每卡持有一部分)
- KV Cache 物理显存
Executor(vllm/v1/executor/abstract.py)是模型执行抽象层,负责在一个设备或多个设备上执行模型,而不是 Scheduler 本身。v0.27.1 中可见的主要实现包括:
UniProcExecutor:单进程单卡MultiprocExecutor:多进程多卡(同机)RayDistributedExecutor:Ray 分布式(直接继承Executor)RayExecutorV2:Ray 分布式的新实现,注意它继承的是MultiprocExecutor而非Executor——即复用同一套 worker 进程管理逻辑,只把进程的拉起方式换成 Ray(RayWorkerProc(WorkerProc))ExecutorWithExternalLauncher:外部 launcher 场景(继承UniProcExecutor)
这条继承链本身就说明了一件事:“用不用 Ray”是部署方式的差异,不是执行模型的差异。 Executor 这层抽象的价值就在于把”进程怎么起、卡怎么分”和”一轮 batch 怎么执行”彻底分开,所以 V2 才能靠换掉进程拉起方式来复用同机多进程的全部逻辑。
三、开发视图:模块在源码树的哪里
逻辑视图给了模块的名字,开发视图回答读代码时最直接的问题:每个模块在源码树的哪个目录、跑在哪个进程里。下表按 v0.27.1(tag 6e448d0)核实,进程 A / B / C 沿用第四章的编号(A = API Server,B = EngineCoreProc,C = WorkerProc)。
| 源码位置 | 逻辑视图里的模块 | 所在进程 |
|---|---|---|
vllm/entrypoints/openai/ |
API Server:FastAPI 路由、ChatCompletionRequest 等协议对象 |
A |
vllm/v1/engine/async_llm.py |
AsyncLLM:异步请求管理、流式输出 |
A |
vllm/v1/engine/input_processor.py |
InputProcessor:tokenize、构造 EngineCoreRequest |
A |
vllm/v1/engine/output_processor.py + detokenizer.py |
OutputProcessor / IncrementalDetokenizer:增量 detokenize、RequestOutput |
A |
vllm/v1/engine/core_client.py |
AsyncMPClient / EngineCoreClient:API 边界的 ZMQ 客户端 |
A(client 侧) |
vllm/v1/engine/core.py |
EngineCore / EngineCoreProc:主循环、进程入口 |
B |
vllm/v1/core/sched/ |
Scheduler、SchedulerOutput / NewRequestData / CachedRequestData |
B |
vllm/v1/core/kv_cache_manager.py |
KVCacheManager:块分配与复用 |
B |
vllm/v1/engine/coordinator.py + utils.py |
DPCoordinator、进程启动与握手 |
B 侧 |
vllm/v1/executor/ |
Executor 抽象与 Multiproc / UniProc / Ray 实现:拉起并驱动 Worker |
B 内,拉起 C |
vllm/v1/worker/gpu_worker.py |
Worker:设备与 KV cache 显存 |
C |
vllm/v1/worker/gpu_model_runner.py |
GPUModelRunner:输入准备、前向、采样 |
C |
vllm/model_executor/models/ |
各模型定义;TP 下每卡加载自己的权重分片 | C |
vllm/v1/attention/backends/ |
FlashAttention、Triton 等 attention backend 实现 | C |
一个值得记住的边界:vllm/v1/engine/ 下的文件分属两个进程——async_llm / input_processor / output_processor 在 A,core / core_client 的服务端在 B,目录按功能分不按进程分。
1. 子系统划分与包图
UML 的开发视图看的是子系统划分与包依赖。把上表按子系统归组,服务一条请求真正相关的包就六组,依赖单向向下:
- entrypoints(
vllm/entrypoints/openai/):协议层。FastAPI 路由、ChatCompletionRequest等 msgspec/pydantic 模型、serveCLI 都在这里,只依赖v1.engine的客户端侧。 - v1.engine(
vllm/v1/engine/):引擎壳。async_llm/input_processor/output_processor/detokenizer是 A 侧;core.py(EngineCore/EngineCoreProc/DPEngineCoreProc)与core_client.py的服务端是 B 侧;coordinator.py、utils.py管 DP 协调与进程启动。 - v1.core(
vllm/v1/core/):调度域。sched/(Scheduler、SchedulerOutput)、kv_cache_manager、block_pool、encoder_cache_manager,全部活在 B。 - v1.executor(
vllm/v1/executor/):执行抽象。abstract.py的Executor基类派生UniProcExecutor/MultiprocExecutor/RayExecutorV2/RayDistributedExecutor,是 B 内拉起并驱动 C 的唯一通道。 - v1.worker(
vllm/v1/worker/):设备域。gpu_worker、gpu_model_runner、gpu_input_batch,每个 C 进程一份。 - 执行依赖:
vllm/model_executor/models/(各模型定义)、vllm/v1/attention/backends/(FA/Triton 等 backend)、vllm/distributed/(parallel_state、pynccl、shm_broadcast)、csrc/(CUDA/C++ kernel)。
2. 构建与打包:Python 包 + 编译扩展
发布物是一个 wheel:vllm 是纯 Python 包,csrc/ 下的 CUDA/C++ 源码由 setup.py 驱动 CMake 编译成 CPython 扩展 vllm._C(自定义 kernel:paged attention、量化、moe 等)与 vllm._moe_C(MoE 专用 kernel),随 wheel 分发;另有 vllm/attention/_fa3 等 FlashAttention/flashinfer 编译产物作可选 backend。模型权重、attention backend、kernel dispatch 全在 C 侧进程按需 dlopen/import 它们,A、B 两侧进程从不碰 csrc 产物。
四、进程视图:谁在什么进程里,如何交互
静态拓扑图画模块之间的调用;这张图把它们放回默认部署的进程和线程里。API Server 处理协议和文本,EngineCoreProc 管请求状态和调度,每个 WorkerProc rank 执行模型。
1. API Server:处理协议与文本
FastAPI 的 OpenAI route 调用同进程里的 AsyncLLM。InputProcessor 产出 EngineCoreRequest;OutputProcessor 把 EngineCoreOutputs 增量 detokenize 成 RequestOutput,输出循环由 asyncio task 驱动(vllm/entrypoints/openai/api_server.py、vllm/v1/engine/async_llm.py)。
2. EngineCoreProc:持有调度状态
EngineCoreProc 运行 EngineCore:输入、输出 socket I/O 各有线程,主线程循环调度与更新。Scheduler 持有权威 Request 状态和 KVCacheManager 元数据;默认 MultiprocExecutor 下,GPU forward 留在 Worker 侧(vllm/v1/engine/core.py、vllm/v1/core/sched/scheduler.py)。
注意 Executor 不是一个进程:它是 EngineCore 构造时创建的一个进程内对象(vllm/v1/engine/core.py 里 self.model_executor = executor_class(vllm_config)),活在进程 B 的主线程里。所以逻辑视图里的 Executor 在这张进程图里落在 B 内部——它不占进程,它的作用是封装“怎么把一轮调度交给 Worker”:UniProc 在本进程直接调,Multiproc 经共享内存 MessageQueue 广播给 WorkerProc,Ray 走 actor。换一个部署形态时 EngineCore 的代码一行不改,改的只是这个对象。
3. WorkerProc:一个模型并行 rank
MultiprocExecutor 为每个模型并行 rank 启动 WorkerProc。每个进程初始化 Worker 和 GPUModelRunner,加载本 rank 的权重分片,并在 GPU 上读写 KV cache;TP/PP 通信由 Worker ranks 直接完成,不经过 EngineCore(vllm/v1/executor/multiproc_executor.py、vllm/v1/worker/gpu_worker.py、vllm/v1/worker/gpu_model_runner.py、vllm/distributed/communication_op.py、vllm/distributed/parallel_state.py、vllm/distributed/device_communicators/pynccl.py)。
4. 两道边界上传什么
API 边界传 EngineCoreRequest 与 EngineCoreOutputs;执行边界传 SchedulerOutput 与 ModelRunnerOutput。模型 forward 的 activations 不经 EngineCore;TP/PP activations 在 Worker ranks 之间传递(vllm/v1/engine/core_client.py、vllm/v1/engine/core.py、vllm/v1/executor/multiproc_executor.py、vllm/distributed/device_communicators/shm_broadcast.py)。
5. 启动顺序与就绪握手
单 API Server 时,vllm serve 在当前进程运行 run_server();AsyncLLM 创建 AsyncMPClient,后者绑定 ZMQ sockets 并通过 launch_core_engines() 启动 EngineCore 进程。--api-server-count > 1 时,run_multi_api_server() 先启动 EngineCore,再由 APIServerProcessManager 启动 API Server 子进程。每个 EngineCore 在自己进程内构造 Executor(EngineCore.__init__),MultiprocExecutor.__init__ 再为每个 rank fork 出 WorkerProc(WorkerProc.make_worker_process)。
EngineCore 先发 HELLO 注册,启动器返回地址和配置;初始化完成后,EngineCore 报告 READY。API client 还会等 EngineCore 在输入 socket 上发送 EngineCoreReadyResponse。Worker 初始化完成后通过 ready pipe 报告 READY 并交回 response queue 的 handle;Executor 随后等待各队列的 ready barrier(vllm/entrypoints/cli/serve.py、vllm/entrypoints/openai/api_server.py、vllm/v1/engine/async_llm.py、vllm/v1/engine/core_client.py、vllm/v1/engine/utils.py、vllm/v1/engine/core.py、vllm/v1/executor/multiproc_executor.py、vllm/v1/utils.py)。
6. 一个 decode step 怎样穿过三个进程
EngineCore 收到请求后,主线程调度出这一轮的 SchedulerOutput,交给进程内对象 Executor(默认 MultiprocExecutor)广播给所有 Worker——这条链路是 EngineCore → Executor → WorkerProc,图 3 里 Executor 画在进程 B 内部,图 5 里它是 B 侧时序上的一小段激活。Worker 执行模型并由 output_rank 返回结果;EngineCore 更新请求,再把 EngineCoreOutputs 交给输出线程。API Server 收到 token ids 后做 detokenize,最后由 HTTP handler 写成 SSE。图里只展开一个 decode step;prefill 也走同一组进程边界。
这里的广播是一写多读:
- Executor 只写一次,写进
rpc_broadcast_mq(MessageQueue)。同机的 Worker 从共享内存环形缓冲里读,跨机的经 ZMQ 发布订阅收,每个 WorkerProc 读到的是同一份SchedulerOutput。 - 要发给每个 Worker,是因为 TP / PP 下每个 Worker 只有一部分权重和自己那份 KV cache,大家要一起算同一个 batch:这一轮跑哪些请求、每个请求几个 token、KV 写进哪些 block,所有 rank 必须一致,前向里的 all-reduce 等 collective 才对得上。
- 调度器只在 EngineCore 里有一份,决定只做一次,所以是一发多收。
- 回程反过来,只收一份:只有
output_rank(最后一个 PP stage 的第一个 TP rank)回传ModelRunnerOutput。最后一个 stage 的各 TP rank 采样结果相同,前面的 stage 没有 logits。
五、部署/物理视图:进程怎样落到 GPU 和节点上
进程视图说了有哪几类进程;这张视图把它们落到硬件上:API Server 和 EngineCoreProc 是纯 CPU 进程,执行侧每个模型并行 rank 占一张(或一份)GPU。几个典型配置:
| 部署 | 进程 | 落位 |
|---|---|---|
| 单卡 | 1 × API Server、1 × EngineCoreProc(UniProcExecutor,Worker 在 EngineCore 进程内,没有 WorkerProc) |
1 台节点,EngineCoreProc 占 1 张 GPU |
| 单机 TP=4 | 1 × API Server、1 × EngineCoreProc、4 × WorkerProc | 1 台节点,4 个 WorkerProc 各占 1 张 GPU,组内集合通信 |
| 多机 TP×PP | 1 × API Server、1 × EngineCoreProc、world_size 个 WorkerProc | 默认 MultiprocExecutor 配合 --nnodes,每台节点起自己的 WorkerProc;--distributed-executor-backend ray 时用 RayExecutorV2 / RayDistributedExecutor(RayWorkerProc),rank 间 TP/PP collective 跨机 |
| DP=2 | 1 × API Server、2 × EngineCoreProc(rank 0 可能再带 DPCoordinator)、每 rank 各自一组 WorkerProc |
两个 EngineCore rank 各管自己的 Scheduler 与 Executor,共用一个 API 入口 |
| 多 API Server | --api-server-count > 1,N × API Server 子进程 |
只增加前端进程,模型侧 rank 数不变;P/D 分离部署则把 prefill 与 decode 拆到不同 EngineCore 实例、连 KV 传输层 |
单卡时没有 WorkerProc,Worker 直接跑在 EngineCoreProc 里,源码依据(v0.27.1):
ParallelConfig.__post_init__里world_size == 1时把distributed_executor_backend置为"uni"(vllm/config/parallel.py),Executor.get_class再把"uni"映射到UniProcExecutor(vllm/v1/executor/abstract.py);它的_init_executor直接在 EngineCore 进程内WorkerWrapperBase.init_worker()、init_device()、load_model()(vllm/v1/executor/uniproc_executor.py),全程没有make_worker_process。单卡时连 A→B 的边界都可能取消(InprocClient),但那是另一层选择。
部署方式怎么改变进程图,对应关系如下:
UniProcExecutor在 EngineCore 进程内直接初始化并调用 Worker,不创建WorkerProc子进程。InprocClient是另一层选择:它把 EngineCore 本身也放在调用方进程里。二者改变的是不同的进程边界(vllm/v1/executor/uniproc_executor.py、vllm/v1/engine/core_client.py)。- data parallel 会启动多个 EngineCore rank,各自有自己的 Scheduler 和 Executor。在线且配置需要协调时,rank 0 还会启动
DPCoordinator;它汇总队列统计、向 API frontends 发布负载信息,并在需要时协调 MoE request wave。它不是每个 token 的执行通道(vllm/v1/engine/utils.py、vllm/v1/engine/coordinator.py、vllm/v1/engine/core.py)。 --api-server-count > 1会启动多个 API Server 进程,并为它们分配各自的 ZMQ 地址;它增加的是前端进程数,不等于增加模型并行 rank。未显式指定时,参数默认值会根据 DP 配置决定(vllm/entrypoints/cli/serve.py、vllm/entrypoints/openai/cli_args.py)。- Ray 改变 Worker 的启动和放置方式。
RayExecutorV2用 Ray actor 承载RayWorkerProc,并复用 MessageQueue 控制面与 Worker 间的 collective;旧RayDistributedExecutor是另一套 Ray executor 实现(vllm/v1/executor/ray_executor_v2.py、vllm/v1/executor/ray_executor.py)。
把这张表翻成两条规则:进程数由两类并行度分别决定——data parallel size 决定 EngineCoreProc 的数量(launch_core_engines 对每个 dp rank 起一个 EngineCore 进程,vllm/v1/engine/utils.py、vllm/v1/engine/core.py);world_size = TP × PP(×PCP,context parallel)决定每个 EngineCore 内 MultiprocExecutor 拉起的 WorkerProc 数(vllm/v1/executor/multiproc_executor.py 的断言 world_size == tp_size * pp_size * pcp_size)。expert parallel 不新增进程:EP 复用 DP×TP 的已有 rank(MoE 权重按 EP 组切分在已有 Worker 上)。DP rank 之间互不共享 KV state,每 rank 一套 Scheduler/Executor/Worker 组。
六、场景视图(+1)
Kruchten 4+1 的“+1”就是场景视图:逻辑、开发、进程、物理四个视图各看一个切面,场景把四个切面串起来——沿时间轴走一遍真实发生的交互,验证前四张图能对上同一件事。这里放两个子场景:先看一条请求的完整生命周期(谁经手、什么顺序),再只看 token 这个对象在四个进程之间怎么变形(数据流)。
1. 请求流:一次请求的完整生命周期
%% 图:一次请求的完整生命周期:从 POST /v1/chat/completions 经 AsyncLLM、EngineCore、Scheduler、Executor 到 GPU,再流式返回
sequenceDiagram
participant C as Client
participant API as API Server
participant E as AsyncLLM
participant EC as EngineCore
participant S as Scheduler
participant KV as KVCacheManager
participant EX as Executor
participant W as GPU Worker
participant MR as ModelRunner
participant GPU as GPU
C->>API: POST /v1/chat/completions
API->>API: 参数解析 & 校验
API->>E: add_request(prompt, params)
E->>E: InputProcessor: tokenize → EngineCoreRequest
E->>EC: EngineCoreClient 经 ZMQ 发送(跨进程)
EC->>S: add_request(Request)
Note over S: Request.status = WAITING
rect rgb(230, 245, 255)
Note over EC,GPU: === 调度循环 step() ===
S->>S: schedule() — 选择可运行请求
S->>KV: get_computed_blocks() — 查 Prefix Cache
KV-->>S: 缓存命中块 + 未命中数
S->>KV: allocate_slots() — 分配新块
KV-->>S: KVCacheBlocks
Note over S: Request.status = RUNNING
S-->>EC: SchedulerOutput
end
EC->>EX: execute_model(SchedulerOutput)
EX->>W: forward pass
rect rgb(255, 245, 230)
Note over W,GPU: === Prefill 阶段 ===
W->>MR: execute_model(SchedulerOutput)
MR->>MR: _prepare_inputs(): input_ids / positions / slot_mapping
MR->>GPU: 模型 Forward (所有 prompt tokens)
GPU->>GPU: Attention 计算 + KV Cache 写入
GPU->>GPU: MLP 计算
GPU-->>MR: logits
W->>MR: sample_tokens()
MR->>MR: Sampling → 第 1 个 output token
end
MR-->>EC: ModelRunnerOutput
EC->>S: update_from_output()
EC-->>E: EngineCoreOutputs
E-->>API: 第 1 个 token (TTFT)
API-->>C: SSE: data: {"token": "Hello"}
loop Decode 循环 (每步 1 token)
rect rgb(245, 255, 230)
S->>S: schedule()
S->>KV: allocate_slots(1 new token)
EC->>EX: execute_model()
W->>MR: execute_model() → _prepare_inputs(1 token per request)
MR->>GPU: Forward (读历史 KV Cache + 计算新 token)
GPU-->>MR: logits
MR->>MR: Sampling → next token
MR-->>EC: ModelRunnerOutput
EC->>S: update_from_output()
end
EC-->>E: EngineCoreOutputs
E->>E: OutputProcessor: detokenize + stop 检查
E-->>API: RequestOutput (stream)
API-->>C: SSE: data: {"token": "..."}
end
Note over S: 遇到 stop token / max_tokens
Note over S: Request.status = FINISHED_STOPPED
S->>KV: free(request) — 释放所有块
API-->>C: SSE: data: [DONE]
2. 数据流:Token 如何穿过整个 Serving 栈
放大:一个 token 的回程(流式输出)
上面的时序图把 EngineCore 和 AsyncLLM 之间画成了同步的请求-应答,实际上它们跨进程、各自有独立的循环。下面把 decode 循环中一个 token 的回程放大,重点看两件事:detokenize 发生在哪个进程,以及引擎循环为什么不需要等它。
这张图解释了第四章的进程边界怎样影响流式输出:进程 B 的主线程把 EngineCoreOutputs 放进 output_queue 后立刻回到 schedule(),序列化由输出线程做,detokenize 和 stop 字符串检查由进程 A 的 output_handler 协程做,三者互不等待。代价是 stop 字符串(而非 stop token id)的判定要晚一步——进程 A 检测到后反向发 abort,GPU 可能已经为这个请求多算了一步。另外,RequestOutputCollector 在 DELTA 模式下会把积压的输出合并,因此客户端收到的一个 SSE chunk 不一定恰好对应一个 decode 步。
去程把文本变成 token ids 送到 GPU,回程只带 token ids,到 API Server 才变回文本。四条泳道对应四类实体:
注意 Detokenizer 不在 GPU 那一侧,而是在 API Server 进程的 OutputProcessor 里:EngineCore 回传的只有 token ids,文本是到这里才拼出来的。上图沿着数据走了一遍,但同一个请求在每一层其实是不同的对象——它们定义在不同文件、活在不同进程、寿命也不同。把这些对象排成一张表,就能看出”谁持有请求的真状态、谁只是一次性的快照”:
| 对象 | 定义位置 | 所在进程 | 生存期 | 它是什么 |
|---|---|---|---|---|
HTTP JSON → ChatCompletionRequest |
vllm/entrypoints/openai/chat_completion/protocol.py |
A(API Server) | 一次 HTTP 连接 | 文本 prompt + 采样参数(用户视角) |
EngineCoreRequest |
vllm/v1/engine/__init__.py |
A → B(msgspec 序列化过 ZMQ) | 只传一次 | 扁平的 IPC 载荷:prompt_token_ids、sampling_params、arrival_time… 已 tokenize,无状态 |
Request |
vllm/v1/request.py(from_engine_core_request) |
B(Scheduler 内部) | 从入队到 free() |
请求状态的唯一权威副本:status 状态机、num_computed_tokens、output_token_ids、block_hashes |
NewRequestData / CachedRequestData |
vllm/v1/core/sched/output.py |
B → C(共享内存广播) | 一个 step | SchedulerOutput 里的两种”订单”:首次调度的请求带全量 prompt_token_ids + block_ids;已在 batch 里的只带增量 new_token_ids + new_block_ids |
SchedulerOutput |
vllm/v1/core/sched/output.py |
B → C | 一个 step | 上面两者 + num_scheduled_tokens{req_id: n} + finished_req_ids 等:这一轮”谁跑、跑多少、块在哪” |
CachedRequestState + InputBatch 行 |
vllm/v1/worker/gpu_input_batch.py |
C(Worker 常驻) | 从首次调度到 finished_req_ids |
Worker 侧对 Request 的镜像,靠增量订单保持同步;InputBatch 是按行排列的持久 batch(token_ids_cpu、block_table) |
ModelRunnerOutput |
vllm/v1/outputs.py |
C → B(worker_response_mq) |
一个 step | req_ids + sampled_token_ids: list[list[int]] + logprobs:只有采样结果,不含任何状态 |
EngineCoreOutput(s) |
vllm/v1/engine/__init__.py |
B → A(ZMQ) | 一个 step | 每个请求的 new_token_ids + finish_reason;仍然只有 token ids |
RequestOutput |
vllm/outputs.py |
A(OutputProcessor 产出) |
一个 SSE chunk | 第一次出现文本:detokenize 后的增量 text + token_ids |
表里有两条规律。第一,状态只在 B 和 C 各有一份:Request 是权威,CachedRequestState 是靠每步增量同步的镜像;所有跨进程的载荷(EngineCoreRequest、SchedulerOutput、ModelRunnerOutput、EngineCoreOutputs)都是无状态的一次性消息,读完即弃,也因此可以随意序列化、走任何 IPC 通道。第二,文本只在进程 A 出现:从 EngineCoreRequest 到 EngineCoreOutputs 全程都是 token ids,进程 B、C 完全不需要 tokenizer。
七、本文小结
读 trace 时,先分清两种流量:请求、token ids 和 SchedulerOutput 属于控制流;TP/PP 的激活张量在 Worker 间走 collective,不经 EngineCore 转发。
把它和第一篇的四问对齐,就得到全系列的骨架:
| 四问 | 承担模块 |
|---|---|
| 一、这一轮谁执行、执行多少 | Scheduler(+ EngineCore 驱动) |
| 二、状态放哪、怎么复用 | KVCacheManager / BlockPool |
| 三、怎么算得更快 | ModelRunner / Attention Backend / Kernel |
| 四、怎么扩出去 | Executor / Worker / 集合通信 |
📂 本章源码导航
入口与引擎循环
| 想看什么 | 从哪开始 |
|---|---|
| HTTP 入口、OpenAI 兼容接口 | vllm/entrypoints/openai/api_server.py(路由)、vllm/entrypoints/openai/chat_completion/(chat 接口实现) |
| 异步请求生命周期、流式响应 | vllm/v1/engine/async_llm.py |
| tokenize 与请求构造 | vllm/v1/engine/input_processor.py → InputProcessor |
| 与 EngineCore 进程通信 | vllm/v1/engine/core_client.py → EngineCoreClient / AsyncMPClient |
| detokenize 与输出整理 | vllm/v1/engine/output_processor.py → OutputProcessor |
| 推理主循环(建议从这里入手) | vllm/v1/engine/core.py → EngineCore.step() |
| 调度器给执行层的”订单” | vllm/v1/core/sched/output.py → SchedulerOutput / NewRequestData / CachedRequestData |
| 执行抽象与各种部署形态 | vllm/v1/executor/abstract.py |
| 一轮 batch 在 GPU 上怎么跑 | vllm/v1/worker/gpu_model_runner.py → GPUModelRunner.execute_model() / sample_tokens() |
| 进程启动、ZMQ 握手与 DP 协调 | vllm/v1/engine/utils.py、vllm/v1/utils.py、vllm/v1/engine/coordinator.py |
八、自测
-
vLLM V1 里
AsyncLLM与EngineCore为什么分成两个进程?中间传什么?答案
把 tokenize / detokenize、HTTP、流式响应这些 CPU 密集且抖动大的工作从引擎循环里摘出去,让
EngineCore.step()稳定地驱动 GPU;中间经 ZMQ 传EngineCoreRequest(token id + 采样参数)与EngineCoreOutputs(新 token id),不传 tensor。 -
SchedulerOutput里有什么、没有什么?为什么 ModelRunner 能只凭它构造这一步的输入?答案
有:每个被调度请求的 id、这一轮的新 token 数、已计算 token 数、block table、采样参数变化、被抢占 / 完成的请求;没有:任何 tensor。ModelRunner 维护持久的
InputBatch,凭这些元数据更新位置与 slot mapping、从 KV 块里读,不需要 Scheduler 传数据。 -
一个请求的状态机有哪几个主要状态?从 WAITING 到 RUNNING 要过哪些检查?
答案
WAITING → RUNNING → FINISHED(或被抢占回 WAITING / PREEMPTED;结构化输出有 WAITING_FOR_STRUCTURED_OUTPUT_GRAMMAR);准入要 KV 块够(
KVCacheManager.allocate_slots)、token budget 够、max_num_seqs未满、(有 LoRA 时)max_loras未满。 -
TP=8 时 Executor 把一步分发给 8 个 Worker,谁做采样?logits 在哪?
答案
8 个 Worker 各算自己那 1/8 的权重,激活经 all-reduce 汇合;lm_head 的输出(logits)在所有 rank 都完整(或 gather 到 rank 0),采样在 rank 0(driver)做,其他 rank 的结果丢弃——所以采样相关的 CPU 工作只在一个进程上。
-
前端 detokenize 为什么放在
AsyncLLM而不是 EngineCore?流式输出时“一个 token 一次返回”有什么坑?答案
detokenize 是纯 CPU 且要维护每请求的字符串状态,放引擎里会拖慢 step;坑:BPE(Byte Pair Encoding,字节对编码)的 token 边界与 UTF-8 字符边界不对齐,一个多字节字符可能跨两个 token,
IncrementalDetokenizer要缓存到能安全输出的完整字符再发。 -
默认多进程路径里,
EngineCoreProc与WorkerProc各自做什么?SchedulerOutput和ModelRunnerOutput分别通过哪种通道传?答案
EngineCoreProc持有 Scheduler 和请求状态,驱动调度、执行、更新;WorkerProc持有本 rank 的 Worker、GPUModelRunner、权重分片和 GPU KV cache。单机时 EngineCore 通过共享内存MessageQueue广播SchedulerOutput,output_rank通过 response queue 回传ModelRunnerOutput。 -
TP/PP、DP、
--api-server-count > 1分别改变哪一部分?RayExecutor 又改变什么?答案
TP/PP 增加 Worker rank,并让 Worker 之间做 collective;DP 增加独立的 EngineCore rank,在线配置需要时由 DPCoordinator 汇总负载或协调 wave;
--api-server-count增加 API Server 进程。RayExecutor 改变 Worker 的启动和放置方式;RayExecutorV2仍复用 MessageQueue 控制面与 Worker 间 collective。
-
链路是固定的:入口(
api_server的 OpenAI 兼容路由)→AsyncLLM→InputProcessor(tokenize、构造EngineCoreRequest)→ 经EngineCoreClient跨进程送进 EngineCore。EngineCore 的step()是驱动循环:Scheduler 决策——这一轮哪些请求跑、每个推进多少 token,产出SchedulerOutput;Executor 分发到一个或多个 Worker;ModelRunner 执行——准备InputBatch、调 attention backend 与模型前向、采样,产出ModelRunnerOutput;EngineCore 把结果交回 Scheduler 更新请求状态,并把EngineCoreOutputs送回前端 detokenize、流式返回。传递的是什么:Scheduler 与 ModelRunner 之间传的不是 tensor 而是元数据——每个请求这一轮的 token 数、block table、slot mapping、采样参数;Worker 之间传的是激活(TP 的 all-reduce、PP 的 P2P);前后端之间传的是 token id。详见第二至第六章(数据流在 6.2)。 ↩
系列 《大模型推理系统揭秘:从 vLLM 看 LLM Serving Infra 核心技术》 第 3 / 16 篇 深入
系列总览 — 为什么这样组织、读它需要什么、读完能做什么 · 幻灯片 — 整个系列的精华,一份可分享的 deck
学习地图 — AI-Infra 工程师学习地图 第 9 / 14 个系列
- 为什么 LLM Serving 比传统 DL 推理难?
- 如何衡量一个 LLM Serving 系统?
- 鸟瞰 vLLM:一个请求如何穿过整个推理系统?
- Scheduler:GPU 这一轮到底给谁用?
- KV Cache:LLM Serving 的第一号内存问题
- GPU 执行:如何让每个 Token 算得更快?
- 解码的扩展:采样、投机解码与结构化输出
- Multi-GPU:一张卡不够时如何扩展?
- PD 分离:从资源混部走向计算解耦
- 模型适配:如何跟上变化极快的模型世界?
- 请求形态的扩展:multi-LoRA 与多模态
- 硬件解耦:如何不让芯片差异污染 Serving 核心?
- Serving Infra 的下一站:从模型执行器到分布式智能操作系统
- 回到源码:一次请求在 vLLM 内部的真实旅程
- vLLM 与 SGLang:同一个请求穿过两套 Serving 系统
- 系列总结与通关自测
上一篇:如何衡量一个 LLM Serving 系统?下一篇:Scheduler:GPU 这一轮到底给谁用?
下一个系列:RL 后训练基础设施:rollout 与训练如何共享一组 GPU 深入 · 9 篇,约 2.9 小时
本文由 arganzheng 创作,采用 CC BY 4.0 许可协议。在保留原文作者、署名以及完整原文链接(https://arganzheng.life/vllm-request-lifecycle-overview.html)的前提下,欢迎各种形式的转载、翻译或商业引用。
COMMENTS
评论存放在 GitHub Discussions, 用 GitHub 账号登录即可发表,支持 Markdown。 想针对正文某句话说?选中那段文字,点浮出的「评论」即可划线评论;觉得哪里写错了,发表时勾上「同时提交 Issue」。 有人回复你时 GitHub 会按你的通知设置发邮件,不用守在这里。