☰
vLLM Data Parallel 请求分发实战:负载感知路由与缓存亲和性优化
2026/9/26 5:52:38 网站建设 项目流程

1. 从一次线上抖动说起:为什么“平均分配”反而拖垮了推理服务

去年冬天,我们一套基于 vLLM 的推理集群出了个怪事。监控面板上,八张卡的 GPU 利用率画出了一条诡异的波浪线——四张卡长期在 90% 以上高位运行,另外四张却在 30% 上下晃悠。请求量并没有突增,但 P99 延迟从 800ms 一路爬到了 2.3s,用户侧开始零星报超时。

排查了一圈,模型没问题,网络没问题,显存也没爆。最后把 vLLM 的调度日志拉出来逐行看,才发现问题出在Data Parallel(数据并行)的请求分发环节:调度器把新来的请求按轮询(Round-Robin)方式平均撒给了所有副本,但每个副本的实际负载能力根本不一样——有的副本上还挂着上一批长序列请求没跑完,KV 缓存占用已经接近阈值,新请求塞进去只能排队;而有的副本刚清空,显存空得很。

这就是 Data Parallel 最容易被误解的地方:它解决的是“吞吐扩展”问题,不是“负载均衡”问题。把请求平均分给副本,听起来公平,实际上是把快的人拖慢、让慢的人更慢。真正要做的,是把请求分给真正有余量的副本。

这篇内容我想把 Data Parallel 这套机制从里到外拆一遍:它到底在并行什么、副本之间怎么协调、路由决策该看哪些指标、vLLM 里这套东西是怎么落地的,以及我在实际部署中踩过的那些坑。不管你是刚接触 vLLM 部署大模型的新手,还是已经在管多副本推理集群的老手,应该都能从里面找到能直接抄作业的东西。

2. Data Parallel 到底在并行什么:先搞清楚它和别的并行不是一回事

2.1 三种并行的分工:TP、PP、DP 各管一段

聊 Data Parallel 之前,得先把大模型推理里常见的几种并行方式摆清楚,不然很容易混。我见过不少同学把 DP 和 TP 当成一回事,结果配置的时候参数乱填,性能直接腰斩。

Tensor Parallel(TP,张量并行)解决的是“单层放不下”的问题。一个大模型的某一层权重矩阵太大,一张卡显存装不下,就把它按列或按行切到多张卡上,计算的时候通过 All-Reduce 通信把结果拼起来。TP 的特点是通信极其频繁,每层前向都要同步,所以它要求卡之间有高速互联(NVLink 那种级别),一般限制在单机 8 卡以内。

Pipeline Parallel(PP,流水线并行)解决的是“层数太多”的问题。把模型的不同层切到不同卡上,像工厂流水线一样,第一组卡算完前几层把中间结果传给下一组。PP 的通信量比 TP 小,但会引入“气泡”(bubble)——流水线填充和排空阶段有卡在空转。

Data Parallel(DP,数据并行)解决的是“请求太多”的问题。它的思路最朴素:每个副本都持有完整的模型,各自独立处理不同的请求。副本之间不需要在计算过程中通信,因为它们算的是不同的数据。这也是为什么 DP 的扩展性最好——加副本几乎线性提升吞吐,前提是路由分得对。

用一个生活化的类比:TP 像是一道菜太多人做,把切菜、炒菜、装盘分给不同的人协作完成一道菜;PP 像是流水席,一道菜做完传下一道工序;而 DP 像是开了多家分店,每家店都能独立做完整的菜,客人去哪家店吃互不影响。分店模式最怕的就是——客人全挤到一家店,另一家空着。

2.2 DP 的核心价值与代价

DP 的价值很直接:吞吐扩展。单副本每秒能处理 20 个请求,加四个副本理论上能到 80。对于在线推理服务这种请求量大、单请求计算量相对固定的场景,DP 是最划算的扩展方式。

