vLLM V1
系统精读 · vLLM V1 · commit e740bad

vLLM V1 把请求拆成三段流水线:API 进程收、Engine Core 排、Worker 算

一个请求从 HTTP 进入到流式返回,要跨过四次进程边界,并在 Engine Core 里把「调度」和「执行」拆成两步。本文按源码把这条路径逐段拆开,重点看每一步发生在哪个进程、跨了什么数据。

vLLM project · V1 source 源码 ↗ 架构文档 ↗ 博客笔记 ↗
TL;DR · 30 秒抓住核心

V1 的第一刀切在进程边界。API server 进程负责 HTTP、分词、多模态加载、增量反分词与流式返回;Engine Core 进程只跑调度器、KV cache 管理与执行编排;每个 GPU 一个 worker 进程跑前向。官方文档把这套布局写成 1 API server + 1 engine core + 4 workers 的单机 TP=4 示例(docs/design/arch_overview.md:117)。

第二刀切在调度循环。Engine Core 的忙循环每轮做三件事:scheduler.schedule 选出这一步的 batch,execute_model 把 SchedulerOutput 交给 worker,update_from_output 收回采样结果并推进请求状态(vllm/v1/engine/core.py:709)。调度器里没有独立的 prefill / decode 阶段,只有一条 token 预算(vllm/v1/core/sched/scheduler.py:721)。

第三刀切在「先 running、后 waiting」。每步先把预算分给 running 请求(decode 通常每请求 1 token),再把剩下的预算给 waiting 请求做 chunked prefill;KV block 不够就停下 waiting,必要时抢占 running(vllm/v1/core/sched/scheduler.py:815、vllm/v1/core/sched/scheduler.py:1071、vllm/v1/core/sched/scheduler.py:1876)。

2048 tokens/step
小显存 / A100 档默认 2048;三档为 16384 / 8192 / 2048(≥160GiB / H100·H200 / 其他 GPU)
64 tokens/block
KV cache block 默认大小:块是分配、回收与抢占的最小单位
6 processes
TP=4 单机示例:1 API server + 1 Engine Core + 4 GPU workers
01

请求的真正难点,是让每个请求停在不同的生命周期位置

读完这节你应该知道:为什么 vLLM 不能把「生成」当成一次普通函数调用,以及为什么这三类工作应该待在不同进程。

一个在线推理请求不是一个同步函数。它先要分词,再占据 KV cache 的若干块,然后进入一个可能持续几百步的循环:每一步通常只生成一个 token(投机解码时多几个),直到遇到停止符或 max_tokens。同一时刻,一个实例里可能同时存在:刚提交、还没分配到 KV block 的新请求;正在做 chunked prefill 的长 prompt;已经生成了一段 token 的短对话;以及已经结束、但文本还没反分词完的请求。

调度器必须用一个统一坐标描述这些请求。V1 的做法是给每个请求记两个数:num_computed_tokens 和 num_tokens_with_spec(prompt + 已生成 + 投机 token);每一步只决定「给这个请求补多少 token」,让它追上前者(vllm/v1/core/sched/scheduler.py:723-731 的注释把这件事讲得很直白)。这也意味着 batch 的组成每步都在变,不能按静态 batch 的方式预排。

KV cache 又把这个循环和显存绑在一起。V1 把 KV cache 切成固定大小的 block,调度器在接纳请求时必须调用 KVCacheManager.allocate_slots(vllm/v1/core/kv_cache_manager.py:401);分配失败时,waiting 请求这一步就不能进 batch,running 请求则可能被抢占、释放 block、退回 waiting,进度重置后重新计算(prefix cache / KV connector 可恢复部分进度)(docs/design/arch_overview.md:156)。

