MS-Swift大模型微调实战:从VSCode调试到自定义Loss全流程
发布时间:2026/10/1 23:04:05来源:尧图网络
最近一段时间我一直在用 ms-swift也就是 Swift 这个微调框架处理一批大模型的微调任务。坦白说从 VSCode 远程调试、自定义数据集注册到动态数据增强、给分词器新增 token、改模型结构、写自定义 loss到最后回归训练验证效果这条链路我算是从头到尾硬趟了一遍。整个过程技术点并不算复杂但坑是真的多——尤其是当你以为自己只是在 Swift 上跑个 sft的时候实际牵涉到的东西远比一条命令行要多得多。这篇内容不是官方文档的复述是我个人在实操中沉淀下来的流程和排坑记录。适合正准备用 ms-swift 做定制化微调、又不想只停留在 Trainer 默认参数的读者。无论你是要处理垂直领域数据、做能力增强还是想深入研究模型内部结构这套工作流都能直接复用。1. 整体设计为什么选 MS-Swift以及训练任务的结构1.1 MS-Swift 能解决什么问题MS-Swift 是一个面向大模型训练与推理的工具链覆盖了 SFT、DPO、RM 等常见训练范式。相比从零手写 Trainer它最大的价值在于把数据加载、tokenize、模型初始化、LoRA 配置、日志与评估这些训练周边的重复工作都收敛成了标准模块。你可以把 MS-Swift 理解成一个组装好的训练流水线工具箱大多数情况下只需要告诉它模型路径、数据集路径、训练轮数和 LoRA 配置它就能把模型跑起来。它的核心优势是默认配置可靠、扩展路径清晰。默认配置覆盖了主流模型结构开箱即用而当你需要做非标准操作时比如修改模型结构、自定义 loss框架又提供了足够的接口让你改。这也是我选择它做定制化训练的原因。1.2 调试与训练方案的选型思路在这批任务里我并没有把所有环节都压在命令行里做。VSCode 承担调试角色用于单步跟踪数据流、检查某个张量的 shape 变化、确认 loss 走势真正的长时间训练则用命令行批量跑避免 IDE 占用资源也方便在服务器上后台运行。这套思路的好处是调试环境与训练环境分离。你在 VSCode 里用一小批数据、1~2 个 GPU 把链路跑通再把同样的脚本丢到命令行做完整训练。如果直接在训练脚本上不断 print效率会低很多——断点能直接看到中间变量尤其是动态数据增强和自定义 loss 这种容易出逻辑错误但不容易出语法错误的部分。1.3 训练工作流总览我最终沉淀下来的工作流按照下面这个顺序执行每一步之间都有明确的验证点准备 VSCode 调试环境确认模型和数据集能被加载注册自定义数据集跑通数据预处理链路加入动态数据增强逻辑验证样本变化是否符合预期设置新增 token 并扩展现有模型词表修改模型结构如替换 attention 层并做冒烟测试自定义 loss接入 trainer用固定随机种子跑回归训练对比修改前后的指标这个顺序的核心理念是先保证输入侧正确再动模型最后动训练目标。否则一旦出现异常你根本分不清是数据问题还是模型问题。2. VSCode 调试训练流程的落地2.1 环境准备远程 SSH 与 Python 解释器选择如果你是在本地开发机上训练这一步比较简单如果是连接远程 GPU 服务器建议直接在 VSCode 里装好 Remote-SSH 扩展然后通过配置文件连接目标机器。远程调试最容易被忽略的是 Python 解释器选择——你在终端里which python看到的路径和 VSCode 右下角解释器显示的路径不一致会导致断点完全失效。我的习惯是在服务器上用 Conda 单独建一个训练环境比如conda create -n swift-train python3.10 conda activate swift-train pip install ms-swift[llm] torch transformers datasets如果你的后端是 MindSpore则在同一个 Conda 环境里安装对应的 mindspore 包。VSCode 中按CtrlShiftP执行Python: Select Interpreter选择swift-train环境下的 Python 路径。要是 Jupyter 场景下想用这套内核先在环境中注册一下python -m ipykernel install --user --name swift-train这样在 VSCode 的 Notebook 里就能直接切到这个内核方便做数据预处理的交互式验证。2.2 launch.json 配置与断点位置调试训练脚本时我不建议直接在命令行里python train.py而是写一个简单的 Python 入口再用 VSCode 的调试配置启动。一个可用的 launch.json 配置如下{ version: 0.2.0, configurations: [ { name: Swift SFT Debug, type: debugpy, request: launch, program: ${workspaceFolder}/run_sft.py, console: integratedTerminal, justMyCode: false, env: { CUDA_VISIBLE_DEVICES: 0, TOKENIZERS_PARALLELISM: false } } ] }这里两个关键点第一justMyCode必须设成 false不然断点只会落在你写的代码里进不到 Swift 框架内部数据迭代和 loss 计算过程就成了黑盒第二通过 env 限定当前调试进程使用的卡避免调试时占满所有 GPU 导致其他任务被杀。入口脚本直接调用 sft_mainfrom swift.llm import sft_main, SftArguments args SftArguments( modelQwen/Qwen2.5-7B-Instruct, dataset/home/me/data/train.jsonl, val_dataset/home/me/data/val.jsonl, num_train_epochs3, max_length4096, loraTrue, lora_rank64, seed42, ) output sft_main(args)2.3 调试训练脚本的关键小技巧调试过程中最有价值的断点位置有三个数据集预处理函数里、模型 forward 入口、Trainer 的 compute_loss。因为大部分看起来模型训坏了的问题根因其实都在数据或 loss 上。另外如果断点停在 Swift 内部时发现变量加载很慢往往是因为调试器正在尝试扫描整个框架源码。可以在 launch.json 里加上debugpy: { logToFile: false }不需要额外配置但建议把断点集中在关键路径上不要全局打断点。还有一个实用技巧在表达式监视窗口里直接输入len(tokenizer),inputs[input_ids].shape,outputs.logits.shape这类表达式能快速定位数据维度问题。我见过很多同行对着几十行日志找 shape 不匹配其实断点下一目了然。3. 数据集注册与动态数据增强实操3.1 自定义数据集注册的常见方式MS-Swift 对自定义数据集的支持非常直接。最简单的做法是准备一个 JSONL 文件每行一个样本然后通过--dataset参数传入。例如训练数据每一行格式如下{query: 请写一段代码反转链表, response: class ListNode: ...}也支持更通用的 messages 格式{messages: [{role: user, content: ...}, {role: assistant, content: ...}]}Swift 的预处理模块会自动识别这些字段并完成指令拼接、tokenize、labels 构造。需要注意字段名不要随意改否则预处理阶段会报missing key错误。如果你的数据不是这么规则的结构就建议先写一个清洗脚本把原始数据处理成标准 JSONL再交给 Swift。这里有一个核心心得尽量让数据集注册这个环节保持简单把复杂逻辑放在数据清洗里完成。这样训练脚本和框架配置都是稳定的后续排障范围会小很多。如果你希望把数据源封装成框架内的注册数据集名称也可以仿照 Swift 的 DatasetName 机制注册一个返回Dataset对象的函数。但除非你多次复用同一套数据源否则直接用文件路径更省事。3.2 数据格式与字段要求我在批处理中发现三个容易踩的字段坑。第一标签字段不需要手动构造Swift 会从 response 自动生成 label并把 prompt 部分标为 -100如果你在数据里自己放了labels, 反而可能干扰预处理。第二tokenizer 的 chat_template 必须能够处理你给的角色类型如果自定义了 system 以外的角色名需要先扩展模板。第三多轮对话如果使用messages格式注意最后一轮必须是 assistant 消息不然预处理时会丢掉目标答案。调试阶段建议先传一个只有几十条样本的小 JSONL确保预处理链路通了再切全量数据。数据加载后我会在断点里检查第一条训练样本的 input_ids 长度和 label 中非 -100 的数量确认数据链路没有偏差。3.3 动态数据增强的实现方式动态数据增强指的是在训练过程中实时对样本做变换而不是预先离线生成一批增强样本。它最大的优势是节省存储空间并且每次 epoch 看到的样本都可能不同相当于变相扩大了训练样本的有效规模。我会在数据加载到 Dataset 后用.map做增强。比如对指令类任务做一个随机的指令前缀注入import random from datasets import load_dataset def dynamic_augment(examples): query_list examples[query] new_query_list [] for q in query_list: if random.random() 0.3: new_query_list.append(请先描述解题思路再给出最终答案 q) else: new_query_list.append(q) examples[query] new_query_list return examples ds load_dataset(json, data_filestrain.jsonl)[train] ds ds.map(dynamic_augment, batchedTrue, load_from_cache_fileFalse)注意load_from_cache_fileFalse这个参数很关键。 datasets 库默认会缓存 map 结果如果不关掉缓存第二次跑训练时你会发现增强逻辑根本没有生效因为框架加载的是上一次的缓存 file。这个问题隐蔽性极强我当时至少浪费了两个小时。在 Swift 的 SftArguments 里传入的 dataset 路径可能是文件或已加载对象。你可以先用上面的方式把增强后的 Dataset 化成 JSONL 临时文件或者直接构造 SftArguments 时传入train_dataset_mapping等字段接入自定义对象。我的经验是动态增强逻辑先跑一个 100 条的小数据集打印增强前后的 query 变化确认没问题再放进训练流程否则很难判断是增强逻辑错了还是训练崩了。如果你需要更强的一致性增强比如对图像、音频模态做随机裁剪或加噪把增强函数写进.map依然是最通用的路径只是要额外注意随机种子对增强结果的影响——固定种子时同一条数据每次跑出来的增强结果应当是一致的这对回归训练对比很重要。4. 新增 Token、回归训练与模型结构修改4.1 新增 Token 的正确姿势在一些任务中默认 tokenizer 缺少表示特定结构的 token比如自定义推理的起始符|begin_generation|或某个垂直领域的专用标识。直接给 tokenizer 添加特殊 token 是常见做法from swift.llm import get_model_tokenizer import torch model, tokenizer get_model_tokenizer(Qwen/Qwen2.5-7B-Instruct) add_tokens [|begin_generation|, |end_generation|] tokenizer.add_special_tokens({additional_special_tokens: add_tokens}) model.resize_token_embeddings(len(tokenizer))如果只做 resize新 embedding 是随机初始化的。这样模型可能在新 token 上输出不稳定的概率训练初期 loss 会突然飙升。我的初始化策略是用相邻已有 token 的 embedding 均值来填充新 token保证新 token 不是从零开始with torch.no_grad(): embed_weight model.get_input_embeddings().weight new_start len(tokenizer) - len(add_tokens) avg_embedding embed_weight[:-len(add_tokens)].mean(dim0, keepdimTrue) embed_weight[new_start:] avg_embedding这个操作在 LoRA 训练场景下尤其重要。因为 LoRA 只更新新增的低秩部分embedding 层如果被冻结新 token 的 embedding 就只能一直停留在随机状态。建议在训练配置中加大 embedding 层的学习率或者干脆把 embedding 设为可训练。训练完成后保存模型时要同时保存 tokenizertokenizer.save_pretrained(./output_model) model.save_pretrained(./output_model)否则重新加载模型时你能切到模型词表但 tokenizer 还停留在旧状态代码里一旦用到新增 token 就会出现 token id out of range 一类的错误。4.2 回归训练跑一个可靠的基线回归训练是所有改动生效的裁判。我在整个流程中始终坚持一条原则任何改动都对应一次可对比的基线运行。做法很简单第一次改任何东西之前先用原版模型、同样数据、固定 seed 跑一遍记录训练 loss 曲线和验证集指标。之后每次修改新增 token、改结构、自定义 loss都基于相同 seed 重新跑。固定随机种子的方式在 SftArguments 中直接设置seed42同时保证数据 Loader 的 shuffle 顺序一致。这里有一个容易被忽略的细节如果你在数据预处理里用了 Python 的random模块做动态增强那么必须在脚本顶部调用random.seed(42)否则每次运行的数据增强结果不同基线对比就失去参照意义。我想特别强调一个认知不要只看最后一个 step 的指标。回归训练要看完整的 loss 曲线走势尤其是 loss 是否在训练前期就异常爬升这也比只盯最终准确率更能暴露问题。我把每次运行的 loss 曲线都截图保存到同一个目录下改动前后对比相当直观。4.3 改模型结构的两种路径如果你需要修改模型结构比如替换 attention 实现、注入新的偏置项MS-Swift 是支持这种改动的但需要你以包装层的思路来做。我不会直接改动原始模型源码而是通过重写 module 来注入逻辑。一个相对安全的示范在 attention 输出上叠加一个可学习偏置import torch import torch.nn as nn class MyAttentionWrapper(nn.Module): def __init__(self, base_attn): super().__init__() self.base base_attn self.extra_bias nn.Parameter(torch.zeros(1, 1, 1, base_attn.head_dim)) def forward(self, hidden_states, *args, **kwargs): outputs self.base(hidden_states, *args, **kwargs) if isinstance(outputs, tuple): attn_output outputs[0] attn_output attn_output self.extra_bias return (attn_output,) outputs[1:] return outputs self.extra_bias然后在加载模型后逐层替换from swift.llm import get_model_tokenizer model, tokenizer get_model_tokenizer(Qwen/Qwen2.5-7B-Instruct) for layer in model.model.layers: layer.self_attn MyAttentionWrapper(layer.self_attn)包装替换而不是源码改写有几个好处。第一不会破坏原有模型结构的完整性量化、并行等框架特性依然可以工作。第二回退非常方便删除包装层就能恢复原模型。第三能跑通 forward 不一定代表结构改对了——你必须用一个固定输入分别跑原始模型和修改后模型对比输出张量 shape 和数值变化范围做一次冒烟测试。改结构后参数名和原始权重不再完全一致。训练时如果加载--resume_from_checkpoint可能因为 checkpoint 里缺少新增参数或 key 不匹配而报错。建议改动结构后的第一轮训练不续跑旧 checkpoint而是从原模型权重重新初始化只把可训练部分设为新模块。等到新结构训练完一轮后再考虑增量续跑。5. 自定义 Loss 的实现与踩坑记录5.1 自定义 Loss 的路径与基本写法MS-Swift 默认的 loss 是标准的交叉熵计算在标签上。如果你要自定义 loss比如希望针对某个 token 区间加大权重或者混合多任务损失最干净的方式是自定义 Trainer 并覆写 compute_loss。一个典型的覆写from transformers import Trainer import torch.nn as nn class CustomLossTrainer(Trainer): def compute_loss(self, model, inputs, return_outputsFalse): outputs model(**inputs) logits outputs.logits labels inputs.get(labels) shift_logits logits[..., :-1, :].contiguous() shift_labels labels[..., 1:].contiguous() loss_fct nn.CrossEntropyLoss(ignore_index-100) loss loss_fct( shift_logits.view(-1, shift_logits.size(-1)), shift_labels.view(-1) ) return (loss, outputs) if return_outputs else loss接入 Swift 时把sft_main里的参数对应改为继承 trainer。命令行方式则可以在自定义训练脚本中注册 trainer 类让框架按名称调用。这里我强调一点return_outputs分支一定要保留。因为 Trainer 在评估阶段或者某些回调里会要求 outputs 作为返回值如果只返回 loss会出现间歇性报错。平时调试时你可能只在 train 阶段跑发现不了这个问题一到trainer.evaluate()就崩。5.2 数值稳定性与并行训练适配自定义 loss 最常出现的问题就是数值溢出和梯度异常。fp16/bf16 下如果 loss 里包含了自定义的加分项比如额外的正则项建议先用 bf16 做验证。BF16 的动态范围比 FP16 大很多不容易出现 loss 变为 NaN 或 inf。如果你的 loss 是多个部分拼起来的注意每个部分的量级需要平衡。比如语言模型 Loss 一般在 1~5 之间正则项如果算出来是 1e-4 量级基本起不了作用如果正则项是 1e3 量级模型会直接被它带偏。解决思路就是每个 loss 项都乘一个可配置的权重系数并用日志打印各分量的大小loss lm_loss 0.1 * aux_loss logs[lm_loss] lm_loss.detach().float() logs[aux_loss] aux_loss.detach().float() logger.log(logs)并行训练时还有一个坑DDP 模式下每个进程会各自计算 loss然后梯度会做 all-reduce。如果你自己实现 loss 时引入了进程内独立的随机数或非确定性操作等价于给每个进程一个不同的优化目标训练效果会显著劣化。自定义 loss 中尽量使用确定性操作有随机项时使用固定的生成器。5.3 常见问题排查与避坑速查我把这次实操中遇到的典型问题整理成下面的速查表每一条都对应我亲手踩过的坑现象可能原因处理方法VSCode 断点不生效解释器选择与实际运行环境不一致检查右下角 Python 解释器切换至训练环境的 Python确认justMyCode为 false数据预处理报 missing keyJSONL 字段名不符合规范检查统一为 query/response 或 messages 格式动态增强不生效datasets 缓存未关.map传load_from_cache_fileFalse新增 token 后 loss 飙升新 embedding 随机初始化用已有 embedding 均值填充新位置模型加载 checkpoint 报 key mismatch模型结构改动导致参数名变化首轮训练不续跑旧 checkpoint从原始权重初始化loss 输出 NaN自定义 loss 数值溢出 / 学习率过高使用 bf16 混合精度降低学习率检查 loss 中各项量级多个 loss 项中某一项不起作用辅助 loss 权重过小打印各分量 loss调整权重系数LoRA 训练时新增 token 学不动embedding 层未设为可训练放开 embedding 参数或单独提高 embedding 学习率自定义 Trainer 评估时报错compute_loss 未返回 outputs确保return_outputsTrue时返回(loss, outputs)在整个训练链路里我最深的感触是MS-Swift 真正强大的地方不在于它默认训练跑得多快而在于它给自定义能力留足了空间。从数据进入模型之前的那一层 map到模型内部的 attention 包装层再到 Trainer 的 compute_loss 覆写每一层都允许你插入自己的逻辑。这种逐层可插拔的结构配合 VSCode 的断点调试其实非常利于做研究型训练——你不需要把整个框架私有化改造只需在几个关键接口处做小切口修改。我个人的建议是无论你是做垂直领域微调还是研究模型结构改进都先把默认链路在一个小数据集上完整跑通再逐步把自定义项加上去。每次加一个自定义项就做一次回归训练哪怕只是 100 条数据跑 1 个 epoch也能帮你快速暴露逻辑错误。训练这件事慢即是快。
网站建设高端定制企业官网