2360 字
约 7 分钟
21
S23-A13 · 推理服务的排队与超时:请求预算怎么分

一个推理服务上线后,P99 延迟从 800ms 涨到 6 秒。扩容也没用——因为瓶颈不在算力,而在排队:每秒进来 60 个请求,服务每秒只能处理 45 个,多出来的 15 个在队列里等待,队列越长、等待越久,而客户端超时之后重试,又让队列更长。

推理服务的容量问题有一个与普通 Web 服务不同的特征:单请求占用资源的时间长且不可预测(序列长度决定计算量),所以"排队"不是边缘现象,而是常态的一部分。


1. 四个时间预算

组成 典型量级 谁决定
排队等待 毫秒到秒 并发度与到达率
预处理(分词等) 毫秒 实现
推理计算 几百毫秒到数秒 模型与序列长度
后处理与传输 毫秒到百毫秒 输出长度

四段的分离也决定了优化方向:排队高就该加并发或分池,计算高就该换模型或量化,预处理高就该换实现。不分开测,所有优化都在猜。

第二项与第四项在小模型上占比可观:一个简单任务的端到端时间里,可能一半不在推理上。所以优化之前必须先把四段分开测,否则会把预处理的时间误算成"模型慢"。

// 四段分开计时:任何一段超过总时长的 20% 都值得单独看
arrive → queue_start → preprocess_done → infer_done → response_sent
         ↑ 排队              ↑ 预处理        ↑ 计算       ↑ 后处理

2. 排队为什么会被放大

放大机制 说明
超时重试 客户端等不及就重试,队列更长
队头阻塞 长请求占住槽位,短请求排在后面
突发到达 批次任务与在线流量叠加
无背压 上游持续投递,队列无上限

第一项是典型的正反馈:延迟升高导致重试,重试导致更高的到达率,如果没有限流与去重,系统会一路恶化到不可用。

可以用一个更直观的口径衡量队头阻塞:同一时刻在跑的请求里,最长的那一个占了多少槽位时间。如果 10% 的请求占用了 50% 的槽位时间,队头阻塞一定存在。

// 按请求统计占用时长,看长尾占了多少槽位时间
awk '{print $NF}' infer.log | sort -rn | awk '
  {s+=$1; a[NR]=$1} END {p90=a[int(NR*0.1)]; print "总时长:", s, "P10(最长的10%):", p90*int(NR*0.1)}'
// 若最长 10% 的请求占了总时长的 50% 以上 → 分池几乎必然收益

第二项在推理服务里特别明显:一个超长序列的请求可能占用一个槽位十几秒,后面十个短请求都得等。这就是"平均负载不高但 P99 很差"的常见原因。

3. 三种排队策略

策略 公平性 吞吐 适用
先进先出 高 低(队长请求阻塞) 请求长度均匀
最短优先 低(长请求被饿死) 高 长度分布悬殊且长请求可延后
分批(batching) 中 最高 吞吐优先、可容忍延迟
分池(按长度/优先级) 高 高 需要隔离长请求

分池的代价要算清:两个池子都要留余量,总资源利用率必然下降。所以分池的前提是"P99 的改善值得这份浪费"——如果业务只看吞吐、不看尾延迟,分池就是负收益。

第四项是最实用的折中:把长序列请求放到单独的池子,短请求用小池子快速消化。代价是资源利用率下降(两个池子都要留余量),收益是 P99 可控。

// 分池:按预估序列长度路由,隔离队头阻塞
pools:
  short:
    max_tokens: 512
    concurrency: 8
    queue_limit: 64
    timeout_ms: 2000
  long:
    max_tokens: 8192
    concurrency: 2          # 少并发,避免占满显存
    queue_limit: 8          # 队列短,快速拒绝
    timeout_ms: 20000

4. 超时该定在哪一段

超时位置 作用
客户端 用户体验上限
网关 保护后端连接
服务端排队 避免长队列(队列超时)
单次推理 避免异常长计算
上游总预算 决定是否重试

