大模型推理服务的算力成本极高,GPU资源天然有限,当并发请求数超过服务承载能力时,排队几乎是必然结果。但排队本身不可怕,可怕的是无差别排队:付费用户的请求和免费用户的请求挤在同一条队列里,一个几秒就能完成的短请求被前面数十个长文本请求堵住,整体响应时间被严重拖长。要解决这个问题,需要从限流和优先级调度两个维度同时入手,本文将结合实际落地经验展开分析。

一、推理服务排队严重的根因分析
在讨论解决方案之前,先要弄清楚请求到底堵在哪里。推理服务的处理链路通常包含接入层、调度层和推理执行层三个环节,每一层都可能出现瓶颈。接入层的连接数上限、调度层的队列设计、推理层的GPU显存和batch能力,任何一处配置不当都会导致排队时间指数级上升。
第一个常见原因是max_concurrency设置过低或过高。vLLM、TGI这类推理框架都有并发上限参数,设置过低会让GPU利用率不足,大量请求白白排队;设置过高则可能触发显存溢出或KV Cache频繁换入换出,反而拖慢整体吞吐。第二个原因是忽略请求的异构性:一个生成500 token的请求和一个生成20000 token的请求,占用的GPU时间相差几十倍,如果按请求数量均等排队,短请求的体验会被长请求严重拖累。
第三个原因在于队列本身没有分层。很多团队直接用框架默认的FIFO队列,所有请求一视同仁。当流量高峰来临时,重要的实时对话请求和离线批量处理请求混在一起,导致核心业务响应劣化。理解了这些根因,限流和优先级管理的设计目标就很清晰了:在资源确定的前提下,最大化整体吞吐,同时保障高价值请求的延迟可控。
二、限流策略设计与实现
1. 并发数限流:最直接的第一道闸门
并发数限流的思路很简单:服务同时最多处理N个请求,超出的请求进入等待队列或直接拒绝。对推理服务来说,这N通常由GPU显存和模型的KV Cache容量决定。并发数限流的优点是实现简单、保护性强,能防止服务被打挂;缺点是粒度较粗,没有考虑不同请求的资源消耗差异。实践中一般把并发数限流放在网关层或服务入口处,作为兜底保护。
import threading
class ConcurrencyLimiter:
def __init__(self, max_concurrency):
self.semaphore = threading.Semaphore(max_concurrency)
self.max_concurrency = max_concurrency
def acquire(self, timeout=5):
# 尝试在超时时间内获取槽位,失败则拒绝请求
return self.semaphore.acquire(timeout=timeout)
def release(self):
self.semaphore.release()
# 使用示例:限制推理服务并发为32
limiter = ConcurrencyLimiter(32)
if limiter.acquire(timeout=3):
try:
result = run_inference(prompt)
finally:
limiter.release()
else:
raise HTTPError(status_code=429, detail="服务繁忙,请稍后重试")
2. 令牌桶限流:平滑突发流量
纯并发限流无法控制请求的到达速率,如果上游瞬间涌入大量请求,队列还是会迅速膨胀。令牌桶算法以固定速率生成令牌,桶有容量上限,请求必须拿到令牌才能进入处理流程。相比固定窗口计数,令牌桶允许一定程度的突发流量,同时又限制了长期平均速率,非常适合推理服务这种需要应对波峰的场景。
需要特别注意的一点是,推理服务的限流维度不应只有QPS。由于请求输出长度差异巨大,更合理的做法是按token预算限流,例如把每个请求的预估生成token数作为消耗量,从令牌桶中扣除。这样长文本请求会消耗更多令牌,天然实现了资源层面的公平分配。令牌桶参数需要结合压测数据调整,一般建议突发容量设为平均速率的2到3倍,避免正常的流量波动被误伤。
3. 队列长度控制与快速失败
除了限流速率,还必须控制等待队列的长度。一个常见的误区是认为队列越长越好,实际上队列过长意味着尾部请求的等待时间已经超出用户容忍度,最终等来的大概率是超时,白白浪费了排队占用的内存和连接资源。合理的做法是设置队列长度上限,超出的请求直接返回429或503状态码,并附带Retry-After头提示客户端退避重试。客户端配合指数退避加抖动的重试策略,可以把失败率控制在较低水平,同时避免重试风暴进一步加剧拥塞。
三、请求优先级管理方案
1. 多级队列:按业务价值分层
优先级管理的基础是多级队列设计。把请求划分为若干优先级,例如P0为实时对话、P1为付费API调用、P2为免费用户、P3为离线批处理任务,调度器总是优先消费高优先级队列。多级队列实现简单直观,但要注意防止高优先级队列长期占满资源导致低优先级请求饿死。常见的缓解手段是加权公平排队(WFQ),给每个队列分配最低保障比例,比如P3队列至少保证10%的处理配额,即使高峰期批处理任务也能缓慢推进。
import heapq
import itertools
class PriorityScheduler:
def __init__(self):
self.heap = []
self.counter = itertools.count()
def submit(self, request, priority, enqueue_time):
# 动态优先级:等待时间越长,实际优先级越高,防止饿死
effective_priority = priority - (current_time() - enqueue_time) * 0.01
heapq.heappush(self.heap, (effective_priority, next(self.counter), request))
def pop_next(self):
if self.heap:
return heapq.heappop(self.heap)[2]
return None
2. 动态优先级与老化机制
静态优先级在实际业务中往往不够用,更精细的方案是引入老化机制:请求的有效优先级随等待时间动态提升。如上面的代码所示,每等待一秒,优先级得分获得一定的补偿,这样即使是低优先级请求,等待足够久之后也能排到高优先级请求前面,从根本上解决饿死问题。老化系数的取值需要权衡,太小的老化速度起不到防饿死作用,太大则会让优先级分层形同虚设。
另一个实用的技巧是请求抢占或拆分。对于已经进入推理执行的请求,强行抢占会浪费已计算的KV Cache,代价较高,因此更推荐的做法是在batch组装阶段考虑优先级:高优先级请求优先进入下一个batch,低优先级请求即使已在队列头部也适当让位。这种非抢占式调度在vLLM等框架中可以通过自定义调度策略插件实现。
3. 按请求成本差异化定价资源
优先级之外,还可以从资源消耗角度做区分。短请求和长请求的竞争可以通过独立的快速通道缓解:为预估输出较短的请求(比如摘要、分类类任务)分配专用配额,避免它们被长文本生成任务阻塞。预估方法可以用输入长度加简单启发式规则,也可以训练一个轻量的长度预测模型。这种方式在对话类产品中效果尤其明显,因为用户对首字延迟极其敏感,而长文写作类请求对总时长的容忍度相对较高。
四、监控指标与调优实践
限流和调度策略上线后,必须有配套的监控体系才能持续调优。核心指标包括:队列长度分布、排队等待时间P95和P99、请求拒绝率、不同优先级的处理延迟、GPU利用率以及batch大小分布。建议按优先级维度拆分所有延迟指标,这样才能验证优先级策略是否真正生效。如果发现高优先级请求的P99延迟仍然很高,需要检查是否限流阈值设置过低,或者优先级调度没有覆盖到推理执行层。
调优是一个持续迭代的过程。可以从一个保守的并发上限出发,逐步放宽并观察GPU显存使用和尾延迟变化,找到吞吐与延迟的最佳平衡点。限流阈值和优先级权重建议做成可动态下发的配置,而不是写死在代码里,这样流量模式变化时能够快速响应。此外,压测时务必使用真实的请求长度分布,用统一长度的合成请求压测得到的结论,与线上表现往往相差甚远。
最后需要强调的是,限流和优先级管理解决的是资源分配问题,不能替代扩容。当业务量持续增长、各优先级的延迟指标都逼近SLO红线时,该扩容还是要扩容,或者考虑将离线任务迁移到成本更低的时段和实例上执行,通过削峰填谷从根本上缓解资源紧张。