vLLM系统拆解-04-Scheduler:为什么调度单位是token budget
vLLM系统拆解-04-Scheduler:为什么调度单位是token budget
对在线 LLM 服务来说,真正难的不是"跑一次 forward",而是:很多请求并发,长度各不相同,有人刚进来做 prefill,有人正在 decode,有人共享 system prompt 可以复用缓存,有人把显存快占满了——在这种情况下,谁先算、谁后算、谁先暂停、谁可以少算一部分?
这就是 scheduler 存在的原因。vLLM 的 scheduler 不是一个给请求排队的模块,而是 engine core 里的决策核心:它每一轮决定如何把有限的 token 预算分配给当前最值得推进的请求。这篇文章从"为什么需要 scheduler"开始,把请求状态机、token budget、统一调度、decode 优先、chunked prefill、preemption 和 prefix caching 的调度含义逐一讲清楚。
静态 batch 为什么不够用
在 LLM 推理框架的早期阶段,"batch"的概念直接来自训练:凑齐一批请求,一起跑,跑完再换下一批。这在离线推理里可以接受,在在线服务里会暴露三个根本问题。
**问题一:请求长度不一致,短请求被迫等长请求。**静态 batch 要等整批请求都完成才能切换,这意味着一批里最慢的那个请求决定了所有人的等待时间。
**问题二:prefill 和 decode 的资源特征完全不同。**第 2 篇中讲过,prefill 是 compute-bound 的,decode 是 memory-bound 的。用一套固定调度策略处理两者,结果通常是两头都不舒服:偏向 prefill,老请求的 ITL(Inter-Token Latency)变差;偏向 decode,新请求的 TTFT(首 token 延迟,Time To First Token)变差。
**问题三:KV cache 是动态增长的,不能只调度算力。**每个请求的 KV cache 都在增长,速度不同、结束时刻不同、可复用前缀也不同。一个请求能不能继续推进,不只是看有没有空闲算力,还要看有没有可用 KV block、有没有 token 预算、有没有 cache 命中。
所以 scheduler 实际上要同时协调:请求状态、GPU 计算机会、KV cache 资源、延迟与吞吐目标这四件事。它和 KV cache manager 是紧耦合的。
Scheduler 在系统中的位置
在 vLLM V1 架构里,scheduler 的默认类路径是 vllm.v1.core.sched.scheduler.Scheduler,它运行在 engine core 进程中:
flowchart TD
A["API Server / Frontend\n接收请求、做输入处理"] --> B["Engine Core\n运行主调度循环"]
B --> C["Scheduler\n决定本轮调度谁、调度多少"]
C --> D["KV Cache Manager\n提供 free block 数量与 cache 命中信息"]
C --> E["Worker(GPU)\n执行 forward pass"]
E --> F["Sampler\n从 logits 里采样 token"]
F --> B
模型负责算,worker 负责执行,sampler 负责出 token,KV cache manager 负责管显存里的历史状态——但哪些请求本轮进入 batch、每个请求推进多少 token、哪个请求被抢占、哪个可以利用 prefix cache 少算一部分,这些全是 scheduler 的决定。
请求在 Scheduler 眼里是一个状态机
用户发来一个请求,在 scheduler 的视角里,它不是一段文本,而是一个带状态的生成任务,会在以下几个状态之间转换:
stateDiagram-v2
[*] --> waiting : 请求进入系统
waiting --> running : 调度器选中,分配 token budget
running --> running : 持续 decode(每轮推进)
running --> preempted : 资源压力 / 更高优先级请求\n触发抢占
preempted --> waiting : 放回等待队列
running --> blocked : 当前资源 / budget 不足\n暂时跳过
blocked --> waiting : 条件改善,重新参与调度
running --> finished : 生成结束(EOS / max_tokens)
finished --> [*]
vLLM 源码中可以直接看到对应的设计:_preempt_request(触发抢占)、_try_promote_blocked_waiting_request(把 blocked 请求提升回 waiting)、finished / stopped 请求的释放逻辑,以及 scheduler 能返回的 running / waiting 请求数统计。
这意味着 scheduler 管的不是"一次 batch 调用",而是请求的完整生命周期。
为什么必须是统一调度器,而不是"prefill 调度 + decode 调度"
直觉上,可以给 prefill 和 decode 各写一套调度逻辑,拼在一起。vLLM V1 选择的是 unified scheduler(统一调度),原因在于真实请求根本不会干净地分层。
同一时刻,系统里可能同时存在:
- 刚进来、还没开始计算的 prefill 请求
- 已经做了一半、剩余部分等待下轮推进的 chunked prefill 请求
- 正在 decode 的老请求
- 前缀部分已命中 cache、只剩一小段需要计算的请求
- 被抢占后重新入队的请求
如果强行分成两个调度域,一个请求从 prefill 转 decode 时要跨域;chunked prefill 让"纯 prefill"的请求跨越多轮;prefix caching 让某些请求的"prefill 量"动态缩小;preemption 让请求可能在任意时刻退回 waiting。这些情况组合起来,双调度器的状态切换逻辑会很难维护。
统一调度的本质好处是:用同一个 token budget 框架管理所有请求的推进,不管它处于哪个阶段。
Token Budget:理解 Scheduler 的钥匙
这是 vLLM scheduler 最重要的概念,也是最容易被忽视的一个。
传统批量推理控制的是"一批几个请求"(request count)。vLLM 控制的是"这一轮推进多少个 token"(token budget)。两者差异很大:
| 控制维度 | request count | token budget |
|---|---|---|
| 能反映工作量差异? | 否(1 个 decode ≈ 1 token,1 个长 prefill ≈ 几千 token,count 都算 1) | 是 |
| 能约束 GPU 负载? | 间接、不准确 | 直接约束本轮 compute 和显存访问量 |
| 能处理混合阶段? | 困难 | 天然支持(decode + prefill 共享同一个 budget 池) |
| prefill/decode 折中? | 难表达 | 直接体现(budget 优先给 decode,剩余给 prefill) |
vLLM 文档中的 max_num_batched_tokens 就是这个思想的直接参数。scheduler 每轮不是在挑"多少个请求",而是在一个有限的 token 预算池里,做多请求、多阶段的动态资源分配。
Decode 优先策略:为什么照顾老请求而不是新请求
vLLM 官方优化文档明确描述了 chunked prefill 下的调度策略:优先调度 decode 请求,再用剩余 budget 填 prefill;如果 prefill 放不下,自动切块。
原因很直接。在线聊天里,用户最敏感的体验指标是:
- 是否很快看到第一个 token(TTFT)
- 后续 token 是否连续出现(ITL)
- 中间会不会突然停住
其中 ITL 由 decode 的推进连续性直接决定。如果长 prompt 的 prefill 占据了大量 budget,decode 被压缩,用户会感觉回答在断断续续地输出。
而 prefill 对延迟的影响相对"可缓冲":一个长 prompt 的 prefill 被分成几轮推进,用户多数情况下感知的是稍慢的 TTFT,而不是流式输出过程中的卡顿。
所以 decode 优先的本质是:保护流式交互体验,让已经在进行中的对话不被新进来的大请求打断。这不是说 prefill 不重要,而是在 ITL 和 TTFT 之间,前者对用户体验的即时影响更大。
Chunked Prefill 是调度问题,不是数据处理问题
"把长 prompt 分段喂给模型"听起来像数据预处理,但 chunked prefill 之所以是系统优化,核心在于:切多少、怎么切,是基于当前轮次的剩余 budget 动态决定的,而不是固定分段。
调度器在决定一个 prefill 请求本轮能推进多少时,要知道:
- 当前 decode 请求已经用掉多少 budget
- 剩余 budget 还有多少
- 这个 prefill 的剩余部分有多少 token
- 剩余 budget 够不够装下全部,不够的话装哪一段
这些判断只有 scheduler 能做,因为只有它知道整个系统的当前状态。
Chunked prefill 的引入还改变了请求的状态结构:prefill 不再是"单轮完成",而是"多轮逐步推进",中间每一轮都在和 decode 请求共享同一个 budget 池。这要求 unified scheduler,因为 prefill 和 decode 此时已经深度交织在同一调度循环里。
带来的好处是:长 prompt 不再独占一整轮,compute-bound 的 prefill 和 memory-bound 的 decode 可以在同一轮内混合,GPU 利用率和整体延迟稳定性都有改善。
Preemption:为什么在线推理系统需要"打断并回退"
scheduler 里有 _preempt_request,这意味着 vLLM 不只是"按顺序排队",还可以让已经在 running 状态的请求退回 waiting 队列。
原因是资源在运行时是动态竞争的:KV cache block 被占用和释放、token budget 每轮都在变、新请求不断进来、某些请求因为 prefix cache 或 encoder cache 状态变化而变得更"容易推进"。
如果系统不能抢占,一旦某个请求占住位置,即使当前轮次条件已经不再适合它继续推进(比如 KV block 不够了),它也会持续占用资源,而更合适的请求无法进来。
抢占在推理系统里的代价比训练里小很多。它不涉及大量的上下文切换开销,本质上是:把一个当前不合适继续推进的请求暂时放回 waiting,让更合适的请求先走,等后面条件改善再把它取回来。这是操作系统式资源调度思想在推理场景下的直接应用。
Prefix Caching 的调度含义
Prefix caching 表面上是 KV cache 的事,但缓存命中本身不会自动产生系统收益。只有 scheduler 利用了这个命中,减少了该请求本轮的实际计算量,它才真正变成性能提升。
一个有前缀命中的请求进来时,scheduler 需要判断:
- 哪一部分前缀已经命中 cache(实际需要计算的 token 数大幅减少)
- cache miss 的剩余部分是否需要 chunk
- 这个请求本轮按什么方式进入 budget 分配
- 是否可以因此更快从 waiting 进入 running
这些不是 cache manager 单独能决定的,最终推进决策仍然在 scheduler 侧。
所以 prefix caching 对调度的影响是:它改变了请求的实际可调度工作量。一个有大量前缀命中的请求,消耗的 token budget 可能比表面上的 prompt 长度小很多,scheduler 对这类请求的处理方式也应该相应调整。
多目标权衡:Scheduler 真正的工程难点
调度器同时要在几个相互冲突的目标之间找平衡:
| 目标 | 含义 | 与其他目标的冲突 |
|---|---|---|
| 吞吐 | 单位时间处理更多 token / 请求 | budget 设大有利于吞吐,但可能拉高延迟抖动 |
| TTFT | 新请求快速进入系统 | 多做 prefill 有利于 TTFT,但会挤压 decode 的 ITL |
| ITL | decode 连续出 token,不卡顿 | 优先 decode 有利于 ITL,但新请求可能积压 |
| 显存利用率 | KV cache 不浪费 | 更多活跃请求有利于利用率,但增加资源竞争风险 |
| 系统稳定性 | 不因长 prompt 或资源波动拖崩整体 | chunked prefill 有利于稳定,但增加调度复杂度 |
没有一个参数能让所有指标同时最优。max_num_batched_tokens 设大,吞吐可能上去,但交互延迟可能抖;设小,交互顺滑,但吞吐下降。decode 优先保护了 ITL,但 TTFT 在高负载下会变长。
这是 scheduler 的真正价值所在:不是某个指标拉满,而是根据部署场景在多目标之间做有意识的权衡。
一轮完整的调度循环
把上面的所有机制放在一起,scheduler 每一轮的工作流程如下:
flowchart TD
A["开始新一轮调度"] --> B["检查 token budget 上限\n(max_num_batched_tokens)"]
B --> C["优先处理 running 中的 decode 请求\n分配各自的 decode token"]
C --> D{"剩余 budget 是否充足?"}
D -- "是" --> E["从 waiting 队列选候选 prefill 请求\n检查 prefix cache 命中情况"]
D -- "否" --> I["跳过 prefill,保护 decode"]
E --> F{"prefill token 量 <= 剩余 budget?"}
F -- "是,整块装入" --> G["该请求进入本轮 batch"]
F -- "否,需要 chunk" --> H["截取剩余 budget 可装的部分\n本轮只推进这一段"]
H --> G
G --> J{"KV cache 有足够 free block?"}
J -- "是" --> K["将请求加入本轮 batch"]
J -- "否" --> L["触发 preemption\n将低优先级 running 请求放回 waiting"]
L --> K
K --> M["提交 batch 给 Worker 执行"]
M --> N["Sampler 采样输出 token"]
N --> O["更新请求状态\n更新 KV cache 占用\n释放已完成请求的 block"]
O --> P{"还有待处理请求?"}
P -- "是" --> A
P -- "否" --> Q["等待新请求进入"]
这个循环不会停。vLLM 是一个持续滚动推进请求的系统,不是"处理一批、返回结果、等下一批"的模式。
结语
vLLM scheduler 的设计核心可以用三句话概括:
- 调度的对象不是"批次",而是请求的生命周期推进——每个请求在 waiting / running / preempted / finished 之间流转,scheduler 负责每一轮的状态推进决策。
- 控制变量不是请求数,而是 token budget——这让不同阶段(prefill / decode)、不同长度的请求可以在同一个框架下被统一管理。
- 统一调度的意义是把 chunked prefill、decode 优先、preemption、prefix caching 这些看似分散的机制,整合进一套动态资源分配框架里——它们都是在同一个 token budget 池上操作的不同策略。
这种设计的局限在于:多目标权衡没有通用最优解,max_num_batched_tokens 等关键参数需要根据具体部署场景(交互式服务 vs 批量推理、短 prompt vs 长 prompt 为主)进行调整,默认值不一定适合所有场景。
参考资料
- vLLM V1 Scheduler 源码路径:
vllm.v1.core.sched.scheduler.Scheduler(来源:vLLM Engine Args 官方文档) - vLLM 官方文档:Chunked Prefill 与 Optimization and Tuning
- vLLM 官方 README:Continuous Batching 与 Scheduler 设计说明