最后,输出还得边生成边还原成文本。增量 detokenizer 要处理 UTF-8 边界和多字节字符,还要在 stop string 可能跨 token 时把尾部字符先扣住(vllm/v1/engine/detokenizer.py:87-93、98-157、245-260)。这是纯 CPU 工作,却发生在每一个输出 token 上。调度、GPU 前向、分词/反分词三者的资源画像完全不同;把它们塞进同一个循环,最慢的 CPU 步骤就会成为 GPU 的空转来源。

02

V0 把调度器和 Worker 0 绑在一起,V1 先把 CPU 工作挪出核心循环

读完这节你应该知道:V1 的进程拆分不是「多开几个进程」,而是针对 V0 四个具体问题的重新分工。

官方 V1 用户指南对 V0 的评价是:功能覆盖很广,但各能力独立演进后系统越来越复杂,技术债拖慢了新能力的集成;V1 重新设计了 scheduler、KV cache manager、worker、sampler 和 API server,目标是「近零 CPU 开销」和「零配置」(docs/usage/v1_guide.md:9-22)。官方 V1 发布博客则把 V0 的具体问题列为四条:调度器与 Worker 0 同进程造成的 TP 布局不对称;CPU 侧的 tokenization、多模态处理、反分词和请求流式返回与 GPU 步进争抢时间;每一步重建输入张量与元数据;prefix caching 因低命中率下的额外开销而默认关闭。

V0 · 耦合

一个进程里塞下太多角色

  • 张量并行时,调度器与 Worker 0 放在同一进程,避免进程间传输入,但让 0 号 worker 的进程布局与其他 worker 不对称。
  • 官方博客把拆分动机写得很直接:CPU 侧的 tokenization、多模态处理、detokenization 和流式返回应该与 Engine Core 的循环重叠,而不是让 GPU 等 Python。
  • 每步重建输入张量和元数据,Python 对象分配进入稳态开销。
  • prefix caching 的默认状态受开销影响:低命中率时收益抵不过成本,所以默认关闭。

来源:vLLM V1 发布博客(Why vLLM V1? / Optimized Execution Loop & API Server / Zero-Overhead Prefix Caching / Clean Architecture for Tensor-Parallel Inference / Efficient Input Preparation)。

V1 · 拆分

让每个进程只做一类工作

  • Engine Core 独立成进程,只保留 scheduler + model executor 的忙循环;官方博客称之为 isolated EngineCore execution loop。
  • API server 保留 tokenization、多模态输入处理、detokenization 和 request streaming,CPU 工作与 GPU 步进可以重叠。
  • Worker 持有请求状态,Engine Core 每步只发差分输入,不再重建整批张量。
  • Scheduler 取消 prefill / decode 的阶段区分,统一成 token 预算(docs/design/arch_overview.md:142)。

来源:同上博客;另见 架构文档。

这个拆分有一个直接的行为后果:V1 里 chunked prefill 默认开启,而 V0 是按模型特征有条件开启(docs/usage/v1_guide.md:39)。当 prefill 可以被切成任意大小的 chunk,调度器就不再需要「先等整段 prompt 算完」的独立阶段,token 预算循环才有条件成立。

03

调度器只做一件事:在 2048 token 的示例预算里,先补 running,再补 waiting

读完这节你应该知道:一个 step 的 batch 是怎么被选出来的,以及 KV block 不足时为什么是「抢占并重算」而不是换出。

路径简写:scheduler.py = vllm/v1/core/sched/scheduler.py;kv_cache_manager.py = vllm/v1/core/kv_cache_manager.py;core.py = vllm/v1/engine/core.py;multiproc_executor.py = vllm/v1/executor/multiproc_executor.py;gpu_worker.py = vllm/v1/worker/gpu_worker.py;output_processor.py = vllm/v1/engine/output_processor.py;async_llm.py = vllm/v1/engine/async_llm.py;core_client.py = vllm/v1/engine/core_client.py。前文首次出现时用完整路径。