但代价也藏在细节里:

  • 显存成本翻倍:每个副本都要完整加载一份模型权重。一个 70B 模型 FP16 大概 140GB,四个副本就是 560GB 显存,这是实打实的硬件开销。
  • KV 缓存独立:每个副本有自己的 KV 缓存池,副本之间不共享。这意味着缓存命中率是“副本内”的,不是“全局”的。同一个用户的连续对话如果被路由到不同副本,前缀缓存就白做了。
  • 路由成为瓶颈:副本越多,路由决策越重要。分错了,加副本不但不提升吞吐,反而因为资源错配拉低整体效率。

提示:DP 适合“请求之间相对独立、单请求计算量适中”的场景。如果你的场景是超长序列生成(单请求就吃满一张卡),DP 帮不上忙,该上 TP 或 PP。

2.3 副本、路由、缓存三者的关系

热词里同时出现了“副本”“路由”“缓存”,这三个词在 DP 语境下是绑在一起的。我用一张表把它们的关系理清楚:

概念在 DP 中的角色关键指标常见误区
副本(Replica)独立承载完整模型的推理单元显存占用、KV 缓存余量、队列长度以为副本越多越好
路由(Routing)决定请求进哪个副本负载均衡策略、亲和性规则用轮询代替负载感知
缓存(Cache)副本内的 KV 缓存 / 前缀缓存命中率、缓存淘汰策略忽略跨副本缓存不共享

这三者的联动逻辑是:路由决策要参考副本的实时余量,而余量很大程度上由缓存占用决定。一个副本的 KV 缓存快满了,它的“余量”就低,路由就该绕开它。反过来,如果路由能把同一会话的请求稳定送到同一副本,前缀缓存命中率就高,副本处理效率也高,余量自然更充裕。这是一个正反馈循环,设计得好会越跑越顺,设计得差会越跑越堵。

3. 路由策略深挖:从轮询到负载感知,差在哪

3.1 轮询、随机、最少连接:三种基础策略的适用边界

最基础的路由策略有三种,很多人一上来就用轮询,其实得看场景。

轮询(Round-Robin):请求按顺序依次分给副本 1、2、3、4、1、2……优点是实现简单、绝对公平。缺点是完全无视副本当前状态。如果副本 2 上有个长请求在跑,轮询还是会把新请求塞给它,导致排队。轮询适合“所有请求计算量高度一致、副本性能完全相同”的理想场景,现实中很少见。

随机(Random):请求随机挑一个副本。从统计上看,请求量足够大时也能接近均匀分布。它比轮询稍微好一点的地方是,不会因为某个副本恰好排在“下一个”而被连续冲击。但本质上还是状态无关的。

最少连接(Least Connections):把请求分给当前活跃请求数最少的副本。这个策略开始“看状态”了,比前两个强不少。但它只看“连接数”,不看“每个连接有多重”。一个副本上挂着 3 个超长序列请求,另一个副本上挂着 5 个短请求,按连接数算会把新请求给前者,实际上前者更忙。

我实测下来的经验是:请求长度方差小的场景,最少连接够用;方差大的场景,必须上更细的指标。

3.2 负载感知路由:该看哪些实时指标

真正靠谱的 DP 路由,得看副本的“综合余量”。我在生产环境里主要盯这几个指标:

  • KV 缓存占用率:这是最核心的指标。vLLM 的 KV 缓存是分块管理的(block),占用率直接反映副本还能接多少活。占用率超过 85% 基本就该限流了。
  • 等待队列长度:副本内排队等待调度的请求数。队列越长,新请求进去等的时间越久。
  • GPU 计算利用率:反映副本当前的计算压力。但要注意,这个指标有滞后性,不能单独用。
  • 最近一批请求的平均处理时长:反映副本的“手感”,处理得快说明余量足。

把这些指标加权成一个“负载分数”,路由时选分数最低的副本。权重怎么定?我的经验公式是:

负载分数 = 0.5 × KV缓存占用率 + 0.3 × 归一化队列长度 + 0.2 × GPU利用率

KV 缓存权重最高,因为它最直接决定“还能不能接”。队列长度次之,GPU 利用率最低,因为它波动大、有滞后。

