☰
大模型推理多副本调度:Data Parallel余量感知路由实战
2026/9/26 12:43:01 网站建设 项目流程

1. 从一个反直觉的现象说起:为什么副本空闲,请求却在排队

如果你部署过多副本的大模型推理服务,大概率见过这种场景:监控面板上四个副本的 GPU 利用率分别是 92%、88%、31%、29%,但用户侧的 P99 延迟依然在飙。你打开日志一看,请求确实被均匀地分到了四个副本上——每个副本拿到的请求数量几乎一样。问题出在哪?

答案藏在"请求数量相等"和"计算量相等"之间那道巨大的鸿沟里。大模型推理的每个请求,输入长度可能从几十 token 到几万 token 不等,输出长度更是完全不可预测。一个 8K 输入的请求和一个 200 token 输入的请求,在 Prefill 阶段消耗的算力可能差几十倍。如果你用轮询或者简单的连接数均衡去分发,就会出现"数量上公平、算力上极度不公平"的局面:某个副本恰好接了几个长请求,它的 KV Cache 瞬间被占满,后续请求只能排队;而另一个副本手里全是短请求,显存空着一大半,GPU 却在等调度。

这就是Data Parallel(数据并行)在推理服务里要解决的核心问题。它不是简单地把请求平均分一分,而是要让每个请求落到"真正还有余量"的那个副本上。这里的"余量"是个多维概念:显存余量、计算队列余量、KV Cache 块余量、甚至网络带宽余量。把这几个维度综合起来做路由决策,才是 Data Parallel 调度真正有价值的地方。

这篇内容我会从工程落地的角度,把 Data Parallel 的路由机制拆开讲清楚:副本的余量信息是怎么被采集和暴露的、路由层怎么根据这些信息做决策、缓存亲和性和负载均衡怎么权衡、以及我在实际压测中踩过的几个坑。适合正在做多副本推理服务部署、或者对 vLLM 这类框架的调度层感兴趣的同学。读完你应该能自己动手改一版路由策略,而不是只能调框架给的几个开关。

2. 副本余量到底包含哪些维度,怎么量化

2.1 显存余量不等于可用余量

很多人第一反应是看 GPU 显存剩余。但显存剩余这个指标在推理场景里非常具有误导性。vLLM 这类框架启动时会预分配一大块显存做 KV Cache 池,启动后显存占用就基本稳定了,你看到的"剩余显存"可能一直是个固定值,根本不反映当前负载。

真正有意义的是KV Cache 块的占用率。vLLM 的 PagedAttention 把 KV Cache 切成固定大小的 block(默认 16 个 token 一块),调度器维护一个空闲块队列。当空闲块数量低于某个水位线时,新请求就无法被接纳,只能进等待队列。所以副本上报的余量指标里,free_blocks / total_blocks这个比值才是第一手信息。

我在实际项目里会把余量定义成一个 0 到 1 的连续值,而不是简单的"能收/不能收":

capacity_score = w1 * (free_blocks / total_blocks) + w2 * (1 - running_seqs / max_num_seqs) + w3 * (1 - waiting_seqs / max_waiting)

三个权重根据你的业务特点调。如果请求长度方差很大,free_blocks的权重应该给高,因为它直接反映"还能塞下多少 token";如果请求长度比较均匀,running_seqs的权重可以提上来,因为它反映的是计算队列的拥挤程度。

2.2 计算队列余量:被忽略的第二维度

显存够不代表算得快。一个副本可能 KV Cache 还剩 40%,但它当前正在跑的 batch 里全是长输出请求,decode 阶段每一步都要等最慢的那个序列,GPU 利用率上不去,新请求进去也是干等。

这时候要看的是调度器的排队深度。vLLM 的 scheduler 每步会从 waiting 队列里挑请求进 running 队列,如果 waiting 队列持续非空,说明这个副本已经过载了。我通常会把waiting_seqs单独拎出来做一个惩罚项:只要 waiting 队列超过阈值,这个副本的 capacity_score 直接打对折,不管它显存还剩多少。

提示:不要只看瞬时值。副本余量抖动很厉害,一个长请求结束的瞬间余量会突然跳升。路由层拿到的应该是滑动窗口内的均值或者分位数,否则会出现"刚把请求发过去,副本就满了"的尴尬。

2.3 一个可落地的余量上报结构

下面是我在项目里用的副本状态上报结构,通过一个轻量的 HTTP 接口暴露,路由层每秒拉一次:

