GPU 饥饿:某个租户的推理提示词如何导致你的共享推理端点停滞
你的仪表盘显示 GPU 状态健康。利用率维持在 80% 左右,每秒生成的 token 吞吐量看起来很正常,冷启动很少见,而且模型也是你要求的那个。然而,你的报警器响了,因为 p99 延迟翻了三倍,少数用户遇到了超时,支持工单都在描述同一件事:“应用冻结了 20 秒,然后又恢复了。” 你调取了一个追踪(trace),发现一个毫不相关的客户发送的 28,000 个 token 的推理请求,正与每一个停滞的调用处在同一个批次(batch)中。某个租户的深度思考提示词刚刚抢走了其他所有人的机会。
这就是队头阻塞(head-of-line blocking),它是推理模型进入流量组合后,破坏共享 LLM 推理的典型故障模式。这种模式并不新鲜 —— 存储系统和网络栈已经与之斗争了几十年 —— 但由于连续批次(continuous batching)和 KV 缓存固定(KV-cache pinning)的工作方式,它在 GPU 上呈现出一种特定的形态。大多数团队针对平均负载进行设计,却太晚才发现,一旦请求大小不再相似,“共享推理更便宜”就不再成立了。
使这个问 题如此微妙的原因在于,你的标准指标中没有一个能显示出问题。GPU 利用率很高,这看起来是件好事。整个端点的每秒生成 token 数也很可观。没有单个请求是“慢”的 —— 推理提示词完成所需的时间与它单独运行的时间一致。遭受苦难的是那些在巨头之后等待的短请求,而你的指标从未按请求类别进行拆分,无法向你展示这一点。
为什么连续批次为了吞吐量而牺牲了队头阻塞
现代 LLM 服务器(如 vLLM、TGI 和 SGLang)运行一个调度程序循环,该循环选取一组工作序列,在 GPU 上对整个批次运行一次 token 生成迭代,然后立即重新评估。完成的序列离开批次,新的序列加入,GPU 永远不会因为等待最慢的成员完成而闲置。这被称为连续批次或迭代级批次(iteration-level batching),也是自 2023 年以来推理吞吐量增长如此之快的最大单一原因。
问题在于,吞吐量不等于延迟。连续批次假设批次中的序列具有大致相当的形状。当批次中的一个序列具有必须在开始生成之前进行预填充(prefill)的 30,000 个 token 的提示词时,该批次中的所有其他序列都会等待该预填充完成。预填充阶段是计算受限的,一次可能消耗整个 GPU 数百毫秒 —— 这段时间足以在任何人收到响应 token 之前耗尽你的 p99 预算。
推理模型从两个方面加剧了这一问题。它们的提示词往往很长,因为用户会粘贴代码、文档或之前的对话轮次。它们的输出也很长 —— 几千个 token 的思维链(chain-of-thought)追踪是很正常的。单个推理请求占用的 KV 缓存槽位和调度程序注意力,比典型的聊天补全长一个数量级。将其与一系列短分类调用分在一个批次中,你会得到两者的最坏情况:短调用被预填充阻塞,而长调用保持其 KV 缓存固定,导致没有任何东西可以干净利落地抢占它。
没人观察的诊断信号
遇到此问题的团队首先会注意到用户报告的卡顿。当他们打开仪表盘时,平均值已经平滑了峰值。需要观察的信号不是平均延迟或利用率 —— 而是按请求类别分桶的请求形状和 p99 延迟的联合分布。
具体来说:将你的流量分成大小桶(提示词长度、预期输出长度、max-tokens 设置),并绘制每个桶随时间变化的 p99 延迟。一个健康的端点显示短请求桶的 p99 保持稳定,无论长请求桶在做什么。不健康的端点则显示短请求桶的 p99 紧跟长请求桶 —— 短请求在为碰巧与其分在同一批次的长请求买单。这种相关性就是队头阻塞的指纹,它永远不会出现在单个聚合延迟图表中。
还有两个信号有助于确认诊断。调度程序的队列等待时间(与 GPU 上的时间分开)可以告诉你请求是卡在准入阶段还是卡在执行阶段。此外,KV 缓存占用率占容量的百分比可以告诉你是否受到抢占限制 —— 当占用率接近 100% 时,服务器正在发生抖动(thrashing),逐出并重新计算前缀,延迟峰值即将呈非线性增长。
四种缓解措施,按侵入性排序
一旦看清这种模式,修复方法取决于你对技术栈的控制程度。按造成干扰的程度粗略排序:
- 启用分块预填充(Chunked prefill)。 大多数现代运行时支持将长提示词拆分为固定大小的块,并与批次中其他请求的解码(decode)步骤交错进行。长提示词生成第一个 token 的时间略长,但队列中的短请求不再被阻塞。这在 vLLM 上通常只是一个 flag 更改,能为你赢得短尾请求的大部分收益,代价是长尾请求的 TTFT 略微变差。
- 添加优先级感知调度。 大多数运行时允许你为请求标记优先级,并抢占低优先级的解码或降低低优先级准入的优先级。分层 SaaS 产品使用此功能来保护付费流量免受免费层突发流量的影响。需要注意的故障模式是优先级反转:一个已经持有大型 KV 缓存槽位的低优先级请求,即使你认为已经抢占了它,仍可能阻塞高优先级准入。
- 对进行中的 token(in-flight tokens)施加每个租户的上限。 按每秒请求数进行限流对于 LLM 推理几乎毫无用处,因为一个推理请求消耗的资源可能相当于一千个短请求。按并发的进行中 token(包括正在预填充的提示词 token 和正在生成的解码 token)对租户进行限制,你就能得到一个真正符合执行成本的公平模型。较新的推理平台称之为“token 池”或“token 预算”模型。
- 将请求类别隔离到单独的池中。 在某种流量规模下,交叉补贴就不再划算了。将推理流量和长上下文流量路由到专用池 —— 相同的模型,独立的副本 —— 并将 对延迟敏感的短流量保留在根据其形状调整大小的池中。你通过支付更多的空闲容量来换取有限的爆炸半径。大多数团队对此表示抵制,因为这看起来很浪费,但一旦 p99 违规的成本超过了第二个池的成本,情况就会发生逆转。
值得一提的更具侵入性的选项:预填充/解码解耦(prefill/decode disaggregation)。 像 DistServe 这样的研究系统和最大型实验室的生产技术栈,将预填充阶段分离到专门的工作节点上,这些节点通过快速互联将 KV 缓存移交给解码工作节点。这为你提供了两个阶段之间的物理隔离,并消除了最严重的队头阻塞形式 —— 预填充队列中的长提示词拖慢了活跃的解码器。它的架构成本很高,可能不是小团队应该开始的地方,但如果你成长为拥有真正沉重的推理流量,这就是未来的发展方向。
当“共享推理更便宜”不再成立时
转向共享多租户推理的动力很强,而且大多是正确的。GPU 价格昂贵,模型体量巨大,冷启动令人痛苦,你自然希望用尽可能少的副本来保持权重处于热加载状态。对于同构流量,这种共享模式运行得非常出色。
拐点在于请求形态的差异(request-shape variance)。一旦你的流量组合中除了普通流量外,还包含了长上下文负载或推理负载,共享池的公平性保证就会遭到侵蚀。你开始以 p99 延迟为代价来支付共享池节省的费用,而支付这笔代价的用户无法告诉你——他们只会直接离开。
一个有用的经验法则:如果池中 最大请求大小与中位数请求大小的比率超过约 10 倍,就该开始计划拆分了。确切的数值取决于你对 SLO 的容忍度以及你运行的备用容量,但在我见过的大多数负载中,10 倍是连续批处理(continuous batching)的假设开始失效的临界点。超过 50 倍,你实际上是在一个池中运行两个不同的服务,却假装它是一个。
先建立指标,再建立缓解方案
当团队首次遇到这种情况时,本能反应是寻求缓解措施——开启分块预填充(chunked prefill)、增加优先级层级或限制某些租户。这可能有效,但也可能让你交付一个权宜之计并自我感觉良好,而真正的问题却隐藏在未被监测的盲区中。
持久的解决方案是在进行任何调整之前,将“感知请求类别”的延迟作为一等指标(first-class metric)。每个请求在准入(admission)时都会根据其大小类别、租户和优先级层级被打上标签。每次请求完成都会分别记录其在 GPU 上的执行时间、排队等待时间和 TTFT。仪表板应显示每个类别的 p99,而不仅仅是整体的 p99。即使总体指标健康,当某个类别的 p99 超过阈值时也应触发告警。
有了这些指标监控,缓解措施就不再是猜测。你可以通过观察短请求类别的 p99 是否与长请求类别的 p99 脱钩,来判断分块预填充是否奏效。你可以通过观察在“噪声邻居”(noisy-neighbor)事件期间优先级层级的 p99 是否保持平稳,来判断优先级机制是否生效。当跨类别的相关性不再响应调优时,你就知道该拆分池了。
你最终会做出的决定
每个服务平台的演进轨迹看起来都一样。首先,团队在共享池上发布,因为它既简单又便宜。流量增长,推理负载叠加在对话负载之上。第一个 p99 事件发生了。团队开启了分块预填充,为自己争取了六个月的时间。接着,一个拥有长上下文负载的客户入驻,同样的事件以新的规模再次上演。团队增加了按租户分配的 Token 池,又为自己争取了六个月。随后,流量组合再次发生变化,而这一次,唯一的答案就是拆分池。
你可以通过提前部署监控并从第一天起就围绕请求类别设计准入机制,直接跳到这条轨迹的终点。这并不意味着第一天就要运行独立的池——而是意味着做好准备,当信号提醒你时,只需通过配置更改即可完成拆分。受苦最深的往往是那些在准入路径中过度假设流量同构性的团队,以至于拆分需要从底层重写。
当请求形态相似时,共享推理更便宜。当形态不相似时,请求类别隔离更便宜。错误不在于选错了答案,而在于拒绝察觉正确答案已经发生了改变。
- https://arxiv.org/html/2504.20828v2
- https://docs.vllm.ai/en/stable/configuration/optimization/
- https://blog.vllm.ai/2025/09/05/anatomy-of-vllm.html
- https://huggingface.co/blog/continuous_batching
- https://huggingface.co/blog/tngtech/llm-performance-prefill-decode-concurrent-requests
- https://huggingface.co/blog/tngtech/llm-performance-request-queueing
- https://www.usenix.org/system/files/osdi24-zhong-yinmin.pdf
- https://arxiv.org/pdf/2511.04791
- https://www.usenix.org/system/files/osdi24-fu.pdf
- https://ennanzhai.github.io/pub/sosp25-aegaeon.pdf
- https://arxiv.org/html/2602.16603