注意:这个权重不是拍脑袋定的,是拿线上流量回放调出来的。不同模型、不同请求分布,最优权重会变。建议你先用这个做起点,再根据自己集群的监控数据微调。

3.3 会话亲和性:让缓存真正发挥作用

光有负载感知还不够,还得考虑会话亲和性(Session Affinity)。什么意思?同一个用户的连续对话,最好一直路由到同一个副本。

原因在于前缀缓存(Prefix Cache)。大模型推理里,多轮对话的前几轮内容会被缓存成 KV,后续轮次直接复用,省掉重复计算。但这个缓存是副本内的。如果第一轮路由到副本 A,第二轮路由到副本 B,副本 B 没有缓存,得从头算一遍,前缀缓存等于白做。

实现会话亲和性的常见做法是:用会话 ID(比如用户 ID + 对话 ID)做哈希,映射到固定副本。但纯哈希有个问题——如果某个副本挂了或者过载,哈希过去的请求就卡住了。所以实际用的是带权重的亲和性路由:优先送亲和副本,如果亲和副本负载过高,才降级到其他副本。

这里有个权衡:亲和性越强,缓存命中率越高,但负载均衡越差;亲和性越弱,负载越均衡,但缓存命中率下降。我的做法是设一个阈值——亲和副本的负载分数低于 0.7 就送过去,高于 0.7 就换副本。这样既保住了大部分缓存收益,又不会把某个副本压垮。

4. vLLM 里的 Data Parallel 落地:EngineCore、Scheduler、Executor 怎么配合

4.1 vLLM 的架构分层:请求从进来到出去走了哪些环节

要理解 vLLM 的 DP 实现,得先知道它的架构分层。热词里出现了“vllm enginecore 与 scheduler、executor 交互流程”,这块确实是理解 DP 的关键。

vLLM 的核心组件大致是这么分层的:

  • API Server 层:接收 HTTP 请求,做初步的鉴权和参数校验。
  • EngineCore 层:推理引擎的核心,负责请求的生命周期管理。
  • Scheduler 层:调度器,决定哪些请求这一轮可以进 GPU 计算。
  • Executor 层:执行器,真正把计算任务下发到 GPU 上跑。

在 DP 场景下,每个副本是一个独立的 EngineCore 实例,各自有完整的 Scheduler 和 Executor。副本之上有一个路由层(可能是 vLLM 自带的,也可能是你自己在前面加的反向代理),负责把请求分发给不同的 EngineCore。

请求的完整路径是:API Server 收到请求 → 路由层选副本 → 目标副本的 EngineCore 接收 → Scheduler 排队调度 → Executor 执行 → 结果返回。

4.2 Scheduler 的调度逻辑:为什么它决定了副本的“余量”

Scheduler 是副本内部的大脑。它每一轮做两件事:决定哪些请求进这一批(batch),以及给它们分配 KV 缓存块。

vLLM 的 Scheduler 用的是Continuous Batching(连续批处理):不等一批请求全部结束,只要有请求完成,就立刻把等待队列里的新请求补进来。这样 GPU 利用率能一直保持高位。

但这也带来一个问题:Scheduler 的调度粒度决定了副本的“余量”变化速度。如果一批请求里有几个超长序列,它们会长时间占用 KV 缓存块,导致后续请求进不来。这时候副本的“余量”就低了,路由层应该感知到并绕开。

所以路由层要拿的“KV 缓存占用率”,本质上就是 Scheduler 当前分配的块数除以总块数。vLLM 通过 metrics 接口暴露这个数据,路由层定时拉取即可。

4.3 Executor 与副本间通信:DP 副本之间到底通不通信

这是很多人搞不清的点:DP 副本之间在推理过程中不通信。每个副本独立完成自己的前向计算,互不干扰。这也是 DP 扩展性好的根本原因。

但副本之间需要共享一些元信息,比如:

  • 全局的请求计数(用于监控和限流)
  • 副本健康状态(用于故障转移)
  • 路由层的负载视图(用于决策)

