前言在大模型落地的浪潮中很多团队都面临同一个问题多张 GPU 卡各自部署了推理服务但负载极不均衡。有的卡显存快爆了有的卡还空着大半。能不能让客户端自动感知每张卡的剩余显存把请求优先打到最空闲的卡上市面上常见的做法是引入额外的调度中间件Redis 自研调度器或者使用 K8s 的 GPU 调度能力但这会显著增加系统复杂度。其实Nacos 本身的服务发现和元数据机制就足够优雅地解决这个问题而且代码量非常少。本篇文章将带你走完一个测试环境可直接运行、具备生产级关键特性的方案Python 推理服务通过 HTTP API 注册到 Nacos并定时上报显存等元数据Java 客户端基于加权随机算法选择最优实例并通过事件订阅保持元数据实时性网络抖动重试、惊群效应防护、多维度评分等打磨细节全部包含读完你会惊叹原来 Nacos 还能这样用一、总体思路我们把每张 GPU 卡视为一个独立的服务实例注册到同一个 Nacos 服务名下如gpu-inference。每个实例的metadata中携带实时显存信息memory_free_mb、GPU 利用率gpu_util、当前并发请求数concurrent_requests等。客户端订阅该服务每次请求前通过加权随机算法选中一个最优实例再将推理请求发送过去。架构图如下整个方案没有引入任何额外中间件全部使用 Nacos 标准功能。二、Python 模拟 GPU 实例带重试机制真实环境中需要安装pynvml库来读取真实显卡数据。这里为方便大家测试我们用随机数模拟显存变化并重点加入了请求重试逻辑防止因网络瞬时抖动导致注册或心跳失败。2.1 代码清单mock_gpu_http.pyimport sys import time import random import socket import requests NACOS_SERVER http://192.168.1.140:8848 # 你的 Nacos 地址 SERVICE_NAME gpu-inference GROUP DEFAULT_GROUP BASE_PORT 8000 # 可配置的元数据范围总显存 80GB已用随机 10~70GB def get_mock_metadata(gpu_index): total_mb 80 * 1024 used_mb random.randint(10 * 1024, 70 * 1024) free_mb total_mb - used_mb # 增加两个可选维度GPU 利用率、并发请求数模拟 gpu_util random.randint(0, 100) concurrent random.randint(0, 10) return { gpu_index: str(gpu_index), memory_total_mb: str(total_mb), memory_used_mb: str(used_mb), memory_free_mb: str(free_mb), gpu_util: str(gpu_util), concurrent_requests: str(concurrent), model_name: llama-7b-mock } # ---------- 重试包装 ---------- def request_with_retry(method, url, params, retries3, delay1): for attempt in range(retries): try: resp method(url, paramsparams, timeout5) if resp.status_code 200: return resp else: print(fHTTP {resp.status_code}, retry {attempt1}) except Exception as e: print(f请求异常 {e}, retry {attempt1}) if attempt retries - 1: time.sleep(delay * (attempt 1)) # 指数退避 print(操作最终失败跳过本次) return None # ---------- 核心操作 ---------- def register(ip, port, meta): params { serviceName: SERVICE_NAME, groupName: GROUP, ip: ip, port: port, ephemeral: true, metadata: ,.join(f{k}{v} for k,v in meta.items()) } return request_with_retry(requests.post, f{NACOS_SERVER}/nacos/v1/ns/instance, params) def heartbeat(ip, port): params { serviceName: SERVICE_NAME, groupName: GROUP, ip: ip, port: port, ephemeral: true } return request_with_retry(requests.put, f{NACOS_SERVER}/nacos/v1/ns/instance/beat, params) def update_meta(ip, port, meta): params { serviceName: SERVICE_NAME, groupName: GROUP, ip: ip, port: port, metadata: ,.join(f{k}{v} for k,v in meta.items()) } return request_with_retry(requests.put, f{NACOS_SERVER}/nacos/v1/ns/instance, params) def deregister(ip, port): params { serviceName: SERVICE_NAME, groupName: GROUP, ip: ip, port: port } requests.delete(f{NACOS_SERVER}/nacos/v1/ns/instance, paramsparams) # ---------- 主循环 ---------- if __name__ __main__: if len(sys.argv) 2: print(Usage: python mock_gpu_http.py gpu_index) sys.exit(1) gpu_idx int(sys.argv[1]) ip socket.gethostbyname(socket.gethostname()) port BASE_PORT gpu_idx meta get_mock_metadata(gpu_idx) if register(ip, port, meta): print(f[GPU {gpu_idx}] 注册成功: {ip}:{port}, free_mb{meta[memory_free_mb]}) else: print(首次注册失败程序退出) sys.exit(1) try: while True: time.sleep(5) # 心跳是维持生命的必须操作若连续失败可能导致被摘除这里单独做重试 if not heartbeat(ip, port): print(心跳失败可能已被摘除请检查 Nacos 状态) # 更新元数据失败也不致命下次再试 meta get_mock_metadata(gpu_idx) if update_meta(ip, port, meta): print(f[GPU {gpu_idx}] 更新: free_mb{meta[memory_free_mb]}, util{meta[gpu_util]}%) else: print(f[GPU {gpu_idx}] 元数据更新失败下次重试) except KeyboardInterrupt: deregister(ip, port) print(f[GPU {gpu_idx}] 已注销)2.2 启动多个实例打开三个终端分别运行python mock_gpu_http.py 0 # 模拟 GPU 0, 端口 8000 python mock_gpu_http.py 1 # 模拟 GPU 1, 端口 8001 python mock_gpu_http.py 2 # 模拟 GPU 2, 端口 8002登录 Nacos 控制台在服务列表 - gpu-inference - 详情中就能看到三个实例且 metadata 每 5 秒变化一次。三、Java 客户端加权随机 实时订阅Java 端直接使用nacos-client原生 API完全摆脱 Spring Cloud 版本匹配的困扰。核心改进点是加权随机选择和事件订阅解决了惊群效应和缓存延迟问题。3.1 依赖pom.xmldependency groupIdcom.alibaba.nacos/groupId artifactIdnacos-client/artifactId version2.3.2/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter/artifactId /dependency3.2 调度器代码import com.alibaba.nacos.api.NacosFactory; import com.alibaba.nacos.api.naming.NamingService; import com.alibaba.nacos.api.naming.pojo.Instance; import org.springframework.stereotype.Component; import javax.annotation.PostConstruct; import java.util.*; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; Component public class GpuScheduler { private final String serverAddr 192.168.1.140:8848; private NamingService namingService; private final AtomicReferenceListInstance instanceCache new AtomicReference(new ArrayList()); private final Random random new Random(); PostConstruct public void init() throws Exception { namingService NacosFactory.createNamingService(serverAddr); refreshInstances(); // 初始拉取 // 订阅服务变更实时刷新本地缓存 namingService.subscribe(gpu-inference, event - { System.out.println(实例列表变化刷新本地缓存); refreshInstances(); }); } private void refreshInstances() { try { ListInstance instances namingService.getAllInstances(gpu-inference); ListInstance healthy instances.stream() .filter(Instance::isHealthy) .collect(Collectors.toList()); instanceCache.set(healthy); System.out.println(当前健康实例数: healthy.size()); } catch (Exception e) { System.err.println(刷新实例列表失败: e.getMessage()); } } /** * 加权随机选择实例综合考虑剩余显存、GPU 利用率、并发请求数 * 防惊群不总是选最大而是按比例随机 */ public Instance selectBestWeighted() { ListInstance healthy instanceCache.get(); if (healthy.isEmpty()) { throw new RuntimeException(没有可用的 GPU 实例); } // 计算每个实例的综合得分 double[] scores new double[healthy.size()]; double totalScore 0.0; for (int i 0; i healthy.size(); i) { Instance inst healthy.get(i); long freeMem Long.parseLong(inst.getMetadata().getOrDefault(memory_free_mb, 0)); double gpuUtil Double.parseDouble(inst.getMetadata().getOrDefault(gpu_util, 100)); double concurrent Double.parseDouble(inst.getMetadata().getOrDefault(concurrent_requests, 0)); // 得分公式剩余显存越大越好利用率越低越好并发越少越好 double score freeMem * 1.0 (100 - gpuUtil) * 0.3 - concurrent * 50; scores[i] Math.max(score, 1); // 最低权重为1避免0 totalScore scores[i]; } // 轮盘赌选择 /** * 轮盘赌不需要排序它靠“累积区间”决定概率 * 拿你给的代码看假设有三个实例得分分别为 * * GPU 010000 * * GPU 15000 * * GPU 22000 * * 总得分 totalScore 17000。 * * 轮盘赌做的事情是 * 把 [0, 17000) 的区间按得分切成三段 * * [0, 10000) → GPU 0概率 10000/17000 ≈ 58.8% * * [10000, 15000) → GPU 1概率 5000/17000 ≈ 29.4% * * [15000, 17000) → GPU 2概率 2000/17000 ≈ 11.8% * * 然后生成一个 [0, 17000) 的随机数看它落在哪个区间。 * * 这个区间划分完全靠 cumulative 的累加顺序跟数组的原始顺序有关但不需要排序。 因为每个实例的得分已经确定了区间长度顺序只影响区间排列不影响每个实例被选中的概率。比如同样三个得分无论数组是 [10000,5000,2000] 还是 [2000,10000,5000]GPU 0 被选中的概率都是 10000/17000。 * * 因此排序不是必须的。 */ double rand random.nextDouble() * totalScore; double cumulative 0.0; for (int i 0; i healthy.size(); i) { cumulative scores[i]; if (rand cumulative) { return healthy.get(i); } } return healthy.get(0); // 浮点精度兜底 } // 简单获取最佳实例 URL public String getBestUrl() { Instance best selectBestWeighted(); return http:// best.getIp() : best.getPort() /inference; } }3.3 定时任务验证可选import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.Scheduled; SpringBootApplication EnableScheduling public class GpuClientApplication { Autowired private GpuScheduler scheduler; public static void main(String[] args) { SpringApplication.run(GpuClientApplication.class, args); } Scheduled(fixedRate 5000) public void printBest() { try { String url scheduler.getBestUrl(); System.out.println(当前最佳实例: url); } catch (Exception e) { System.err.println(获取失败: e.getMessage()); } } }启动后控制台每隔 5 秒打印一次当前选中的最佳实例 URL你会发现它不会总指向同一台机器而是按照权重在实例间流转。四、为什么这些打磨细节很重要从最初简单的“选最大 free_memory”Demo到上面这个版本我们做了三项关键优化重试机制Python 端网络抖一下就会导致注册失败、心跳中断进而实例被摘除。加入指数退避重试后抗抖动能力大幅提升。加权随机选择Java 端如果所有客户端都盯着“显存最多”的那张卡瞬间流量会把它压垮随后又集体切换到下一张形成震荡。加权随机让请求按比例分散负载自然均衡。事件订阅代替轮询默认getAllInstances可能返回 10 秒前的缓存当元数据变化时感知延迟较大。通过subscribe监听可以秒级刷新本地实例列表调度更及时。这些打磨点正是 Demo 与生产级方案之间的分水岭。五、真实 GPU 环境如何适配只需将 Python 脚本中的get_mock_metadata()换成基于pynvml的真实采集即可其余代码完全不变。示例from pynvml import nvmlInit, nvmlDeviceGetHandleByIndex, nvmlDeviceGetMemoryInfo nvmlInit() def get_real_metadata(gpu_index): handle nvmlDeviceGetHandleByIndex(gpu_index) mem nvmlDeviceGetMemoryInfo(handle) total_mb mem.total // (1024*1024) used_mb mem.used // (1024*1024) free_mb mem.free // (1024*1024) return { gpu_index: str(gpu_index), memory_total_mb: str(total_mb), memory_used_mb: str(used_mb), memory_free_mb: str(free_mb), # gpu_util 和 concurrent 需另外采集此处略 }Java 客户端无需任何改动。六、总结这篇文章给出了一套轻量、可落地的多 GPU 动态调度方案核心价值在于零中间件完全复用 Nacos 的服务发现和元数据能力架构极简生产级打磨重试防抖动、加权随机防惊群、事件订阅保实时代码量少Python 端不到 90 行Java 端一个类搞定可平滑迁移从模拟环境到真实 GPU只需替换数据采集函数如果你正在为多 GPU 推理服务的负载均衡头疼不妨花半小时跑通这个 Demo相信你会打开一扇新的大门。本系列持续更新从 Nacos 核心原理到高阶实战。如果文章对你有帮助欢迎点赞、收藏你的支持是我持续创作的动力