# 副本侧:状态采集与上报 import time from collections import deque class ReplicaMetrics: def __init__(self, window_size=10): self.free_block_ratio = deque(maxlen=window_size) self.waiting_depth = deque(maxlen=window_size) self.running_ratio = deque(maxlen=window_size) def sample(self, free_blocks, total_blocks, waiting, running, max_seqs): self.free_block_ratio.append(free_blocks / total_blocks) self.waiting_depth.append(waiting) self.running_ratio.append(running / max_seqs) def snapshot(self): # 用窗口均值平滑抖动 fb = sum(self.free_block_ratio) / len(self.free_block_ratio) wd = sum(self.waiting_depth) / len(self.waiting_depth) rr = sum(self.running_ratio) / len(self.running_ratio) # 排队深度做非线性惩罚 penalty = 1.0 if wd < 2 else max(0.2, 1.0 / (1 + wd)) score = (0.5 * fb + 0.3 * (1 - rr) + 0.2 * fb) * penalty return { "capacity_score": round(score, 4), "free_block_ratio": round(fb, 4), "waiting_depth": round(wd, 2), "ts": time.time() }

这个结构的关键在于penalty那一行。排队深度用倒数衰减而不是线性衰减,是因为排队一旦形成,延迟是雪崩式增长的——第 1 个排队请求可能只多等 50ms,第 10 个可能就要多等 2 秒。用非线性惩罚能让路由层对"刚开始排队"的副本就保持警惕。

3. 路由层怎么做决策:从轮询到余量感知

3.1 常见路由策略的失效场景

先把几种常见策略的失效边界说清楚,你才知道为什么需要余量感知。

策略适用场景失效场景
轮询 Round Robin请求同质、副本同构请求长度方差大时,长请求扎堆
随机 Random副本数量多、请求轻小集群下随机性不够,仍会倾斜
最少连接 Least Conn连接时长均匀大模型请求时长差异极大,连接数无意义
一致性哈希有缓存亲和需求热点 key 会把单个副本打爆
余量感知请求异构、副本异构需要额外的状态采集开销

最少连接这个策略在大模型场景里几乎必然失效,因为一个请求可能跑 200ms 也可能跑 20 秒,连接数完全不能反映负载。我见过有团队用 Nginx 的 least_conn 做前置,结果长请求全堆到一个副本上,就是因为那个副本恰好先处理完了一批短请求,连接数看起来最少。

3.2 余量感知路由的核心逻辑

余量感知路由的本质是一个带约束的最优化问题:在保证缓存亲和性的前提下,把请求发给 capacity_score 最高的副本。但直接取最大值会有问题——所有请求都涌向当前最空闲的那个副本,瞬间把它打满,然后下一个请求又涌向新的最空闲副本,形成震荡。

我的做法是引入概率化选择:按 capacity_score 做加权随机,而不是取 argmax。这样既能让余量大的副本承接更多请求,又不会出现瞬时尖峰。

import random def pick_replica(replicas, cache_key=None, cache_map=None): # 缓存亲和优先:如果该 key 之前落在某个副本且该副本仍有余量 if cache_key and cache_map: preferred = cache_map.get(cache_key) if preferred: for r in replicas: if r["id"] == preferred and r["capacity_score"] > 0.3: return r["id"] # 过滤掉余量过低的副本 candidates = [r for r in replicas if r["capacity_score"] > 0.15] if not candidates: # 全部过载时退化为选余量最高的,避免请求被直接拒绝 candidates = sorted(replicas, key=lambda x: -x["capacity_score"])[:1] # 加权随机 weights = [r["capacity_score"] ** 2 for r in candidates] total = sum(weights) r = random.random() * total acc = 0 for cand, w in zip(candidates, weights): acc += w if r <= acc: return cand["id"] return candidates[-1]["id"]

注意capacity_score ** 2这个平方操作。它是在放大差异:余量 0.8 的副本权重是 0.64,余量 0.4 的是 0.16,前者被选中的概率是后者的 4 倍。如果不平方,两者只差 2 倍,倾斜不够明显。平方指数可以根据集群规模调,副本越多、指数可以越小,因为大数定律会自然平滑。

3.3 缓存亲和性和负载均衡的冲突怎么解

这是 Data Parallel 路由里最纠结的一对矛盾。缓存亲和性要求"同一个 key 的请求尽量落到同一个副本",这样能命中前缀缓存(prefix cache)或者 KV Cache 复用;负载均衡要求"请求尽量分散",避免热点。

我的经验是分级处理:

  • 第一级:如果请求带有明确的前缀缓存 key(比如系统提示词相同),且目标副本余量健康(score > 0.3),直接走亲和路由。
  • 第二级:如果目标副本余量偏低,但还没到危险线(0.15 < score < 0.3),以 70% 概率走亲和,30% 概率走负载均衡。
  • 第三级:如果目标副本余量危险(score < 0.15),强制走负载均衡,同时更新 cache_map 指向新副本。