这些信息通过一个轻量的协调层传递,不涉及模型计算。常见做法是用 Redis 或者 etcd 存这些状态,路由层定期读取。

提示:DP 副本之间的“不通信”是推理层面的,不是系统层面的。别把这两者混了,否则会误以为 DP 有通信开销而不敢扩展。

4.4 一个可参考的 vLLM DP 部署配置

下面是我在实际项目里用过的一套配置,基于 vLLM 的 OpenAI 兼容接口,前面挂一个负载感知的反向代理。这里用 Python 写一个简化的路由逻辑示意:

import requests import time # 副本列表,每个副本暴露 vLLM 的 metrics 接口 REPLICAS = [ {"url": "http://replica-1:8000", "metrics": "http://replica-1:8000/metrics"}, {"url": "http://replica-2:8000", "metrics": "http://replica-2:8000/metrics"}, {"url": "http://replica-3:8000", "metrics": "http://replica-3:8000/metrics"}, {"url": "http://replica-4:8000", "metrics": "http://replica-4:8000/metrics"}, ] def get_load_score(replica): """从副本的 metrics 接口拉取负载指标,计算综合负载分数""" try: resp = requests.get(replica["metrics"], timeout=0.5) metrics = parse_metrics(resp.text) kv_usage = metrics.get("kv_cache_usage_ratio", 0) queue_len = metrics.get("num_waiting_requests", 0) gpu_util = metrics.get("gpu_utilization", 0) # 归一化队列长度,假设 20 个请求为满负载 norm_queue = min(queue_len / 20.0, 1.0) score = 0.5 * kv_usage + 0.3 * norm_queue + 0.2 * gpu_util return score except Exception: # 拉取失败视为高负载,避免把请求送过去 return 1.0 def route_request(session_id, payload): """根据会话亲和性和负载分数选择副本""" # 会话亲和:用 session_id 哈希得到首选副本 preferred_idx = hash(session_id) % len(REPLICAS) preferred = REPLICAS[preferred_idx] preferred_score = get_load_score(preferred) # 亲和副本负载可接受,直接送 if preferred_score < 0.7: target = preferred else: # 否则选全局负载最低的副本 scores = [(get_load_score(r), r) for r in REPLICAS] scores.sort(key=lambda x: x[0]) target = scores[0][1] return requests.post(f"{target['url']}/v1/chat/completions", json=payload)

这段代码的核心逻辑就三句话:先看亲和副本忙不忙,不忙就送过去保缓存;忙就全局挑最闲的;拉不到指标就当它挂了,绕开。实际生产里还要加上重试、熔断、超时控制,但骨架就是这样。

5. 缓存治理:DP 场景下最容易被忽视的一环

5.1 KV 缓存与前缀缓存:两种缓存别搞混

热词里“kv缓存”“ai缓存命中”“缓存一致”都出现了,说明大家对缓存这块关注度很高。但在 DP 场景下,得先分清两种缓存:

KV 缓存:每个请求在生成过程中,每一层的 Key 和 Value 张量都要存下来供后续 token 使用。这是推理的必需品,不是可选项。KV 缓存的大小直接决定了副本能同时处理多少请求。

前缀缓存(Prefix Cache):多个请求如果有相同的前缀(比如相同的 system prompt、相同的对话历史),这部分前缀的 KV 可以复用,不用重复计算。这是性能优化项,命中率越高,副本处理越快。

两者的关系是:前缀缓存是 KV 缓存的一种“复用方式”。前缀缓存命中率高,意味着同样的 KV 块被更多请求共享,单位显存能支撑的请求数就更多,副本的“余量”就更充裕。

5.2 缓存命中率怎么影响路由决策

在 DP 场景下,缓存命中率不是一个副本内的事,它和路由强相关。

假设一个用户发了 5 轮对话,每轮都路由到同一个副本,那么第 2 到第 5 轮都能命中前缀缓存,副本只需要计算新增的 token。如果每轮路由到不同副本,每轮都得从头算,计算量翻好几倍。

