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 ParallelTP张量并行解决的是“单层放不下”的问题。一个大模型的某一层权重矩阵太大一张卡显存装不下就把它按列或按行切到多张卡上计算的时候通过 All-Reduce 通信把结果拼起来。TP 的特点是通信极其频繁每层前向都要同步所以它要求卡之间有高速互联NVLink 那种级别一般限制在单机 8 卡以内。Pipeline ParallelPP流水线并行解决的是“层数太多”的问题。把模型的不同层切到不同卡上像工厂流水线一样第一组卡算完前几层把中间结果传给下一组。PP 的通信量比 TP 小但会引入“气泡”bubble——流水线填充和排空阶段有卡在空转。Data ParallelDP数据并行解决的是“请求太多”的问题。它的思路最朴素每个副本都持有完整的模型各自独立处理不同的请求。副本之间不需要在计算过程中通信因为它们算的是不同的数据。这也是为什么 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], timeout0.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(keylambda x: x[0]) target scores[0][1] return requests.post(f{target[url]}/v1/chat/completions, jsonpayload)这段代码的核心逻辑就三句话先看亲和副本忙不忙不忙就送过去保缓存忙就全局挑最闲的拉不到指标就当它挂了绕开。实际生产里还要加上重试、熔断、超时控制但骨架就是这样。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 张卡组成一个 TP4 的副本多个这样的组再做 DP。路由层面对的是“副本组”而不是单卡。配置上的关键是TP 组内的通信走 NVLinkDP 组之间走网络。路由层要感知的是“组”的负载而不是单卡的负载。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 的请求分发本质是在“均衡”和“亲和”之间找平衡点。均衡保证不浪费副本亲和保证缓存能复用。找到这个平衡点你的推理集群就能既跑得快又跑得稳。