这个分级的关键是动态调整亲和强度。集群空闲时,亲和性拉满,缓存命中率最高;集群繁忙时,亲和性让位于吞吐,宁可牺牲一点缓存命中也要保证不排队。

注意:cache_map 的更新要小心。如果频繁把同一个 key 在副本间搬来搬去,缓存永远命中不了,还不如不做亲和。我一般会给 cache_map 加一个最小驻留时间,比如 30 秒内不迁移同一个 key。

4. 和 vLLM 调度器的配合:谁负责什么

4.1 vLLM 自身的调度边界

vLLM 的 EngineCore 里有一套完整的 scheduler,它负责的是单副本内部的请求调度:从 waiting 队列挑请求、分配 KV Cache block、组 batch、交给 executor 执行。它不关心副本之间的负载分布,那是路由层的事。

所以 Data Parallel 的职责划分很清楚:

  • 路由层:副本间决策,决定请求进哪个副本。
  • vLLM scheduler:副本内决策,决定请求什么时候被执行、和谁组 batch。

两者通过副本状态上报接口解耦。路由层不需要知道 vLLM 内部怎么调度,只需要拿到 capacity_score;vLLM 也不需要知道外面有几个副本,它只管把自己这摊活干好。

4.2 为什么不能让 vLLM 自己做全局调度

有人会想,既然 vLLM 有 scheduler,为什么不直接让它感知所有副本、做全局调度?原因有两个。

第一是耦合度。vLLM 的 scheduler 和 executor 是紧耦合的,它假设自己管理的就是本地那几张卡。要让它跨节点调度,得把整个 EngineCore 改成分布式的,工程量巨大且容易引入 bug。

第二是延迟。全局调度需要副本间通信,每次决策都要等所有副本上报状态,这个往返延迟在高速请求场景下不可接受。分层调度让路由层用稍微陈旧的状态(1 秒前的快照)做决策,虽然不完美,但延迟可控。

4.3 一个实际的部署拓扑

我常用的拓扑是这样的:

Client -> Router (余量感知) -> Replica 1 (vLLM) -> Replica 2 (vLLM) -> Replica 3 (vLLM) -> Replica 4 (vLLM) ^ | |--- 状态上报 (1s) -------|

Router 是个无状态服务,可以水平扩展。每个 vLLM 副本暴露一个/metrics接口,Router 的后台协程每秒拉一次,更新本地副本状态表。请求进来时,Router 查本地状态表做决策,不需要实时通信。

这个设计的好处是 Router 挂了可以随时重启,副本状态几秒内就能重建;副本扩缩容也只需要 Router 重新拉一次列表。缺点是状态有最多 1 秒的延迟,在请求突增的场景下可能决策滞后。我的缓解办法是把上报间隔降到 500ms,同时在 Router 侧对 capacity_score 做趋势预测——如果某个副本的 score 在连续下降,提前降低它的权重。

5. 压测中暴露的三个真实问题

5.1 冷启动副本被瞬间打爆

新扩容的副本刚起来时,KV Cache 是空的,capacity_score 接近 1.0。路由层一看这么空闲,把大量请求瞬间发过去,副本还没来得及初始化完 CUDA graph 和 warmup,直接被打爆,请求超时。

修复方案:给副本加一个warmup状态。副本启动后先自己跑几个 dummy 请求做 warmup,warmup 期间上报的 capacity_score 强制为 0,路由层不会选它。warmup 完成后才逐步放开,前 10 秒把 score 打个 0.5 的折扣,让流量缓慢爬升。

def effective_score(raw_score, uptime_seconds): if uptime_seconds < 30: return 0.0 # warmup 期不接流量 if uptime_seconds < 60: return raw_score * 0.5 # 爬坡期减半 return raw_score

5.2 长请求把余量指标"骗"了

有个副本的 free_block_ratio 显示还有 35%,看起来余量充足,但实际上一批长请求正在 decode,每个请求每步都要申请新的 block,35% 的余量在几秒内就会被吃光。路由层基于这个快照做了决策,结果请求发过去就排队了。

修复方案:引入余量消耗速率。记录过去 5 秒内 free_blocks 的变化斜率,如果斜率是负的且绝对值大,说明余量在快速消耗,即使当前值还高,也要打折。

def predict_score(current_ratio, history): if len(history) < 3: return current_ratio # 简单线性外推:预测 3 秒后的余量 slope = (history[-1] - history[0]) / len(history) predicted = current_ratio + slope * 3 return max(0.0, min(current_ratio, predicted))

这个预测不需要很准,只要能在余量快速下降时提前预警就够了。实测下来,加了预测之后长请求扎堆导致的排队减少了大概 40%。

