vLLM 的 token 预算还有余量,为什么请求仍被抢占?
一批语音 Agent 正在持续输出,旁边又来了几个携带长检索材料的请求。服务没有报 OOM,GPU 也还在工作,但用户听到的停顿变长了。此时把 max_num_batched_tokens 调大,看起来能让长输入更快处理完;问题是,如果真正先耗尽的是 KV Cache,可调度的 token 更多,并不意味着请求就能继续执行。
这个冲突可以从 vLLM 的调度代码里直接看见:"本轮允许计算多少 token"与"这些 token 所需的状态能否分配成功",是两个不同的判断。 前一个通过,后一个仍可能失败,甚至触发对运行中请求的抢占。
下面以 2026-09-08 核查、2026-09-12 追加固定 v0.28.0 源码核验的文档与 Scheduler 实现为依据,限定普通文本自回归生成、单个 Engine Core 的讨论范围。数字均为解释机制的假设,不是压测结果;多模态编码预算、推测解码、混合注意力和异步执行还会增加约束,不套用这里的简化步数。
还剩 1,984 个 token,只能证明第一件事
先假设没有前缀命中,64 个请求各准备推进一次普通 decode,一条新请求还剩 8,192 个输入 token 待处理。本轮总 token 预算设为 2,048;所有请求均满足其他调度条件,decode 和 prefill 都能拿到所需 KV 块。
按这个简化模型,64 个 decode 占用 64 个预算,prefill 最多使用剩余 1,984 个。长输入因此需要分块处理,而不是一次把 8,192 个全部放进去。
| 同一轮的假设 | decode 计划 | 留给 prefill 的预算上限 | 能据此推出什么 |
|---|---|---|---|
| 总预算 2,048 | 64 token | 1,984 token | 这轮最多安排多少输入计算 |
| 总预算 4,096 | 64 token | 4,032 token | 可以尝试安排更大的输入块 |
这个表没有给出毫秒数。token 计数不是 GPU 时间配额:序列长度、内核、批次形状和通信都会改变一次执行耗时。把预算翻倍,既不能推出首 token 延迟减半,也不能推出流式间隔保持不变。
官方的 Chunked Prefill 说明提供了这个算例的策略依据:在适用的 V1 配置下优先调度 decode,再用剩余预算处理 prefill;小预算与大预算分别影响输出间隔和首 token 等待的取舍。具体收益必须按负载验证。Chunked Prefill 与调优说明
对语音 Agent,这个取舍有直接含义:长材料的首字快一些,不代表正在播放的回答更连贯。应该把长输入请求的 TTFT(收到首 token 的等待)与已有流式请求的 ITL(相邻 token 间隔)分别观察。把两类请求合成一个平均延迟,会把受益者和受损者抵消掉。
然而,上表还有一句容易被省略的前提:这些请求都拿得到 KV 块。

有计算预算,仍可能拿不到下一块 KV
KV Cache 保存注意力计算需要复用的历史状态。单轮 token 预算约束本轮新增计算,KV 块则承载跨轮保留的状态。因此,正在运行的请求越积越长,即使每轮各推进很少 token,后续也可能遇到状态空间不足。
在本次核查的 Scheduler 源码展示中,运行请求先计算受预算限制的 num_new_tokens,随后调用 kv_cache_manager.allocate_slots。分配失败会进入抢占处理;若最终仍无新块,当前请求不能被加入本轮计划。等待请求也有自己的分配检查,不能把两条路径混写成"每个新请求分配失败都会抢占别人"。Scheduler 的预算、分配与抢占分支
现在改变算例的一个条件:经过已有请求的必要分配后,长 prefill 本次需要新增 124 个块,可供分配的只有 100 个。这里的块数是独立假设,不从 1,984 token 换算;真实块大小、已有尾块和缓存布局需要读取实际配置。即便 token 预算还剩 1,984,该分配也不满足要求。
继续增大 token 预算,改变的是"打算算多少",没有凭空增加这 24 个缺口。相反,更大的计划可能要求更多新块。问题因而从"是否让长输入多跑一段",变成"哪些请求可以同时保留足够状态"。
抢占也不是 OOM 的同义词。vLLM 可以释放被抢占请求的状态,待资源允许时重算;V1 调优文档把 RECOMPUTE 作为默认抢占方式,并明确指出重算会损害端到端延迟。抢占与重算说明
所以,"没有进程崩溃"只证明服务避开了一种失败方式。对于要连续播报的请求,暂停和恢复带来的长间隔已经可能违反体验目标。恢复所做的工作也不能全部当成新输出产能。