Scheduler.schedule 一进来就把 token_budget 设为 max_num_scheduled_tokens;这个值默认等于 max_num_batched_tokens(scheduler.py:139-143),也就是 SchedulerConfig 的字段默认值 2048;EngineArgs 会按设备和用途覆盖这个字段(见 §06 的表 4)。预算不是「最多几个请求」,而是这一步所有请求新增 token 的总和。

第一轮遍历 self.running(scheduler.py:815)。每个 running 请求带着自己的 num_computed_tokens 和 num_tokens_with_spec 进来;调度器给它分配足够多的 token 去追上后者。普通 decode 请求只差一个 token,所以通常只拿 1 个;处于 chunked prefill 中途的请求则可能拿一大段。预算被这些「补课」消耗掉一部分。

第二轮才遍历 waiting 队列(scheduler.py:1071)。新请求先做 prefix cache 查找:get_computed_blocks 找出已经缓存的完整 block(kv_cache_manager.py:288),再把「新 block + lookahead」交给 allocate_slots(kv_cache_manager.py:401)。如果返回 None,说明 KV block 不够,这个 waiting 请求本步不接纳;如果 running 已经占满 max_num_active_reqs(默认等于 max_num_seqs)的槽位,waiting 也不会被接纳,只有已在途的异步 KV load 还能推进(scheduler.py:134-138、scheduler.py:1102)。

当 running 请求需要新 block 而池子已空,调度器会调用 _preempt_request(scheduler.py:1876):释放该请求的 block,把它放回 waiting 队首,进度重置后等待重新计算。V1 不把 KV cache 换出到 CPU 再换回来;恢复进度的手段是 prefix cache 或外部 KV connector(docs/design/arch_overview.md:156-171)。这也是为什么 block 大小和块数会直接影响尾延迟:一次抢占的代价是重算。推断(由 _preempt_request 释放 block 后请求回到 waiting 的路径推出)

token_budget = max_num_batched_tokens
decode_tokens = Σ 1(每个 running decode 请求)
prefill_tokens = min(prompt_remaining, token_budget − decode_tokens)
new_blocks = ⌈prefill_tokens / block_size⌉
本页用于解释交互的简化关系;真实路径还叠加投机 token、encoder 输入、prefix cache 命中与异步 KV load(scheduler.py:721、kv_cache_manager.py:401)。

自己动手:拖动负载,看一个 step 的预算怎么被分完

三个滑块分别控制 running decode 请求数、waiting 请求的 prompt 长度、以及空闲 KV block 数。预算固定为 2048(小显存档 API server 默认),block 固定为 64 tokens,示例并发上限设为 256(同档 max_num_seqs)。读数直接对应调度器「先 running、后 waiting」的顺序。

简化模拟 decode 优先 waiting 用剩余预算
DECODE
64 tokens
PREFILL 首块
1984 tokens
KV 首块
31 blocks
本步 batch 组成
读数为组 A 的默认参数:1984 tokens 的 prefill 首块需要 31 个 block,空闲 64 块,本步可接纳。
手算对账 两组参数,逐位与 JS 输出相同

组 A:run=64,prompt=3968,free=64。decode = 64;prefill 预算 = 2048 − 64 = 1984;首块 = min(3968, 1984) = 1984;分块数 = 3968 ÷ 1984 = 2;首块 KV = 1984 ÷ 64 = 31 块;31 ≤ 64,本步接纳。

组 B:run=127,prompt=1920,free=16。decode = 127;预算 = 2048 − 127 = 1921;首块 = min(1920, 1921) = 1920;分块数 = 1;首块 KV = 1920 ÷ 64 = 30 块;30 > 16,差 14 块,本步不接纳该 waiting 请求。

两组都没有触到 256 的示例并发上限;两组结果均为整数,组 B 的分块数由 ceil(1920/1921)=1 得到,JS 用 Math.ceil(a / b) 计算,结果与手算一致。模拟只覆盖「running decode 每请求 1 token」与「prefill 新块」;未覆盖 prefix cache 命中、投机 token、decode 跨 block、异步调度与抢占后的重算。真实调度路径见 vllm/v1/core/sched/scheduler.py:721。