我做过一个粗略的测算:在一个多轮对话占比 40% 的场景里,开启会话亲和性路由后,整体计算量下降了约 25%,P99 延迟下降了 30%。这个收益比单纯加副本还划算,因为加副本要花钱买卡,优化路由只是改几行代码。

所以路由决策里,缓存亲和性的权重应该和负载均衡的权重放在同一量级,不能只顾均衡不顾缓存。

5.3 缓存淘汰与副本过载的联动

KV 缓存不是无限的,满了就得淘汰。vLLM 默认用的是LRU(最近最少使用)淘汰策略:最久没被访问的块先被踢出去。

这里有个坑:如果路由策略导致某些副本频繁接收新请求,它的缓存淘汰会非常频繁,前缀缓存命中率会暴跌。因为老请求的缓存块还没被复用就被新请求挤掉了。

反过来,如果某个副本接收的请求太少,它的缓存里堆着一堆没人用的块,也是浪费。

理想状态是:每个副本的缓存都保持在一个“有足够老请求的缓存可供复用,又有足够空间接新请求”的平衡点。这个平衡点大概在缓存占用率 60% 到 80% 之间。路由层应该尽量让所有副本都落在这个区间,而不是有的 95% 有的 20%。

注意:缓存占用率不是越低越好。太低说明副本没吃饱,资源浪费;太高说明快满了,新请求进来就得淘汰老缓存,命中率下降。60%-80% 是我实测下来比较舒服的区间。

6. 实操排查:那些年我在 DP 部署上踩过的坑

6.1 常见问题速查表

现象可能原因排查方向解决思路
副本负载严重不均路由策略是轮询/随机看各副本 KV 占用率差异换成负载感知路由
P99 延迟突然升高某副本 KV 缓存打满查该副本缓存占用率限流 + 路由绕开
前缀缓存命中率低会话被路由到不同副本查同一会话的副本分布开启会话亲和性
加副本后吞吐没提升路由层成为瓶颈看路由层 CPU/延迟优化路由层或加路由实例
副本频繁 OOM单副本并发请求过多查副本并发数和序列长度设置副本级并发上限
缓存命中率波动大请求长度方差大分析请求长度分布按长度分桶路由

6.2 三个我踩过的真实坑

坑一:以为轮询就够了,结果长尾请求拖垮全局。

刚上线那会儿图省事,路由层直接用了轮询。前两周流量平稳,看着挺正常。第三周来了个批量任务,里面混了一批超长序列请求。轮询把这些长请求均匀撒到所有副本,每个副本都被拖住一个长请求,导致所有副本的短请求都开始排队。P99 直接从 1s 飙到 5s。

后来改成负载感知路由,长请求会自动集中到负载低的副本,其他副本不受影响。这个教训是:请求长度方差大的场景,状态无关的路由策略就是灾难。

坑二:会话亲和性设太死,副本挂了请求全卡住。

为了提升缓存命中率,我一开始把会话亲和性设成了“强绑定”——同一个会话永远送同一个副本。结果有一次副本 3 因为显存碎片重启,所有绑定到副本 3 的会话全部超时,用户侧炸锅。

后来改成“软亲和”:优先送亲和副本,但亲和副本负载超过阈值或者健康检查失败时,自动降级到其他副本。这样既保住了大部分缓存收益,又有了容错能力。

坑三:忽略路由层自身的性能,路由成了新瓶颈。

副本从 4 个扩到 16 个之后,吞吐没涨多少,反而路由层的 CPU 打满了。原因是路由层每个请求都要同步拉取 16 个副本的 metrics,网络往返加上解析开销,单请求路由耗时从 2ms 涨到了 15ms。

解决办法是把 metrics 拉取改成异步 + 本地缓存:后台起一个协程每 500ms 拉一次所有副本的指标,存到内存里;路由决策直接读内存,不再实时拉取。路由耗时降回 3ms 以内。

6.3 监控该盯哪些指标

