一个推理服务上线后,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. 自测
- 推理服务的容量问题与普通 Web 服务有什么不同?为什么排队是常态?
- 四个时间预算中,哪两项在小模型上可能占比可观?为什么必须先分开测?
- "延迟升高导致重试、重试导致更高到达率"这个正反馈靠什么打断?
- 队头阻塞为什么会让"平均负载不高但 P99 很差"?分池如何缓解?
- 排队超时为什么必须短于客户端超时?拒绝时应当给出什么信息?
- 为什么会出现"显存满了但 GPU 空"?它与批大小的关系是什么?