
写 PyTorch 训练脚本这件事入门时觉得不就是个 for 循环真到项目里才发现坑一个接一个Dataset 读数据慢得像蜗牛、DataLoader 的 num_workers 一调就崩、Scheduler 的 step 位置放错导致学习率曲线莫名其妙、训练跑了 8 小时断电后没法接着练。这篇就围绕 Dataset、DataLoader、Train/Eval Loop、Scheduler、Checkpoint 这五块核心把一套可以直接抄作业的完整训练循环从头搭一遍涉及断点续训和随机状态复现这些平时文档里不太讲透的细节也一并说清。适合刚上手 pytorch 实战、想摆脱现成 Trainer 框架、或者训练脚本总在奇怪地方报错的人。1. 先把训练循环拆成 10 个零件整体设计与思路我见过太多人一上来就把所有逻辑塞进一个 200 行的train.py结果调试的时候连哪一步出的问题都定位不到。所以第一步不是写代码而是把完整训练循环这个概念拆开。一个能跑通、能复现、能续训的训练流程本质上就是 10 个互相依赖的环节串成一条线每个环节都有它存在的理由。1.1 为什么不用现成的 Trainer 框架有人会问Lightning、HuggingFace Trainer 这些框架不香吗香但它们把很多东西封装掉了。你调一个trainer.fit()背后的 DataLoader 怎么起 worker、scheduler 什么时候 step、checkpoint 存了哪些 key全被藏起来了。平时跑标准任务没问题一旦要定制采样策略、改损失权重、接自定义指标就得钻进去读源码。我的做法是先用原生 PyTorch 手写一遍完整的循环把每个环节都摸透之后用框架才有底气,知道它到底替你做了什么、哪些地方它做得不一定对。另一个现实原因是可控性。生产环境里经常有非标准需求比如训练中途动态调整某些层的学习率、按验证集指标回滚到某个历史 checkpoint、把多个数据集按不同权重混合。这些在封装框架里实现起来反而绕。1.2 十个环节的依赖关系把这 10 个环节列出来你能清楚看到谁依赖谁序号环节依赖核心作用1环境与设备无确定 CPU/GPU、版本匹配2数据准备 Dataset无定义单样本的读取逻辑3数据加载 DataLoaderDataset批量化、打乱、并行读取4模型/损失/优化器设备定义前向与参数更新目标5训练循环 Train Loop3、4完成单 epoch 参数更新6验证循环 Eval Loop3、4无梯度评估泛化能力7学习率调度 Scheduler4、5控制学习率随时间变化8Checkpoint 保存4、5、6、7冻结训练状态到磁盘9Resume Training8从磁盘恢复全部状态10随机状态复现全流程保证结果可复现这张表建议贴在显示器边上。你以后调任何训练问题先判断它属于哪一环定位效率会高很多。比如损失突然爆炸先看第 4 环损失函数和优化器验证指标不涨但训练损失降多半是第 6 环评估逻辑或第 3 环数据划分有问题。1.3 一个原则状态和逻辑分开我在设计时坚持一个原则——凡是会跨 epoch 存活的变量都当成状态单独管理。模型参数、优化器动量、scheduler 的步数、当前 epoch、最佳指标、混合精度的 scaler这些都是状态。把它们集中成字典统一保存和加载第 8、9 环就能写得非常干净。逻辑部分前向、反向、算指标则保持无状态写成纯函数。这个划分方式在后续排查 resume 出错时帮了大忙因为出错的地方几乎总在状态恢复上。2. 环境准备与工程目录先把地基打平环境这块看着简单栽跟头的人不少。pytorch 安装教程网上一大堆但版本、CUDA、Python 三者对不上号是高频事故。我一般先用 conda 建独立环境再装对应版本的 PyTorch最后在编辑器里确认能识别到这个环境。2.1 conda 创建环境与安装 PyTorchconda create -n pth_train python3.10 -y conda activate pth_train # CPU 版本做教程和调试够用 pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu # GPU 版本以 CUDA 12.1 为例先确认自己显卡驱动支持的 CUDA 版本 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121安装完一定要验证别装完就走import torch print(torch.__version__) print(torch.cuda.is_available()) print(torch.cuda.device_count())torch.cuda.is_available()返回 False 但有独显八成是装了 CPU 版、或者驱动和 CUDA 版本不匹配。这种情况别急着重装系统先nvidia-smi看驱动支持的最高 CUDA 版本再回官网 PyTorch 页面选对应的安装命令。用 vscode anaconda 的朋友记得在左下角把解释器切到刚建的pth_train否则代码里 import torch 会找不到。2.2 工程目录怎么摆一个能长期维护的训练项目目录我通常这样组织project/ ├── configs/ # 超参数配置yaml 或 py ├── data/ # 原始数据不建议放这太占空间用软链 ├── src/ │ ├── dataset.py # Dataset 定义 │ ├── model.py # 模型结构 │ ├── engine.py # train_one_epoch / evaluate │ ├── utils.py # checkpoint、种子、日志 │ └── train.py # 主入口串流程 ├── checkpoints/ # 保存模型和状态 ├── logs/ # 文本日志、tensorboard └── requirements.txt把 train 逻辑和 engine 拆开的好处是engine 里的train_one_epoch可以在不同项目间直接复用只换 Dataset 和模型就行。checkpoint 和日志单独目录方便做完实验一键清理。这套结构不算复杂但能省掉以后到处找文件的痛苦。3. Dataset 与 DataLoader数据管道才是性能大头很多人训练慢第一反应是换更强的显卡实际上瓶颈常常在数据读取。GPU 利用率忽高忽低、显存占满但算力没跑满基本是 DataLoader 供给跟不上。这一环把 Dataset 写对、把 DataLoader 参数调顺训练速度能提升一大截。3.1 自定义 Dataset 的写法与要点PyTorch 的 Dataset 只有两个必须实现的方法__len__返回样本总数__getitem__按索引返回单样本。新手最容易犯的错是在__getitem__里做重活比如每次都重新读整个文件、重新做一遍昂贵的预处理。import torch from torch.utils.data import Dataset class MyDataset(Dataset): def __init__(self, features, labels, transformNone): # 轻量数据可以直接放内存重的路径索引只存地址 self.features features self.labels labels self.transform transform def __len__(self): return len(self.labels) def __getitem__(self, idx): x self.features[idx] y self.labels[idx] if self.transform is not None: x self.transform(x) return x, y关键点__init__里只做一次的事就别放到__getitem__里。图像任务里那些 resize、归一化如果能在__init__预计算就预计算不能预计算的比如随机增强也要保证单次开销尽量小。我做过一个对比把读图和 resize 从__getitem__挪到预处理好之后同样的 GPU 训练吞吐翻了三倍多。注意__getitem__在多进程下会被多个 worker 同时调用里面不要修改实例的共享状态否则会出现难查的竞态。要改就改局部变量。3.2 DataLoader 参数怎么选DataLoader 的参数里真正影响性能的是这几个参数作用选择建议batch_size每批样本数显存能吃满的前提下尽量大shuffle是否打乱训练集 True验证集 Falsenum_workers并行加载进程数从 CPU 核数的 1/2 起步调pin_memory锁页内存用 GPU 时开 Truedrop_last丢弃不满一批训练集建议 True防 BN 抖动persistent_workers保持 worker 存活epoch 多且 num_workers0 时开num_workers不是越大越好。设成 CPU 核数会互相抢资源尤其在数据增强吃 CPU 的任务上反而变慢。我的经验是从 4 起步用nvidia-smi看 GPU 利用率如果一直在 90% 以上说明数据供给够了利用率掉到 60% 以下就往上加直到找到拐点。pin_memoryTrue配合 GPU 训练能明显减少 host 到 device 的拷贝时间这个几乎无脑开。persistent_workersTrue则避免每个 epoch 结束时销毁再重建 worker 进程如果你有几十个 epoch省下的启动时间很可观。from torch.utils.data import DataLoader train_loader DataLoader( train_set, batch_size64, shuffleTrue, num_workers4, pin_memoryTrue, drop_lastTrue, persistent_workersTrue, )在 Windows 上把 num_workers 设大于 0整个训练入口必须包在if __name__ __main__:里否则会疯狂创建进程直到内存爆掉。这是新手最常踩的坑之一。3.3 collate_fn 处理变长数据默认的 collate 会把一批样本堆成 tensor要求每个样本形状一致。遇到变长序列文本、不定长点云就得自定义collate_fn在批内做 padding。from torch.nn.utils.rnn import pad_sequence def collate_fn(batch): xs, ys zip(*batch) xs pad_sequence(xs, batch_firstTrue, padding_value0) ys torch.stack(ys) return xs, yspadding 完记得配一个 mask或者在损失里设ignore_index否则模型会把补的 0 也当成真实数据学指标虚高。这个细节不写出来很多人会卡很久觉得模型学了个寂寞。4. 模型、损失与优化器的初始化这一环像是给训练装发动机配置错了后面全白搭。初始化顺序、设备迁移、参数组的划分都有讲究。4.1 设备迁移和参数注册模型要.to(device)数据也要.to(device)两者漏一个就会报 tensor 不在同一设备的错。我喜欢在开头统一device torch.device(cuda if torch.cuda.is_available() else cpu) model MyModel().to(device)有个容易忽略的点如果模型里有手动注册的 buffer比如位置编码表、均值方差用.to(device)会一起搬但如果它们不是 buffer 也不是 parameter而是普通属性就不会跟着走。traced 或 torchscript 场景下这个问题尤其隐蔽记得用register_buffer注册。4.2 损失函数与优化器的搭配损失函数的选择取决于任务但有个通用提醒PyTorch 的 CrossEntropyLoss 内部自带 softmax所以模型最后一层不要再接 softmax否则等价于算了两遍数值上还不稳定。这是 pytorch 入门阶段最高频的错误。优化器方面Adam 收敛快、对学习率不敏感适合快速验证想法SGD momentum 泛化有时更好但要精调学习率。参数组划分是进阶技巧——不同层用不同学习率比如微调时 backbone 用小学习率、分类头用大学习率optimizer torch.optim.AdamW([ {params: model.backbone.parameters(), lr: 1e-5}, {params: model.head.parameters(), lr: 1e-3}, ], weight_decay1e-2)划分参数组时注意同一个参数不要出现在两个组里否则行为未定义。另外权重衰减一般不加在 bias 和归一化层的参数上讲究的话也可以分组处理。5. Train/Eval Loop把循环写对这是训练的核心也是最容易写脏的地方。我的标准是训练和验证各写成一个函数接收模型、loader、优化器返回指标字典不带任何全局状态。5.1 训练循环骨架def train_one_epoch(model, loader, optimizer, criterion, device, scalerNone, grad_clipNone): model.train() total_loss, total_correct, total_num 0.0, 0, 0 for x, y in loader: x, y x.to(device, non_blockingTrue), y.to(device, non_blockingTrue) optimizer.zero_grad(set_to_noneTrue) with torch.cuda.amp.autocast(enabledscaler is not None): logits model(x) loss criterion(logits, y) if scaler is not None: scaler.scale(loss).backward() if grad_clip is not None: scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), grad_clip) scaler.step(optimizer) scaler.update() else: loss.backward() if grad_clip is not None: torch.nn.utils.clip_grad_norm_(model.parameters(), grad_clip) optimizer.step() # scheduler.step() 的位置在第 6 章展开 total_loss loss.item() * x.size(0) total_correct (logits.argmax(1) y).sum().item() total_num x.size(0) return {loss: total_loss / total_num, acc: total_correct / total_num}几个细节值得展开。optimizer.zero_grad(set_to_noneTrue)比默认写法省内存因为梯度直接置 None 而不是填零推荐用。non_blockingTrue配合pin_memory能实现异步拷贝卡住同步的开销就省下来了。累加指标时乘x.size(0)是为了最后除以真实样本数用 batch 平均值直接相加在最后一个不满批的情况下会有偏差。5.2 验证循环与 no_grad验证循环结构几乎一样区别是必须关掉梯度torch.no_grad() def evaluate(model, loader, criterion, device): model.eval() total_loss, total_correct, total_num 0.0, 0, 0 for x, y in loader: x, y x.to(device, non_blockingTrue), y.to(device, non_blockingTrue) logits model(x) loss criterion(logits, y) total_loss loss.item() * x.size(0) total_correct (logits.argmax(1) y).sum().item() total_num x.size(0) return {loss: total_loss / total_num, acc: total_correct / total_num}model.eval()会切换 Dropout 和 BatchNorm 的行为这一步千万别漏否则验证指标会随机波动看起来像是过拟合其实只是没切模式。验证集的所有计算都要放在torch.no_grad()或torch.inference_mode()里。inference_mode更省内存但不适合需要反向的场景纯推理两者都行。5.3 梯度累积与梯度裁剪显存不够但又想要大 batch 效果时梯度累积是标准方案把小 batch 的梯度攒几次再更新一次。accum_steps 4 for i, (x, y) in enumerate(loader): loss criterion(model(x.to(device)), y.to(device)) / accum_steps loss.backward() if (i 1) % accum_steps 0: optimizer.step() optimizer.zero_grad(set_to_noneTrue)注意损失要先除以累积步数保证梯度尺度等价于大 batch。梯度裁剪则在优化器更新前用clip_grad_norm_能有效防止 RNN 或 transformer 训练中的梯度爆炸阈值一般设 1.0 或 5.0 起步。6. Scheduler学习率调度的接入位置与坑Scheduler 是看起来简单放错位置就全错的典型。它决定学习率随训练推进怎么变直接影响收敛质量。6.1 常用 Scheduler 对比Scheduler变化方式适用场景StepLR每隔 N 个 epoch 乘 gamma传统 CNN节奏固定CosineAnnealingLR余弦曲线衰减到最小值transformer、大多数任务OneCycleLR先升后降搭配 AdamW收敛快ReduceLROnPlateau指标不降时降 LR不确定训练时长时LambdaLR自定义函数Warmup 任意曲线我大部分任务用 CosineAnnealingLR 或 OneCycleLR 起步前者稳定后者收敛快但对总步数敏感。ReduceLROnPlateau 比较特殊它需要传入验证指标scheduler.step(val_loss)别传错了。6.2 step 的调用时机这里是最出名的一个坑。PyTorch 1.1.0 之前scheduler.step()要在optimizer.step()之前调用1.1.0 之后反过来optimizer.step()先走scheduler.step()后走。顺序放反控制台会打印Detected call of lr_scheduler.step() before optimizer.step()的警告学习率也会错位。还有一个维度是 epoch 级还是 step 级。StepLR、CosineAnnealingLR 默认按 epoch 走放在 epoch 循环末尾OneCycleLR 必须按 step 走放在每个 batch 的optimizer.step()之后for epoch in range(epochs): for x, y in train_loader: optimizer.step() if isinstance(scheduler, torch.optim.lr_scheduler.OneCycleLR): scheduler.step() if not isinstance(scheduler, torch.optim.lr_scheduler.OneCycleLR): scheduler.step()用ReduceLROnPlateau时更要分清它要在验证结束后调scheduler.step(val_loss)放在 epoch 循环里验证之后。6.3 Warmup 与余弦的组合Transformer 类模型几乎都要 Warmup先让学习率从很小线性升到峰值再余弦衰减。PyTorch 自带的 scheduler 没有直接的 warmup可以用 LambdaLR 自己写def lr_lambda(step): if step warmup_steps: return step / max(1, warmup_steps) progress (step - warmup_steps) / max(1, total_steps - warmup_steps) return 0.5 * (1.0 math.cos(math.pi * progress)) scheduler torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)用 LambdaLR 时记得按 step 调用。warmup 的作用是防止训练初期参数被大梯度带飞尤其是深层网络。我做过对比加了 500 步 warmup 的 transformer 比不加固的前几个 epoch 的损失曲线平滑太多。7. Checkpoint 与 Resume Training训练中断后能不能无损接着练是工程化程度的分水岭。核心思想就一句把所有跨 epoch 的状态都存下来加载时一五一十还原。7.1 checkpoint 里到底存什么很多人只存model.state_dict()结果 resume 后学习率从头开始、优化器动量归零效果直接打折。完整的状态应该包含def save_checkpoint(path, model, optimizer, scheduler, scaler, epoch, best_metric, configNone): ckpt { epoch: epoch, model: model.state_dict(), optimizer: optimizer.state_dict(), scheduler: scheduler.state_dict() if scheduler is not None else None, scaler: scaler.state_dict() if scaler is not None else None, best_metric: best_metric, config: config, } torch.save(ckpt, path)少存 scheduler 状态学习率就会跳回初始值余弦调度直接乱套少存 scaler混合精度恢复后前几个 step 会有梯度缩放异常。这些细节不踩一次不会重视。7.2 断点续训的完整实现加载时用map_location指定设备避免在没有 GPU 的机器上报错def load_checkpoint(path, model, optimizerNone, schedulerNone, scalerNone, devicecpu): ckpt torch.load(path, map_locationdevice) model.load_state_dict(ckpt[model]) if optimizer is not None and ckpt.get(optimizer) is not None: optimizer.load_state_dict(ckpt[optimizer]) if scheduler is not None and ckpt.get(scheduler) is not None: scheduler.load_state_dict(ckpt[scheduler]) if scaler is not None and ckpt.get(scaler) is not None: scaler.load_state_dict(ckpt[scaler]) return ckpt.get(epoch, 0), ckpt.get(best_metric, 0.0)主循环里承接返回的 epoch从下一个 epoch 继续start_epoch, best_acc 0, 0.0 if resume_path: start_epoch, best_acc load_checkpoint(resume_path, model, optimizer, scheduler, scaler, device) start_epoch 1 for epoch in range(start_epoch, total_epochs): ...注意start_epoch 1因为 checkpoint 存的是已完成的 epoch要从下一个接着练。这块差一错误很常见。7.3 随机状态复现想在 resume 后完全复现原训练轨迹光恢复模型和优化器还不够随机数状态也得恢复。涉及 Python random、numpy、torch 三处import random, numpy as np, torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark Falsecudnn.deterministicTrue会让卷积算法固定代价是可能慢一点但对复现是必要的。benchmarkFalse同理开了它会让 cudnn 自动选最快的算法结果就不一定可复现了。DataLoader 的多进程 worker 也会影响随机性需要给 generator 和 worker_init_fng torch.Generator() g.manual_seed(seed) def worker_init_fn(worker_id): np.random.seed(seed worker_id) train_loader DataLoader(..., generatorg, worker_init_fnworker_init_fn)resume 时如果要严格复现 shuffle 顺序还得存g.get_state()并在加载时g.set_state(...)。这一层比较极端大部分场景下只要固定 seed 就够了。一个实用建议给 checkpoint 做本版本管理保存时按epoch_{n}_acc_{v}.pt命名同时维护一个last.pt和best.pt。断了直接加载last.pt线上推理用best.pt两不耽误。8. 完整代码实战把所有零件拼起来前面拆开讲了这一章把它们连成一条能跑的流水线。用一个合成分类任务演示你可以直接把 Dataset 换成自己的。import os, math, random import numpy as np import torch import torch.nn as nn from torch.utils.data import Dataset, DataLoader device torch.device(cuda if torch.cuda.is_available() else cpu) set_seed(42) # ---------- Dataset ---------- class SyntheticDataset(Dataset): def __init__(self, n5000, dim32, classes10): torch.manual_seed(0) self.x torch.randn(n, dim) w torch.randn(dim, classes) self.y (self.x w).argmax(1) def __len__(self): return len(self.y) def __getitem__(self, idx): return self.x[idx], self.y[idx] train_set SyntheticDataset(5000) val_set SyntheticDataset(1000) train_loader DataLoader(train_set, batch_size64, shuffleTrue, num_workers0, pin_memoryTrue, drop_lastTrue, generatorg) val_loader DataLoader(val_set, batch_size128, shuffleFalse, num_workers0, pin_memoryTrue) # ---------- Model / Loss / Optimizer ---------- model nn.Sequential( nn.Linear(32, 128), nn.ReLU(), nn.Linear(128, 128), nn.ReLU(), nn.Linear(128, 10), ).to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-2) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max20) scaler torch.cuda.amp.GradScaler(enabled(device.type cuda)) # ---------- Loop ---------- total_epochs 20 best_acc 0.0 os.makedirs(checkpoints, exist_okTrue) for epoch in range(total_epochs): train_metrics train_one_epoch(model, train_loader, optimizer, criterion, device, scalerscaler, grad_clip5.0) val_metrics evaluate(model, val_loader, criterion, device) scheduler.step() lr_now optimizer.param_groups[0][lr] print(fepoch {epoch:02d} | train_loss {train_metrics[loss]:.4f} ftrain_acc {train_metrics[acc]:.4f} | val_loss {val_metrics[loss]:.4f} fval_acc {val_metrics[acc]:.4f} | lr {lr_now:.6f}) save_checkpoint(checkpoints/last.pt, model, optimizer, scheduler, scaler, epoch, best_acc) if val_metrics[acc] best_acc: best_acc val_metrics[acc] save_checkpoint(checkpoints/best.pt, model, optimizer, scheduler, scaler, epoch, best_acc)train_one_epoch和evaluate用第 5 章的实现即可。这段代码里每个环节都对应前面讲的一块跑起来后你会看到学习率随余弦曲线下降、验证准确率逐步上升。20 个 epoch 在合成数据上几分钟就能出结果适合用来验证整条流水线是否通畅。要测试 resume可以把total_epochs改成 10 跑一遍再写个新脚本从last.pt恢复把total_epochs设成 20看日志里的 epoch 是不是从 10 接着走、学习率是不是衔接上。这个验证步骤很重要别等真训练到一半才检查续训逻辑。9. 常见问题与排查技巧实录训练脚本报错五花八门我整理了高频问题速查表按出现频率从高到低排现象可能原因排查方向验证指标随机跳动忘了 model.eval()eval 里补上确认 no_grad损失变成 nan学习率过大 / 除零降 lr检查损失函数输入学习率不按预期变化scheduler.step 顺序/频率错确认在 optimizer.step 之后GPU 利用率低DataLoader 供给不足调 num_workerspin_memoryresume 后指标退化漏存 optimizer/scheduler补齐 checkpoint 字段多进程卡死/报错Windows 没包 main入口放 ifname mainRuntimeError 设备不一致数据或模型漏 to(device)逐个检查张量 .device显存缓慢增长累加了带梯度的张量item() 后用别存 tensor除了表里的再补几个排查思路。损失 nan时先不要盲目降学习率把输入和中间激活打印出来看是哪一层先出的问题。我遇到过一次是因为数据里有 inf归一化算出来全黑排查了半天才发现是数据清洗没做。GPU 利用率低先用nvidia-smi -l 1持续观察如果利用率在 30% 到 100% 之间锯齿跳动一定是数据供给断断续续去调 DataLoader如果一直很低但很平稳可能是模型太小、batch 太小问题在计算侧。还有一个隐蔽问题显存缓慢增长最终 OOM。常见原因是把带计算图的 tensor 存进了列表比如losses.append(loss)而不是losses.append(loss.item())。前者保留了整张图跑几百个 step 就爆。养成习惯日志和指标一律.item()之后再存。一个排查小技巧在训练循环里定期打印torch.cuda.memory_allocated() / 1024**2能直观看到显存是否泄漏比等到 OOM 才发现要主动得多。10. 实操心得与踩坑清单聊几个文档里不太写、但实战中真实影响体验的点。第一个是关于 checkpoint 的存储策略我不会每个 epoch 都存而是每 N 个 epoch 存一次 验证指标刷新时存一次 训练结束存一次。频繁存盘在共享存储上会成为瓶颈一个几百 MB 的 ckpt 每 epoch 写一次IO 开销比训练本身还大。我一般 N 取 5 或者按时间比如每 30 分钟存一次。第二个是 resume 时的一个陷阱从 checkpoint 恢复后scheduler 的总步数如果和原来不一致学习率曲线会断裂。比如你用 OneCycleLR 按总步数规划换了 total_epochs 之后恢复学习率会重新走一条曲线。解决办法是把总步数写进 config 一并保存恢复时用原值。这个问题第一次遇到时我盯了半天学习率日志才反应过来。第三个是关于 metaspace 和可复现性的取舍。torch.backends.cudnn.deterministicTrue保证复现但要牺牲一点速度做科研需要复现时开着上线跑性能时关掉用 config 控制。别一根筋地全程开着快 10% 也是钱。第四个是日志。建议用 tensorboard 或者直接写文本日志把每个 epoch 的 loss、acc、lr、耗时都记下来。训练跑了半天回头看曲线能很快判断是欠拟合还是过拟合、学习率是不是该调。我见过太多人训练完只有最终一个数字想复现调优根本无从下手。日志目录和 checkpoint 目录分开做完实验好清理。最后分享一个判断数据管道是否健康的小习惯训练启动后前 100 个 step手动算一下每秒处理的样本数再和你 GPU 的理论吞吐对比。如果差得远八成是 DataLoader 或数据预处理的问题趁早优化比训练到一半再改省事得多。这套完整训练循环我用了挺久换数据集、换模型基本只动 Dataset 和 model 两个文件engine 和 checkpoint 逻辑稳如老狗希望你也能搭出这么一套省心的骨架。