去年我们在给一个端侧AI应用做云端推理服务的时候,遇到一个特别典型的问题:明明GPU集群的利用率只有30%,但用户侧频繁报“请求超时”“排队过久”,运维那边一看监控,负载均衡器又显示所有实例都在健康运行。那时候我就意识到,AI推理场景下的负载均衡,跟传统Web架构完全是两码事,你拿Nginx那套轮询思路去套,一定会被掐得很难受。
今天想把这个话题完整梳理一遍。AI原生应用的云端推理负载均衡,关键词不仅仅是“请求分发”,更核心的是“算力感知”“状态感知”和“排队策略”。这篇文章会覆盖方案选型、核心难点、实操配置、常见故障排查,也把LVS、Nginx、MOE负载均衡这类热门词放进去讲透。如果你是运维、后端开发或者AI平台工程师,正在被线上推理服务压测、卡顿、资源空转等问题折磨,这篇文章应该有直接参考价值。
1. 为什么传统负载均衡策略在推理场景下会失效
1.1 传统LB关注的是连接而非计算
传统负载均衡的核心指标是“连接数”和“QPS”。一个Web应用,每个请求消耗的CPU时间基本对称,所以按连接数分发、按轮询分发都能工作得很好。Nginx的默认策略就是轮流发,后端实例只要活着就能接流量,因为Tomcat处理一个首页请求和另一个首页请求的代价几乎一样。
但AI推理不一样。同样是一个HTTP请求,可能命中了一个极短的文本分类模型,几毫秒返回;也可能是一个多模态模型,要跑视觉编码器加语言解码器,一次推理吃满整张A100几十秒钟。假如你按连接数做负载均衡,短推理实例会被打得过载,长推理实例却还在排队等待,整体吞吐被严重拉低。
1.2 推理服务是长期占用型而非短平快型
再说一个容易被忽略的细节:GPU推理请求一旦进入模型执行阶段,显存和算力就是持续占用的,不是CPU那种微秒级切换的任务。你在Nginx层面看到的“连接建立”并不意味着计算已经完成,可能在连接上等待几十秒才出结果。这样传统的“健康检查+连接分发”模式就失去了意义。
我从实操中得到的教训是:AI推理的负载均衡必须从“连接均衡”转向“算力均衡”。你要知道每一台推理节点当前有多少并发在跑、还有多少显存可用、平均推理耗时是多长,才能真正把请求分到“现在正好有空”的节点上。
1.3 通用NGINX方案只能当入口,不能当核心
用Nginx做AI推理入口仍然是合理的,比如做TLS终止、鉴权、路由转发,这些都没问题。但如果指望Nginx的upstream轮询来做推理负载均衡,我说句实话:压测一上,问题立刻暴露。比如API服务的输出尺寸差异大,某些请求要返回一整个图片,某些只返回一个JSON,直接导致Nginx到后端的网络吞吐不平衡,加上GPU实例本身状态差异,单靠权重配置根本无法动态适配。
后来我们把架构改成两层:Nginx只负责鉴权与流量接入,真正的均衡决策放在独立的调度服务中,调度服务实时拉取推理节点状态,再根据每个节点当前的显存、队列长度、模型类型来做决策。这块后面会详细展开。
2. AI原生应用云端推理负载均衡的方案选型
2.1 从传统LVS到现代网关:你该怎么选
这里把主流方案拉出来对比一轮。LVS是Linux内核级的四层负载均衡,性能极高,走的是DR/TUN/NAT那套模式,长连接维护能力很强。但问题在于,LVS只认IP和端口,它不知道上层跑的是不是一个大模型推理请求,也不会管你显存还剩多少。所以LVS在AI场景里适合做入口流量承接,不适合做最终的推理分发决策。
Nginx的优势是七层路由,可以做细粒度的URL路由、Header改写、流量灰度,但在推理场景它的状态感知几乎为零。你必须在它之上叠加状态同步机制,加上自定义的调度策略,才能让它起作用。
现在不少AI平台团队选择自研一个轻量级网关,基于Go或Java,自己维护节点状态表。这种做法看起来成本高,但一旦推理集群规模超过几十个节点,自研的收益就很明显。你可以自定义打分算法,比如节点综合得分=剩余显存×权重1+GPU利用率反比×权重2+请求队列长度反比×权重3,然后按得分加权随机选节点。
2.2 消息队列与请求队列在推理中的价值
Web场景下,同步请求最直观,但AI推理场景中异步队列在不少场景下稳定性更高。如果是端侧应用上报一段音频或者图像,不要求立刻返回结果,你完全可以把请求投递到Kafka或者RabbitMQ,推理Worker集群拉取任务处理,处理完再回调给客户端。这种模式下负载均衡变得非常优雅——不需要在请求进入时做分发,而是让Worker主动取任务。谁空闲谁取,天然就是“自适应负载均衡”。
我做过一个真实项目:视频内容审核模型,单条视频可能要处理30秒以上。同步调用完全扛不住,后来改成了异步队列+动态消费者数量调节,每一路消费者拉取任务后处理,队列积压数变成唯一需要关注的指标。负载均衡的重心从“分发”变成“消费速率控制”,反而简单可靠。
2.3 节点权重动态调整的前提:可观测数据
不管你怎么选型,有个前提绕不过去:你得先拿到节点状态数据。否则负载均衡就是盲配。
最小要采集的指标至少包括:
- 当前正在推理的请求数(并发量)
- 当前GPU利用率与显存占用率
- 最近5分钟平均推理耗时与P95耗时
- 请求队列排队长度
- 健康状态与最后心跳时间
有了这些数据,你才能决定“哪个节点可以接新请求”。否则所谓智能负载均衡就是空中楼阁。
我们从实现角度是这样做的:推理节点在启动时注册到ETCD,每5秒上报一次状态快照。调度服务订阅ETCD变更,本地维护一份节点状态缓存。每个请求过来时,调度器根据缓存做一次打分计算,选出目标节点。打分算法我建议用加权TOPSIS或者简单的加权线性回归。实测下来,加权线性回归在可控复杂度的前提下已经足够稳定。
3. 核心难点拆解:算力、状态、请求调度
3.1 有状态推理如何做亲和性调度
文本生成类应用,比如大语言模型的对话服务,存在一个很麻烦的问题:会话上下文在显存里。客户端连续发送多轮消息,如果每一轮被分配到不同的GPU实例上,要么每次都需要重新加载KV Cache,浪费巨量显存和时间,要么就得把历史上下文通过网络传来传去,延迟直接爆炸。
最优的负载均衡策略是会话亲和性调度:同一个用户的多轮请求尽可能固定在同一个推理实例上。实现方式可以用一致性哈希,以用户ID或会话ID作为哈希键。当实例数量变化时,只需要迁移一部分会话,不会造成全局震荡。
这里有一个细节很多人踩过坑:一致性哈希实现的时候,虚拟节点的数量设置很关键。虚拟节点太少,实例之间的映射会严重倾斜;虚拟节点太多,内存占用和计算开销会变大。我们实测下来,每个物理节点映射150到200个虚拟节点是性价比比较高的区间。要注意,如果推理实例是容器,容器重启后虚拟节点会重建,旧节点的会话全部失效,客户端需要考虑重新建立上下文,这就要配合前端的容错重试机制。
3.2 显存感知调度:不让一个GPU呆着、另一个爆掉
显存感知是推理负载均衡和传统负载均衡最显著的分水岭。同一个集群里,可能存在多种模型、不同尺寸的显存需求。比如一个BERT模型只要2GB显存,一个GPT类模型要40GB显存。如果调度器不考虑显存,直接把大模型请求发到仅剩5GB显存的节点上,推理进程直接OOM。
我们在实践中采用的显存预估逻辑是这样:假设每个并发请求占用K GB显存,K的值通过模型配置或者历史统计得到,节点剩余显存预估为总显存减去已占用减去当前并发请求数乘以K。只有预估剩余显存大于本次请求所需显存加上安全余量,这个节点才进入候选集合。安全余量我们一般设置为20%,防止显存碎片化带来的不确定性。
如果推理进程是Python写的,还可以用nvidia-smi的查询或者PyTorch的显存统计接口做实时校验。这样调度器不仅能防止OOM,还能把不同模型实例的流量尽量分散开,避免同一个GPU上堆叠过多同类计算导致SM竞争严重。
3.3 排队策略:比高速分发更重要的被动保护
推理场景有个反直觉但很重要的思想:负载均衡不只是“快点把请求发出去”,更要知道“什么时候该把请求拒绝掉或者排队”。如果所有节点都已经满负荷,继续往里头塞请求只会让所有请求一起变慢,最后谁也跑不完。
网上热词里提到“等开销负载均衡”,指的是让每个请求的预期开销近似相等,这跟推理场景很契合。实现方案是维护一个全局的请求排队区,每个新请求先进入队列,调度器根据节点空闲情况以相对稳定的速率从队列中取请求。你可以把每个节点的响应时间看作是服务时间,目标是让所有节点的队列长度乘以平均推理时长近似一致。
实际参数上,我们会给每个节点设置一个最大并发推理数。比如单张A100跑一个7B模型,显存64GB,实测单并发推理延迟50ms,双并发延迟95ms,四并发延迟就飙到220ms了。这种情况下并发数设为2到4之间比较合适,超过上限的请求让它在调度器中等待,而不是直接打到GPU上。配置合适后,P95延迟大幅下降,整体吞吐反而提升,因为避免了多请求争抢导致的性能衰减。
4. 实操:在Kubernetes上搭建一个推理负载均衡器
4.1 整体架构与核心组件
我们当前的线上架构是在Kubernetes里部署推理服务,每个模型组是一个Deployment,Pod数量弹性伸缩。独立部署一个LoadBalancer组件,作为所有推理请求的唯一入口。LoadBalancer组件内部有几块职责:配置监听、节点状态同步、调度决策、指标导出。Pod的副本数建议大于等于2,避免调度器自身单点故障。
数据流是这样的:
客户端请求到Ingress,Ingress转发到LoadBalancer Service,LoadBalancer从ETCD拉取节点状态,选择目标推理Pod,然后通过HTTP/gRPC调用目标Pod。响应原路返回。
这里我建议保留Ingress做域名路由和TLS管理。而LoadBalancer Service不要用默认的ClusterIP轮询模式,直接把外部流量引导到调度器的Pod上,让调度器全权决定目标实例。
4.2 调度器核心代码示例
核心调度逻辑不复杂,但细节很重要。下面是用Go写的调度器打分函数示例:
type NodeInfo struct { NodeID string GPUUtil float64 // 0.0-1.0 MemLeft int64 // 单位MB Concurrent int // 当前并发请求数 MaxConcurrent int // 最大并发限制 AvgLatencyMs float64 // 最近5分钟平均延迟 } func Score(n NodeInfo, reqMem int64) float64 { if n.MemLeft < reqMem { return -1 } if n.Concurrent >= n.MaxConcurrent { return -1 } // 权重可根据业务调整 w1, w2, w3 := 0.4, 0.3, 0.3 // 算力空闲度 computeScore := 1 - n.GPUUtil // 并发余量 concurScore := (1 - float64(n.Concurrent) / float64(n.MaxConcurrent)) // 延迟越短,score越高,这里用倒数归一化 latencyScore := 1000.0 / (n.AvgLatencyMs + 1) return w1*computeScore + w2*concurScore + w3*latencyScore }每次请求进来,遍历所有候选节点,调用Score函数,得分最高的节点接受请求。这里有两个关键点:一是要求节点状态数据必须实时,如果是5秒前上报的,可能出现请求打到刚占满的节点上,所以要缩小上报间隔并在调度侧加一个状态过期判断;二是打分之后要预留一小段“冷却时间”,比如已经选定为目标的节点在200毫秒内不再重复被选,否则并发一起打向同一个节点,后续请求就会发现该节点已经超额了。
4.3 健康检查与优雅摘除
推理节点的健康检查比Web复杂,不能只看进程存活。进程活着但显存耗尽或者CUDA报错,照样不能接流量。我们做了两层检查:
第一层是调度器主动拉取节点状态,如果状态上报超过15秒未更新,标记为“可疑”。可疑节点不参与调度,等状态恢复后才能重新放量。
第二层是启动时的模型预热检查,类似“并发预演”。在Pod启动后,LoadBalancer只给它分配极小流量,观察GPU显存是否正常、推理延迟是否在预期范围内,连续成功5次之后才把这个节点的权重拉满。实测这样能屏蔽不少模型加载失败但容器还显示Running的情况。
摘除节点的时候也要注意优雅终止。如果直接缩容Pod,正在进行的推理请求会被硬杀,客户端拿不到结果。我们的做法是在LoadBalancer里维护一份“正在处理请求的节点清单”,缩容前先把该节点的调度权重调为0,等待两分钟,确认没有正在处理的请求后再真正缩容。
4.4 配置参数的经验值参考
给的这些参数都是我们线上跑稳定的经验值,你可以当作起点再调:
| 参数项 | 参考值 | 说明 |
|---|---|---|
| 状态上报间隔 | 3秒 | 平衡实时性与开销 |
| 状态过期阈值 | 10秒 | 超过则摘除节点 |
| 节点冷却时间 | 200毫秒 | 防止同一节点被短时间重复选中 |
| 最大并发数 | GPU显存适配 | 7B模型建议2到4 |
| 安全显存余量 | 20% | 防止OOM |
| 虚拟节点数 | 150到200 | 一致性哈希时使用 |
| 调度器自身副本 | 2以上 | 避免单点故障 |
5. 常见问题与排查技巧实录
5.1 现象一:GPU利用率低但延迟很高
这个太常见了。很多人一看GPU不到30%,就觉得集群闲得很,但请求就是慢。拆开来看,大概率是并发推理导致的任务排队。GPU利用率低并不等于计算没有瓶颈,也可能是显存带宽受限,或者是单请求内部存在CPU预处理和GPU计算串行等待。
排查步骤分三步:第一步看请求在各节点的分布是否均匀;第二步看单请求的“推理纯耗时”和“总耗时”差多少,差得多说明有很大时间花在排队或网络传输上;第三步看是不是模型服务端开启了动态batching。动态batching在TensorRT和vLLM里都有,它会把多个短请求聚合成一个batch推理,这样GPU利用率看上去不太高,但实际吞吐很好。如果延迟仍然高,检查batch size是不是设置得过大了。
5.2 现象二:请求在调度器堆积但节点没满
有时候调度器发现所有节点都在最大并发状态,就拒绝新请求。但看节点那边,GPU占用率也没有打满。这时候要考虑是不是最大并发参数设得太保守了。我当时遇到过类似情况,单卡A100跑一个量化过的7B模型,并发数设成1,性能表现非常稳定,但P95延迟只有60ms,实际上并发到4的时候,P95到了200ms,也还在可接受范围内。于是分阶段把并发数从1调到2、再调到4,逐步压测验证,最终吞吐提升了近两倍。
关键是要理解“并发超限”和“排队”是两种不同的调度策略。如果你选择宁可排队也不超限,那调度器堆请求,GPU留余量,是正常现象,不必恐慌。你只需要确认排队的时延是否在业务容忍范围内。
5.3 现象三:某个实例频繁OOM,其他实例空闲
这种情况通常是显存碎片化或者静态显存占用没释放。Python侧用PyTorch的话,没有及时清空cache可能会导致显存越占越多。推理框架比如vLLM有连续批处理和显存管理,一般不会轻易OOM,但如果是自己用FastAPI包一层模型推理,就要格外小心。
我的建议是在推理框架层做厚重管理,不用自己像写传统服务一样逐请求加载模型。LoadBalancer加入“显存剩余”作为筛选条件,能杜绝大部分OOM。另外排查时留意:A节点OOM之后,如果GC和显存释放不及时,它会反复OOM,调度器要有一个“连续OOM自动摘除”机制,不能让它半死不活地留在集群里拖累整体。
5.4 现象四:同模型在多个卡上延迟差距巨大
同一型号GPU,同一种模型,不同节点响应时间差两三倍。多数原因是GPU型号不一致,比如有的节点是A10,有的是A100,这种异构集群里如果你不给不同节点设置不同的并发权重,就会导致慢节点排队、快节点空转。
解决方案是针对每个节点做性能基准测试。启动时让节点跑一批标准测试请求,记录平均推理延迟,然后把它作为Score函数里的权重系数。慢节点的权重低一点,自然流量就少一点。这个思路也对应到MOE负载均衡的讨论里。MOE(专家混合模型)的负载均衡核心是解决“某几个专家网络被大量路由,其他专家饥饿”的问题,本质上就是节点间算力不均的另一种表现形式。在实际操作中,你可以借鉴MOE里常见的“辅助损失均衡”思想,给每个专家节点统计被选中次数,如果被选中次数超过均值太多,就在打分时降低它的权重,反过来饥饿节点加分,让选中的概率逐渐趋向一致。
5.5 现象五:短视频类推理服务抖动大,P95不稳
短视频业务的推理负载特别考验均衡策略。因为它流量突发性很强,同一条视频里多个片段会被同时发送,导致请求在极短时间内堆积。我建议这类型业务直接把同步网关换掉,采用“异步队列+消费组”的方式。每条视频拆成N个片段任务丢进队列,消费者每取一个任务才实际占用一个GPU并发。你可以自由调节消费者数量,做到精细化削峰。LoadBalancer在这里扮演的不是“分发器”,而是“消费者数量控制器”。当队列积压超过阈值,自动扩容消费者Pod数量,低于阈值自动缩容。就像水龙头一样,既能保护后端不被打爆,又能把GPU资源在突发压力下压干榨净。
6. 后续演进思路
我目前最想尝试的方向是把负载均衡决策做进GPU推理框架内部。不是外挂一个调度器,而是让推理框架把自己的显存、计算队列、batch状态通过gRPC流式暴露给调度器,把调度频率从秒级提升到毫秒级。这样最大并发数可以进一步逼近GPU的物理上限。
另外,多网卡场景也值得关注。像Windows Server上配置多网卡负载均衡,或者Linux上绑定多张网卡做吞吐汇聚,在推理场景同样有需求。因为单张25G网卡在分发大输出时可能成为瓶颈,尤其像图片生成模型输出几个MB的数据,高并发下网卡很容易打满。我们自己是用Linux的bonding或者ECMP方案来解决这个问题的。
最后再分享一个感受:AI推理的负载均衡不会有一个万能开关,必须结合你的模型、GPU型号、请求延迟要求、业务并发模型综合设计。与其直接抄一个看似漂亮的开源方案,不如先把自己集群的监控数据跑出来,了解负载的高峰低谷,再动态调整。数据在线的时候,很多决策都会变得顺理成章。