从一次分配失败,走完 B 请求的暂停与恢复
前面的 124 块需求与 100 块可用量,只能展示分配缺口。下面换一个独立算例,追踪已经在运行的请求被抢占后发生什么。这里固定普通同步调度、FCFS 策略、单一全注意力 KV 组、每块 16 个 token;没有推测解码、多模态、KV 连接器或外部卸载,也不考虑前缀命中。块数和请求长度是教学假设,不是特定模型的内存测量。
假设 KV 池只有 16 个可用块,没有共享引用。当前运行队列为 [A, B],A 在前,B 在后;两者各占 8 块,池里已经没有可供分配的新块。本轮 token 预算仍有余量,其他调度条件满足。
A 的请求记录里已有 129 个 token,其中 128 个已计算,下一个普通 decode 要处理余下的 1 个 token。以每块 16 个位置计算,128 个位置占 8 块,推进到 129 个位置需要第 9 块。A 只申请 1 个新增块,仍会失败,因为当前可分配量是 0。造成分配失败的请求是 A,不代表被选中暂停的也一定是 A。
谁被拿出运行队列,由当前调度策略决定
固定版本的运行请求分支在 allocate_slots() 返回失败后进入抢占处理。使用优先级策略时,代码按优先级与到达时间选择对象;本例固定 FCFS,走的是从 self.running 尾部取对象的分支,因此选中 B。
应用不应该从这段代码推出"总是抢最长的请求"或"抢占最占显存的请求"。在本例中 B 被选中,只因为它位于该分支使用的队列尾部。若策略、队列次序或其他条件不同,对象也可能不同;若最终只能抢占当前正尝试调度的请求,代码还有停止为它排入本轮计划的判断。
对 B 执行 _preempt_request() 后,源码释放请求的块状态,将状态设为 PREEMPTED,将 num_computed_tokens 置为 0,并把请求放回 waiting 队列。本例无共享引用、无在途异步执行,假设 B 的 8 块都因此成为可再分配容量。A 的分配随后重试,可以取得其中 1 块:A 占 9 块,剩余可分配量为 7,B 等待恢复。
这是同一轮内部的资源转移,不是 GPU 内存上限被调大了。并且这份调度实现只有在本轮未发生抢占时才进入后面的等待请求调度阶段,所以不能把 B 画成"释放完后同一轮立刻又恢复"。运行请求分配、抢占选择与等待分支
归零的是已计算进度,不是已经发给用户的文本
为了追踪 B,假设它有 96 个输入 token,已经生成 32 个输出 token。其请求保存的 token 序列长度为 128,抢占前 num_computed_tokens 为 127:最后一个刚生成的 token 尚未作为下一步输入完成计算,这与普通自回归推进相符。
抢占把这个计算计数重置为 0,但没有把请求中的输入与输出 token 列表一并清空。Request 分别保存输出 token 和完整 token 序列,新增输出会追加到两者中。因此,这里的恢复是依据已有 token 序列重新建立模型状态,不是让模型从头重新抽样并把前 32 个输出再发一次,更不是删除用户已经收到的文字。抢占状态变化 · 请求中的 token 序列
假设经过若干轮后 A 正常结束、其块可回收,B 又满足所有调度条件。等待路径会重新检查缓存命中和所需块;本例明确无前缀命中,因而没有已缓存的历史计算可以抵扣。分配成功后,B 被识别为恢复请求,重新加入 running,状态改回 RUNNING。这说明"开始恢复"与"已经产出下一个新 token"仍是两个时点。
再假设恢复期间,每轮其他工作之后能留给 B 的计算预算为 64,允许分块,且必要 KV 块均可成功分配。下面把检查点放在对应模型执行完成后,避免将 Scheduler 提交工作时更新的计数误当作 GPU 已完成的墙钟观测。
| B 的检查点(假设轨迹,非真实 trace) | 本例计算进度 | 发生的事 | 用户新收到的内容 |
|---|---|---|---|
| 抢占前 | 127 | 已保存 96 个输入与 32 个输出 token,占 8 块 | 前 32 个输出已经形成;是否到客户端需实际输出记录确认 |
| 抢占后等待 | 0 | 块释放,状态 PREEMPTED,已有 token 序列仍在 | 暂无下一段新输出 |
| 恢复第 1 段计算完成 | 64 | 从保存的序列重建前 64 个位置 | 这是恢复计算,不是新生成 64 个回答 token |
| 恢复第 2 段计算完成 | 128 | 处理其余 64 个已有位置,赶上保存的序列末尾 | 才具备继续生成后续新输出的条件 |
| 后续输出被交付 | 继续推进 | 基于重建状态继续自回归生成 | 本例期望从后续新输出接续,不重复交付已发送的前缀 |
128 个恢复计算位置也不能全部称作"重复了 128 个已经算过的 token"。抢占前只完成了 127 个位置,最后 1 个在本来下一次 decode 中也需要计算。这个例子里,恢复代价包括重新处理前 127 个历史位置,而不是新增了 128 个有效输出。表格用计数说明机制,不据此计算恢复需要多少毫秒。
如果有可复用的前缀命中,等待路径可据缓存结果恢复一部分已计算进度,重新计算的范围会缩小;"每次抢占都完整重算上述 128 个位置"只在本例无命中的前提下成立。异步调度还需要处理在途输出及计数,不能直接照搬这个同步检查点表。等待请求的缓存检查与恢复分类
怎样从记录里确认,真正付出恢复代价的是 B
对这条轨迹,最少需要把 A 的分配失败、B 被选为抢占对象、B 的重新调度,以及 B 的相邻输出时间关联起来。只记录"发生 1 次抢占",无法知道 A 继续推进时,哪条流替它让出了状态。只看 B 的 ITL 尖峰,也不能排除通信或输出队列造成的停顿。
可把本例预期的对应关系写入一次待执行验证:A 申请新增块失败→同轮选择 B→B 进入 PREEMPTED/waiting→后续以 resumed 请求进入计划→历史位置重新执行→B 的下一段新输出被交付。源码已有的请求状态和事件只是采样入口;字段是否暴露在当前日志、事件记录是否开启,需要以部署配置为准,本文没有伪造一组可以直接搜索的日志行。
最后再回到等待中的新请求 C:如果轮到等待分支时,C 需要的块仍分配失败,该分支会停止这次等待调度处理。它不是上面"运行中 A 分配失败,选 B 抢占"的同一段循环。新请求带来总体容量压力,和"每次新请求失败都会主动抢走一条运行流",是两种不同的因果描述。辨认它们,要先确认失败发生在哪条调度路径。
缓存里有旧内容,不等于没有可用块
排障时还要避开另一种误读:看到缓存保存了很多前缀,就把所有缓存内容都当成不可回收的占用。
vLLM 的前缀缓存设计区分了块的引用状态和可分配队列。请求结束后,没有其他请求引用的块可以进入 free queue;块里可能仍保留可复用内容,后续分配也可以驱逐这些缓存内容。正在被请求引用的块,则不能仅因为"比较旧"就按同样方式收回。前缀缓存的分配、驱逐与释放
这使同样的"缓存很多"至少有两种不同解释:一类是仍被活跃请求使用的状态,另一类是可以回收、但暂时保留以争取后续命中的缓存。它们对下一次分配的限制不同。
因此,上节假设的"100 个可分配块"必须已经包含当前允许驱逐并复用的块,不能只数从未写入数据的空白块。反过来,也不能把所有历史缓存一律算作可释放容量。共享前缀还意味着,一个请求结束,不保证其引用过的所有块都能回收。
读者在仪表盘上看到的 cache usage 百分比,也需要先确认当前版本的定义,不能直接替代上述引用关系。若要证明某次停顿来自分配失败,应将该次分配或抢占事件、运行请求状态和客户端停顿对齐;单张显存截图不足以完成归因。

