vLLM 请求调度完全指南:高并发下如何接住几百个并发请求?
【免费下载链接】vllmA high-throughput and memory-efficient inference and serving engine for LLMs项目地址: https://gitcode.com/GitHub_Trending/vl/vllm
GPU 利用率钉在 95%,新请求的首 token 却要等 8 秒。vLLM 的请求调度正是为解决这类高并发痛点而生:用连续批处理做负载均衡、用 KV Cache 块做资源分配、靠抢占在内存吃紧时兜底。下面从一个请求的完整旅程拆解。
一屏看懂 vLLM 调度系统的全貌
把 vLLM 想象成一家只有一口灶(GPU)的餐厅:调度器是传菜员,每来一个请求就像一张点单;它每“步”看一眼预算还剩多少,决定哪些菜立刻下锅、哪些先记号。而每个请求占用的 KV Cache 块 🅿️,就像车位——车(请求)走不了一定能立刻腾出车位,车在就得占着。
调度器每个引擎步骤只做一件事:在“本步最多算多少 token”的预算内,把 waiting 和 running 两个队列里的请求装进一个批。V1 引擎中不再有独立的 swapped 队列,等待、运行、被抢占三条线全部收敛到这条主线上。宏观架构可参考下图:
跟踪一个请求:从进队到被抢占的完整旅程
请求生命周期只有三个主干状态(定义在vllm/v1/request.py的RequestStatus):
- 到达 → 排队:请求进入 waiting 队列。队列策略由
policy决定:fcfs先到先服务,priority按优先级数值小的先走(vllm/config/scheduler.py)。这就是第一层负载均衡——队头顺序决定了谁先拿到 GPU。 - 排队 → 预填充:调度器先遍历 running 队列保证老请求不断档,再看 waiting 队列。新请求要通过两道闸门:本步剩余令牌预算、KV Cache 剩余块数。通过后才分配块、进入 running。
- 预填充 → 解码:长 prompt 不会一口气算完,而是被切成多段(分块预填充),每段和一批解码请求混在同一个 forward 里。
- 完成或被抢占:满足停止条件就释放块、结束;若后续请求挤不进来了,调度器会挑 running 里的请求踢回 waiting,清空它的 KV 块并重置进度,腾地给新请求。
源码入口在 vllm/v1/core/sched/scheduler.py,其中schedule()方法就是上面这条线的实现。
三大核心机制逐个讲清
连续批处理与分块预填充:长 prompt 为什么不再卡死短请求
一句话类比:传菜员不盯着“一桌菜齐了才端”,而是“每口灶空出来就塞下一道菜”。连续批处理(Continuous Batching)让批次不再是固定名单——每步结束有请求退场,新请求立刻补位;分块预填充(Chunked Prefill)则把长 prompt 切成小块,和别人的解码一起跑,避免 2 万 token 的 prompt 独占一整个前向。
# 每步调度伪逻辑(自拟概括,见 scheduler.py 真实实现) token_budget = max_num_batched_tokens for req in running + waiting: # 先保 running,再填 waiting n = min(req.remaining_tokens, token_budget) if kv_manager.can_allocate(req, n): schedule(req, n) token_budget -= n这段循环就是“预算装箱”:max_num_batched_tokens是每步的总容量,谁排在前面谁先装。
调它会发生什么:调大max_num_batched_tokens,吞吐上升、单步延迟略增;调小则短请求 TTFT(首 token 延迟)更稳。默认已开启分块预填充,long_prefill_token_threshold可给单个长请求设每步上限,防止它一次吃满整锅预算。
KV Cache 块管理与水印:怎么在内存边缘稳住不抖动
一句话类比:KV Cache 是停车场,车位按 16 个 token 一格编号,请求的车停哪几格由块表记录;水印(Watermark)就是“永远空出最后几格,不让停车场贴满”。
块分配逻辑在 vllm/v1/core/kv_cache_manager.py。水印的关键细节:它只对 waiting/preempted 请求“放行”时起作用,已在 running 的解码请求不受影响——保护的是“新客入场”,不是“老客续住”。
# 水印准入检查伪逻辑(自拟概括) free = kv_manager.num_free_blocks() need = blocks_for(req) if req in (waiting, preempted): ok = free - need >= watermark_blocks # 留余量 else: # running 请求 ok = free >= needwatermark是 KV 块总数的比例,默认 0.0(关闭)。
调它会发生什么:内存偏紧时开watermark=0.01~0.05,能显著减少“刚放行就驱逐、接着连锁抢占”的抖动;开到 0.1 以上则空闲块被长期占用,吞吐明显下降。
抢占怎么选:V1 为什么用“重算”而不是 Swap
一句话类比:Swapping 像把车临时挪去楼上的机械车库(CPU 内存),回来再挪回楼下;而 vLLM V1 的选择是“清场重停”——直接释放车位,请求回队首,等下次进场时重新预填充。
V1 的抢占路径很短(伪代码概括自_preempt_request):
def _preempt(req): # 自拟伪代码 free_blocks(req) # 释放全部 KV Cache 块 req.num_computed_tokens = 0 # 进度清零 req.status = PREEMPTED # 放回 waiting 队首为什么放弃 Swap?GPU↔CPU 的块搬运带宽是瓶颈,长序列挪一次的成本常常比重算还高;而且 V1 抢占后重新预填充时能命中前缀缓存,实际重算量远小于表面进度(前缀缓存见 docs/features/automatic_prefix_caching.md)。V0 时代的双队列 + swap 机制已被这条更简单的路径取代。
调它会发生什么:抢占次数可看监控里的preemptions指标。频繁抢占说明 KV 容量或准入太激进——优先加大 KV 池、开水印,而不是加机器;被抢占请求回到 waiting 队首,所以它恢复时不会被新请求插队。
高并发下的关键调优参数速查表
| 参数 | 含义 | 默认值 | 建议范围 |
|---|---|---|---|
max_num_batched_tokens | 每步前向最多算的 token 数(批处理容量) | 2048 | 2048–16384 |
max_num_seqs | 单步最多并发的序列数 | 128 | 64–512 |
block_size | 一个 KV 块容纳的 token 数 | 16 | 16–64 |
watermark | 准入时预留的 KV 块比例 | 0.0 | 0–0.1 |
long_prefill_token_threshold | 单步内单个长请求的预填充上限 | 0(关闭) | 512–2048 |
policy | 队列策略:fcfs 或 priority | fcfs | 按业务 |
max_num_queued_reqs | 在途请求上限,超限直接 503 拒绝 | 不限 | 约 DP×max_num_seqs |
enable_chunked_prefill | 是否开启分块预填充 | True | 保持 True |
两组场景化配置:
# 场景一:高并发短请求(问答类) LLM(model=m, max_num_batched_tokens=8192, max_num_seqs=256, policy="fcfs") # 大预算装满批,fcfs 保证公平 # 场景二:长序列生成(文档类) LLM(model=m, max_num_batched_tokens=4096, max_num_seqs=64, long_prefill_token_threshold=512, watermark=0.02) # 限制单请求每步占用,水印防驱逐抖动短请求场景把容量拉满换吞吐;长序列场景收窄单请求每步占用并开水印,用一点容量换稳定。
结语
vLLM 请求调度的本质是两道预算(令牌数、KV 块)加一条兜底线(抢占重算),把负载均衡和资源分配拆到了每一步、每个块。先盯住max_num_batched_tokens与 KV 容量两个旋钮,再看抢占指标,绝大多数高并发问题都能定位到参数层。更多设计细节见 docs/design/arch_overview.md 与 docs/usage/faq.md。
【免费下载链接】vllmA high-throughput and memory-efficient inference and serving engine for LLMs项目地址: https://gitcode.com/GitHub_Trending/vl/vllm
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考