一个请求从 HTTP 进入到流式返回,要跨过四次进程边界,并在 Engine Core 里把「调度」和「执行」拆成两步。本文按源码把这条路径逐段拆开,重点看每一步发生在哪个进程、跨了什么数据。
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)。
读完这节你应该知道:为什么 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 的空转来源。
读完这节你应该知道: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 因低命中率下的额外开销而默认关闭。
来源:vLLM V1 发布博客(Why vLLM V1? / Optimized Execution Loop & API Server / Zero-Overhead Prefix Caching / Clean Architecture for Tensor-Parallel Inference / Efficient Input Preparation)。
EngineCore execution loop。来源:同上博客;另见 架构文档。
这个拆分有一个直接的行为后果:V1 里 chunked prefill 默认开启,而 V0 是按模型特征有条件开启(docs/usage/v1_guide.md:39)。当 prefill 可以被切成任意大小的 chunk,调度器就不再需要「先等整段 prompt 算完」的独立阶段,token 预算循环才有条件成立。
读完这节你应该知道:一个 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 的路径推出)
三个滑块分别控制 running decode 请求数、waiting 请求的 prompt 长度、以及空闲 KV block 数。预算固定为 2048(小显存档 API server 默认),block 固定为 64 tokens,示例并发上限设为 256(同档 max_num_seqs)。读数直接对应调度器「先 running、后 waiting」的顺序。
组 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。
读完这节你应该知道:每个角色在哪个进程/线程里跑,以及一步推理跨了几次边界。
官方架构文档把 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 server | API 进程 · asyncio 事件循环 | HTTP、分词、多模态、反分词、SSE / JSON | docs/design/arch_overview.md:71 |
| Engine Core | Engine Core 进程 · 主线程 busy loop | 收请求、调度、发 RPC、更新输出 | vllm/v1/engine/core.py:1764 |
| socket IO | Engine Core 进程 · 两个后台线程 | ZMQ 收包 / 发包;input 线程解 ADD 并调 preprocess_add_request,再队列化交给 busy loop | vllm/v1/engine/core.py:1393、vllm/v1/engine/core.py:2203 |
| Executor | Engine Core 进程 | 把 SchedulerOutput 广播给 worker,聚合 ModelRunnerOutput | vllm/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)。
读完这节你应该能指出:每个阶段发生在哪个进程,跨边界时传的是对象、张量,还是只有 token id。
下面的逐步展开把这条路径拆成 10 个可核对的阶段。每一步都标注了所在进程、动作和跨界对象;按钮只切换阶段,不改变任何参数。
阶段说明里的路径与行号都对应 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 | 执行返回 | ModelRunnerOutput | vllm/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 的关键路径上拿掉了。
读完这节你应该知道:这套结构改变了哪些可观察行为,哪些数字能直接对上文档和源码。
V1 是一个系统实现,不是一篇带 benchmark 表的论文;这一节不引用吞吐提升数字,只核对结构带来的三个可计算后果。第一个后果是进程数。按 A + DP + N(DP > 1 时再加 coordinator)的公式,单机与多 DP 的进程数如下。
表 3 · 进程数(按 docs/design/arch_overview.md:103-125 的公式与示例)
| 部署 | API server | Engine Core | GPU worker | Coordinator | 总计 |
|---|---|---|---|---|---|
| TP=4,DP=1 | 1 | 1 | 4 | 0 | 6 |
| TP=2,DP=4 | 4 | 4 | 8 | 1 | 17 |
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_tokens | max_num_seqs | 源码锚点 |
|---|---|---|---|
| ≥160GiB GPU(如 B200 / B300) | 16384 | 1024 | arg_utils.py:3004-3013 |
| ≥70GiB 且非 A100 / GB10(如 H100 / H200) | 8192 | 1024 | arg_utils.py:3014-3027 |
| 其他 GPU(含 A100) | 2048 | 256 | arg_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 归属推出)
读完这节你应该知道:什么时候这套架构会卡住,以及哪些问题不在本页的覆盖范围内。
schedule() 在主线程里一次处理一个 step。横向扩展的单位是 DP rank:每个 rank 一个 Engine Core,各自持有独立的 KV 池推断(由每 rank 独立进程与各自的 KV cache manager 推出)。跨 rank 的负载均衡由 DP coordinator 负责(docs/design/arch_overview.md:95)。2048 个 token(H100/H200 档为 8192,见 §06 表 4);更长的 prompt 会被切成多个 chunk,首 token 延迟随之拉长。推断(由 token 预算与 chunked prefill 的定义推出):预算调大能减少 chunk 数,但会增加单步的 GPU 时间与调度粒度;预算调小则相反。这个权衡不是架构能消除的,只能按负载选择。CacheConfig 字段默认值是 64 tokens;allocate_slots 返回 None 时,waiting 请求本步不进入 batch(kv_cache_manager.py:401);running 请求需要新块而池子已空时,调度器抢占并让请求回到 waiting(scheduler.py:1876)。V1 不做 CPU swap,恢复靠重算或 prefix cache / KV connector(docs/design/arch_overview.md:156-171)。长上下文、高并发、低显存三者同时出现时,allocate_slots 会先返回 None。OutputProcessor.process_outputs 的增量 detokenizer(output_processor.py:731、output_processor.py:828);请求数很多时,这部分 CPU 成本会回到 API 进程。V1 把它移出了 Engine Core,但没有让它在端到端路径上消失。LLMEngine 默认走 InprocClient(core_client.py:388),就是把 Engine Core 放回同进程的路径。async_scheduler.py)、投机解码、KV connector、disaggregated prefill/decode、多模态 encoder cache 都会改变上面的时序或载荷。第 03 节的交互是简化模型:只算 running decode 每请求 1 token 与 prefill 新块,不含 prefix cache 命中、投机 token、decode 跨 block 与抢占重算;它对应 scheduler.py:721 的主干,不对应所有分支。