沿失败分支决定调哪个参数
Scheduler 决定本轮工作,KV 管理负责状态分配,Executor 将执行任务交给 Worker。官方架构说明中,Engine Core 负责调度和协调,GPU Worker 执行模型前向;这给出了查问题的顺序:先确认工作有没有进入执行计划,再追执行为何慢。V1 进程与执行职责
如果请求没有进入本轮计划,直接把排查起点放在 NCCL 或增加执行进程,并不能解释调度拒绝。若已安排的模型执行本身变慢,才沿同一时间段的 Worker、通信和 GPU trace 继续定位。客户端流式停顿也可能发生在执行之后,因而还要保留服务端输出时间与客户端接收时间;本文没有把所有 ITL 尖峰归因于 Scheduler。
将前面的两个条件分开,可以形成一张实际可填写的对照表:
| 观察组合 | 优先验证的解释 | 一次只改变的条件 | 改动后必须同时保留的指标 |
|---|---|---|---|
| 无抢占证据,长 prefill 混入时输出间隔变差 | 单轮执行混合造成延迟取舍 | 调小 token 预算,复放相同请求流 | 两类请求的 TTFT、ITL,完成率与排队变化 |
| 分配失败或抢占增长,并与停顿对齐 | 活跃状态需求超过可分配容量 | 降低同时运行序列上限,或限制输入/输出长度,分别试验 | 抢占事件、等待时间、完成率,不能只看 GPU 忙碌程度 |
| 已调度的执行明显变慢,无对应分配异常 | 执行或通信路径需要进一步定位 | 保持调度配置,先采执行 trace | Worker 时间、通信时间以及对应请求时间线 |
第二行的动作也有代价:减少同时运行的序列,可能把内部重算变成入口或等待队列中的排队。如果实际到达流未变,队列持续增长,就不能把抢占下降宣称为服务能力提升。缩短输入或输出还改变了业务质量,必须记录哪些请求被截断或拒绝。
复现时可先固定模型、精度、并行布局、缓存设置和版本,用同一组短流式请求作为基线,再以固定时序插入长输入。保留两类请求的长度分布、实际到达时间、完成与失败数量,分别计算延迟分位数;随后只改 token 预算,或只改运行序列上限。不要同时改两项,否则无法判断是单轮计算变短,还是状态竞争减少。
vLLM 的生产指标可以作为采样入口,但聚合指标不自动提供单请求因果链。需要逐请求定位时,补齐请求 ID 和事件时间;缺少这些记录就把原因留作待验证假设。生产指标入口
对开头的服务,下一步该调什么,最终取决于一个具体事实:长请求是已经获得状态、只是在单轮执行里拖慢了其他请求,还是连所需状态都未分配成功。前者要比较计算混合的取舍,后者要处理活跃状态容量。把这两种情况分清,调参才有可验证的方向。 