DP 部署上线后,这几个指标必须加到监控面板里,缺一个都可能出问题:

  • 各副本 KV 缓存占用率:看是否均衡,是否有副本长期高位。
  • 各副本等待队列长度:看是否有副本排队严重。
  • 路由层决策延迟:看路由本身是否成为瓶颈。
  • 前缀缓存命中率:看会话亲和性是否生效。
  • 副本健康状态:看是否有副本频繁重启或失联。
  • 端到端 P50/P99 延迟:最终的用户体验指标。

我习惯把这几个指标放在同一张大盘上,一旦某个副本的缓存占用率和队列长度同时飙升,基本就能定位到问题。

7. 扩展思考:DP 之外,还有哪些组合玩法

7.1 DP + TP 混合:大模型多副本的正确姿势

单靠 DP 有个前提:单副本能装下整个模型。如果模型大到单卡装不下,就得先上 TP 把模型切开,再在 TP 组之上做 DP。

这种混合架构下,一个 DP 副本实际上是一个 TP 组(比如 4 张卡组成一个 TP=4 的副本),多个这样的组再做 DP。路由层面对的是“副本组”,而不是单卡。

配置上的关键是:TP 组内的通信走 NVLink,DP 组之间走网络。路由层要感知的是“组”的负载,而不是单卡的负载。vLLM 支持这种配置,通过--tensor-parallel-size和副本数配合实现。

7.2 请求分桶:按序列长度路由的进阶玩法

前面提到请求长度方差大会影响路由效果。一个进阶做法是按序列长度分桶:把请求按预估的输出长度分成短、中、长三桶,不同桶路由到不同副本组。

短请求桶的副本配置可以偏向高并发、小 KV 缓存;长请求桶的副本配置偏向大 KV 缓存、低并发。这样每类请求都能找到最适合自己的副本,整体效率更高。

这个玩法实现复杂度高一些,需要对请求长度有较好的预估能力。我的做法是用历史数据训练一个简单的长度预测模型,准确率能到 70% 左右,已经能带来明显收益。

7.3 缓存预热:让新副本快速进入状态

新副本刚启动时,KV 缓存是空的,前缀缓存命中率为零,处理效率低。如果这时候路由层把大量请求送过去,新副本会处理得很慢,拖累整体延迟。

解决办法是缓存预热:新副本启动后,先用一批“热身请求”把常见的前缀(比如系统 prompt、高频对话模板)灌进去,等缓存占用率达到 40% 左右再正式接入流量。

预热请求可以从历史流量里采样,也可以人工构造。我一般用历史流量里出现频率最高的 100 个前缀做预热,大概 2 分钟就能让新副本进入状态。

8. 最后分享几个实操小技巧

关于 Data Parallel 的请求分发,我最后再补几个零碎但很实用的点。

第一,路由层的超时设置要比副本的推理超时短。如果副本推理超时是 30s,路由层超时设 25s,这样副本还没返回,路由层已经判定失败并重试其他副本了,用户感知的延迟更短。但重试要幂等,别把同一个请求重复执行。

第二,副本的并发上限要显式设置,别让它无限接。vLLM 默认会尽量接请求直到 KV 缓存满,但满了之后新请求会排队,排队久了就超时。更好的做法是在副本层面设一个并发上限(比如 KV 缓存能支撑的 80%),超过就拒绝,让路由层把请求送给别的副本。

第三,定期做一次全链路压测,验证路由策略在极端流量下的表现。我每个季度会做一次,用历史峰值流量的 1.5 倍打一遍,看路由层会不会成为瓶颈、副本会不会雪崩。这个习惯帮我提前发现了好几次潜在问题。

第四,别迷信单一指标。KV 缓存占用率、队列长度、GPU 利用率,任何一个单独看都可能误判。我见过 GPU 利用率很低但 KV 缓存快满的情况——那是因为请求都在排队等缓存块,GPU 反而闲着。所以负载分数一定要多指标加权。

这套东西说到底,核心就一句话:Data Parallel 的请求分发,本质是在“均衡”和“亲和”之间找平衡点。均衡保证不浪费副本,亲和保证缓存能复用。找到这个平衡点,你的推理集群就能既跑得快又跑得稳。

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

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

立即咨询