04

调度与执行分居两个进程,中间只传这一步的差分

读完这节你应该知道:每个角色在哪个进程/线程里跑,以及一步推理跨了几次边界。

官方架构文档把 V1 的进程分成四类:API server、Engine Core、GPU worker,以及数据并行时才出现的 DP coordinator(docs/design/arch_overview.md:67-101)。API server 默认 1 个,开启 DP 后自动扩到 DP 个,可用 --api-server-count 覆盖;每个 API server 通过 ZMQ 与所有 Engine Core 做多对多连接(docs/design/arch_overview.md:75)。Engine Core 每个 DP rank 一个(docs/design/arch_overview.md:83),GPU worker 每个 GPU 一个,数量等于 TP × PP(docs/design/arch_overview.md:91)。

表 1 · 角色分工(源码锚点见各行)

角色所在进程 / 线程每个 step 的动作源码锚点
API serverAPI 进程 · asyncio 事件循环HTTP、分词、多模态、反分词、SSE / JSONdocs/design/arch_overview.md:71
Engine CoreEngine Core 进程 · 主线程 busy loop收请求、调度、发 RPC、更新输出vllm/v1/engine/core.py:1764
socket IOEngine Core 进程 · 两个后台线程ZMQ 收包 / 发包;input 线程解 ADD 并调 preprocess_add_request,再队列化交给 busy loopvllm/v1/engine/core.py:1393、vllm/v1/engine/core.py:2203
ExecutorEngine Core 进程把 SchedulerOutput 广播给 worker,聚合 ModelRunnerOutputvllm/v1/executor/multiproc_executor.py:430
Worker每 GPU 一个进程前向、采样、管理本卡显存vllm/v1/worker/gpu_worker.py:1541

Engine Core 的忙循环在 run_busy_loop 里(core.py:1764):先 _process_input_queue 把输入队列清到「有活可干」(core.py:1790),再 _process_engine_step 调一次 step() 并把输出塞进输出队列(core.py:1825)。step() 本身分四步:scheduler.schedule 生成 SchedulerOutput;model_executor.execute_model(scheduler_output, non_block=True) 发起异步执行;_wait_for_output 等结果,若模型输出为 None 再单独 sample_tokens;最后 scheduler.update_from_output 把采样结果并回请求状态(core.py:719-733)。

跨进 worker 进程的不是整批 prompt,而是 SchedulerOutput 里的「这一步每个请求要算多少 token」以及新增 block 的差分。MultiprocExecutor 在初始化时为每个本地 rank 启动一个 worker 进程(multiproc_executor.py:234),执行时通过 collective_rpc 把 SchedulerOutput 广播下去(multiproc_executor.py:430);worker 的 execute_model 直接调用 model runner 做前向(gpu_worker.py:1541)。这条路径就是官方博客说的「worker 持有请求状态,只收差分」。

进程数的公式是 A + DP + N,DP > 1 时再加 1 个 coordinator;其中 N = DP × PP × TP(docs/design/arch_overview.md:103-113)。代入单机 TP=4:1 个 API server + 1 个 Engine Core + 4 个 worker = 6 个进程(docs/design/arch_overview.md:117)。代入 TP=2、DP=4:4 个 API server + 4 个 Engine Core + 8 个 worker + 1 个 coordinator = 17 个进程(docs/design/arch_overview.md:125)。

这套布局把 Python 开销集中到了 Engine Core 的主循环,把分词、反分词和 HTTP 流式返回放回 API 进程;但 Engine Core 与 worker 之间的 RPC、以及 API 与 Engine Core 之间的 ZMQ 序列化并没有消失,只是被移到了「不在 GPU 关键路径上」的线程和进程里。socket IO 由两个后台线程处理(core.py:1393、core.py:1405),input 线程负责 ADD 请求的 preprocess_add_request(core.py:2203),busy loop 只碰队列;源码注释的说法是,这两个线程释放 GIL,从而把 ZMQ socket IO 与 GPU 执行重叠(core.py:1387-1389)。