5.3 缓存亲和导致的隐性热点

前缀缓存命中率上去了,但某个副本的负载明显高于其他副本。排查发现是某个高频系统提示词被大量请求共用,cache_map 把这个 key 固定指向了副本 2,所有带这个提示词的请求都往副本 2 跑。

修复方案:给 cache_map 加热度感知。统计每个 key 的请求频率,如果某个 key 的 QPS 超过阈值,就不再对它做亲和,改为分散路由。因为高频 key 的缓存收益会被负载倾斜的代价抵消掉。

def should_affinity(cache_key, key_qps, threshold=50): # 高频 key 放弃亲和,避免热点 return key_qps.get(cache_key, 0) < threshold

这个阈值要根据副本数量和单副本处理能力来定。4 副本、单副本能扛 100 QPS 的话,阈值设在 50 左右比较合适,留一半余量给其他请求。

6. 几个可以立刻用上的调参经验

6.1 权重不是拍脑袋定的

前面那个 capacity_score 公式里的三个权重,我建议用压测数据反推。方法很简单:构造几组不同长度分布的请求(比如全短、全长、长短混合),分别用不同的权重组合跑,看哪组权重的 P99 延迟最低、吞吐最高。

我实测下来,长短混合场景下free_blocks权重给到 0.5 到 0.6 效果最好,纯短请求场景下running_seqs权重可以提到 0.5。如果你的业务请求长度比较稳定,直接用固定权重就行;如果长度波动大,可以考虑根据请求的输入长度动态调权重——长请求进来时更看重 free_blocks。

6.2 上报间隔和决策延迟的权衡

上报间隔越短,状态越新鲜,但 Router 和副本的通信开销越大。我试过 100ms、500ms、1s、2s 四档,结论是 500ms 是个甜点:状态足够新鲜,开销也可以接受。100ms 的收益不明显,反而让副本的 metrics 接口成为瓶颈;2s 在请求突增时决策明显滞后。

如果你的集群规模很大(比如 32 个副本以上),可以考虑分层上报:每个副本 500ms 上报给一个聚合节点,聚合节点 1s 上报给 Router。这样 Router 只需要处理聚合节点的连接,压力小很多。

6.3 过载保护要有兜底

余量感知路由在极端情况下会失效——所有副本都过载时,加权随机选出来的副本也是过载的。这时候必须有兜底策略:

  • 如果所有副本 score 都低于 0.15,Router 直接返回 503 或者把请求放进一个短队列等待,而不是硬塞给某个副本。
  • 短队列的等待时间要设上限(比如 2 秒),超时就拒绝,避免请求在 Router 层堆积。
  • 同时触发告警,提示需要扩容。

我见过有团队为了"不丢请求",在过载时无限排队,结果 Router 内存爆掉,整个服务雪崩。宁可快速失败,也不要拖垮整个链路。

6.4 监控要能看到路由决策

最后一点,路由层的决策过程必须可观测。我至少会记录这几个指标:

  • 每个副本被选中的请求数(验证负载是否真的均衡)
  • 每次决策时各副本的 capacity_score(排查为什么某个副本被冷落)
  • 缓存亲和命中的比例(评估亲和策略的效果)
  • 因过载被拒绝的请求数(判断是否需要扩容)

这些指标用 Prometheus 暴露出来,配合 Grafana 看板,基本能覆盖 90% 的路由问题排查。没有这些数据,你只能靠猜,而路由问题恰恰是最难靠猜定位的——它涉及多个副本的状态交互,单看任何一个副本的日志都看不出问题。

7. 写在最后的一点个人体会

Data Parallel 的路由看起来是个简单的分发问题,但真正做起来会发现它是个状态估计 + 决策优化的组合题。副本的真实余量永远无法被精确观测,你拿到的永远是滞后、有噪声的快照;而决策又必须在毫秒级完成,不能等状态完全准确。

我的经验是,不要追求"最优路由",那不存在。追求的是"在状态不准确的情况下,仍然不会做出灾难性决策"。具体来说就是:宁可把请求发给一个中等余量的副本,也不要赌一个看起来最空闲但可能马上被打满的副本;宁可牺牲一点缓存命中率,也不要让任何副本排队;宁可快速拒绝,也不要让请求在路由层堆积。

这套逻辑我在几个不同规模的项目里都用过,从 4 副本到 32 副本都能跑,核心代码不到 200 行。真正花时间的不是写代码,而是调那几个权重和阈值,以及建立一套能让你看清路由行为的监控。如果你正准备做多副本推理部署,建议先把状态上报和监控搭起来,再上路由策略——没有观测的路由优化,都是盲人摸象。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询