
1. 从零搭建AI工程体系为什么我劝你别一上来就调包ai-engineering-from-scratch这个标题第一次看到的时候我愣了一下。市面上讲AI的教程铺天盖地但绝大多数都在教你import torch然后跑一个预训练模型真正从工程地基开始讲起的少之又少。我自己带过几个刚入行的同事发现一个很普遍的现象他们能背出Transformer的结构图却说不清楚一个推理服务从请求进来到结果返回中间到底经过了哪些环节更别提自己动手搭一套能扛住并发、能监控、能回滚的AI工程链路了。所谓from scratch我的理解不是让你手写CUDA核函数也不是从零实现反向传播而是从工程视角把AI系统拆开一层一层自己搭起来。这包括数据管道的搭建、特征处理、模型训练与评估的工程化封装、推理服务的部署、监控告警、版本管理、灰度发布这一整套东西。它解决的核心问题是当你离开Jupyter Notebook面对一个真实业务场景时怎么让模型真正跑起来、跑得稳、跑得可维护。这套内容适合谁我认为有三类人值得花时间啃一是刚转行做AI工程的同学有算法基础但缺乏工程经验二是后端或数据工程师想切入AI领域但不想只做调包侠三是算法工程师模型调得不错但一到上线就抓瞎。如果你属于这三类中的任何一类接下来的内容应该能帮你少走不少弯路。2. 整体架构设计先想清楚分层再动手写代码2.1 为什么分层是AI工程的第一课我见过太多项目一开始图省事数据加载、模型定义、训练循环、评估逻辑全塞在一个train.py里两三百行还能忍等到要加个新模型、换个数据集、接个新指标整个文件就变成了一团乱麻。改一处牵动全身最后谁都不敢动。从零搭建AI工程第一件事就是分层。我的经验是至少分成四层数据层、模型层、训练层、服务层。数据层负责数据的读取、清洗、增强、批处理模型层只关心网络结构定义和前向计算训练层管优化器、学习率调度、损失计算、日志记录服务层处理推理请求、批处理、超时控制、健康检查。层与层之间通过明确的接口通信上层不关心下层的实现细节。这么分的好处是什么举个例子你哪天要把PyTorch换成别的框架只需要改模型层和训练层的部分代码数据层和服务层的接口不变改动量可控。再比如你要把训练好的模型部署到不同的硬件上服务层换个后端就行训练逻辑完全不用动。这就是分层的价值——把变化隔离在最小的范围内。2.2 目录结构怎么定我踩过的坑具体到代码组织我推荐这样的目录结构project/ ├── configs/ # 配置文件按环境分 ├── data/ # 数据相关 │ ├── raw/ # 原始数据 │ ├── processed/ # 处理后数据 │ └── dataset.py # Dataset和DataLoader定义 ├── models/ # 模型定义 │ ├── __init__.py │ └── backbone.py ├── training/ # 训练相关 │ ├── trainer.py │ ├── losses.py │ └── metrics.py ├── serving/ # 推理服务 │ ├── app.py │ └── handler.py ├── utils/ # 通用工具 │ ├── logger.py │ └── seed.py ├── scripts/ # 入口脚本 │ ├── train.py │ └── evaluate.py └── tests/ # 测试这个结构不是拍脑袋定的。configs单独拎出来是因为我吃过硬编码参数的亏——训练时写死的batch size到推理时忘了改结果显存直接爆掉。把配置抽离成YAML或JSON文件不同环境用不同配置改参数不用动代码这是血泪教训。scripts目录只放入口脚本每个脚本尽量薄核心逻辑都在对应的模块里这样测试的时候可以直接import模块来测不用去跑整个脚本。注意不要一上来就追求完美的目录结构。我见过有人花两天时间设计目录结果一行模型代码没写。先跑通一个最小闭环再逐步重构这才是务实的做法。2.3 技术选型的几个关键决策技术选型这块我的原则是优先选生态成熟、社区活跃的方案除非有明确的性能或合规需求否则不要轻易造轮子。环节推荐方案备选选择理由深度学习框架PyTorchTensorFlow动态图调试方便社区资源多配置管理Hydra / OmegaConf纯YAML支持配置组合和命令行覆盖实验追踪MLflow / Weights BiasesTensorBoard能记录参数、指标、产物推理服务FastAPI UvicornFlask异步支持好性能更优容器化Docker裸机部署环境一致性有保障监控Prometheus Grafana自建生态完善接入成本低拿推理服务来说为什么选FastAPI而不是Flask因为AI推理往往是IO密集和计算密集混合的场景FastAPI原生支持async在处理并发请求时能更好地利用资源。而且它自带Pydantic做请求校验省去了手写校验逻辑的麻烦。当然如果你的团队对Flask更熟用Flask也没问题关键是统一别一个项目里两种框架混着用。3. 核心模块拆解数据、模型、训练、服务逐个击破3.1 数据管道AI工程里最容易被低估的环节很多人觉得数据管道就是写个Dataset类其实远不止。一个健壮的数据管道要考虑的事情包括数据格式的统一、缺失值和异常值的处理、类别不平衡的应对、数据增强的策略、批处理时的内存控制、多进程加载的稳定性。我拿一个图像分类任务举例。原始数据可能是各种尺寸的JPG标签存在CSV里。第一步是数据校验检查图片是否能正常打开、标签是否有越界、类别分布是否严重失衡。这一步我建议写成独立的脚本输出一份数据报告而不是等到训练时才发现有坏图。# data/dataset.py import os from PIL import Image import torch from torch.utils.data import Dataset, DataLoader from torchvision import transforms class ImageDataset(Dataset): def __init__(self, df, img_dir, transformNone): self.df df.reset_index(dropTrue) self.img_dir img_dir self.transform transform def __len__(self): return len(self.df) def __getitem__(self, idx): row self.df.iloc[idx] img_path os.path.join(self.img_dir, row[filename]) try: image Image.open(img_path).convert(RGB) except Exception as e: # 记录坏图返回一个占位图避免训练中断 print(fBad image: {img_path}, error: {e}) image Image.new(RGB, (224, 224), (0, 0, 0)) label int(row[label]) if self.transform: image self.transform(image) return image, label这段代码里有个细节值得说__getitem__里做了异常捕获。为什么因为真实数据里总有那么几张坏图如果不处理训练跑到一半崩掉前面的时间全白费。返回占位图虽然不优雅但能保证训练流程不中断后续可以通过日志定位问题数据。DataLoader的num_workers设置也有讲究。设太小数据加载成为瓶颈GPU利用率上不去设太大内存和进程管理开销增加甚至可能因为共享内存不足而报错。我的经验是从4开始试观察GPU利用率和内存占用逐步调整。如果用的是SSDnum_workers可以设大一些如果是机械硬盘设太大反而会因为IO争抢导致性能下降。3.2 模型定义别急着堆结构先把接口定好模型层最容易犯的错误是接口不统一。今天写个ResNet明天写个ViT输入输出格式各不相同训练代码里到处是if-else判断。正确的做法是先定义一个基类或者协议所有模型都遵循同样的接口。# models/base.py from abc import ABC, abstractmethod import torch.nn as nn class BaseModel(ABC, nn.Module): abstractmethod def forward(self, x): 输入: (B, C, H, W) 输出: (B, num_classes) pass property abstractmethod def num_classes(self): pass有了这个基类训练代码就可以统一处理logits model(images)然后loss criterion(logits, labels)。换模型的时候只要新模型继承BaseModel并实现forward训练代码一行不用改。这就是面向接口编程在AI工程里的体现。模型初始化也很关键。我见过有人直接model MyModel()就开训结果loss不下降排查半天发现是权重初始化有问题。PyTorch的默认初始化对某些结构并不友好尤其是深层网络。我的习惯是在模型定义里显式做初始化def init_weights(m): if isinstance(m, nn.Linear): nn.init.kaiming_normal_(m.weight, modefan_out, nonlinearityrelu) if m.bias is not None: nn.init.zeros_(m.bias) elif isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight, modefan_out, nonlinearityrelu) if m.bias is not None: nn.init.zeros_(m.bias) model.apply(init_weights)Kaiming初始化配合ReLU激活函数能有效缓解梯度消失问题。这个不是玄学是有数学推导支撑的核心思想是让每一层的输出方差保持一致。3.3 训练循环把能自动化的都自动化训练循环是AI工程的核心也是最容易写成一坨的地方。我的原则是能自动化的绝不手动能记录的绝不靠脑子记。一个完整的训练循环至少包含这些要素训练模式切换、梯度清零、前向传播、损失计算、反向传播、梯度裁剪、参数更新、学习率调度、指标累计、日志记录、检查点保存。我习惯把它封装成一个Trainer类# training/trainer.py import torch from torch.cuda.amp import autocast, GradScaler from tqdm import tqdm class Trainer: def __init__(self, model, optimizer, scheduler, criterion, device, config): self.model model self.optimizer optimizer self.scheduler scheduler self.criterion criterion self.device device self.config config self.scaler GradScaler() if config.use_amp else None self.best_metric float(-inf) def train_one_epoch(self, dataloader, epoch): self.model.train() total_loss 0.0 correct 0 total 0 pbar tqdm(dataloader, descfEpoch {epoch}) for images, labels in pbar: images images.to(self.device, non_blockingTrue) labels labels.to(self.device, non_blockingTrue) self.optimizer.zero_grad(set_to_noneTrue) if self.scaler: with autocast(): logits self.model(images) loss self.criterion(logits, labels) self.scaler.scale(loss).backward() self.scaler.unscale_(self.optimizer) torch.nn.utils.clip_grad_norm_(self.model.parameters(), max_norm1.0) self.scaler.step(self.optimizer) self.scaler.update() else: logits self.model(images) loss self.criterion(logits, labels) loss.backward() torch.nn.utils.clip_grad_norm_(self.model.parameters(), max_norm1.0) self.optimizer.step() total_loss loss.item() * images.size(0) preds logits.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) pbar.set_postfix(lossloss.item(), acccorrect/total) self.scheduler.step() return total_loss / total, correct / total这里有几个点值得展开。set_to_noneTrue比默认的zero_grad()更省内存因为它把梯度设为None而不是零张量PyTorch在反向传播时会重新分配内存实测能省下不少显存。梯度裁剪clip_grad_norm_是防止梯度爆炸的常规操作max_norm设1.0是个比较通用的值但具体任务可能需要调整。混合精度训练autocast配合GradScaler在支持Tensor Core的GPU上能提速30%到50%显存占用也能降下来几乎是现代训练的标配。实操心得tqdm的进度条不只是好看它能让你实时看到loss和acc的变化。如果loss突然变成nan或者acc卡住不动你能第一时间发现而不是等跑完一个epoch才后知后觉。3.4 推理服务从模型文件到可用API训练完模型只是第一步把它变成别人能调用的服务才是工程化的关键。我见过太多模型躺在硬盘里吃灰就是因为没有做好服务化。推理服务的核心需求是加载模型、接收请求、预处理、推理、后处理、返回结果。听起来简单但要做好需要考虑模型加载一次还是每次请求都加载、并发请求怎么处理、超时怎么控制、异常怎么返回、服务怎么监控。# serving/app.py from fastapi import FastAPI, HTTPException from pydantic import BaseModel import torch import numpy as np from PIL import Image import io import base64 app FastAPI() class PredictRequest(BaseModel): image_base64: str class PredictResponse(BaseModel): class_id: int confidence: float # 全局加载模型只加载一次 model None transform None app.on_event(startup) def load_model(): global model, transform model torch.jit.load(model.pt) model.eval() transform get_inference_transform() app.post(/predict, response_modelPredictResponse) async def predict(request: PredictRequest): try: image_bytes base64.b64decode(request.image_base64) image Image.open(io.BytesIO(image_bytes)).convert(RGB) except Exception: raise HTTPException(status_code400, detailInvalid image data) tensor transform(image).unsqueeze(0) with torch.no_grad(): logits model(tensor) probs torch.softmax(logits, dim1) confidence, class_id probs.max(dim1) return PredictResponse( class_idint(class_id.item()), confidencefloat(confidence.item()) ) app.get(/health) def health(): return {status: ok, model_loaded: model is not None}这段代码有几个工程上的考量。模型在startup事件里加载保证服务启动后模型就绪而不是第一次请求时才加载导致超时。torch.jit.load加载的是TorchScript模型它不依赖Python解释器推理速度更快也方便跨语言调用。/health接口是给负载均衡和监控系统用的Kubernetes的健康检查就靠它。预处理和后处理的逻辑要和服务解耦。我习惯把transform的定义抽到单独的函数里训练和推理共用同一套逻辑避免训练时用的归一化参数和推理时不一致——这种bug极其隐蔽表现是离线指标很好上线后效果一塌糊涂。4. 实操全流程从零到一跑通一个完整项目4.1 环境准备与依赖管理动手之前先把环境理清楚。我的习惯是用conda创建独立环境然后用pip安装依赖最后导出requirements.txt锁定版本。conda create -n ai-eng python3.10 -y conda activate ai-eng pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install fastapi uvicorn pydantic pillow numpy pandas scikit-learn mlflow tqdm pip freeze requirements.txt为什么要锁定版本因为AI框架的API变动频繁今天能跑的代码明天换个版本可能就报错。requirements.txt里记录精确版本号换机器或者过几个月再回来环境还能复现。注意CUDA版本要和PyTorch版本匹配。我遇到过有人装了CUDA 12的PyTorch但驱动只支持CUDA 11结果torch.cuda.is_available()返回False排查了半天。装之前先nvidia-smi看一下驱动支持的CUDA版本。4.2 数据准备与校验假设我们有一个图像分类数据集目录结构是data/raw/train/class_name/image.jpg。第一步是生成一份数据清单# scripts/prepare_data.py import os import pandas as pd from sklearn.model_selection import train_test_split def build_dataframe(root_dir): records [] for split in [train, val]: split_dir os.path.join(root_dir, split) if not os.path.exists(split_dir): continue for class_name in os.listdir(split_dir): class_dir os.path.join(split_dir, class_name) if not os.path.isdir(class_dir): continue for fname in os.listdir(class_dir): if fname.lower().endswith((.jpg, .jpeg, .png)): records.append({ filename: os.path.join(split, class_name, fname), label: class_name, split: split }) return pd.DataFrame(records) df build_dataframe(data/raw) # 标签映射 label2id {label: idx for idx, label in enumerate(sorted(df[label].unique()))} df[label_id] df[label].map(label2id) df.to_csv(data/processed/manifest.csv, indexFalse) print(fTotal samples: {len(df)}) print(df[label].value_counts())跑完这个脚本你会得到一份清单和类别分布。如果发现某个类别样本数特别少就要考虑过采样、欠采样或者数据增强了。类别不平衡不处理模型会倾向于预测多数类少数类的召回率惨不忍睹。4.3 训练配置与启动配置文件用YAML写不同环境用不同的文件覆盖# configs/base.yaml data: manifest: data/processed/manifest.csv img_dir: data/raw image_size: 224 batch_size: 32 num_workers: 4 model: name: resnet50 num_classes: 10 pretrained: true training: epochs: 30 lr: 0.001 weight_decay: 0.0001 use_amp: true grad_clip: 1.0 save_dir: checkpoints/ logging: log_interval: 50 use_mlflow: true experiment_name: baseline启动训练python scripts/train.py --config configs/base.yaml训练脚本里做几件事设置随机种子、加载配置、构建数据加载器、初始化模型和优化器、循环训练、保存最佳模型。随机种子一定要设否则每次跑的结果都不一样没法复现。# utils/seed.py import random import numpy as np import 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会让结果可复现但会牺牲一点性能。如果追求极致速度可以设benchmarkTrue但结果就不完全可复现了。这是个权衡看你的需求。4.4 模型导出与服务部署训练完成后把最佳模型导出为TorchScript# scripts/export.py import torch from models.backbone import build_model model build_model(num_classes10, pretrainedFalse) checkpoint torch.load(checkpoints/best.pt, map_locationcpu) model.load_state_dict(checkpoint[model_state_dict]) model.eval() example_input torch.randn(1, 3, 224, 224) traced_model torch.jit.trace(model, example_input) traced_model.save(serving/model.pt) print(Model exported successfully)用torch.jit.trace而不是script是因为trace对大多数CNN结构都适用操作简单。但如果模型里有控制流比如if-else依赖输入值就得用script否则trace会丢失分支逻辑。部署用DockerFROM python:3.10-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY serving/ ./serving/ COPY serving/model.pt ./serving/ EXPOSE 8000 CMD [uvicorn, serving.app:app, --host, 0.0.0.0, --port, 8000, --workers, 2]--workers 2启动两个工作进程能利用多核CPU。但注意每个worker都会加载一份模型显存占用翻倍。如果模型很大worker数要相应减少或者改用GPU推理。5. 常见问题与排查技巧实录5.1 训练不收敛先查这五个地方训练loss不下降或者震荡是最常见的问题。我的排查顺序是排查项检查方法常见原因学习率打印当前lr太大导致震荡太小导致不下降数据标签可视化一批数据标签错位、图片损坏损失函数检查输入格式分类用MSE、回归用CrossEntropy梯度打印梯度范数梯度消失或爆炸初始化检查权重分布全零初始化、方差过大学习率是最常见的元凶。我习惯先用一个很小的学习率比如1e-5跑几十步确认loss能下降再逐步调大。如果小学习率都不降那问题不在学习率上往数据或模型结构上查。5.2 显存不够用这些招数能救急显存OOM是训练时的家常便饭。按优先级排序可以尝试减小batch size、开启混合精度、使用梯度累积、清理不必要的缓存、用更小的模型或输入尺寸。梯度累积是个很实用的技巧它让你在小batch size下模拟大batch的效果accumulation_steps 4 for i, (images, labels) in enumerate(dataloader): loss model(images, labels) / accumulation_steps loss.backward() if (i 1) % accumulation_steps 0: optimizer.step() optimizer.zero_grad()这样等效于batch size扩大了4倍但显存占用不变。代价是训练速度会慢一些因为多了几次前向传播。5.3 推理服务上线后延迟高怎么定位服务上线后延迟高先分清是预处理慢、推理慢还是后处理慢。在代码里打时间戳import time t0 time.time() tensor preprocess(image) t1 time.time() with torch.no_grad(): logits model(tensor) t2 time.time() result postprocess(logits) t3 time.time() print(fpreprocess: {(t1-t0)*1000:.2f}ms, inference: {(t2-t1)*1000:.2f}ms, postprocess: {(t3-t2)*1000:.2f}ms)如果推理慢考虑模型量化、剪枝或者换更小的模型。如果预处理慢检查是不是每次请求都重新创建了transform对象或者图片解码用了低效的库。如果后处理慢看看是不是有循环里的Python操作可以向量化。实操心得推理服务一定要设超时。我见过因为一张超大图导致推理卡住整个服务被拖垮的情况。在FastAPI里可以用asyncio.wait_for给推理逻辑加超时超时直接返回错误保护服务整体可用性。5.4 模型效果离线好线上差八成是这几个原因离线评估指标漂亮上线后效果打折这种问题最让人头疼。常见原因有训练和推理的预处理不一致、数据分布漂移、评估指标和业务指标不匹配。预处理不一致是最隐蔽的。比如训练时用了RandomResizedCrop推理时用了CenterCrop这本身没问题但如果归一化的均值方差写错了或者颜色通道顺序搞反了RGB vs BGR离线评估时因为用的是同一套代码所以看不出来上线后和真实数据一对比就露馅了。我的做法是把预处理逻辑抽成一个独立的模块训练和推理都调用它从源头上杜绝不一致。数据分布漂移需要监控。上线后定期统计输入数据的分布和训练数据对比如果发现明显偏移就要考虑重新训练或者做领域适应。这个可以做成一个定时任务每周跑一次输出分布对比报告。6. 工程化进阶让AI项目真正可维护6.1 实验管理别再用文件名记参数了我早期做实验模型文件命名是model_lr0.001_bs32_epoch30.pt后来参数一多文件名长到没法看而且想对比两次实验的结果得手动去翻日志。后来改用MLflow每次训练自动记录参数、指标、模型文件在UI上就能对比不同实验效率提升不是一点半点。import mlflow mlflow.set_experiment(image-classification) with mlflow.start_run(): mlflow.log_params(config) for epoch in range(epochs): train_loss, train_acc trainer.train_one_epoch(...) val_loss, val_acc trainer.validate(...) mlflow.log_metrics({ train_loss: train_loss, train_acc: train_acc, val_loss: val_loss, val_acc: val_acc }, stepepoch) mlflow.pytorch.log_model(model, model)MLflow的好处是它把实验的元数据和产物统一管理换个人接手也能快速了解之前做过哪些尝试、效果如何。团队协作时尤其有用避免重复造轮子。6.2 监控告警上线只是开始服务上线后至少要监控这几个指标请求量、延迟分布P50/P95/P99、错误率、GPU利用率、显存占用。Prometheus加Grafana是标配FastAPI有现成的中间件可以暴露指标。from prometheus_fastapi_instrumentator import Instrumentator Instrumentator().instrument(app).expose(app)加上这几行就能自动采集请求量、延迟、状态码等指标。然后在Grafana上配面板设告警规则比如P99延迟超过500ms就发通知。别等到用户投诉了才发现服务有问题。6.3 版本管理与回滚给自己留条后路模型版本、代码版本、配置版本三者要绑定。我的做法是每次上线打一个tag记录模型文件的哈希、代码的commit id、配置文件的路径。出了问题能快速定位是哪个环节的变更导致的也能一键回滚到上一个稳定版本。# 上线时记录版本信息 echo model_hash: $(md5sum serving/model.pt | cut -d -f1) version.txt echo git_commit: $(git rev-parse HEAD) version.txt echo config: configs/prod.yaml version.txt回滚的时候把旧版本的模型文件和配置重新部署就行。听起来简单但如果没有提前准备出事的时候手忙脚乱很容易做出错误决策。6.4 测试AI项目也需要单元测试很多人觉得AI项目没法写测试因为结果不确定。其实可以测的东西很多数据处理的正确性、模型输入输出的形状、损失函数的数值、预处理和后处理的一致性。# tests/test_dataset.py def test_dataset_output_shape(): dataset ImageDataset(df, img_dir, transform) image, label dataset[0] assert image.shape (3, 224, 224) assert isinstance(label, int) def test_model_output_shape(): model build_model(num_classes10) x torch.randn(2, 3, 224, 224) out model(x) assert out.shape (2, 10) def test_preprocess_consistency(): # 训练和推理的预处理应该产生相同的结果 image Image.new(RGB, (300, 300)) train_out train_transform(image) infer_out infer_transform(image) assert torch.allclose(train_out, infer_out, atol1e-6)这些测试跑起来很快但能在早期发现很多低级错误。比如输入通道数写错、输出类别数不对、预处理参数不一致这些问题如果等到训练完才发现浪费的是几个小时甚至几天的时间。7. 我个人的一些经验和建议从零搭建AI工程这件事最大的坑不是技术难度而是心态。很多人一开始雄心勃勃想搭一个完美的系统结果陷入过度设计的泥潭几个月过去连个能跑的demo都没有。我的建议是先跑通最小闭环再逐步迭代。最小闭环是什么就是数据能加载、模型能训练、loss能下降、模型能保存、服务能调用。这五件事跑通你就有了一个可工作的系统。然后再去加监控、加实验管理、加自动化测试、加CI/CD。每一步都建立在已经能工作的基础上风险可控。另一个体会是日志和文档要随手写。训练时遇到的坑、调参的经验、某个参数为什么这么设当时不记过两周就忘了。我习惯在项目根目录放一个NOTES.md想到什么写什么不求格式只求记录。后来发现这个习惯帮我省了很多重复排查的时间。还有一点不要迷信最新技术。AI领域新东西层出不穷但工程上稳定可靠比时髦重要。PyTorch出了新版本先看看社区反馈再升级新的推理框架先在小项目上试试水再考虑迁移。生产环境的稳定性永远排在第一位。最后说个具体的技巧模型保存的时候除了保存state_dict把优化器的状态、当前的epoch、最佳的指标也一起存下来。这样训练中断后能接着跑不用从头开始。我吃过这个亏训练到第25个epoch机器断电因为只存了模型权重优化器状态丢了只能重新训练。torch.save({ epoch: epoch, model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), scheduler_state_dict: scheduler.state_dict(), best_metric: best_metric, config: config }, checkpoints/last.pt)恢复训练的时候把这些状态都加载回去训练曲线能接上不会出现loss突然跳变的情况。这个细节看起来小但关键时刻能救命。