1. 从"微信群喊卡"到自建训练管理平台:我们为什么做Panelai
好几周前,我们实验室的GPU资源管理还是这个画风:某位同学要跑实验,先在群里喊一声"谁有空卡?",然后挨个登服务器看显存占用,确认没人用再手动把任务拉起来。等模型跑完,想复现参数还要翻聊天记录找当时的启动命令。到了月底,想看每张卡的利用率给采购做依据,只能凭记忆拍脑袋。
后来我们做了一个内部训练管理平台,代号就叫Panelai,本意是"AI训练的操控面板"。这次更新的核心是三块:GPU监控、子服务器集群、项目/模型管理。前两个解决"卡去哪儿了""任务跑在哪台机器上"的问题,第三个解决"这个模型是怎么训出来的、训练产物搁哪了"的问题。再加上年前开源版进入冲刺,我打算把这段时间的工程思路、踩坑过程、以及最后几周要收紧的细节一次性讲透。
这套东西不是要给大厂那种几千卡集群做管理的,它的目标很朴素:让一个二三十人的AI团队,不需要专门运维,也能把散落在几台服务器上的GPU用明白。如果你是那种"手上管着几台带卡服务器、又不想靠嘴和Excel维护资源"的人,这篇文章应该能给你不少可以直接抄走的方案。
2. GPU监控模块:别被"显存占用率"骗了,监控要看的是综合健康度
2.1 指标体系的取舍:不只是看显存和利用率
刚开始做GPU监控的时候,我以为就是调NVIDIA的NVML接口,把显存占用、GPU利用率、温度、功耗拽出来画个曲线就完事了。真正接入之后才发现,这里面的坑比预想的多得多。
最大的误区是拿显存占用当作"卡被用着"的依据。显存占用高不代表GPU在做计算,比如有人把数据预加载进显存、或者开了几个常驻进程把显存占着不释放。同样的道理,GPU利用率也不能单看那一栏百分比,CUDA Core的计算利用率和显存带宽利用率经常是两回事。有些算子瓶颈在显存带宽,利用率90%,GPU Core反而闲着;有些小batch的推理任务,GPU Core跑得很欢,但整体利用率也就是20%。
所以我们最后定下来的监控指标分了三层:
- 第一层是资源状态:显存占用、显存总量、GPU利用率、功耗、温度、风扇转速、NVLink带宽(多卡机器必看)。
- 第二层是任务状态:当前这台机器上跑了哪些训练进程、进程占用的显存和算力、启动时间、所属项目。
- 第三层是健康趋势:过去1小时、24小时的负载曲线,异常事件日志,比如掉卡、温度告警、训练进程意外退出。
第三层是最容易被漏掉的。很多监控工具都能展示实时曲线,但"这个卡昨天晚上发生了什么""为什么凌晨三点训练断掉了"这种追溯需求,才是排障最常用的。我们为每一台服务器维护了独立的本地历史数据库,按天滚动保留90天,查询接口支持按时间范围拉取,把这些数据做成对比视图,方便同时看几张卡在同一时段的负载情况。
2.2 数据采集的频率与推送机制:频率太高会拖垮训练
采集频率的设计也走过弯路。刚开始图省事,每2秒采样一次,结果发现NVML的调用虽然本身很轻,但在多卡机器上高频调用,加上每个进程都要汇报,会明显挤占CPU时间片,尤其是在数据加载和CPU预处理密集的任务里,训练速度肉眼可见地掉了几个百分点。
后来我们把采样周期调整成:常规指标每15秒采一次,高频变化的指标(显存、利用率)每30秒取一个均值上报;温度、功耗这种变化慢的指标每60秒一次。实时展示的"当前值"其实是从最近几轮采样里做滑动窗口算出来的。
数据上行用的是轻量的HTTP轮询加一次性推送结合。监控Agent在每台服务器上运行,只负责采集和本地缓存,主控端定期来拉取。遇到断网或主控端重启,Agent会把最近的数据写进磁盘上的临时缓冲文件,等网络恢复后再补传。这样不会因为监控链路抖动丢掉关键时段的数据。
告警也做了分层,而不是一刀切。显存>95%持续超过10分钟才触发"资源紧张"警告;温度超过85度直接触发"硬件告警";GPU利用率30秒内从90%掉到10%,说明训练可能挂了,会立刻发一条通知。通知渠道我们集成了通用的Webhook,可以直接接到企业内部群,不绑定具体厂商的推送服务。
2.3 训练进程与GPU卡的映射:让"哪个进程吃了哪张卡"可追溯
只有指标曲线还远远不够。实际排障时问得最多的问题是:"这张卡到底是谁在用、跑的什么任务?"所以我们把进程级监控也做了进去。
借助NVML的按进程查询能力,可以列出每张卡上各进程占用的显存、SM利用率。我们把这些数据和Panelai里的任务表做关联,能识别出这个进程是不是通过平台发起的训练。如果是平台任务,直接显示项目名、任务ID和启动人;如果不是平台发起的,就标记为"外部进程",显示PID和可执行文件路径。曾经出现过有同学绕开平台直接跑实验,把显存占满导致线上推理服务被挤下线的情况,这个映射功能一下就抓到了真凶。
进程级监控还有一个用途,就是判断显存泄漏。正常训练过程中显存占用应该稳定或有规律的波动,如果看到某个进程的常驻显存持续单调增长,那基本可以断定代码里有泄漏。我们把每小时的显存增长斜率算出来,超过阈值就在项目页面上标一个"疑似内存泄漏"的提示。
3. 子服务器集群:从单机脚本调度到多机任务分发
3.1 架构选型:为什么选择轻量Agent而不是一步到位上K8s
子服务器集群这块,团队内部其实讨论过要不要直接上Kubernetes。但评估到一半就放弃了。核心原因是:我们的场景是GPU训练任务,需要一个能够感知GPU拓扑、支持显存预占和排队调度的调度器,而K8s原生的调度逻辑并不擅长处理这类资源。为了在K8s上跑GPU任务,还要额外引入设备插件、自定义调度器,结果就是给自己制造了一大堆新问题。
所以最终采用了轻量Agent的架构:每一台物理服务器上部署一个独立的Agent进程,负责心跳上报、任务接收、日志回传。主控端只有一个Web服务加一个任务队列数据库。服务器的扩缩容只需要部署Agent然后注册接入,不需要改任何主控端配置。
这样的好处是,任何一个环节挂了都能各自存活。Agent挂了,主控端只是暂时接收不到那台机器的状态,不会影响其他机器上的任务继续跑;主控端挂了,Agent上的训练任务照常进行,只是没有办法再发起新任务和更新状态。这个"各自独立、通过心跳同步"的设计让我们省了很多事。
3.2 机器注册与环境标记:避免任务跑在不兼容的机器上
服务器集群最大的麻烦不是通信,而是环境差异。同样一张A100,有的机器CUDA版本12.1,有的还是11.8;有的机器上装了新版驱动支持,有的卡着旧驱动不敢动。如果把任务随机分发过去,经常是代码都传上去了,一启动就报找不到CUDA库或者驱动版本不支持。
我们的解决办法是给每台服务器打上环境标签。注册机器时,Agent会自动上报操作系统版本、GPU型号列表、驱动版本、CUDA运行时版本、Python版本、关键Python包(PyTorch、TensorFlow)的版本号。用户在提交训练任务时,必须声明任务对环境的依赖,只填一个标准化的"环境配置文件"。调度器做匹配时,会把环境配置文件里的要求与机器标签做比对,不满足直接跳过这台机器,不会出现"跑起来才发现装错环境"的尴尬。
另外,仓库级的依赖冲突我们也做了隔离。每台机器上维护了一个环境目录树,同一个框架版本可以共存多个实例,训练任务通过环境变量指向特定目录。这样即便两个项目要求不同的PyTorch版本,也能在同一台机器上并存互不干扰。
3.3 任务调度与并发控制:显存预占和排队机制
调度逻辑我们做的比较保守,因为训练任务一旦跑起来,中途迁移的成本极高。策略是这样的:
- 任务提交时,调度器先看所有机器的实时显存空闲量,选出显存足够且环境标签匹配的候选机器。
- 在多张卡都满足的情况下,优先选当前空闲显存总量最大的机器,降低碎片化的概率。
- 确定目标机器后,调度器会先"预占"显存,也就是在本地状态里先把显存扣掉,避免并发提交时出现两个任务都被分到同一台机器上。
- 如果所有机器都不满足,任务进入排队,并给出预计等待时间。这个预计值是参考当前队列里高优先级任务的预估时长算出来的,不一定准,但能让大家心里有个底。
预占机制用数据库的原子操作实现:每一张卡都对应一条记录,调度的第一步就是在事务里把目标卡的记录状态改成"已预占",同时写入任务ID。如果后面Agent上报的显存实际占用和预占值偏差太大(比如任务代码里并行加载了很多CPU数据),系统会发起一个校准流程,把预占值修正成实际值。
实际操作下来,这个调度器已经能满足我们每天几十个训练任务的并发规模,而且排队时间比原先"靠肉眼找卡"不知道快到哪里去了。
3.4 心跳、断连与任务迁移的边界条件
心跳机制听起来很简单,但超时时间的设定很讲究。设太短,Agent稍微卡顿一下主控端就误判为机器离线;设太长,机器真的挂了半天才发现,任务恢复不及时。最后我们设定的方案是:Agent每3秒上报一次心跳,主控端连续丢失9个心跳(27秒)判定机器"疑似离线",会再发起一轮主动探测,如果还是不通才标记为"离线"。
标记为"离线"后,该机器上正在运行的任务并不会立刻被调度到别处重跑。因为训练任务的断点续训需要配合检查点机制,盲目的自动迁移很可能把已经跑了几小时的进度白白浪费掉。我们的策略是:前台的任务列表里把受影响的任务标红,提示可能中断;如果任务本身开启了周期性检查点保存,系统允许操作者选择"在另一台机器上从最近检查点恢复";如果没有检查点,那就只能人工确认后重新发起。
断电、拔卡这类硬件故障是躲不掉的,能做的只有两点:一是每台机器上的Agent把重要日志和最新检查点尽量留在本地磁盘的同时,定期同步到文件服务器;二是程序侧引入"检查点抢占"机制,训练框架在每次保存检查点时,确认一下远端备份的版本是最新的,避免本地磁盘损坏后连备份都是旧的。
4. 项目/模型管理:让每个模型都说得清"怎么来的"
4.1 项目作为一等公民:把资源、数据、代码绑在一起
GPU监控和集群调度解决的是"资源怎么分"的问题,项目模型管理解决的是"东西怎么找"的问题。以前我们经常遇到这种情况:过了一个月,看到一个大模型文件,文件夹名字起得还挺清楚,但谁都不记得这个模型是用哪份数据、哪份代码、哪个参数训出来的。
Panelai的项目管理模块就是为了终结这种混乱。每个"项目"在平台上是一个独立的命名空间,里面包含了:
- 代码仓库地址(支持私有仓库的只读权限)
- 运行环境要求(即前面说的环境配置文件)
- 数据集路径,可以是平台上的某个数据版本,也可以是外部存储的路径
- 训练入口脚本和默认超参
- 该项目下的所有任务列表和模型产物
新建项目时,系统会根据代码仓库的默认分支自动拉一个快照,记录commit哈希。训练任务启动时,平台会把当前的commit哈希和关键文件指纹写入任务元数据。这样任何时候回看一个任务,都能看到"这个任务用的代码版本到底是什么",再也不用考记忆力。
4.2 模型版本与训练产物的归档
模型管理这块,我们没有用复杂的大模型注册中心方案,而是采用"文件系统+元数据清单"的组合。每个训练任务结束后,平台会把产出目录(包括权重文件、日志、配置副本、评估指标)整个归档到统一的存储路径下,命名规则是"项目名-任务ID-时间戳"。同时生成一个JSON格式的元数据文件,记录:
- 模型名称和版本标签(比如baseline_v1、finetune_v2)
- 来源任务的ID和commit哈希
- 训练超参的完整JSON
- 数据集版本的引用
- 评估指标结果
- 文件的SHA256校验和
这样做的好处是,模型文件在哪里一目了然,元数据跟着模型走,哪怕拷贝到别的地方也能通过清单文件还原出背景信息。版本对比也很简单:把两个版本的元数据JSON拿来diff一下,就知道超参和数据集的差异在哪。
对"模型版本"我们还做了软链机制。不想每次写死绝对路径,所以每个项目下有一个latest软链指向最新的归档目录,另有v1、v2这种语义化版本标签可以手动指定,便于在推理服务里引用固定版本。
4.3 训练任务与模型的双向追溯
一个比较实用的场景是:线上推理效果突然变差了,怀疑是某个版本的模型出了问题。传统做法是翻部署记录、翻代码提交记录,折腾半天。在我们的Panelai里,推理服务部署时会记录"模型版本标签",点开就能看到这个模型来自哪个任务、用的是什么数据、谁在什么时候把它设置为线上版本。反过来的追溯也支持:从一次训练任务的详情页可以看到它产出的所有模型文件,以及哪些环境里正在引用这些模型。
另外,我们在模型归档时会自动计算每个检查点的训练进度和损失曲线,生成的曲线图会嵌入模型详情页。这样即使训练过程中途挂了,后来从某个检查点继续训练,也能看到"从这个检查点接着训之后收敛情况如何",判断是不是该从更早的检查点重来。
5. 开源版年前的冲刺规划:什么该保、什么该砍
5.1 技术栈与部署形态的最终选择
Panelai开源版的构建,我们选了一个对中小团队最友好的组合:后端用FastAPI + PostgreSQL + Redis,前端用Vue3加一个开源的仪表盘框架;监控数据采集的Agent用Python实现,方便二次开发;所有组件都打包成Docker镜像,通过Docker Compose一键拉起。
不少朋友问过为什么不用更高性能的方案。我们的权衡是:这个平台的使用场景是几十台服务器的规模,瓶颈根本不在后端性能,而在易部署性和二次开发的门槛。FastAPI能在一两天里把接口都理清楚,PostgreSQL足够存几百万条监控采样(配合时间分区),Redis用来做队列和WebSocket实时推送,任何一个有Python基础的人都能看懂整个系统的代码结构,这就够了。
Docker Compose的编排分三块:核心服务(Web、数据库、缓存)、Agent(可选,也可以直接在一台服务器上以裸进程方式跑)、以及一个可选的存储服务(用来统一归档模型产物)。默认的快速安装模式是docker compose up -d一条命令起全部服务,后面再用独立的命令把各台机器的Agent拉起来注册进来。
5.2 功能优先级:核心链路打磨优于功能铺量
离发布只剩几周,我们对功能的取舍非常明确。年前冲刺必须保证这三条链路跑通、跑稳:
- 资源入网:新服务器一键注册、Agent自动上报环境信息、GPU状态可见。
- 任务闭环:提交任务、调度匹配、在多机间分发、实时看日志和指标、产出归档。
- 模型可溯:训练结束后自动生成元数据,模型版本可标记,查看任务能定位到模型产物。
一些虽然好用但容易增加复杂度的功能,我们先砍掉了:多租户的权限细分(保留基础的角色控制就够了)、完整的计费统计、自动化的多机断点续训、推荐系统的因素分析等,都留在下一个版本再做。
安全方面,开源默认配置会把所有需要鉴权的接口统一挂到一个网关后面,只暴露最小的端口集合。平台本身不提供操作服务器终端的通道,远程命令执行能力默认关闭,需要的人可以自己加一层跳板。这样能把"开源后被人拿去当跳板机"的风险降得很低。
5.3 文档与示例:让大家真的装得上、跑得动
只有代码没有文档的开源项目,基本等于没开源。我们正在准备三类内容。第一份是面向新手的快速开始指南,从准备一台带GPU的服务器,到注册一台设备、发起一个训练任务,全程截图和命令都验证过两遍;第二份是管理员手册,讲清楚环境标签怎么设、告警阈值怎么调、模型归档目录怎么规划;第三份是API文档,方便有人想把它集成到自己的工具链里。
示例项目我们准备了一个小的图像分类训练脚本,做了一个"提交任务"按钮,点下去能看到一个标准任务的完整生命周期。这个例子不复杂,但把环境和产物管理都串起来了,起码让大家第一次跑通的时候有东西可以对照。
6. 开发过程中踩过的几个真坑,写出来帮大家避雷
6.1 显存曲线毛刺和采样频率的拉锯战
前面提到采样频率,实际调试中出现过一个让人费解的Bug:监控面板上的显存曲线,明明任务稳定运行,却每隔几分钟出现一次掉到零的"大坑"。查了半天,发现是Agent在采集时,偶尔会因为进程列表请求超时,把一次无效读数当成0上报了。后来给采样模块加了一层数据有效性校验:显存值变化超过50%时,如果下一个采样周期没有延续,就判定为异常读数,直接丢弃再修正曲线。这个问题解决之后,告警误报率直接降了一个量级。
类似的坑在温度监控上也出现过。冬天机房温度低,某台机器回风温度触发了告警阈值,原因是风扇策略处于低转速状态。我们调整了告警阈值的策略,把"温度绝对阈值"增加了一个"温度变化率"条件,短时升温过快才告警,单纯低温不打扰。
6.2 心跳"假死"和CPU占用异常
Agent的心跳线程早期用的是最简单的一次HTTP请求一个线程的模式,连接数一多,线程频繁创建销毁,CPU占用接近40%,这在一台要跑训练的机器上完全不可接受。后来改成线程池加连接池复用,心跳请求合并成一个批量接口,把监控上报的序列化和网络开销摊在一起,CPU占用降到了5%以下。
另外心跳本身也有一个反模式:不要在一个死循环里发完请求就sleep,这样万一请求时间抖动,心跳的实际间隔会漂移。更好的做法是记录上一拍时间,用"下一次应该发送的时间"减去当前时间得到等待时间,保证平均间隔稳定,不会因为某次网络慢导致整条心跳连发。
6.3 模型文件损坏的隐患:归档前的校验省了一整天的麻烦
有一段时间,训练任务结束后归档的模型偶尔无法加载,损失函数和训练日志对不上,查了很久发现是因为归档时文件还在被训练进程的异步写入线程使用,复制过去的文件其实是半截的。排查到这一步其实已经晚了,正确姿势是在训练脚本里约定"先显式保存检查点并落盘,再发归档通知"。同时,归档动作结束后,平台会在目标存储上重新计算一次SHA256,和训练节点上报的值做比对,不一致就直接把这条归档标为"损坏",提醒重跑归档。
这件事给我一个很深的体会:平台能帮人规范流程,但前提是规范本身得够硬。靠自觉记住"保存完之后再归档"完全不靠谱,一定要靠接口层的约定和校验兜底。
6.4 环境标签被忽略导致的任务错配
环境匹配规则上线后,仍然出现过一次任务被派到错误机器的案例。跟踪日志发现,Agent上报的Python版本和用户任务配置文件里的版本号写法不一致:一个写"3.10.9",另一个写"3.10",简单字符串匹配直接判为不兼容。类似的情况还有"ubuntu20.04"和"20.04"这种写法差异。
解决方案是把环境标签的匹配逻辑改成语义化版本比较,而不是字符串相等匹配。系统和用户都只维护一个"语义版本号",解析完毕后分别比较主版本、次版本、修订号,主次版本相同才认为是兼容的。这个坑不算复杂,但如果不统一规则,后面会被各种写法的标签反复折磨。
7. 写在年前冲刺的小结
Panelai这次的三个模块——GPU监控、子服务器集群、项目/模型管理——开发到现在,最直接的感受是:它们其实解决的是同一件事,就是让AI团队的资源使用从"靠口头传话"变成"系统里有据可查"。监控不是目的,调度不是目的,模型管理也不是目的,最终目的是让负责训练的同学和负责资源的同学,都能把精力放回模型本身。
最后分享一个我自己体会最深的小技巧:如果你也想搭类似的平台,别急着从零开发重度功能,先把"一台服务器注册进系统、能看到GPU状态、能提交一个训练任务、能回看训练日志"这条最小链路跑通,再逐步加东西。这个最小闭环看起来简单,但它能逼你把环境、调度、存储、监控的接口真正理顺,后面的扩展就有了地基。我们也正是把这块地基夯实了,才敢在年前集中精力冲刺开源版发布。