MindSpore Transformers训练实时监控实战:Callback+WebSocket+ECharts
前阵子我调一个 Deformable DETR 的微调实验模型用 MindSpore Transformers 套件加载睡前看了一眼 loss 还在 0.8 附近心里想着“还行明早起来应该能收敛”。结果第二天打开终端屏幕上一行刺眼的 loss: nan而且这个 nan 大概从第二个 epoch 中段就开始出现了。也就是说整整半宿的训练都在原地空转。从那以后我就明白一件事训练大模型光靠终端日志是赌运气。必须把训练过程在线监控起来把 loss、lr、梯度范数、吞吐量这些指标实时画成曲线图出了问题第一时间就能从曲线形态上看出来而不是等天亮才发现。这篇文章就把我在 MindSpore Transformers 训练里落地的一套在线监控方案完整写出来包括实时曲线图可视化的整体设计、基于 Callback 的指标采集、WebSocket 加 ECharts 的前后端代码以及接进 Deformable DETR 这类 Transformer 模型的实战过程。1. 为什么训练必须做在线监控1.1 终端日志只能回答“发生了什么”曲线能回答“正在发生什么”很多人觉得训练监控不就是把 loss 打印出来吗LossMonitor 加 TimeMonitor 已经是标配了。但终端日志有两个天然缺陷第一它是离散的你看到的是一个一个数字而不是一段趋势。loss 从 0.8 掉到 0.6 再涨回 0.7你根本看不出这是正常波动还是模型在发散第二它是事后的你今天晚上睡不着凌晨两点爬起来看一眼日志发现早上八点就已经 nan 了中间六个小时全浪费了。实时曲线图解决的就是这两个问题横轴是 step纵轴是 loss 或其它指标曲线的形状、斜率、突变点都是可读的信息。loss 是缓慢下降还是突然抬头lr 是线性衰减还是出了尖刺梯度范数是不是在某一步突然爆到几十这些从一条连续曲线上能一眼判断而终端日志要翻几十屏才能对出个大概。1.2 实时曲线能提前暴露的五类训练问题我总结了自己踩过的坑训练过程中的大多数“事故”都会在曲线形态上留下痕迹而且往往比 loss 变成 nan 更早出现。第一类是梯度爆炸loss 曲线在某个 step 处出现明显的垂直上升尖峰紧接着后面几个 step 全是 nan这是优化器被炸穿的前兆需要盯梯度范数曲线。第二类是学习率策略失效lr 曲线本该线性衰减却变成了台阶状或者 warmup 阶段过了之后 loss 反而一路横盘说明 warmup steps 配多了。第三类是数据加载瓶颈throughput每秒样本数曲线在某个 epoch 后突然从 2000 掉到 300往往是 dataloader 的 shuffle 或者预处理队列出了问题模型在空等数据。第四类是过拟合信号验证集 loss 在训练集 loss 还在下降时就开始反弹两条曲线的交叉点就是早停的位置。第五类是硬件资源异常GPU 利用率曲线如果呈锯齿状说明通信开销已经超过了计算收益分布式并行配置需要调整。这些信息如果只靠终端日志很难在第一时间捕捉到。1.3 在线监控不是锦上添花是止损工具我之前也觉得监控这东西属于“锦上添花”模型能跑就行搞一堆可视化是浪费时间。直到有一次在云上租了八卡机器跑 BERT 微调实验跑了两天发现 loss 根本就没动过原因是数据集里 label 全部错位了模型一直在学噪声。如果当时在 loss 曲线旁边放一条 accuracy 曲线第一轮就能看出来这是死局。在线监控本质上是止损工具它让实验失败的成本从“几天”压缩到“几十分钟”。特别是 Transformer 这类大模型单次训练动辄几个小时起步哪怕提前半小时发现问题省下的都是真金白银的算力。这也是我为什么坚持要把监控指标做得尽量全loss 只是结果梯度范数和 lr 才是过程只看结果等于等诊断报告出来了才去医院。2. 方案选型MindInsight 还是自研可视化2.1 原生方案SummaryCollector 与 MindInsightMindSpore 生态里自带了一套可视化方案训练时挂一个 SummaryCollector训练结束后启动 MindInsight 服务就能在浏览器里看标量曲线、计算图、数据图之类的面板。接入成本很低代码基本就是三五行from mindspore.train.callback import SummaryCollector summary_collector SummaryCollector(summary_dir./summary_dir, collect_freq10) model.train(epoch_size, train_dataset, callbacks[summary_collector])训练跑完以后在命令行启动 MindInsightmindinsight start --port 8080 --summary-base-dir ./summary_dir浏览器打开http://localhost:8080就能看到训练过程中记录的标量曲线。这套方案的好处是白嫖不写一行前端代码对标量、图片、计算图都有支持而且和 MindSpore 的算子层做了打通某些指标在 NPU 上采集更准确。但它在我实际的“在线监控”需求下有几个痛点一是延迟MindInsight 的数据是从 Summary 文件里异步刷新的训练过程中打开页面曲线要隔一段时间才会跳一下更像是事后复盘不是真正意义的实时二是自定义指标不灵活我想看梯度范数、吞吐量这些指标SummaryCollector 默认是不采集的得自己去处理 trainable_params 的梯度绕一圈很麻烦三是服务比较重如果只是想在本地快速看一眼训练状态启动一个 MindInsight 服务有点杀鸡用牛刀。2.2 为什么我还要自研一套轻量可视化MindInsight 适合离线分析但我要的是“训练还在跑我喝着咖啡就能看到曲线在动”。这个诉求在技术形态上其实很简单训练进程把指标写出来浏览器定时拉或者通过 WebSocket 被推送过去。自研方案可以完全按自己的需求裁剪想监控什么指标就加什么指标想用什么前端图表库就用什么甚至可以把告警逻辑接进去比如 gradient norm 超过阈值就在页面上弹红。另外一个很实际的原因是MindSpore Transformers 套件里的训练脚本封装层次比较深我想往里面加一个旁路监控从 callback 或者 train loop 里把指标透传出来直接改套件源码不现实自研方案反而能作为一个独立模块解耦在外面训练主流程不需要大动。综合下来我选择了一个轻量级方案Callback 采集指标写入 JSONL 文件一个独立进程监控文件变化并通过 WebSocket 推送浏览器端 ECharts 渲染实时曲线。2.3 整体架构与数据流整套监控系统的数据流分四个环节训练进程、日志文件、WebSocket 服务、浏览器前端。环节职责关键技术点训练进程通过 Callback 在 step_end 或 epoch_end 里采集指标MindSpore Callback 机制解析 net_outputs日志文件把指标按行追加写入 JSONL 文件用文件解耦训练进程和网络服务避免网络 IO 阻塞训练WebSocket 服务轮询 JSONL 文件尾部把新数据广播给所有订阅的浏览器websockets 库维护连接集合浏览器前端接收推送数据动态更新 ECharts 曲线ECharts setOption增量更新 series为什么要把指标先写文件而不是在 Callback 里直接发 WebSocket这是我在最开始就踩过的坑。训练进程是算力密集型任务如果在 step_end 里同步发网络请求哪怕只是毫秒级的阻塞遇到网络抖动就可能拖慢训练速度。文件写入是本地磁盘操作开销极低而且天然具备持久化能力训练崩了之后还能从文件里回放整个训练曲线。独立的 WebSocket 服务进程即使挂了也不影响训练主流程重启服务之后它可以继续从文件尾部读数据相当于断点续传。这套解耦设计带来的稳定性远大于多写几行代码的成本。3. 核心代码实践Callback 采集与实时推送3.1 训练进程自定义 Callback 采集指标MindSpore 的 Callback 机制和很多框架一样提供step_begin、step_end、epoch_begin、epoch_end这些钩子。我要采集的核心指标有四类loss、learning rate、吞吐量、梯度范数。其中梯度范数稍微特殊一点因为它并不在默认的cb_params里需要在自定义训练循环或者 hook 里额外拿。为了优先保证链路能跑通我先把 loss、lr、throughput 这三种最容易拿到的指标做进第一版梯度范数作为一种增强指标放在后面单独说明。import json import time from mindspore.train.callback import Callback class LiveMonitor(Callback): def __init__(self, log_filemetrics.jsonl, log_interval10): super().__init__() self.log_file log_file self.log_interval log_interval self._step_time None def epoch_begin(self, run_context): self._step_time time.time() def step_end(self, run_context): cb_params run_context.original_args() step cb_params.cur_step_num loss self._parse_loss(cb_params.net_outputs) lr self._parse_lr(cb_params) if step % self.log_interval ! 0: return now time.time() throughput self.log_interval / (now - self._step_time) if self._step_time else 0.0 self._step_time now record { step: step, loss: loss, lr: lr, throughput: round(throughput, 2), ts: now, } with open(self.log_file, a, encodingutf-8) as f: f.write(json.dumps(record) \n) def _parse_loss(self, outputs): if hasattr(outputs, asnumpy): return round(float(outputs.asnumpy()), 6) if isinstance(outputs, (tuple, list)): # 自定义 TrainOneStepCell 时输出可能是 (loss, ...) return round(float(outputs[0].asnumpy()), 6) return round(float(outputs), 6) def _parse_lr(self, cb_params): opt cb_params.optimizer if opt is None: return 0.0 lr opt.learning_rate if hasattr(lr, value): lr lr.value() try: return round(float(lr.asnumpy()), 8) except Exception: return round(float(lr), 8)这里有几个细节需要注意。cb_params.net_outputs在不同场景下类型不一样用Model.train且传了loss_fn时它通常是 loss 的 Tensor如果你自定义了TrainOneStepCell它可能是 tuple所以_parse_loss里做了兼容。_parse_lr里的opt.learning_rate可能是动态学习率对象这种对象有value()方法调用后拿到当前 step 的实际学习率值也可能直接就是固定数值或者 Tensor所以包了一层异常处理。通过log_interval控制写入频率很重要如果每个 step 都写一次一条 2000 step 的训练就会生成 2000 行 JSON页面渲染也会越来越卡一般设成 10 或 20 比较合适。3.2 中转层JSONL 文件加 WebSocket 广播有了 metrics.jsonl 这个文件接下来需要一个独立的进程把新写入的行推给前端。这里我直接用websockets库实现逻辑很薄维护一个连接集合定时轮询文件尾部的新增内容广播给所有客户端。import asyncio import json import os import websockets CONNECTIONS set() LOG_FILE metrics.jsonl POLL_INTERVAL 0.5 async def register(ws): CONNECTIONS.add(ws) try: async for _ in ws: pass finally: CONNECTIONS.remove(ws) async def watch_log(): last_pos 0 while True: if os.path.exists(LOG_FILE): with open(LOG_FILE, r, encodingutf-8) as f: f.seek(last_pos) lines f.readlines() last_pos f.tell() for line in lines: line line.strip() if line and CONNECTIONS: # 每个连接的 send 都放到任务里并发执行 await asyncio.gather(*[ws.send(line) for ws in CONNECTIONS]) await asyncio.sleep(POLL_INTERVAL) async def main(): async with websockets.serve(register, 0.0.0.0, 8765): await watch_log() if __name__ __main__: asyncio.run(main())这段代码要注意两点。一是websockets.serve的 handler 签名在不同版本里不一样老版本是async def register(ws, path)新版本只接收ws一个参数如果启动时提示参数不匹配把register改成async def register(ws, pathNone)或者只保留一个参数即可。二是asyncio.gather并发发送因为一个客户端连接卡住不应该影响其他客户端。实际生产环境可以把它做得更健壮比如加一个发送超时保护连接断开时从集合里移除否则异常连接会一直堆积。3.3 浏览器端用 ECharts 画实时曲线前端我选 ECharts因为它的折线图交互完整支持缩放、tooltip、多 Y 轴而且社区庞大遇到问题好查。页面逻辑很简单WebSocket 连接服务收到一条记录就解析成 JSON然后 push 到对应的数据数组里再setOption更新图表。!DOCTYPE html html langzh-CN head meta charsetUTF-8 titleMindSpore 训练实时监控/title script srchttps://cdn.jsdelivr.net/npm/echarts5/dist/echarts.min.js/script /head body div idchart stylewidth: 100%; height: 600px;/div script const chart echarts.init(document.getElementById(chart)); const steps []; const losses []; const lrs []; const throughputs []; chart.setOption({ tooltip: { trigger: axis }, legend: { data: [loss, lr, throughput] }, grid: { left: 80, right: 80, top: 50, bottom: 50 }, xAxis: { type: category, name: step, data: steps }, yAxis: [ { type: value, name: loss }, { type: value, name: lr }, { type: value, name: throughput } ], series: [ { name: loss, type: line, data: losses, smooth: true, yAxisIndex: 0 }, { name: lr, type: line, data: lrs, smooth: true, yAxisIndex: 1 }, { name: throughput, type: line, data: throughputs, smooth: true, yAxisIndex: 2 } ] }); const ws new WebSocket(ws://localhost:8765); ws.onmessage (event) { const data JSON.parse(event.data); steps.push(data.step); losses.push(data.loss); lrs.push(data.lr); throughputs.push(data.throughput); // 只保留最近 1000 个点防止长时间训练把浏览器撑爆 if (steps.length 1000) { steps.shift(); losses.shift(); lrs.shift(); throughputs.shift(); } chart.setOption({ xAxis: { data: steps }, series: [ { data: losses }, { data: lrs }, { data: throughputs } ] }); }; ws.onerror (err) { console.error(WebSocket error:, err); }; /script /body /html多 Y 轴这里我特意做了分层loss 放左边第一根轴lr 和 throughput 放右边第二、第三根轴。原因是 loss 量纲通常是 0 到几lr 是 1e-4 这种小数throughput 可能是几百上千如果全放在一根轴上小数量级的曲线会被压成一条直线什么都看不出来。ECharts 的多 Y 轴模式可以很好地解决这个问题代价是图例和 tooltip 需要花点时间调得顺手。steps.length 1000这个滑动窗口很重要Transformer 训练动辄几万 step数组无限增长会让浏览器渲染帧率掉到个位数到时候曲线图卡得连鼠标都拖不动。3.4 在 Transformers 训练脚本中挂载监控监控模块写好后接入训练脚本很容易。以 MindSpore Transformers 套件里的模型为例如果你是用Model.train这种偏底层的 API 训练的直接在 callbacks 列表里加一个实例就行from mindspore import Model from mindspore.train.callback import LossMonitor, TimeMonitor model Model(networknet, loss_fnloss_fn, optimizeroptimizer, metrics{acc: acc_metric}) callbacks [ LiveMonitor(log_file./logs/metrics.jsonl, log_interval20), LossMonitor(per_print_times20), TimeMonitor() ] model.train(epoch_size, train_dataset, callbackscallbacks)如果你用的是套件自带的高层 Trainer 封装一般也会在初始化参数里暴露callbacks或者trainer.callbacks这类的透传字段把LiveMonitor实例传进去即可。我有一个习惯不管用哪种方式都在训练脚本里单独把log_file参数提出来方便每次实验写到不同的目录比如logs/exp001/metrics.jsonl这样后面回放和对比实验都很方便。VS Code 里跑这整套东西也很顺手工作区选好装了 MindSpore 的 Python 解释器左边终端跑训练右边起 WebSocket 服务浏览器开监控页所有操作在一个 IDE 里就能完成。4. 实战从迷你 Transformer 到 Deformable DETR4.1 用迷你 Transformer 快速验证监控链路监控代码写出来之后直接拿真实大模型调试链路是不明智的数据加载和模型初始化太慢一个问题要等很久才能复现。我先写了一个极简的 Transformer 分类模型配合随机生成的数据先把整条链路跑通。这段代码的意义不在模型精度而是验证 Callback 采集到的数据能稳定出现在浏览器页面上。import numpy as np import mindspore as ms from mindspore import nn, dataset as ds class MiniTransformer(nn.Cell): def __init__(self, vocab_size1000, d_model64, nhead4, num_layers2, num_classes10): super().__init__() self.embedding nn.Embedding(vocab_size, d_model) self.encoder_layer nn.TransformerEncoderLayer(d_model, nhead) self.encoder nn.TransformerEncoder(self.encoder_layer, num_layers) self.classifier nn.Dense(d_model, num_classes) def construct(self, input_ids): x self.embedding(input_ids) x self.encoder(x) x x.mean(axis1) return self.classifier(x) def fake_data(num_samples2000): for _ in range(num_samples): seq np.random.randint(0, 1000, (32,)).astype(np.int32) label np.random.randint(0, 10, ()).astype(np.int32) yield seq, label train_dataset ds.GeneratorDataset(fake_data(), column_names[input_ids, label]).batch(8) net MiniTransformer() loss_fn nn.SoftmaxCrossEntropyWithLogits(sparseTrue, reductionmean) optimizer nn.Adam(net.trainable_params(), learning_rate1e-3) model Model(net, loss_fnloss_fn, optimizeroptimizer) model.train(3, train_dataset, callbacks[LiveMonitor(log_file./logs/demo_metrics.jsonl, log_interval10)])跑起来之后你会在demo_metrics.jsonl里看到每 10 步一条记录比如{step: 10, loss: 2.302, lr: 0.001, throughput: 108.5, ts: 1735387200.12}如果这一步浏览器页面没有数据问题基本出在 WebSocket 服务没启动、端口不对或者 JSONL 文件路径不一致。先用命令行curl ws://localhost:8765或者浏览器控制台看 WebSocket 连接状态可以快速定位是哪一环断了。4.2 上真实模型接入 Deformable DETR 微调迷你模型验证通过后就可以把同一套监控挂到真实模型上。我这次实验用的是 MindSpore Transformers 套件里的 Deformable DETR 模型做目标检测微调。Deformable DETR 是 DETR 的改进版把可变形注意力机制引入 Transformer收敛速度比原始 DETR 快不少是视觉 Transformer 里很有代表性的检测模型。接入的核心思路和迷你 Transformer 完全一样只有两点不同。第一Deformable DETR 的输出和 loss 结构比分类模型复杂得多net_outputs可能是一个包含了分类 loss、回归 loss、IoU loss 的 tuple甚至总 loss 是多个 loss 的加权和。我的LiveMonitor._parse_loss在处理只要第一个元素是总 loss 的 tuple 时是可行的但如果套件返回的是字典结构就需要在_parse_loss里做额外适配。第二检测模型训练时显存占用高batch size 往往很小throughput 的绝对数值没有参考价值它的价值在同一次训练中的相对变化throughput 曲线突然往下掉通常说明数据处理有瓶颈。Deformable DETR 的配置里有一组关键参数直接决定训练曲线长什么样backbone用 ResNet 还是 Swinnum_queries默认 300transformer层的d_model默认 256num_encoder_layers和num_decoder_layers默认各 6这些配置会直接影响 loss 收敛速度。我习惯在启动训练前把模型的配置文件也留档一份并把它和 metrics.jsonl 放同一个实验目录这样回放曲线的时候能迅速对上号。4.3 从曲线形态反推训练状态曲线图跑起来之后重点不是“看着它动”而是能从形态里读出模型状态。我总结了几个高频场景。第一个场景最常见loss 曲线在 warmup 阶段先涨后跌新手容易误判为模型坏了。很多 Transformer 模型的学习率策略是 warmup 加 cosine decaywarmup 阶段 lr 从很小往上涨loss 出现一定幅度的抬升是正常的关键看 warmup 结束之后 loss 是否开始稳定下滑。如果 warmup 都过了 loss 还在持续上升那才是真有问题。第二个场景是梯度范数尖峰。我在监控里额外加了一条 grad_norm 曲线方法是在自定义 TrainOneStepCell 里把梯度的全局范数拼接进输出或者直接用一个 hook 在反向传播后取梯度。梯度范数的意义在于它比 loss 更早暴露训练不稳。如果 grad_norm 曲线在几百个 step 的平稳后突然出现一个几十倍于基线的尖峰那通常意味着某一个 batch 的数据异常或者参数更新步长过大这时候应该立刻降低 lr或者在优化器里加梯度裁剪。第三个场景是 throughput 阶梯式下降。这个我在分布式训练里遇到过多个 epoch 之后 throughput 从 1200 掉到 600查了一圈发现是 MindSpore 的数据集对象没有正确执行repeat导致每个 epoch 结束后重建数据集重建过程卡住了训练循环。通过实时曲线把 throughput 和 loss 放在一起看问题定位会快得多。5. 常见问题与排查技巧实录5.1 高发问题速查表这套监控系统我在本地和云服务器上都跑过也踩过不少坑。下面这张表是我最常遇到的问题和对应解法可以直接当排查手册用。现象可能原因排查与解决浏览器页面一直空白没有曲线更新WebSocket 服务没启动或者端口不一致先确认python monitor_server.py在运行再确认前端连接的是同一个 IP 和端口Callback 没有写入 JSONL 文件dataset_sink_modeTrue导致 Callback 在设备侧执行异常在model.train里显式设置dataset_sink_modeFalse或者改用 summary collector_parse_loss报错拿到的是 tuple 但下标不对网络输出结构不是期望的(loss,)先打印type(cb_params.net_outputs)和内容再调整解析逻辑训练结束后文件里有数据但 WebSocket 推不过来文件读取位置last_pos定位不准确保每次打开文件都要seek到上一次的位置不要每次从 0 开始读多卡训练时每个卡都写文件数据混乱所有 rank 都在跑 Callback在 Callback 里判断 rank只有rank_id 0的进程写文件长时间训练后页面变卡前端数组无限增长用滑动窗口只保留最近 1000 个点或者用 ECharts 的 sampling 策略降采样loss 在曲线图上全是 nan数据或梯度已经爆了监控本身没问题降低学习率、加入梯度裁剪、检查数据预处理是否出现 NaN5.2 几个容易忽略的细节第一个细节是日志文件的覆盖策略。我一开始用open(log_file, w)每次启动训练都会清空旧文件。后来发现对比实验回放时需要保留每次实验的完整记录于是改成a追加模式并且文件名带时间戳或者实验编号。这样同一份代码跑多个实验时每个实验都有一份独立的 JSONL 记录复盘时直接按实验 ID 搜索。第二个细节是 WebSocket 服务的重启问题。如果训练已经跑了一半WebSocket 服务挂了重启服务后它从 JSONL 尾部继续读前面的历史数据不会补发给前端。也就是说页面上只会有重启之后的新数据。如果需要完整的训练回放最方便的办法是打开页面后调用一个历史数据接口先加载已有的 JSONL 内容再续接实时推送。我实现时是把这两步拆开的前端先 fetch 一下 JSONL 的静态内容再建立 WebSocket 连接。这样即使中途打开监控页面也能看到从开头到现在的完整曲线。第三个细节是梯度范数的采集。我前面说可以在 TrainOneStepCell 里做但这个做法在不同 MindSpore 版本里 API 差异不小。我踩过一次坑是升级 MindSpore 之后自定义 TrainOneStepCell 的梯度计算函数改名了整个训练脚本直接跑不起来。后来我换了一种更省心的方式不对训练主流程下手单独在模型网络的 construct 里返回一个辅助输出。具体做法是给网络的 forward 返回值后面拼一个 loss然后在 Callback 里同时解析总 loss 和辅助 loss用两条曲线的差值近似判断梯度状态。这个方案不优雅但极其稳定几乎不随框架版本变化而失效。5.3 关于数据落盘与回放的一点建议整套监控跑通之后最让我受益的反而不是“实时”这两个字而是数据的落盘回放能力。训练中断了第二天想复盘直接把对应的 JSONL 文件拖进一个离线页面整条训练曲线瞬间重现。我甚至可以为了某个异常 step 把曲线局部放大看它前 50 步到底发生了什么。这个回放能力依赖一个最简单的设计所有指标都带 step 和时间戳按行存在纯文本文件里没有任何二进制格式。如果你要长期积累实验记录这个设计值得参考。另外再提一句 MindInsight 的用法。我虽然在“在线监控”场景里自研了一版但事后分析时还是会用 MindInsight因为它的细节面板更完善能看到算子级的数据。我的建议是两者配合训练中用自研轻量方案做实时看板训练结束后用 MindInsight 做深度分析。SummaryCollector 的collect_freq参数记得设得大一点比如 50 或者 100否则 Summary 文件会膨胀得很快磁盘爆满的坑我踩过一次。6. 最后再说几句掏心窝的话这套监控系统我最常用的一句话是先看趋势再定位问题。很多人喜欢把页面做得很炫堆一堆指标结果训练的时候盯着曲线图反而更焦虑。我的经验是先保证三条核心曲线loss、lr、grad_norm这三条能覆盖训练过程中大多数异常。等你真的遇到瓶颈了再去加 throughput、显存利用率、数据加载耗时这些辅助指标。另一个实际体会是监控代码不要过度设计能够以最小代价挂进不同训练脚本比功能全面更重要。我现在的做法是把 LiveMonitor、WebSocket 服务、前端页面打包成一个独立的monitor目录每个实验只需要在训练脚本里加一行from monitor import LiveMonitor然后传一个日志路径完事。这样无论是跑 BERT 分类、Deformable DETR 检测还是别的 Transformer 模型接入成本几乎为零。最后分享一个小技巧每次启动长时间训练前盯着监控页面看前 100 个 step。如果前 100 步的曲线形态就不对千万不要抱着“再跑跑看”的侥幸心理。训练不会自己变好只会越跑越歪。前面已经看了曲线图就不要再赌运气了。