vLLM 请求调度完整指南:高峰期如何把 GPU 压在"刚好满载"
【免费下载链接】vllmA high-throughput and memory-efficient inference and serving engine for LLMs项目地址: https://gitcode.com/GitHub_Trending/vl/vllm
本文带你深入 vLLM 的请求调度系统:看一个请求如何从排队走到出结果,连续批处理(Continuous Batching)与分块预填充(Chunked Prefill)如何平衡吞吐与延迟,KV Cache(KV 缓存,注意力计算中保存的历史键值向量)的块如何分配与驱逐,以及显存挤爆时的交换与抢占机制,最后附调参速查。
一次请求的 30 秒旅程
想象周五上午十点,你的推理服务同时涌入 200 个请求。有人问一个一句话的问题,有人贴进十万字的合同。调度器(Scheduler,每个推理步骤决定"这批算谁"的核心组件)就站在这条流量洪水的总闸口,它的工作类似机场塔台:谁先登机、谁靠边等、谁被请下飞机,都由它一拍板。
请求在队列里排号
请求进入引擎后,第一件事是进入等待队列(waiting queue)。vLLM 的 V1 引擎提供两种排队策略,见 vllm/v1/core/sched/request_queue.py:
- 先进先出(FCFS):默认策略,按到达时间排号,公平且可预测
- 优先级队列:基于堆实现的优先级队列,priority 数值小的优先;同优先级回到"先来先服务"
被调度器选中后,请求的状态从 WAITING 翻到 RUNNING;如果中途被抢占,则进入 PREEMPTED 状态回到等待队列,等资源再战。经典的 V0 版本还有 SWAPPED 状态(KV Cache 被换到 CPU 内存),V1 把这条路径从核心调度循环中剥离,交由 KV 卸载机制处理。
状态定义可以直接在 vllm/v1/request.py 的RequestStatus枚举里看到:WAITING、RUNNING、PREEMPTED 之外,完成态被细分成 FINISHED_STOPPED(遇到停止符)、FINISHED_LENGTH_CAPPED(达到最大长度)、FINISHED_ABORTED(用户取消)等,方便上层精确统计"为什么结束"。
预填充与解码:两次心跳
请求被选中后经历两个计算阶段:
- 预填充(Prefill):一次性处理完整 prompt。计算密集,像复印整本文档,GPU 利用率拉满
- 解码(Decode):逐 token 生成。每次只算一步,但要把整个 KV Cache 读进显存,访存密集,像逐字朗读
调度器每步schedule()做的事,本质上就是回答三个问题:哪些 RUNNING 请求继续跑、等待队列里哪些新请求能挤进来、挤不进来时踢走谁。调度入口在 vllm/v1/core/sched/scheduler.py,核心逻辑全部围绕"这一步的 token 预算还剩多少、KV 块还剩多少"展开。
让 GPU 始终刚好满载
连续批处理:航班随到随登
传统静态批处理像火车:八点发车,八点没到站就别想上车。连续批处理则像滚装船装卸——某条船完成卸货,新船立即顶上去,批次永不"发车等待"。
vLLM 的实现里,批次不是一次性圈定的:每个推理步骤,调度器都会重新审视"RUNNING 里谁完成了、waiting 里谁能补位"。请求 A 在第 37 步生成完最后一个 token,请求 D 在第 38 步就拿到它的行位和刚释放的 KV 块。这正是下图里 Block Table(块表,记录每个请求的 token 存在哪些 KV 块中)逐帧变化的含义:D 加入、A 离开,行列始终排得满满的。
分块预填充:长文档分段落印
真正的大麻烦是预填充:一篇 3 万 token 的合同如果整块塞进 GPU,所有正在解码的请求会跟着卡十几秒。分块预填充(Chunked Prefill,把长 prompt 切成多段、每步只算一段)就是为此设计的"分段落印"。
调度器每步的预算由max_num_batched_tokens封顶:这一步所有请求合计最多处理这么多 token,预填充的分片和解码的单 token 竞争同一份额度。一个 3 万 token 的 prompt 会被拆成若干分片,穿插在解码步骤之间消化。
相关的两个进阶旋钮在 vllm/config/scheduler.py:
max_num_partial_prefills:允许同时"半程预填充"的请求数,默认 1,调大可让多个长文档并行消化long_prefill_token_threshold:长预填充的 token 阈值,超过才按"长请求"管理,避免短请求也被分片拖累
一句话总结权衡:批越大吞吐越高,但单请求延迟越高;分块预填充用"长文慢咽"换来了"解码不掉线",是延迟敏感服务(聊天、Agent 工具调用)的默认姿势。
KV Cache 的经营学
块大小与 block_size 的权衡
KV Cache 是 LLM 推理的显存大头:每个 token 的每层注意力都要存一份 K 向量和 V 向量,长上下文下轻松吃掉几十 GB。vLLM 的管理思路类似酒店经营:不卖整层楼,只卖房块(Block)——每个块固定容纳block_size个 token(常见 16),分配、复用、回收都按块进行,请求的块表只记"你的 token 在哪些块"。
块大小是个经典权衡:
- 块小:装填更细碎、内存更省,但块表更长、管理开销更高
- 块大:管理省,但每个请求的最后一块平均浪费一半容量,且前缀缓存的命中粒度变粗
多组 KV Cache 张量(如混合注意力模型)如何铺在块上,可以看 vllm/v1/core/kv_cache_manager.py 的设计文档配图:
水印机制:永远留几个空房
如果 GPU 块被分配得一滴不剩,下一步新请求一来就可能触发连锁驱逐甚至抢占。vLLM 用水印(watermark,预留的缓冲块数)留后路:watermark_blocks = watermark × 总块数(见 vllm/v1/core/kv_cache_manager.py),并且只在准入等待/被抢占请求时生效——正在解码的请求不受影响,只有"新客人进门"才需要看到预留房。
这个设计很讲究:水印太激进,显存常年空转;水印为零,高峰期抢占频发、延迟抖动。它是"吞吐"和"稳态延迟"之间最便宜的一个旋钮。
前缀缓存与 LRU 驱逐:谁先让位
多个请求共享同一段系统提示词时,**前缀缓存(Prefix Caching,相同前缀的 KV 块只算一次、被多个请求共享)**能让第二个请求的预填充近乎免费。引用计数归零的块不会立刻消失,而是带着前缀哈希标记进入空闲队列,等待被后来的相同前缀"认领"。
空间不够时谁先让位?看 vllm/v1/core/block_pool.py 的空闲块队列:
- 没有缓存哈希的块(纯私有块)排在队首,最先被复用
- 有缓存哈希的块排在队尾,形成LRU(最近最少使用,最久没被访问的先淘汰)顺序,命中时再刷新位置
也就是说驱逐故事线是:先用没人要的私有块,再按"最久没被读过"的顺序拆缓存块。缓存命中率直接决定这个顺序值多少钱——前缀重复度高的工作负载(RAG、多轮对话)能从这套机制里拿到最大红利。
挤爆之后怎么办
把 KV 换到 CPU 内存:Swap
显存实在不够时,一个选择是把某些请求的 KV 块从 GPU换出(Swap Out)到 CPU 内存:请求暂时停跑,显存腾给急事;资源空闲后再换回来继续。代价是 PCIe 搬运——换出 1GB KV 大约相当于白等一次小模型推理,所以它只适合"CPU 内存够大、请求还要续跑很久"的场景。
抢占的两种模式怎么选
**抢占(Preemption,显存不足时强制中断某个请求、释放其资源)**是调度器的最后手段。经典 vLLM 提供两种模式:
- SWAP 模式:被抢占者的 KV 换到 CPU,回来时零重算,延迟恢复快;要求 CPU 内存充足,且频繁抢占会让 CPU-GPU 带宽成为新瓶颈
- RECOMPUTE 模式:直接丢弃 KV,被抢占者回到 WAITING 重新预填充;不占额外内存,最坏情况多算一遍已完成的 token
V1 引擎的_preempt_request(vllm/v1/core/sched/scheduler.py)只保留重算这一条路径,而且会挑"最晚进入"的 RUNNING 请求下手,尽量让先来者少受牵连。选型依据可以这样记:
- 长上下文、单请求价值高、CPU 内存充裕 → SWAP 类机制
- 通用在线服务、请求短而杂 → RECOMPUTE,简单且无二次瓶颈
调参速查
两个典型场景的配置建议
场景一:高并发短请求(问答、分类、Agent 工具调用)——目标是高吞吐 + 稳延迟:
# 高并发短请求:放宽吞吐,压低尾延迟 SchedulerConfig( max_num_batched_tokens=8192, # 每步 token 预算拉大 max_num_seqs=256, # 并发序列数放宽 max_num_partial_prefills=1, # 长请求串行消化 ) CacheConfig( block_size=16, watermark=0.02, # 少量预留,防准入抖动 )场景二:长文本生成(代码、报告、长文档问答)——目标是别让长请求互相踩踏:
# 长文本生成:限流预填充,给解码留足空间 SchedulerConfig( max_num_batched_tokens=4096, # 压缩单步预算 max_num_seqs=64, # 压低并发 max_num_partial_prefills=2, # 允许多个长 prompt 并行分片 long_prefill_token_threshold=2048, # 定义"长请求" ) CacheConfig( watermark=0.05, # 预留更厚,减少长请求中途被抢占 )| 参数 | 管什么 | 往大了调 | 往小了调 |
|---|---|---|---|
max_num_batched_tokens | 单步 token 预算 | 吞吐升、预填充更快,但单步延迟变长 | 延迟更稳,吞吐下降 |
max_num_seqs | 单步最多序列数 | 并发更高,KV 竞争加剧 | 显存压力小,排队变长 |
block_size | 每块 token 数 | 管理开销小,浪费与缓存粒度变粗 | 更省显存,管理开销上升 |
watermark | 准入预留比例 | 抢占更少,可用显存变少 | 吞吐更高,尾延迟抖动加大 |
max_num_partial_prefills | 并发长预填充数 | 多长文档并行消化 | 单长请求独占算力 |
建议盯住的监控指标
- GPU KV 块水位:空闲块占比持续贴地,说明要调小预算或加卡
- 前缀缓存命中率:低于预期检查请求前缀是否稳定、块大小是否过粗
- 抢占次数:偶发正常,持续上涨说明
watermark或并发参数过激 - 排队时长(waiting 队列深度):突增往往先于延迟告警出现
- 预填充耗时分布:判断分块预填充是否在有效兜底长请求
延伸阅读:调度主循环 vllm/v1/core/sched/scheduler.py、块池与驱逐 vllm/v1/core/block_pool.py、缓存配置 vllm/config/cache.py。
一次请求的旅程里,调度器做的所有事归结起来就一句:在 token 预算和 KV 块预算两条硬约束下,让 GPU 每一毫秒都有活干,又别让任何一条请求被饿死。从固定批次的"火车"到随到随登的"滚装船",vLLM 的调度哲学已经被验证过无数次;下一步值得期待的,是调度器开始结合负载预测做主动式资源规划,把"挤爆后再抢救"变成"提前排好班"。
【免费下载链接】vllmA high-throughput and memory-efficient inference and serving engine for LLMs项目地址: https://gitcode.com/GitHub_Trending/vl/vllm
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考