推理服务和普通Web接口有一个本质区别:单次请求的计算成本极高。一个普通的CRUD接口耗时可能在10毫秒以内,而一次大模型或者视觉模型的推理,动辄需要几百毫秒甚至数秒。GPU资源又是有限的,当请求到达速率持续超过GPU的处理速率时,等待处理的请求就会在队列中越积越多。如果队列没有长度上限,内存会被慢慢吃光;如果设置了超时但设置得不合理,用户会看到大量超时错误,体验急剧下降。这篇文章就围绕推理请求的排队、积压和超时这三个核心问题,展开讲清楚背后的原理和落地做法。

一、请求积压是怎么产生的:从排队论说起
理解积压问题,绕不开排队论的基本模型。推理服务本质上是一个典型的M/M/1或M/M/c排队系统:请求按泊松分布到达,服务时间近似指数分布,服务窗口数量就是GPU worker的数量。当到达率λ接近甚至超过服务率μ时,队列长度会呈指数级增长。举个例子,单卡每秒能处理20个请求,当流量从每秒15个涨到19个,看似只多了25%的流量,但平均排队长度会从3个左右暴涨到19个,平均等待时间直接翻好几倍。这就是为什么很多团队发现流量只是稍微涨了一点,服务就先撑不住了。
更麻烦的是推理请求的服务时间方差通常很大。同样是文本生成请求,输入50 token和输入2000 token的处理时间可能相差十倍。高方差会让队列出现明显的“长尾阻塞”——一个大请求占住worker,后面一堆小请求跟着排队。所以在设计队列时,不能只看平均耗时,还要关注P99耗时,甚至要考虑按请求规模做分类处理。
从工程角度看,积压的直接原因通常有三个:一是前端网关没有做并发限制,把所有请求都放进来;二是队列没有设置上限,积压请求无限堆积;三是超时后请求没有被真正丢弃,依然占着队列位置继续计算。这三个问题分别对应限流、队列容量控制和超时联动,后面逐一展开。
二、队列设计与超时时间的正确设置
第一个关键决策是队列的最大长度。很多人要么不设上限,要么随手设一个很大的值,比如10000。实际上队列长度的合理值可以这样估算:假设用户的可容忍等待时间是T秒,单个请求平均处理时间是S秒,worker数量是N,那么队列上限大约是N乘以T除以S。比如8个worker,用户能接受2秒等待,平均处理耗时500毫秒,队列上限设32左右就够了。超过这个值还在排队的请求,大概率会在客户端先超时,服务端继续算纯属浪费GPU资源。
第二个关键决策是超时的三层联动。客户端超时应该最短,网关或负载均衡的超时居中,服务端队列等待超时最长,形成一个递增的漏斗。这样能保证客户端已经放弃的请求,服务端能及时感知并清理。服务端要做的是“等待超时”和“执行超时”分开处理:请求在队列里等太久直接返回503或429,已经开始执行的请求设置执行上限,避免异常大请求无限占用worker。下面是一个Python实现的简单示意:
import time
import threading
import queue
class TimedRequestQueue:
def __init__(self, maxsize, wait_timeout, exec_timeout):
self.q = queue.Queue(maxsize=maxsize)
self.wait_timeout = wait_timeout # 队列等待超时(秒)
self.exec_timeout = exec_timeout # 执行超时(秒)
def submit(self, request):
try:
# 队列满时快速失败,而不是无限阻塞
self.q.put(request, timeout=0.1)
return True
except queue.Full:
return False # 直接拒绝,让网关返回429
def worker(self, infer_fn):
while True:
req = self.q.get()
# 已经在队列里等太久的请求直接丢弃
waited = time.time() - req.enqueue_time
if waited > self.wait_timeout:
req.respond(status=503, msg="queue timeout")
self.q.task_done()
continue
# 执行阶段同样设置超时保护
timer = threading.Timer(self.exec_timeout, req.abort)
timer.start()
try:
result = infer_fn(req.payload)
req.respond(status=200, data=result)
finally:
timer.cancel()
self.q.task_done()</code>这段代码体现了两个核心思想:入队快速失败和等待超时主动丢弃。队列满了立刻拒绝,比让请求在TCP层面慢慢排队要诚实得多——客户端收到明确的429后可以走重试或者降级逻辑,而不是傻等一个注定超时的响应。
三、优先级队列与动态扩缩容
统一的FIFO队列在高峰期会把重要请求和普通请求一视同仁地卡死。更合理的做法是引入优先级队列,把请求分成两到三个等级:在线实时请求走高优先级通道,批量离线任务和内部探测请求走低优先级通道。worker优先从高优先级队列取任务,低优先级队列只在空闲时消费。要注意优先级队列可能让低优先级请求出现饥饿,可以引入老化机制:一个请求等待超过一定时间后自动提升优先级,保证最坏等待时间有上界。
另一个缓解积压的有效手段是动态扩缩容。推理服务的负载往往有明显周期性,白天高峰、夜间低谷。基于队列长度或者队列等待时间这两个指标做自动扩容,比基于CPU利用率的传统方式更贴合推理场景,因为GPU利用率高不代表队列在积压,而队列长度是最直接的积压信号。下面是用Prometheus指标配合简单策略的伪代码:
def autoscale(metrics, current_replicas):
queue_depth = metrics.get("inference_queue_depth") # 当前队列长度
wait_p95 = metrics.get("inference_wait_seconds_p95") # 等待时间P95
if queue_depth > 20 or wait_p95 > 1.5:
desired = current_replicas + 2 # 积压明显,快速扩容
elif queue_depth < 5 and wait_p95 < 0.3:
desired = max(1, current_replicas - 1) # 空闲,缓慢缩容
else:
desired = current_replicas
return min(desired, MAX_REPLICAS)扩容策略上建议“快扩慢缩”:积压时果断加实例,流量回落后等几分钟再缩,避免流量抖动导致实例反复横跳。如果底层用的是Kubernetes配合Triton Inference Server或vLLM这类框架,可以直接暴露自定义指标给HPA或者KEDA使用。
四、优雅降级与过载保护
再好的扩容也有天花板,GPU机器毕竟昂贵,不可能无限加。所以在架构上必须预留降级路径。常见的降级手段包括:返回缓存的近似结果(对相似问题做语义缓存)、切换到更小的蒸馏模型、对非核心功能直接返回简化响应。降级逻辑要在客户端和服务端同时实现,服务端通过响应头标识本次结果是完整推理还是降级结果,让客户端有机会提示用户。
过载保护的最后一道防线是入口限流。基于令牌桶或者滑动窗口对QPS做硬限制,阈值设置成略低于服务的实际处理能力,宁可主动拒绝一部分流量,也不要让所有流量都陷入慢等待。对于多租户场景,还要做按租户的配额隔离,避免一个大客户把整个队列堵死,其他租户跟着遭殃。
最后强调一点监控:队列长度、入队速率、出队速率、等待时间分布、拒绝数量,这五个指标必须上监控和告警。队列长度持续上升而速率持平时,说明服务能力已经到顶;入队速率和出队速率的差值,能直接告诉你积压消化需要多久。有了这些数据,容量规划就不再靠猜,而是可以提前一个月知道什么时候该加机器。
总结一下,处理推理请求积压的核心思路是:限制入口流量、设置队列上限、等待超时主动丢弃、优先级分级、按积压信号扩容、预留降级路径。这套组合拳打下来,推理服务在面对流量洪峰时才能做到有界延迟和可控失败,而不是整条链路一起雪崩。