第三项常被忽略:排队超时必须短于客户端超时。否则请求在客户端已经放弃,服务端还在为它排队并最终执行——算力被浪费在一个没人要的结果上。

// 超时的合理关系(数值示例)
客户端 8000ms > 网关 6000ms > 排队 3000ms + 推理 2500ms
// 排队超时到期 → 直接返回 503 并带估计等待时间,不要静默丢弃

预计等待时间的估算不需要精确,一个量级就够:

// 拒绝时给出可行动信息:估计等待 = 队列长度 / 吞吐
queued=37, throughput≈45/s → 估计等待 ≈ 0.8s
返回:503 + Retry-After: 1 + body { "queued": 37, "eta_ms": 800 }
// 比"服务繁忙"有用得多:客户端能据此决定等还是换路径

第三行还带出一个设计点:拒绝时要给出可行动的信息(稍后重试、预计等待),而不是一个空泛的错误。

5. 重试的放大与去重

机制 作用
幂等键(请求指纹) 相同请求只算一次
客户端退避 + 抖动 打散重试
服务端拒绝时给等待建议 引导重试节奏
重试次数上限 防止无限放大

第一项对推理服务尤其重要:同一段文本的重复推理是纯浪费。用请求内容的哈希做去重键,短时间内相同的请求可以直接复用结果或合并等待。

// 请求去重:同一内容在途时合并等待,已完成时直接返回
String key = sha256(model + prompt + params);
Result r = inflight.computeIfAbsent(key, k -> {
    CompletableFuture<Result> f = executor.submit(() -> infer(prompt));
    inflightRef.put(k, f);
    return f.join();          // 后来者等待同一个 future
});
// 注意:需要设置"在途结果"的过期时间,避免内存持续增长

6. 排错:五种"扩容没用"

现象 成因 处理方向
加机器延迟不变 瓶颈在单请求时长或队列 分流长请求
P99 高但均值正常 队头阻塞 分池或限制长请求
高峰后仍不恢复 重试潮未退 幂等去重 + 退避
吞吐上不去 批处理未启用或批太小 调批窗口
显存满了但 GPU 空 批的显存预算过大 降批大小或并发

批处理的另一个取舍是"等批"的时间:批窗口越长,吞吐越高、延迟越差。这个参数应当按业务的延迟上限倒推,而不是按"跑分最好"来定。

第五项是推理特有的:批大小与显存是强耦合的,批开大了显存先满,于是并发被卡在一个很低的水平,GPU 利用率看起来不高,但吞吐也上不去。

7. 边界:什么情况不必做排队治理

场景 为什么 做法
请求量远低于容量 排队不发生 只保留超时
离线批处理 无交互延迟要求 只关心总时长
单用户内部工具 并发为一 不必排队
请求时长高度均匀 无队头阻塞 先进先出即可

还有一条与容量规划有关的边界:推理服务的容量不能只按 QPS 算,要按"每秒消耗的 token 数"。同一个 QPS 下,序列长度翻倍就意味着算力翻倍——这是推理服务与普通服务在容量模型上最大的不同。

判断口径一句话:请求时长是否差异悬殊。差异大,必须分池或限制长请求;差异小,排队策略的选择影响很小。


8. 自测

  1. 推理服务的容量问题与普通 Web 服务有什么不同?为什么排队是常态?
  2. 四个时间预算中,哪两项在小模型上可能占比可观?为什么必须先分开测?
  3. "延迟升高导致重试、重试导致更高到达率"这个正反馈靠什么打断?
  4. 队头阻塞为什么会让"平均负载不高但 P99 很差"?分池如何缓解?
  5. 排队超时为什么必须短于客户端超时?拒绝时应当给出什么信息?
  6. 为什么会出现"显存满了但 GPU 空"?它与批大小的关系是什么?
S23-A13 · 推理服务的排队与超时:请求预算怎么分
https://www.clxhxhhr.top/posts/6434/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。