上个月我被本地显卡折腾得够呛一个小型图像分类任务6G显存的卡跑ResNet级别的模型batch size稍微开大就OOM开小一点又慢得让人想关电脑。折腾了两周后我决定把训练和部署整体搬到华为云ModelArts上完整跑一遍顺便把整个流程整理成这篇学习笔记。这篇内容记录了从OBS数据准备、训练作业配置、模型转换到在线服务部署的全过程也包含我在里面踩过的几个坑和排查思路给同样打算第一次用ModelArts做模型训练与部署的朋友做个参考。1. 为什么折腾ModelArts本地GPU把我磨得没脾气1.1 本地训练的三座大山先说本地训练的困境。第一座大山是显存。6G的显存看起来能跑不少模型但真训练起来就捉襟见肘输入图片分辨率稍高一点batch size就只能压到8甚至4梯度更新不平滑模型收敛也慢。第二座大山是环境CUDA版本、cuDNN、PyTorch版本、一堆依赖库这些一旦混在一起就容易打架有时候重装驱动就要耗掉一晚上。第三座大山是时间本地训练时机器不能干别的cpu和GPU都被占着想同时写代码调参基本不可能。有人说那租云服务器不就行了但租一台裸机后还是要自己装驱动、配环境、搭训练脚本部署模型又要单独搞一套流程本质上问题没有消失只是从一台机器换到了另一台机器。我需要的其实是一个能从数据上传一直管到模型发布的全流程平台而不是一台更昂贵的“裸机”。1.2 ModelArts解决的是哪类问题ModelArts是华为云上的一站式AI开发平台我这次用下来最大的感受是它把训练和部署中间的“胶水工作”包掉了。你不需要再自己维护训练机的操作系统、驱动、框架镜像也不需要单独搭推理服务因为平台已经提供了训练作业和在线服务这两套托管能力。它的核心部件包括几个训练作业填好算法框架、资源规格、数据路径和输出路径平台自动拉起训练容器。模型管理训练产物可以自动注册成模型版本也可以从OBS手动导入其他模型包。在线服务模型版本一键部署成可对外调用的HTTP API自带负载均衡和日志采集。资源池可以选用公共资源池按需计费也可以创建专属资源池独享算力。对于我第一次跑通流程来说公共资源池按需计费就够用了。具体计费方式按规格和时长算用多少扣多少不用囤卡这点我后面专门算一笔账。1.3 我这次准备跑的任务为了验证全流程我选了一个不算太大但能体现完整性的任务垃圾图片分类。数据集大概有20个类别每个类别几百张图总共不到2万张图片单张尺寸统一压缩到224x224。模型用ResNet50预训练权重做微调训练脚本基于PyTorch写全部流程在ModelArts上完成。之所以选这个任务是因为图片分类是训练部署的经典入门场景ResNet50也是热搜词里出现频率很高的预训练模型权重和推理逻辑都相对成熟出了问题容易排查。如果你打算跑自己的数据集流程也是一样的换数据、换模型名就行。2. 数据先行把训练集从本地搬到OBS2.1 建桶与目录规划ModelArts本身不存数据训练时数据要从OBS对象存储服务拉取。所以第一步是在华为云控制台创建一个OBS桶。我的桶名是modelarts-study区域和你后续创建训练作业的区域要保持一致否则跨区域访问又慢又会产生额外流量费。桶创建好之后目录结构我建议一开始就规划清楚别随手乱堆。我这次的结构如下modelarts-study/ ├── dataset/ │ ├── train/ │ │ ├── glass/ │ │ ├── paper/ │ │ └── plastic/ │ └── val/ │ ├── glass/ │ ├── paper/ │ └── plastic/ └── output/ └── model/train和val下面按类别建子目录这种结构不只是方便人看也方便训练脚本用标准的ImageFolder直接加载数据不需要额外写复杂的标签映射逻辑。2.2 上传数据与检查上传工具我推荐用obsutil命令行工具因为文件量大的时候比网页上传省心太多还支持断点续传。安装好obsutil并配置AK/SK之后一条命令就能同步整个目录。./obsutil cp /home/user/data/dataset obs://modelarts-study/dataset -r上传完成后我额外做了一个检查这也是我第一次踩坑后养成的好习惯。有一批图片下载不完整文件头损坏训练时数据加载直接抛异常。我在本地先跑了一段脚本扫描图片损坏的图片直接剔除from PIL import Image import os broken [] for root, _, files in os.walk(data/dataset): for f in files: path os.path.join(root, f) try: img Image.open(path) img.verify() except Exception: broken.append(path) print(fbroken images: {len(broken)}) for p in broken: os.remove(p)这一步听起来麻烦但它能帮你排除训练中一类非常隐蔽的报错。我曾遇到过训练到一半突然崩掉最后发现就是数据里有几张损坏的图平台日志里只显示DataLoader worker died不查数据根本找不到原因。2.3 数据路径如何传给训练作业ModelArts训练作业对数据路径有一套约定平台会把你在控制台填的data_url从OBS下载到训练容器里的本地路径同时提供一个train_url对应的本地输出目录。也就是说训练脚本里不需要硬编码obs://开头的外部路径只处理本地文件就行。# 控制台填写的两个参数 data_urlobs://modelarts-study/dataset train_urlobs://modelarts-study/output/model训练容器启动后平台会自动把data_url指向的内容同步到容器本地的一个临时目录并把/tmp/output这样的本地目录映射回train_url。你在脚本里只需要这样读取参数import argparse parser argparse.ArgumentParser() parser.add_argument(--data_url, typestr) parser.add_argument(--train_url, typestr) args parser.parse_args() train_root os.path.join(args.data_url, train) val_root os.path.join(args.data_url, val)把路径交给平台而不是自己拼接这是ModelArts想让你遵循的规则。一开始我不理解为什么控制台一定要填这两个参数后来才明白这相当于平台帮你把“云上存储”和“容器本地磁盘”两套文件系统打通了。3. 创建训练作业写好脚本后的一次点击3.1 选算法框架预置算法还是自己写的脚本ModelArts训练作业可以选择“预置算法”或“自定义算法”。预置算法相当于平台内置好的训练模板你只需要填几个超参数就能跑对新手很友好。我这次选择用自定义算法因为我想在ResNet50的顶层加几个全连接层做微调并且想完全控制数据加载、数据增强和优化器逻辑。选择自定义算法后需要提交一个训练脚本压缩包同时指定需要使用的镜像。镜像方面我直接选择了ModelArts预置的PyTorch镜像比自己从零构建镜像省事得多。预置镜像里已经包含了PyTorch、TorchVision、OpenCV这些常用库连国内pip源都配好了下载预训练权重基本不会卡住。关于训练脚本的入口平台会执行你在控制台填写的“启动命令”通常是这样一行python train.py --epochs 20 --batch-size 32 --lr 1e-33.2 训练脚本里的关键参数训练脚本本身和本地训练差别不大但有几个细节值得说明。数据增强我用了随机裁剪和水平翻转ImageFolder天然按子目录生成标签损失函数用交叉熵。整个脚本的核心部分如下from torchvision import datasets, transforms, models train_transform transforms.Compose([ transforms.RandomResizedCrop(224), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) train_set datasets.ImageFolder(train_root, transformtrain_transform) train_loader torch.utils.data.DataLoader( train_set, batch_sizeargs.batch_size, shuffleTrue, num_workers4 ) model models.resnet50(pretrainedTrue) num_classes len(train_set.classes) model.fc torch.nn.Linear(model.fc.in_features, num_classes)超参数我整理成了一张表方便直接照抄参数值说明base modelResNet50使用ImageNet预训练权重epochs20全部数据跑20轮batch size32单卡V100下不会OOMoptimizerSGDmomentum0.9, weight_decay1e-4learning rate1e-3前5轮用warmup后15轮余弦退火num_workers4数据加载进程数学习率策略我没有用固定的1e-3而是在代码里做了一小段warmup前几个epoch让学习率从很小的值线性升到目标值之后再逐渐下降。这样做是为了避免预训练模型在初期被过大的梯度冲垮微调场景下很管用。输出方面我在每个epoch结束都保存一版checkpoint到args.train_url保留最好的那个模型权重torch.save({ model: model.state_dict(), classes: train_set.classes, epoch: epoch, acc: val_acc }, os.path.join(args.train_url, best_model.pth))train_url指向本地输出目录训练结束后平台会自动把该目录内容回传到OBS所以你不需要自己写任何OBS上传代码。3.3 算力规格、资源池与日志观察训练作业的创建页面上我选了PyTorch 2.0预置镜像规格选了单卡V100资源池用公共资源池。第一次做完整训练我先用2个epoch试跑了一遍确认数据加载、模型前向反向、checkpoint保存都没问题再跑完整20个epoch。这种做法能帮你把“配置错误”和“真实训练问题”快速分开。训练过程中最重要的观察窗口是日志。ModelArts训练作业详情页自带日志面板可以直接查看容器内stdout输出。我当时盯着日志主要看三样东西第一个epoch的loss值是否正常、GPU利用率是否持续在工作、每个epoch结束时验证集的准确率有没有逐步上升。best_model.pth最终会出现在OBS的modelarts-study/output/model目录下。如果你在日志里看到Uploading model files这样的输出说明平台正在把训练产物回传OBS这时候再去OBS控制台刷新就能看到完整的模型文件列表。3.4 模型训练常见问题loss为NaN训练过程中我突然发现loss变成了NaN这是模型训练里最经典也最让人头疼的报错之一。我当时观察到的现象是前几个epoch正常第5个epoch开始loss突然变成nan。很多人遇到这种情况第一反应是“数据有问题”但排查要一层层来。我按这个顺序排查的可能性判断方法我这次的结论学习率过大看loss是否从某个epoch开始突然变NaN有一定嫌疑但前几轮warmup很平稳lr并不大数据中有NaN或无穷大检查输入tensor是否存在非有限值图片是标准RGB检查后没有NaN优化器权重衰减过大观察weight_decay相关loss项1e-4不算大预训练权重加载异常看模型第一层输出分布归一化参数反了导致输入范围异常混合精度计算溢出检查是否开启AMP没开AMP排除最后的根因居然是标准化参数的顺序写错了。数据集的mean和std顺序与预训练权重不一致导致输入图片被标准化后出现极端数值模型经过BN层后梯度不稳定最终loss变NaN。修正方法很简单把mean/std改回与ImageNet预训练一致问题立刻消失。处理NaN类问题的通用建议是先固定随机种子复现再逐段缩小范围看loss是在哪一步开始异常的不要一上来就重训整个模型。4. 模型入库把权重变成可部署的推理服务4.1 ModelArts推理模型包长什么样训练完成后OBS里只有一个best_model.pth权重文件和类别映射ModelArts在线服务不能直接识别这种裸权重必须把它打包成平台约定的模型包结构。这个模型包通常长这样model/ ├── config.json ├── customize_service.py ├── resnet50.pth ├── class_names.json └── 其他依赖文件config.json负责声明模型类型、推理镜像和依赖安装方式。我写的最小配置文件如下{ model_type: PyTorch, runtime: python3.9, dependencies: [ { installer: pip, packages: [torch2.0.1, torchvision0.15.2, Pillow] } ] }customize_service.py则包含推理处理的入口类。平台会根据config.json中的声明找到这个文件在服务启动时加载模型权重并执行预处理、预测、后处理三个步骤。4.2 编写推理脚本ModelArts推理脚本核心是实现一个继承自BaseModelInference的类平台会自动调用你实现的方法。下面是我在这类任务中使用的实现模板import json import torch import torch.nn.functional as F from torchvision import transforms from PIL import Image from io import BytesIO from model_service.base_model_inference import BaseModelInference class UserService(BaseModelInference): def __init__(self, model_path, **kwargs): super().__init__(model_path, **kwargs) with open(model/class_names.json, r) as f: self.class_names json.load(f) self.model torch.load(model_path, map_locationcpu) self.model.eval() self.transform transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) def _preprocess(self, data): image_bytes data[image].encode(utf-8) image Image.open(BytesIO(base64.b64decode(image_bytes))).convert(RGB) tensor self.transform(image).unsqueeze(0) return tensor def _postprocess(self, data): probs F.softmax(data, dim1)[0] topk torch.topk(probs, k3) results [ {class: self.class_names[idx], score: float(prob)} for idx, prob in zip(topk.indices, topk.values) ] return {results: results}实现_preprocess时要注意请求发过来的图片通常是Base64字符串必须先解码才能交给PIL。_postprocess负责把tensor变成可读结果我用softmax加topk的方式输出前三名类别这样调用方能看到置信度排序而不只是一个标签。写完customize_service.py和config.json后把两个文件和权重一起放置进model/目录整体压缩成zip或者直接以目录形式上传到OBS。4.3 创建模型与部署在线服务在ModelArts控制台左侧选择“模型管理”点击“创建模型”来源有两种选择一种是从训练作业直接导入平台会把训练产物的目录映射成初始模型包另一种是从OBS手动导入自定义模型包。我这次用第二种因为我的模型包结构是在本地调整好的。创建模型时填好名称和版本选择OBS路径平台会校验模型包格式。如果校验失败最常见的原因是config.json格式写错或者customize_service.py里类的路径不对。校验通过后模型会出现在模型列表里状态变为“正常”。然后是部署在线服务。进入模型详情页点击“部署”在线服务配置页有几个字段要填配置项我填的值说明服务名称waste-classify-v1需要全局唯一资源规格CPU 2核 4GB推理任务不重CPU足够实例数1先跑通不提并发服务流量限制默认按实际请求量调整点击部署后服务会先进入“创建中”显示进度条随后自动变成“运行中”。从创建到运行大概需要几分钟这个时间主要花在拉取推理镜像和初始化模型上。5. 推理验证与服务治理不只跑通那么简单5.1 用curl测试接口服务部署成功后控制台会给出一个调用地址。认证方式有很多种最直接的是使用华为云IAM的临时Token请求头里带上X-Auth-Token。调用时把本地图片Base64编码后放在JSON里curl -X POST https://your-endpoint/v1/xxx/predictions \ -H X-Auth-Token: $TOKEN \ -H Content-Type: application/json \ -d {data: {req_data: [{image: base64字符串}]}}我第一版返回的结果是{ results: [ {class: paper, score: 0.912}, {class: plastic, score: 0.052}, {class: glass, score: 0.021} ] }看到这个结果说明从数据准备到训练再到部署的整条链路已经通了。需要注意的是返回结果里的score不是置信度严格意义上经过校准的概率但对于分类排序来说完全够用。5.2 服务监控与自动停止在线服务一旦部署成功它就是一个持续运行并且持续计费的实例所以监控和成本控制必须同步考虑。ModelArts在线服务页面会展示每个实例的调用次数、平均响应时间、错误率等基础指标。我另外设置了一条监控习惯每天早上去控制台看一眼“日志”页签日志里记录了每次请求的时间、输入大小和推理耗时。如果发现某次请求耗时明显偏高通常就是图片太大或并发突增导致的需要调整实例规格或者增加实例数。更关键的是不用的时候要停掉服务。ModelArts在线服务支持手动停止和自动停止。我排查几次后把它设置为“自动停止”模式超过24小时没有任何调用就自动释放实例。这样即使周末忘了手动关也不会白白扣费。5.3 成本算账训练一次到底花了多少钱学习笔记如果不写钱就是耍流氓我把这次全流程的成本按大致区间列了一下。需要说明的是不同区域、不同规格价格会有差异下面只是基于我实际使用的量级估算项目规格时长说明训练作业单卡V100公共资源池约1.5小时20个epoch含数据加载在线服务CPU 2核4GB约5小时后停止测试和调试使用OBS存储标准存储按天计费数据加模型合计约2GB流量内网/公网少量主要在上传数据时总费用大概是一顿工作餐的价格。如果使用Ascend或者更大规格GPU费用会相应升高但相比自己买一块卡闲置按需付费的性价比在面对偶发性训练任务时优势非常明显。6. 这次实操我踩过的坑以及给第一次使用者的建议6.1 三个最典型的坑第一个坑是OBS路径写成了s3://开头。ModelArts支持的是obs://前缀。这个错误很隐蔽因为页面上的输入框不会校验前缀训练作业启动后会一直卡在数据同步阶段。排查时看训练作业的事件日志会发现平台不断尝试从错误路径拉取数据。第二个坑是Python依赖版本不一致。训练容器里用的torch可能是2.0系列本地验证推理脚本时用的可能是2.1两者的torch.load保存格式在极少数情况下会有差异。我的解决办法是创建模型包前先明确权重保存时所用的版本并在config.json的依赖列表里固定版本号避免推理镜像启动后自动装上不同版本。第三个坑是checkpoint加载方式不对。我训练时保存的是包含model、classes、epoch等信息的字典但推理脚本一开始直接torch.load(model_path)把它当作state_dict传给了模型结果报错缺key。改成先加载字典再取[model]就正常了。checkpoint torch.load(model_path, map_locationcpu) self.model.load_state_dict(checkpoint[model])这类问题本质是“训练代码和推理代码对模型文件格式的理解不一致”建议在训练脚本里把保存结构写清楚并单独写一个推理加载函数两边共用。6.2 梳理一下最省心的流程经过这次操作我给自己总结了一套比较顺手的流程可以用来跑图像分类之外的很多任务数据先过一遍清洗脚本剔除损坏文件统计类别数量和样本分布。训练脚本先在本地小规模跑通确认可以过几个step。ModelArts上先跑小epoch数试训确认日志、checkpoint、OBS回传都正常。确认无误后再跑完整训练期间定期看日志。训练完成后用标准模型包结构整理产物本地mock一个请求验证推理脚本能跑。最后创建模型、部署在线服务、用curl实测。这套流程看起来多了一步“试训”但省掉的是后面反复调试部署的几小时。我在试训阶段发现过数据路径错误、依赖缺失、checkpoint保存失败等多个问题如果直接跑完整训练甚至直接部署排查起来会痛苦得多。6.3 最后分享一条实操体会ModelArts这类平台真正提升效率的地方不是某个按钮有多么智能而是把数据、训练、部署三段工作串成了一条清晰的流水线。你在本地训练时需要亲手处理的脏活累活平台用约定好的目录结构、参数规则和模型包规范帮你接管了。只要愿意花半小时把平台规范读明白后面实操基本不会碰到太多意外。我现在的习惯是每个新项目都先用手头数据跑通这条链路把训练脚本和推理模板沉淀成自己的固定模版下次有新任务直接替换数据和模型名称就能复用。