05

请求的一生要跨四次进程边界,文本只在 API 进程还原

读完这节你应该能指出:每个阶段发生在哪个进程,跨边界时传的是对象、张量,还是只有 token id。

vLLM V1 进程与请求路径 API server 进程通过 ZMQ 把 EngineCoreRequest 发给 Engine Core;Engine Core 调度后把 SchedulerOutput 交给 GPU worker;worker 返回 ModelRunnerOutput;Engine Core 再用 ZMQ 把 EngineCoreOutputs 发回 API 进程做反分词和流式返回。 API Server 进程 HTTP / OpenAI 路由 InputProcessor 分词 OutputProcessor 反分词 AsyncLLM.generate 流式返回 asyncio · 1 个进程(默认) Engine Core 进程 Scheduler:token 预算 KVCacheManager:块分配 EngineCore.step 忙循环 input / output socket 线程 主线程 + 2 个 IO 线程 GPU Worker 进程 × N WorkerProc / ModelRunner 执行 SchedulerOutput 前向、采样 返回 ModelRunnerOutput N = TP × PP(每 core) 1 2 3 4 1 · EngineCoreRequest(ZMQ)  2 · SchedulerOutput(collective RPC) 3 · ModelRunnerOutput  4 · EngineCoreOutputs(ZMQ)  5 · API 进程内:RequestOutput → SSE
图 1 · V1 在线 serving 的进程与数据流。API server 默认 1 个(docs/design/arch_overview.md:75);worker 数 = TP × PP(docs/design/arch_overview.md:91);API 与 Engine Core 之间走 ZMQ,Engine Core 与 worker 之间由 Executor 广播 SchedulerOutput(vllm/v1/executor/multiproc_executor.py:430)。文本的反分词不在这条 GPU 路径上,而在 API 进程完成(vllm/v1/engine/output_processor.py:731)。

下面的逐步展开把这条路径拆成 10 个可核对的阶段。每一步都标注了所在进程、动作和跨界对象;按钮只切换阶段,不改变任何参数。

请求生命周期 阶段 1 / 10
进程 / 线程
API Server 进程
动作
OpenAI 路由
跨界数据
JSON / SSE
第 1 / 10 步 · HTTP 到达
请求进入 OpenAI chat completion 路由(vllm/entrypoints/openai/chat_completion/api_router.py:59),handler 调用 engine_client.generate(vllm/entrypoints/openai/chat_completion/serving.py:397);此时还没有 token id。

阶段说明里的路径与行号都对应 commit e740bad;不是所有部署都走同一条路径,离线 LLMEngine 默认用 InprocClient(core_client.py:388),不经过 ZMQ。

表 2 · 四次进程边界的载体与方向

边界方向载体源码锚点
API → Engine Core请求进入EngineCoreRequest(ZMQ)vllm/v1/engine/core_client.py:1363 → vllm/v1/engine/core.py:2131
Engine Core → Worker调度下发SchedulerOutput(collective RPC)vllm/v1/executor/multiproc_executor.py:430
Worker → Engine Core执行返回ModelRunnerOutputvllm/v1/worker/gpu_worker.py:1541
Engine Core → API输出回传EngineCoreOutputs(ZMQ)vllm/v1/engine/core.py:2240 → vllm/v1/engine/async_llm.py:894

注意最后一个边界的载荷:Engine Core 回传的最外层是 EngineCoreOutputs,其中包含每个请求的 EngineCoreOutput,字段是 new_token_ids、finish_reason、new_logprobs 等(scheduler.py:2607),不是文本。文本要到 API 进程的 OutputProcessor.process_outputs 里才由增量 detokenizer 还原(output_processor.py:731、output_processor.py:828):fast detokenizer 走 Rust tokenizers 的 DecodeStream,只有 slow 路径才在 Python 里解码。这条分工把「每个输出 token 的文本解码」从 Engine Core 的关键路径上拿掉了。

