ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

ms-swift 大模型微调实战:vscode 调试、数据集注册与自定义 loss 踩坑指南

2026/10/1 12:23:36 拓冰建站 浏览量
ms-swift 大模型微调实战:vscode 调试、数据集注册与自定义 loss 踩坑指南 1. 为什么我最终选择了 ms-swift 而不是自己手搓训练脚本刚接触大模型微调那会儿我和很多人一样第一反应是找一份开源训练代码clone 下来改巴改巴就跑。结果折腾了两周光是分布式启动、显存优化、checkpoint 断点续训这几件事就把我拖垮了。后来一个做算法的朋友甩给我一句你试试 ms-swift 吧我才算真正把精力从搭架子挪回到调模型上。ms-swift 是魔搭社区出品的一套大模型与多模态大模型微调框架它把训练、推理、评测、量化、部署这条链路基本都覆盖了。我之所以最后留下来用它核心原因有三个第一它对主流模型的适配做得足够全从 Qwen、Llama 到 InternVL 这类多模态模型配置文件改几行就能切换第二它把 LoRA、QLoRA、DoRA、全参训练这些方式统一成了一套参数接口不用为每种微调方式单独写脚本第三它和 ModelScope 生态打通得比较顺数据集注册、模型上传、推理验证是一条龙的。但框架好用不代表没有坑。这篇内容我想聊的不是ms-swift 有多强这种空话而是把我这段时间在ms-swift 框架训练、vscode 调试、注册数据集、动态数据增强、新增 token、回归训练、改模型结构、自定义 loss这些具体环节里踩过的坑和总结出来的做法尽量完整地摊开讲一遍。适合已经跑通过一次官方 demo、准备往生产级微调走的人看也适合还在犹豫要不要从手搓脚本迁移过来的朋友参考。下面我会按环境与调试 → 数据 → 训练策略 → 模型改造 → 损失函数这条主线展开每一块都尽量给到能直接抄的配置和命令。2. vscode 调试 ms-swift从能跑到能断点2.1 为什么不用命令行硬跑非要配 vscode 调试很多人跑 ms-swift 就是一行swift sft --config xxx.yaml丢到终端里看日志刷屏。训练能跑起来当然没问题但一旦遇到数据加载报错、tokenizer 异常、loss 突然变 NaN 这类问题纯看日志的效率极低。你没法在forward里打个断点看看张量形状也没法在数据预处理函数里单步走一遍看哪条样本出了问题。vscode 调试的价值就在这里它让你能像调试普通 Python 程序一样在 ms-swift 的训练入口、数据 collator、模型 forward 里下断点。尤其是自定义 loss 和改模型结构的时候没有断点基本等于盲调。2.2 调试配置的关键launch.json 怎么写ms-swift 的训练入口通常是swift命令行工具本质上是调用了swift.llm.train或类似的模块。要在 vscode 里调试最稳的方式是找到它真正的 Python 入口然后用module方式启动。我常用的launch.json配置大概是这样{ version: 0.2.0, configurations: [ { name: swift-sft-debug, type: debugpy, request: launch, module: swift.cli.main, args: [ sft, --config, my_sft_config.yaml ], console: integratedTerminal, justMyCode: false, env: { CUDA_VISIBLE_DEVICES: 0, MODELSCOPE_CACHE: /data/modelscope_cache } } ] }这里有几个细节值得说。justMyCode一定要设成false否则你断点只能打在自己写的代码里进不去 ms-swift 框架内部的函数而很多问题恰恰出在框架的数据处理或模型加载环节。console用integratedTerminal而不是internalConsole是因为训练过程输出量大内部控制台容易卡。CUDA_VISIBLE_DEVICES建议在调试阶段只留一张卡多卡调试断点会互相干扰而且 DeepSpeed 的分布式启动在 vscode 调试下很容易出问题。等逻辑调通了再上多卡。2.3 断点该打在哪几个位置我调试 ms-swift 时断点基本固定打在这几个地方数据预处理函数通常在swift/llm/dataset相关模块里用来确认每条样本经过 template 拼接后长什么样special token 有没有被正确插入。collator 的__call__看 batch 里的input_ids、labels、attention_mask形状对不对padding 方向有没有搞反。模型 forward 入口确认labels传进去了loss是不是在预期位置计算。自定义 loss 函数内部这个不用多说改 loss 必打。提示ms-swift 版本迭代比较快模块路径可能随版本变化。如果断点打不进去先在终端用python -c import swift; print(swift.__file__)找到安装路径再顺着找对应模块。2.4 调试多模态模型时的额外注意点调多模态模型比如 InternVL、Qwen-VL 这类时数据里会带images或pixel_values字段。断点里要重点看图像张量的 shape 和 dtype以及图像 token 在input_ids里占了多少个位置。我遇到过一次图像 resize 后尺寸和模型预期不匹配导致视觉编码器直接报维度错误日志里只显示一句很模糊的 shape mismatch靠断点才定位到是数据增强那一步把图像裁成了奇数尺寸。3. 注册数据集别小看这一步坑最多3.1 ms-swift 的数据集注册机制到底怎么回事ms-swift 的数据集管理有一套自己的注册逻辑。简单说它内置了一批标准数据集比如 alpaca、sharegpt 格式的你如果用自己的数据要么转成它认识的格式要么通过dataset_info.json注册一个自定义数据集。注册的核心是告诉框架三件事数据文件在哪、用什么格式解析、走哪个 template。很多人第一次用会卡在我数据明明放对了为什么框架说找不到数据集。3.2 自定义数据集注册的完整流程假设我有一份自己的 SFT 数据放在/data/my_sft.jsonl格式是 sharegpt 风格的多轮对话。注册步骤如下第一步准备数据文件每行一个 JSON 对象字段名要和 ms-swift 的解析器对得上。sharegpt 格式一般是{conversations: [{from: human, value: 你好}, {from: assistant, value: 你好有什么可以帮你}]}第二步在配置里通过--dataset指定数据集。如果是本地文件可以直接写路径如果要注册成命名数据集需要在dataset_info.json里加一条{ my_sft: { file_name: /data/my_sft.jsonl, formatting: sharegpt, columns: { messages: conversations } } }第三步训练时用--dataset my_sft引用。3.3 注册数据集最容易踩的三个坑坑一字段名对不上。ms-swift 不同版本对字段名的要求略有差异有的版本认conversations有的认messages。最稳的办法是去看框架自带的示例数据集文件照着它的字段名来。我一般会先head一下官方示例数据确认字段结构再动手。坑二template 选错。数据集注册对了但 template 没配对会导致 special token 插入位置错误训练出来的模型对话格式全乱。比如 Qwen 系列要用qwentemplateLlama 要用llama3之类的。这个参数在配置里是--template一定要和你的基座模型匹配。坑三数据路径权限。这个听起来很蠢但真的常见。尤其是用容器或远程服务器训练时数据文件在宿主机上容器里路径映射不对框架报file not found。我现在的习惯是注册完数据集后先用一个小样本跑--max_steps 1验证数据能正常加载再上全量。3.4 数据集格式转换的实用脚本实际项目里数据来源五花八门我写了个小脚本把常见的instruction/input/output格式转成 sharegptimport json def convert_to_sharegpt(src_path, dst_path): with open(src_path, r, encodingutf-8) as f, \ open(dst_path, w, encodingutf-8) as out: for line in f: item json.loads(line) instruction item.get(instruction, ) inp item.get(input, ) output item.get(output, ) human instruction (\n inp if inp else ) record { conversations: [ {from: human, value: human}, {from: assistant, value: output} ] } out.write(json.dumps(record, ensure_asciiFalse) \n) convert_to_sharegpt(raw_data.jsonl, my_sft.jsonl)转换完记得抽查几条确认没有空值、没有超长样本超长样本会被截断可能把关键信息截掉。4. 动态数据增强让有限的数据发挥更大价值4.1 为什么大模型微调也需要数据增强有人觉得大模型见多识广不需要数据增强。这个想法在通用能力上或许成立但在垂直领域微调时完全不成立。你的领域数据可能只有几千条模型很容易过拟合到特定表达上。动态数据增强的意义在于在不增加标注成本的前提下让模型见到同一语义的多种表达形式提升泛化。动态两个字是关键——不是离线把数据扩增成几倍存下来而是在训练过程中每个 epoch 实时生成增强样本这样每个 epoch 见到的数据都略有不同。4.2 文本任务里我常用的几种增强手段对于纯文本 SFT我常用的增强方式有同义替换对指令部分做同义词替换但要注意别把关键实体替换掉。我一般只对非实体词做替换。指令改写用一个小模型或规则模板把指令换个说法比如请总结以下内容改成帮我概括一下这段话。回译翻译成另一种语言再翻回来能引入表达多样性但成本较高适合数据量特别少的场景。随机截断与拼接对长文本做随机截断模拟不同长度的输入。4.3 在 ms-swift 里挂载动态增强ms-swift 本身的数据 pipeline 支持自定义预处理。我的做法是继承它的 dataset 类在__getitem__里加一层增强逻辑import random class AugmentedDataset(MyBaseDataset): def __getitem__(self, index): sample super().__getitem__(index) if random.random() 0.3: # 30% 概率触发增强 sample self.augment(sample) return sample def augment(self, sample): # 对 human 部分做同义替换 text sample[messages][0][content] text text.replace(请, random.choice([请, 麻烦, 帮忙])) sample[messages][0][content] text return sample这里有个经验增强概率不要设太高我一般控制在 0.2 到 0.4 之间。太高会让训练分布偏离真实分布模型学到的表达反而不自然。另外增强逻辑要保证幂等性——同一条数据增强多次不能产生语义冲突。4.4 多模态场景下的增强要更谨慎如果做的是多模态微调图像增强翻转、裁剪、颜色抖动要特别小心。有些任务的图像语义对方向敏感比如文字识别、仪表读数翻转一下就完全错了。我的原则是图像增强只在确认任务对几何变换不敏感时才用而且强度要低。文本侧的增强可以照常做。5. 新增 token什么时候需要怎么加才不出错5.1 新增 token 的典型场景不是所有微调都需要新增 token。以下几种情况我会考虑加领域里有大量特殊标记比如医疗领域的[DRUG]、[SYMPTOM]用普通文本表达会占用多个 token 且语义模糊。需要模型输出结构化控制符比如[BEGIN]、[END]这类边界标记。基座模型对某些领域术语的分词很碎一个词被切成五六个 token加一个专用 token 能显著提升效率。5.2 新增 token 的正确姿势在 ms-swift 里新增 token核心是改 tokenizer 并同步 resize 模型 embedding。步骤大致是from transformers import AutoTokenizer, AutoModelForCausalLM tokenizer AutoTokenizer.from_pretrained(your-base-model) model AutoModelForCausalLM.from_pretrained(your-base-model) new_tokens [[DRUG], [SYMPTOM], [BEGIN], [END]] num_added tokenizer.add_tokens(new_tokens) model.resize_token_embeddings(len(tokenizer))关键点在于新增 token 后必须 resize embedding否则模型遇到这些 token 会索引越界。而且 resize 之后新 token 的 embedding 是随机初始化的需要足够的训练步数让它收敛。如果新增 token 很多而训练数据很少新 token 可能学不好反而拖累整体效果。5.3 新增 token 后训练 loss 异常的处理我遇到过一次加了 10 个新 token 后训练前几百步 loss 居高不下。排查下来是两个原因一是新 token 的 embedding 随机初始化前期 loss 高是正常的二是我把新 token 加在了词表末尾但数据里这些 token 出现频率极低模型几乎没机会学到它们。解决办法有两个一是提高新 token 在训练数据里的出现频率让模型多见到二是考虑用mean_resizing方式初始化新 token 的 embedding取已有 token embedding 的均值比纯随机初始化收敛快。transformers 较新版本支持model.resize_token_embeddings(len(tokenizer), mean_resizingTrue)。注意新增 token 后如果要做推理部署tokenizer 和模型必须一起保存和加载只存模型不存 tokenizer 会导致推理时 token 对不上。6. 回归训练把分类模型改造成回归模型6.1 回归训练和分类训练的本质区别分类任务输出的是离散类别loss 用交叉熵回归任务输出的是连续值loss 用 MSE、MAE 或 Huber。在 ms-swift 里做回归训练核心改动有两处模型输出头改成回归头loss 换成回归损失。6.2 改造模型输出头的具体做法假设基座是一个带分类头的模型我要把它改成输出一个标量。做法是替换score或classifier层import torch.nn as nn class RegressionHead(nn.Module): def __init__(self, hidden_size): super().__init__() self.regressor nn.Linear(hidden_size, 1) def forward(self, hidden_states): return self.regressor(hidden_states[:, -1, :]) # 取最后一个 token取最后一个 token 的 hidden state 是常见做法因为因果语言模型的最后位置聚合了全序列信息。如果你的任务更适合用 mean pooling也可以改成对有效 token 求平均。6.3 回归训练的标签处理回归任务的标签是浮点数数据格式和分类任务不同。我一般把标签放在一个单独字段里在 collator 里转成 float tensordef collate_fn(batch): input_ids torch.stack([b[input_ids] for b in batch]) labels torch.tensor([b[score] for b in batch], dtypetorch.float32) return {input_ids: input_ids, labels: labels}这里有个容易忽略的点回归标签的数值范围差异很大时最好做归一化。比如预测房价标签从几十万到几百万直接算 MSE 梯度会非常大。我通常把标签归一化到 0 到 1 或标准化到均值 0 方差 1推理时再反归一化。6.4 回归训练的效果评估分类任务看准确率回归任务要看 MAE、RMSE 和 R²。我在训练时会挂一个自定义的评估回调每个 eval step 算一次 MAE比只看 loss 直观得多。因为 loss 下降不代表预测值合理有时候模型学会了预测均值loss 很低但 MAE 很差。7. 改模型结构从改一层到改整个 head7.1 改模型结构前必须想清楚的事改结构不是目的解决问题才是。我见过有人为了显得高级去改模型结构结果效果还不如原版。改结构之前先问自己现有结构到底哪里不够用是输出维度不对还是中间层信息利用不充分还是需要多任务输出7.2 在 ms-swift 里替换模型组件的两种方式第一种是继承原模型类重写方法。比如我要改某个 transformer 层的 attention 计算可以继承原模型重写对应层的 forward。这种方式改动小但要求你对原模型结构足够熟悉。第二种是直接替换子模块。比如把model.lm_head换成一个自定义 headmodel.lm_head MyCustomHead(model.config.hidden_size, num_outputs)这种方式简单直接适合只改输出层的场景。7.3 多任务输出的结构改造如果模型要同时输出分类和回归结果head 要改成多分支class MultiTaskHead(nn.Module): def __init__(self, hidden_size, num_classes): super().__init__() self.classifier nn.Linear(hidden_size, num_classes) self.regressor nn.Linear(hidden_size, 1) def forward(self, hidden_states): pooled hidden_states[:, -1, :] return { logits: self.classifier(pooled), score: self.regressor(pooled) }对应的 loss 也要改成多任务加权求和。权重怎么定是个经验活我一般让两个 loss 量级接近比如分类 loss 在 1 左右回归 loss 也归一化到 1 左右然后各给 0.5 权重起步再根据验证集表现微调。7.4 改结构后加载预训练权重的坑改完结构后如果还想复用原模型的预训练权重要注意参数名匹配问题。新增的层没有预训练权重需要随机初始化被改名的层如果名字对不上权重也加载不进来。我的做法是加载时用strictFalse然后打印出哪些参数没加载上逐一确认是否符合预期。missing, unexpected model.load_state_dict(state_dict, strictFalse) print(Missing keys:, missing) print(Unexpected keys:, unexpected)如果 missing 里出现了本该有预训练权重的层说明你的命名和原模型不一致得回去改。8. 自定义 loss从公式到代码的完整落地8.1 什么情况下需要自定义 loss标准交叉熵和 MSE 覆盖不了所有需求。以下几种情况我会写自定义 loss类别极度不平衡需要 focal loss 或带权重的交叉熵。需要非对称损失asymmetric loss对假阳和假阴的惩罚不同。多任务需要动态调整各任务权重。需要引入正则项约束中间层表示。8.2 在 ms-swift 里挂载自定义 lossms-swift 的 loss 计算通常在模型 forward 里。要挂自定义 loss最干净的方式是继承模型类重写compute_loss方法import torch import torch.nn.functional as F class CustomLossModel(MyBaseModel): def compute_loss(self, outputs, labels, **kwargs): logits outputs[logits] # 自定义 focal loss ce F.cross_entropy(logits, labels, reductionnone) pt torch.exp(-ce) focal (1 - pt) ** 2 * ce return focal.mean()如果框架的 loss 计算逻辑比较复杂也可以不改模型而是在训练器层面替换 loss 函数。具体挂载点要看 ms-swift 版本建议先在断点里确认 loss 是在哪一行算出来的再决定改哪里。8.3 非对称损失的实现细节非对称损失的核心思想是对正样本和负样本用不同的聚焦参数。公式上正样本用较小的聚焦参数负样本用较大的聚焦参数从而抑制大量易分负样本的梯度。代码实现def asymmetric_loss(logits, targets, gamma_pos0, gamma_neg4, clip0.05): prob torch.sigmoid(logits) pos_prob prob neg_prob 1 - prob if clip 0: neg_prob torch.clamp(neg_prob, minclip) pos_loss -targets * torch.log(pos_prob) * (1 - pos_prob) ** gamma_pos neg_loss -(1 - targets) * torch.log(neg_prob) * pos_prob ** gamma_neg return (pos_loss neg_loss).mean()参数gamma_neg越大对易分负样本的抑制越强。我一般从 4 开始试根据正负样本比例调整。正负比 1:100 以上时gamma_neg可以设到 5。8.4 自定义 loss 的调试与验证写完 loss 别急着上全量训练先做三件事第一数值检查。用几个构造的样本手动算一遍 loss和代码输出对比确认公式没写错。第二梯度检查。确认 loss 对 logits 的梯度不为 NaN量级合理。我遇到过log(0)导致梯度爆炸的情况加个eps就好了。第三小样本过拟合测试。拿 20 条数据训练几百步看 loss 能不能降到接近 0。如果降不下去说明 loss 或数据有问题。提示自定义 loss 里所有涉及 log 的操作都要加 eps涉及除法的要防零除。这两个是 loss 出 NaN 的头号原因。9. 训练过程中的几个实战经验9.1 学习率和 batch size 的搭配ms-swift 默认的学习率不一定适合你的任务。我的经验是LoRA 微调学习率可以设大一些1e-4 到 5e-4全参微调要小1e-5 到 5e-5。batch size 受显存限制时用梯度累积补上但要注意梯度累积会改变有效学习率的动态累积步数大时学习率可以适当调大。9.2 checkpoint 策略训练时间长了一定要设 checkpoint 保存间隔别等训练完才存。我一般每 500 步存一次同时保留最近 3 个。ms-swift 支持--save_steps和--save_total_limit参数。另外验证集评估间隔也要设不然你不知道模型是不是在过拟合。9.3 显存不够时的排查顺序显存 OOM 是高频问题我的排查顺序是先降 batch size再开梯度检查点再考虑 LoRA 替代全参最后才上 DeepSpeed ZeRO。顺序别搞反很多人一上来就上 DeepSpeed配置复杂还容易出问题其实降个 batch size 就解决了。9.4 训练日志里该盯哪几个指标loss 当然要看但别只盯 loss。我还会看梯度范数判断是否梯度爆炸、学习率确认调度器按预期工作、吞吐tokens/s判断有没有性能异常。这几个指标一起看能提前发现很多问题。10. 一些零散但重要的补充关于 vscode 环境配置如果用的是远程服务器推荐用 Remote-SSH 插件连上去开发本地只做编辑训练在服务器上跑。这样既享受 vscode 的调试体验又不受本地机器性能限制。Python 解释器要选对别选成系统自带的否则 ms-swift 的依赖找不到。关于数据集我强烈建议在正式训练前用--max_steps 1跑一遍确认数据加载、template 拼接、label 对齐都没问题。这一步花五分钟能省掉后面几小时的无效训练。关于自定义 loss 和改模型结构改完一定要做小样本过拟合测试。这是区分代码写对了和代码看起来对的唯一可靠方法。关于新增 token记住 tokenizer 和模型要一起存。我踩过一次只存了模型权重推理时 tokenizer 还是旧的新 token 全被当成 unknown输出全是乱码。这套流程我前后在几个项目里跑下来从数据注册到自定义 loss 上线基本能稳定复现。ms-swift 的迭代速度不慢具体参数名和模块路径可能随版本变遇到对不上的地方以你本地安装版本的源码为准断点打进去看一眼比查文档快。