1. 为什么我最终选择了 ms-swift 作为主力训练框架
接触大模型训练这两年,我试过不少框架。早期用原生 PyTorch 手写训练循环,灵活是灵活,但每次换模型、换任务都要重写一大堆胶水代码;后来也用过一些封装程度较高的方案,上手快,可一旦想改模型结构或者自定义 loss,就发现处处受限,改起来比从头写还累。直到开始用ms-swift,才算找到一个平衡点——它把常见的训练流程都封装好了,同时又留足了口子,让我能在需要的时候深入进去改结构、换损失函数、加 token。
这篇内容我打算把最近一段时间用 ms-swift 做训练踩过的坑、验证过的流程完整梳理一遍。核心会覆盖这么几块:ms-swift 框架训练的整体思路、vscode 调试环境怎么搭、注册数据集的几种方式、动态数据增强训练怎么配、新增 token的操作细节、回归训练和分类训练的区别、改模型结构的切入点,以及自定义 loss的完整实现。这些点单拎出来都不算特别难,但串在一起就是一条完整的“从零到跑通一个定制化训练任务”的链路。
适合谁来读?如果你已经会用 ms-swift 跑通官方示例,但想进一步做定制化改造,那这篇会很对口。如果你还没接触过这个框架,也没关系,我会把关键概念用生活化的方式解释清楚,你跟着操作也能跑起来。我尽量不堆术语,多讲“为什么这么做”和“我实际怎么做的”。
2. ms-swift 框架训练的整体设计与思路拆解
2.1 它到底解决了什么问题
一句话概括:ms-swift 把大模型训练里那些重复度极高的部分——数据加载、分布式配置、混合精度、梯度累积、checkpoint 管理——都做成了开箱即用的模块,同时把模型定义、损失计算、数据处理这些需要定制的部分留成可插拔的接口。
我打个比方。原生 PyTorch 训练就像自己买菜、洗菜、切菜、炒菜全流程动手,你想吃什么完全自由,但每做一道新菜都要从头来一遍。ms-swift 更像一个配好的厨房:灶台、锅具、调料都摆好了,你只需要决定今天炒什么菜(选模型)、放什么料(配数据)、口味怎么调(改 loss)。大部分家常菜直接按它的流程走就行,遇到特殊口味再自己动手加料。
这个设计思路带来的直接好处是:标准化流程用配置就能跑,定制化需求用代码就能改,两者不冲突。
2.2 训练流程的骨架
不管什么任务,ms-swift 的训练流程基本都遵循这条主线:
- 加载模型和 tokenizer
- 准备数据集并注册
- 配置训练参数(学习率、batch size、精度等)
- 定义 Trainer 并启动训练
- 评估与保存
看起来简单,但每一步都有讲究。比如第 2 步“注册数据集”,ms-swift 支持直接读本地文件、读 Hub 上的数据集、也支持你自定义 Dataset 类再注册进去。选哪种方式取决于你的数据形态和复用需求。第 4 步的 Trainer 也不是只有一种,标准训练、序列分类、回归、Embedding 训练用的 Trainer 都不一样。
2.3 为什么值得花时间学它
我的判断是:如果你的训练任务超过两个,或者需要反复调参、反复换模型,那用框架的收益会非常明显。手写训练循环在第一个任务上可能更快,但从第二个任务开始,框架的复用优势就体现出来了。而且 ms-swift 对主流模型的支持更新比较勤,省去了自己适配新模型的时间。
提示:不要一上来就想着改框架源码。先把官方示例跑通,理解它的配置体系和数据流,再动手定制。我见过太多人连 Trainer 怎么初始化都没搞明白就去改模型,结果卡在莫名其妙的报错上。
3. vscode 调试环境搭建与配置要点
3.1 为什么用 vscode 而不是命令行硬跑
命令行python train.py当然能跑,但调试的时候你会很痛苦。想看某个变量的值、想在某一行停下来检查张量形状、想单步跟踪数据预处理逻辑——这些在命令行里要么靠 print 大法,要么靠 pdb,效率都不高。
vscode 的调试器可以直接在源码里打断点,鼠标悬停看变量,调用栈一目了然。对于 ms-swift 这种层次比较多的框架,能随时看到“数据从 Dataset 出来长什么样”“进模型前 shape 对不对”,排查问题的速度会快很多。
3.2 环境准备的关键步骤
第一步是装好 Python 环境和依赖。我习惯用 conda 建独立环境,避免和系统 Python 打架:
conda create -n swift python=3.10 conda activate swift pip install ms-swift第二步是在 vscode 里选对解释器。按Ctrl+Shift+P,输入Python: Select Interpreter,选中刚才建的 conda 环境。这一步很多人会漏,结果调试时用的还是系统 Python,报“找不到模块”。
第三步是配置launch.json。在项目根目录建.vscode/launch.json,内容大概这样:
{ "version": "0.2.0", "configurations": [ { "name": "Debug ms-swift train", "type": "debugpy", "request": "launch", "program": "${workspaceFolder}/train.py", "console": "integratedTerminal", "env": { "CUDA_VISIBLE_DEVICES": "0" }, "args": [] } ] }console设成integratedTerminal很重要,因为训练过程会有大量输出,用内部调试控制台容易卡。env里指定 GPU 编号,多卡机器上能避免占错卡。
3.3 调试训练脚本的实用技巧
断点不要乱打。训练循环里每个 step 都断的话,你按继续要按到手酸。我的做法是:
- 在数据预处理函数里打一个断点,只看第一批数据
- 在 loss 计算处打一个断点,确认 loss 数值合理
- 在模型 forward 入口打一个断点,检查输入 shape
另外,vscode 的“条件断点”很好用。右键断点可以设条件,比如step == 0,这样只在第一步停下来,后面自动放行。
注意:调试分布式训练时,vscode 直接 attach 比较麻烦。我的经验是先用单卡跑通逻辑,确认没问题再上多卡。多卡阶段主要靠日志排查,别指望断点。
3.4 常见配置坑
有个坑我踩过:vscode 的 Python 插件和 conda 环境偶尔会不同步,表现为终端里import swift没问题,但调试时提示找不到。解决办法是在 vscode 设置里搜python.terminal.activateEnvironment,确保它是开启的,然后重启 vscode 窗口。
还有一个是路径问题。ms-swift 读数据集时用的是相对路径,而 vscode 调试时的工作目录默认是${workspaceFolder}。如果你在子目录里跑脚本,路径就会错。要么在launch.json里加"cwd": "${workspaceFolder}/your_subdir",要么统一用绝对路径。
4. 注册数据集的三种方式与选择逻辑
4.1 直接读本地文件:最快上手
数据量不大、格式简单的时候,直接读本地文件最省事。ms-swift 支持 json、jsonl、csv 等格式。以 jsonl 为例,每行一条样本:
{"query": "这句话的情感是什么?", "response": "正面"} {"query": "今天天气怎么样?", "response": "晴朗"}然后在配置里指定dataset_path指向这个文件就行。这种方式的好处是零门槛,坏处是数据预处理逻辑写死在框架里,你想做复杂清洗就得自己来。
4.2 从 Hub 加载:适合标准数据集
如果你用的是公开数据集,ms-swift 可以直接从 Hub 拉取。配置里写数据集名称,框架自动下载和缓存。这种方式适合做 baseline 对比,省去自己准备数据的时间。
但要注意,Hub 上的数据集格式五花八门,字段名不一定和框架预期的一致。加载后可能需要做字段映射。我一般会先加载一小部分看看结构,再决定怎么处理。
4.3 自定义 Dataset 并注册:定制化的正解
这是最灵活的方式,也是做定制任务必须掌握的。核心是继承Dataset类,实现__getitem__和__len__,然后用register_dataset注册进去。
from swift import register_dataset from torch.utils.data import Dataset class MyDataset(Dataset): def __init__(self, data_path, tokenizer, max_length=512): self.tokenizer = tokenizer self.max_length = max_length self.samples = self._load(data_path) def _load(self, path): samples = [] with open(path, 'r', encoding='utf-8') as f: for line in f: samples.append(json.loads(line)) return samples def __getitem__(self, idx): item = self.samples[idx] encoded = self.tokenizer( item['query'], max_length=self.max_length, truncation=True, padding='max_length', return_tensors='pt' ) return { 'input_ids': encoded['input_ids'].squeeze(0), 'attention_mask': encoded['attention_mask'].squeeze(0), 'labels': encoded['input_ids'].squeeze(0) } def __len__(self): return len(self.samples) register_dataset('my_dataset', MyDataset)注册之后,配置里就能用my_dataset这个名字了。
4.4 三种方式的对比
| 方式 | 上手难度 | 灵活性 | 适用场景 |
|---|---|---|---|
| 直接读本地文件 | 低 | 低 | 快速验证、简单任务 |
| 从 Hub 加载 | 低 | 中 | 标准数据集、baseline |
| 自定义注册 | 中 | 高 | 定制任务、复杂预处理 |
我的建议是:先用第一种快速跑通,确认模型和流程没问题,再逐步迁移到第三种做正式训练。不要一上来就写自定义 Dataset,容易在数据格式上卡半天。
5. 动态数据增强训练的配置与实操
5.1 什么是动态数据增强
静态数据增强是训练前把数据扩增好,存成新文件。动态数据增强是在训练过程中,每个 epoch 或每个 batch 实时生成增强后的样本。后者的好处是:不占额外磁盘、增强策略可以随训练进度变化、能结合随机性做更丰富的组合。
打个比方。静态增强像提前把菜切好放冰箱,用的时候直接拿;动态增强像现切现炒,新鲜度更好,但要求你切菜的手速跟得上炒菜的节奏。
5.2 在 ms-swift 里怎么配
ms-swift 的数据增强主要通过自定义 Dataset 的__getitem__实现。因为每次取数据都会调用这个方法,你在这里做随机变换,就实现了动态增强。
以文本任务为例,常见的增强手段有:随机替换同义词、随机删除部分词、随机交换句子顺序。下面是一个简化示例:
import random class AugmentedDataset(Dataset): def __init__(self, samples, tokenizer, aug_prob=0.3): self.samples = samples self.tokenizer = tokenizer self.aug_prob = aug_prob def _augment(self, text): if random.random() > self.aug_prob: return text words = text.split() if len(words) < 3: return text # 随机删除一个词 idx = random.randint(0, len(words) - 1) words.pop(idx) return ' '.join(words) def __getitem__(self, idx): item = self.samples[idx] text = self._augment(item['query']) encoded = self.tokenizer(text, max_length=512, truncation=True, padding='max_length', return_tensors='pt') return { 'input_ids': encoded['input_ids'].squeeze(0), 'attention_mask': encoded['attention_mask'].squeeze(0), 'labels': encoded['input_ids'].squeeze(0) }5.3 增强强度的控制
aug_prob这个参数很关键。设太高,数据失真严重,模型学不到有用信息;设太低,增强效果不明显。我的经验是从 0.1 到 0.3 开始试,观察验证集指标。如果训练 loss 下降但验证 loss 上升,说明增强过头了,要调低。
另一个技巧是让增强强度随训练轮次衰减。前期增强强一点帮助泛化,后期减弱让模型拟合真实分布。实现方式是在 Dataset 里记录当前 epoch,动态调整aug_prob。
提示:动态增强会增加每个 batch 的数据处理时间。如果发现 GPU 利用率上不去,瓶颈可能在数据加载。这时候可以增加
num_workers,或者把增强逻辑做得更轻量。
5.4 图像任务的增强差异
如果是图像任务,动态增强通常用 torchvision 的 transforms 组合。注意一点:训练时用带随机的 transforms,验证和测试时必须关掉随机性,否则评估结果不可复现。ms-swift 里可以通过 Dataset 的 mode 参数区分训练和验证阶段。
6. 新增 token 的完整操作流程
6.1 什么时候需要新增 token
预训练模型的词表是固定的。当你遇到词表里没有的符号——比如特殊标记、领域专有符号、新的占位符——就需要新增 token。典型场景包括:给对话模型加角色标记、给代码模型加特殊语法符号、给多模态模型加图像占位符。
不新增会怎样?tokenizer 会把这些符号拆成多个子词,或者映射成 unknown token。前者让序列变长、语义变模糊,后者直接丢失信息。
6.2 操作步骤
第一步,加载 tokenizer 和模型:
from transformers import AutoTokenizer, AutoModelForCausalLM model_name = "your-base-model" tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModelForCausalLM.from_pretrained(model_name)第二步,定义要新增的 token 列表:
new_tokens = ["<|role_user|>", "<|role_assistant|>", "<|img_placeholder|>"] num_added = tokenizer.add_tokens(new_tokens) print(f"新增了 {num_added} 个 token")第三步,调整模型 embedding 层:
model.resize_token_embeddings(len(tokenizer))这一步不能漏。tokenizer 的词表变大了,模型的 embedding 矩阵也必须同步扩大,否则输入新 token 的 id 会越界。
6.3 新 token 的初始化策略
resize_token_embeddings默认用正态分布随机初始化新 token 的 embedding。这在某些任务里会导致训练初期不稳定。更好的做法是用已有 token 的 embedding 均值来初始化:
import torch new_embeddings = model.get_input_embeddings() old_num = len(tokenizer) - num_added mean_embedding = new_embeddings.weight[:old_num].mean(dim=0) with torch.no_grad(): for i in range(old_num, len(tokenizer)): new_embeddings.weight[i] = mean_embedding这样新 token 的初始表示和已有 token 在同一量级,训练更平滑。
6.4 保存与加载的注意事项
新增 token 后,保存模型时要同时保存 tokenizer,否则下次加载会对不上:
tokenizer.save_pretrained("./my-model-with-new-tokens") model.save_pretrained("./my-model-with-new-tokens")加载时也要从同一个目录加载 tokenizer 和模型,保证词表一致。我踩过的坑是:只保存了模型没保存 tokenizer,结果推理时 tokenizer 还是旧的,新 token 被拆成了子词,输出完全不对。
注意:新增 token 后,如果要做继续预训练,学习率要比正常微调低一些。因为新 token 的 embedding 是随机初始化的,学习率太高容易震荡。
7. 回归训练与分类训练的关键差异
7.1 任务本质的区别
分类训练输出的是离散类别,用交叉熵损失。回归训练输出的是连续数值,用均方误差或类似损失。这个区别决定了模型头部结构、损失函数、评估指标都不一样。
举个例子。判断一条评论是正面还是负面,这是分类。预测一套房子的价格,这是回归。前者输出“正面/负面”,后者输出“235.7”。
7.2 ms-swift 里的配置差异
分类任务通常用SequenceClassificationTrainer,指定num_labels。回归任务需要把num_labels设为 1,并且用回归专用的损失。
from swift import Swift, TrainArguments from swift.trainers import RegressionTrainer args = TrainArguments( output_dir="./regression_output", num_train_epochs=3, per_device_train_batch_size=8, learning_rate=2e-5, problem_type="regression" )关键是problem_type="regression",它会让框架自动切换损失函数和评估逻辑。
7.3 回归训练的评估指标
分类看准确率、F1,回归看 MSE、MAE、R²。ms-swift 默认会算 loss,但如果你想看 MAE 和 R²,需要自定义 compute_metrics:
import numpy as np from sklearn.metrics import mean_absolute_error, r2_score def compute_metrics(eval_pred): predictions, labels = eval_pred predictions = predictions.squeeze() mae = mean_absolute_error(labels, predictions) r2 = r2_score(labels, predictions) return {"mae": mae, "r2": r2}7.4 回归任务的常见坑
第一个坑是标签尺度。如果标签范围是 0 到 1000000,而模型输出层没有做归一化,训练会非常慢甚至不收敛。我的做法是训练前把标签标准化到 0 到 1 或 -1 到 1,推理时再反变换回去。
第二个坑是异常值。回归对异常值比分类敏感得多。一个极端值可能把整个模型的预测拉偏。训练前一定要做异常值检测和处理。
第三个坑是评估集分布。如果评估集和训练集分布差异大,回归指标会很难看。这不是模型的问题,是数据的问题。要确保划分数据时做分层采样。
8. 改模型结构的切入点与实操
8.1 哪些地方可以改
ms-swift 封装程度高,但留了不少口子。常见的改动点包括:
- 替换分类头或回归头
- 在 backbone 后面加自定义层
- 修改 attention 机制
- 调整 embedding 层
改之前先想清楚:这个改动是必须的吗?能不能通过配置实现?很多时候,你以为要改结构,其实调个参数就行。
8.2 替换任务头的标准做法
以分类任务为例,原始模型输出的是 hidden states,需要接一个分类头。ms-swift 默认会加一个线性层,但如果你想换成更复杂的结构:
import torch.nn as nn from transformers import AutoModel class CustomModel(nn.Module): def __init__(self, model_name, num_labels, hidden_dim=768): super().__init__() self.backbone = AutoModel.from_pretrained(model_name) self.classifier = nn.Sequential( nn.Linear(hidden_dim, hidden_dim // 2), nn.ReLU(), nn.Dropout(0.1), nn.Linear(hidden_dim // 2, num_labels) ) def forward(self, input_ids, attention_mask, labels=None): outputs = self.backbone(input_ids=input_ids, attention_mask=attention_mask) pooled = outputs.last_hidden_state[:, 0, :] logits = self.classifier(pooled) loss = None if labels is not None: loss = nn.CrossEntropyLoss()(logits, labels) return {"loss": loss, "logits": logits}然后在 ms-swift 里注册这个模型。
8.3 修改时的维度对齐问题
改结构最容易出错的地方是维度对不上。比如 backbone 输出的 hidden size 是 768,你接的线性层输入维度写成 512,就会报错。改之前先打印一下各层的输出 shape:
outputs = model.backbone(input_ids, attention_mask) print(outputs.last_hidden_state.shape)确认维度后再写后续层。这个习惯能省很多调试时间。
8.4 冻结与解冻策略
改结构后,通常新加的层需要训练,backbone 可以选择冻结或微调。冻结 backbone 能省显存、加快训练,但可能欠拟合。我的经验是:数据量小的时候冻结,数据量大的时候解冻。
for param in model.backbone.parameters(): param.requires_grad = False # 只训练分类头 optimizer = torch.optim.AdamW( filter(lambda p: p.requires_grad, model.parameters()), lr=1e-3 )注意优化器要过滤掉不需要梯度的参数,否则会报错或浪费计算。
9. 自定义 loss 的实现与调试
9.1 为什么要自定义 loss
标准损失函数不总是够用。比如:
- 类别极度不平衡时,需要 focal loss
- 回归任务里异常值多时,需要 huber loss
- 多任务学习时,需要加权组合多个 loss
- 对比学习时,需要自定义的对比损失
这些场景下,改 loss 比改数据或改模型更直接有效。
9.2 在 ms-swift 里注入自定义 loss
有两种方式。第一种是改模型的 forward,在里边算 loss。第二种是自定义 Trainer,重写compute_loss。我倾向第二种,因为不动模型代码,复用性更好。
from swift.trainers import Seq2SeqTrainer import torch.nn.functional as F class CustomLossTrainer(Seq2SeqTrainer): def compute_loss(self, model, inputs, return_outputs=False): labels = inputs.pop("labels") outputs = model(**inputs) logits = outputs.logits # 自定义 focal loss ce_loss = F.cross_entropy( logits.view(-1, logits.size(-1)), labels.view(-1), reduction='none' ) pt = torch.exp(-ce_loss) focal_loss = (0.25 * (1 - pt) ** 2 * ce_loss).mean() return (focal_loss, outputs) if return_outputs else focal_loss9.3 损失函数的数值稳定性
自定义 loss 最容易出问题的地方是数值稳定性。比如算 log 的时候,输入可能是 0;算除法的时候,分母可能是 0。这些都会导致 nan。
我的习惯是:所有 log 操作加一个极小值,所有除法加一个 epsilon。
eps = 1e-8 loss = -torch.log(pred + eps) ratio = a / (b + eps)另外,训练初期打印几次 loss 值,确认不是 nan 或 inf。如果出现,先检查数据里有没有异常值,再检查 loss 公式。
9.4 多损失加权组合
多任务学习时,经常要把多个 loss 加权求和。权重怎么定?常见做法有:
- 手动调:简单但费时
- 不确定性加权:让模型自己学权重
- 梯度归一化:平衡各任务的梯度量级
我一般先用等权重跑一版,看各任务的收敛情况,再手动调整。如果某个任务 loss 一直不降,就加大它的权重。
total_loss = 1.0 * task1_loss + 0.5 * task2_loss + 0.3 * task3_loss权重的选择没有标准答案,要靠实验。建议记录每次调整后的验证指标,找到最适合当前数据的组合。
10. 常见问题与排查技巧实录
10.1 训练不收敛的排查顺序
遇到 loss 不降,按这个顺序查:
- 学习率是不是太大或太小
- 数据标签有没有问题
- 模型输出和损失函数是否匹配
- 梯度有没有爆炸或消失
我遇到最多的是学习率问题。大模型微调学习率一般在 1e-5 到 5e-5 之间,设成 1e-3 基本必炸。
10.2 显存不够的应对策略
| 策略 | 效果 | 代价 |
|---|---|---|
| 减小 batch size | 直接有效 | 训练变慢 |
| 梯度累积 | 保持等效 batch | 实现稍复杂 |
| 混合精度 | 省显存约一半 | 可能影响精度 |
| 梯度检查点 | 省显存明显 | 速度下降 |
| LoRA 微调 | 省显存显著 | 需要适配 |
我通常先开混合精度,不够再上梯度检查点,还不够就换 LoRA。
10.3 数据加载慢的优化
如果 GPU 利用率低,nvidia-smi 显示显存占用不高,多半是数据加载瓶颈。解决办法:
- 增加
num_workers - 把数据预处理结果缓存起来
- 用更快的存储介质
num_workers不是越大越好,一般设成 CPU 核心数的 2 到 4 倍。设太大反而会因为进程切换开销变慢。
10.4 新增 token 后输出异常的排查
新增 token 后如果模型输出乱码或重复,检查这几点:
- tokenizer 和模型是否同步保存加载
- embedding 层是否 resize 了
- 新 token 的 id 是否在词表范围内
- 训练时新 token 的 loss 是否正常下降
我遇到过一次,原因是保存模型时忘了保存 tokenizer,加载时词表对不上,新 token 被映射成了别的 id。
10.5 自定义 loss 出现 nan 的处理
先定位是哪个 batch 出的问题。可以在 compute_loss 里加判断:
if torch.isnan(loss): print("NaN detected, inputs:", inputs) return torch.tensor(0.0, requires_grad=True)打印出问题 batch 的数据,检查有没有空值、异常值。多数时候是数据问题,不是 loss 公式问题。
11. 我个人的一些实操体会
用 ms-swift 做定制化训练,最大的感受是:框架帮你省掉的是重复劳动,但定制化的部分一点都省不了。改模型结构、写自定义 loss、新增 token,这些都需要你对模型原理有理解,对数据流有把握。
我的建议是循序渐进。先把标准流程跑通,再一个一个引入定制点。每引入一个改动,都单独验证一次,确认没问题再加下一个。一次性改太多地方,出了问题根本不知道是哪个改动导致的。
另外,日志要打全。训练时的 loss、学习率、梯度范数,评估时的各项指标,都记下来。出问题的时候,这些日志就是你的线索。我习惯用 tensorboard 或者 wandb 记录,回头对比不同实验很方便。
最后说一个细节:checkpoint 要定期存,而且最好存多个。训练崩了可以从最近的 checkpoint 恢复,不用从头再来。这个习惯在长周期训练里能救命。