06

拆开之后,行为落在进程数、预算和块数三个可核对处

读完这节你应该知道:这套结构改变了哪些可观察行为,哪些数字能直接对上文档和源码。

V1 是一个系统实现,不是一篇带 benchmark 表的论文;这一节不引用吞吐提升数字,只核对结构带来的三个可计算后果。第一个后果是进程数。按 A + DP + N(DP > 1 时再加 coordinator)的公式,单机与多 DP 的进程数如下。

表 3 · 进程数(按 docs/design/arch_overview.md:103-125 的公式与示例)

部署API serverEngine CoreGPU workerCoordinator总计
TP=4,DP=111406
TP=2,DP=4448117

2048 只是一个档位,不是所有部署的生效值。SchedulerConfig 的字段默认值(2048 / 128,vllm/config/scheduler.py:41、vllm/config/scheduler.py:43)会在 EngineArgs 创建配置时按设备显存和 usage context 覆盖:同一个 OpenAI API server 入口,在 ≥160GiB 的 GPU 上默认 16384 tokens / 1024 seqs,在 H100/H200 上默认 8192 / 1024,在其他 GPU(含 A100)上默认 2048 / 256(vllm/engine/arg_utils.py:3004-3037)。本页交互固定用 2048 / 256 这一档做算术示例;换档后预算和单步可处理的最大块数按同一比例放大(受 prompt 长度限制),调度顺序不变。

表 4 · EngineArgs 按设备与 usage context 解析的 API server 默认值(源码:vllm/engine/arg_utils.py)

设备档位max_num_batched_tokensmax_num_seqs源码锚点
≥160GiB GPU(如 B200 / B300)163841024arg_utils.py:3004-3013
≥70GiB 且非 A100 / GB10(如 H100 / H200)81921024arg_utils.py:3014-3027
其他 GPU(含 A100)2048256arg_utils.py:3028-3037

第二个后果是预算和块的换算。在本页的 2048 档示例里,一个满预算的 step 最多新算 2048 个 token,按 64 tokens/block 切分;不考虑 prefix cache 命中时,最多涉及 32 个新 block(2048 ÷ 64 = 32)。假设这一步有 128 个 running decode 请求,它们先吃掉 128 个 token,留给 waiting prefill 的预算是 1920 个 token;chunked prefill 因此可以和 decode 共享同一个 token 预算。反过来说,running 越多,单个 waiting 请求单步能拿到的 prefill chunk 越短,长 prompt 的完成步数就越多。推断(由预算固定、chunk 变小推出)

第三个后果是默认行为的组合。V1 的 prefix caching 默认开启(vllm/config/cache.py:260);调度器在接纳 waiting 请求前先查 get_computed_blocks(kv_cache_manager.py:288),命中部分直接从 num_computed_tokens 里扣掉,不重复计算。chunked prefill 也默认开启(vllm/config/scheduler.py:147),所以长 prompt 可以被切进多个 step,而不是占住整条 GPU 直到算完。两者叠加,调度器才有条件把「一个新请求」和「一批 decode」放进同一个 token 预算里。

这三条也解释了为什么 V1 的 API 进程仍然重要:它承担了所有请求的分词与反分词、HTTP 连接和流式输出。官方文档专门提醒,V1 的进程布局会直接影响 CPU 资源的配置(docs/design/arch_overview.md:67-69);把 Engine Core 独立出来后,CPU 预算要按 API 进程与 Engine Core 分别计算,而不是按一个进程估算。推断(由进程拆分后的 CPU 归属推出)

07

边界:每个 DP rank 仍是一条串行调度线,预算和块数仍是硬约束

读完这节你应该知道:什么时候这套架构会卡住,以及哪些问题不在本页的覆盖范围内。