☰
ms-swift大模型微调实战:数据集、Token与Loss全解析
2026/10/1 4:34:26 网站建设 项目流程

最近把 ms-swift 框架的 LLM 微调链路完整跑了一遍,从 VSCode 远程调试、注册自己的数据集、动态数据增强,到新增 token、回归训练、改模型结构、自定义 loss,一路踩了不少坑。这些事表面上是一个个零散知识点,实际串起来就是一条完整的“领域模型训练流水线”:先把数据用框架认识的方式注册进去,再把 tokenizer 扩展到领域词汇,最后用合适的模型结构和 loss 把训练目标校准。这篇就按我实际操作的顺序,把每个环节的配置、代码和排查经验写清楚,给同样在用 ms-swift 炼丹的朋友做个参考。

适合看这篇的人,主要是这几类:已经在用 ms-swift 或 transformers 做微调、但还没系统搞过自定义数据管线和训练细节的;准备给领域模型新增一批 token,又担心灾难性遗忘的;以及想把 Trainer 默认 loss 换成自己的目标函数,却不知道从哪下手的。我会尽量避免“这里填个参数就行”这种没头没尾的写法,尽量把每一步为什么这么做也说清楚。

1. VSCode 调试 ms-swift:别再用 print 硬扛训练脚本

1.1 为什么训练代码一定要上断点调试

大模型训练脚本和普通 Python 脚本最大的区别是:一次跑起来可能要几十分钟甚至几小时,如果中间某个 tensor 的 shape 不对、某个字段是 None,print 只能打印你主动打的内容,出了问题往往要加一堆日志重跑一遍,时间成本极其难受。我一开始也是 print 流选手,直到被一个数据预处理的小 bug 卡了半天,才老老实实把 VSCode 调试配好。

用断点调试 ms-swift 有几个明显的好处:可以在数据加载进 model 之前,直接查看一条样本经过 template 处理后变成什么样子;可以在 loss 计算前检查 logits 和 labels 的 shape 是否对齐;还能在自定义 dataset 的__getitem__里看增强后的样本长什么样。这些场景如果用 print,你至少要改两轮代码才能看到完整信息,断点一次就能摸清楚。

另外,ms-swift 底层是 transformers 那套 Trainer,训练循环里的调用链比较深。直接看框架源码其实才是最快定位问题的方式,而 VSCode 的justMyCode: false能让你断进 transformers、ms-swift 内部代码,配合调用堆栈基本能把问题锁死在某个函数。

1.2 一份可以直接用的 launch.json 配置

我平时是 Remote-SSH 连到 GPU 服务器上写代码,所以调试配置是远程模式的。你只需要在服务器上装好 ms-swift、transformers、debugpy 这些依赖,然后在本地 VSCode 里装好 Remote-SSH、Python、debugpy 扩展即可。

.vscode/launch.json我一般这样写:

{ "version": "0.2.0", "configurations": [ { "name": "Python: ms-swift sft", "type": "debugpy", "request": "launch", "program": "${workspaceFolder}/train_swift.py", "console": "integratedTerminal", "cwd": "${workspaceFolder}", "env": { "CUDA_VISIBLE_DEVICES": "0", "OMP_NUM_THREADS": "8" }, "justMyCode": false } ] }

几个字段的作用说一下:

  • program指向你的训练入口脚本。我习惯把所有训练相关逻辑放一个train_swift.py,里面调用sft_train,而不是直接用命令行传一大堆参数。这样断点更容易命中。
  • env里的CUDA_VISIBLE_DEVICES很关键。调试阶段我固定只用单卡,因为多卡场景下断点会同时命中多个进程,非常混乱。先把单卡逻辑调通,再上多卡。
  • justMyCode: false是必须的。不打开的话,你断不进 transformers 的Trainer.compute_loss、ms-swift 的数据预处理函数,排查问题等于少了一条腿。

1.3 多卡训练和子进程调试的实战解法

如果必须调试多卡训练,比如要复现一个只在多卡下出现的问题,我不会直接在 launch.json 里写torchrun。更稳的办法是在训练代码里手动插入 debugpy 监听:

import debugpy debugpy.listen(("127.0.0.1", 5678)) print("Waiting for debugger attach...") debugpy.wait_for_client()

然后本地 VSCode 再用 attach 模式连上去。这里注意,监听地址别随便暴露到公网,内网环境只绑127.0.0.1或者做好端口隔离,否则谁都能连上来下断点。等 attach 成功后,断点会像正常本地调试一样命中。

实际上我大多数时候不会这样折腾。多卡训练的问题大概率出在数据并行、模型并行和 loss 归并这几块,而这些逻辑在单卡下也能复现八九成。我的习惯是:先用CUDA_VISIBLE_DEVICES=0限制单卡,把数据集注册、字段映射、动态增强、新增 token 这些跟分布式无关的逻辑全部调通,最后再放开多卡跑效率验证。这样能省掉大量“每个 rank 都断一次”的等待时间。

调试还有一个容易被忽略的坑:本地文件路径和远程服务器路径不一致时,断点会显示“找不到源代码”。这通常是 VSCode 远程调试时解析源文件路径失败,最直接的解法是把工作区直接开到服务器上的项目目录,保证${workspaceFolder}和远程代码路径一致。如果你习惯本地编辑、远程运行,那就在 launch.json 里手动配pathMappings把本地路径映射到服务器路径,不然断点会很灵异。

2. 注册数据集:让 ms-swift 认识你的私有数据

2.1 ms-swift 默认吃什么格式的数据

ms-swift 对数据集格式是有约定的。最通用的是对话格式,每条样本是一个messages数组,里面的每个元素包含role和content,这三个角色是system、user、assistant,内容和 OpenAI 的对话接口几乎一样:

{ "messages": [ {"role": "system", "content": "你是一个法律助手,请依据现行法规回答问题。"}, {"role": "user", "content": "什么是违约金?"}, {"role": "assistant", "content": "违约金是指当事人约定或法律规定,一方不履行合同时应向对方支付的款项。"} ] }

如果你的数据是更早的 Alpaca 格式,也就是instruction、input、output三段式,ms-swift 内部一般会把它转换成对话格式:instruction加上input拼成user内容,output当成assistant内容。所以注册数据前,先看一眼原始数据长什么样。我建议新项目直接统一成messages格式,少一层转换就少一层出错可能。

还有一类数据是 continuation 格式,只有一段文本,适合做续写类预训练。这种格式常用于领域语料扩充,虽然没有对话结构,但 ms-swift 也能处理。不过如果你后面要新增 token 并做回归训练,我更建议把续写样本也整理成带角色结构的数据,至少加上一组简单的 system/user 包装,方便后续跟指令数据混合训练。

2.2 两种注册私有数据集的姿势

第一种最直接:路径即注册。在调用sft_train时,dataset参数可以直接传一个 jsonl 文件路径列表,甚至传一个文件夹。框架会按约定好的格式去读。比如:

from swift.llm import sft_train sft_train( model="Qwen/Qwen2.5-7B-Instruct", dataset=[ "data/law_train.jsonl", "data/general_qa.jsonl", ], train_type="lora", output_dir="output/law_model", )

这种方式适合一次性的私有数据,不用改任何框架代码。缺点也很明显:如果字段名不是messages,或者你需要做一些特殊清洗,就得先离线把数据转成框架认识的格式。麻烦但可控。

第二种是注册一个数据集加载和预处理函数。我理解标题里说的“注册数据集”,本质上就是你想让自己的数据加载逻辑变成框架内可复用的一等公民。在 ms-swift 中,可以写一个函数把任意来源的数据加载成LazyDataset,然后在训练入口里直接引用:

from swift.llm import LazyDataset def build_law_dataset(): dataset = LazyDataset.from_json("data/law_train.jsonl") # 这里可以做字段映射、过滤、采样等操作 dataset = dataset.map(lambda x: {"messages": normalize_messages(x)}) return dataset dataset = build_law_dataset()

把这类函数集中放在dataset_registry.py里,用字典登记好名字,就等于做了一套轻量级注册。训练时统一通过名字获取,代码维护起来比到处散落 jsonl 路径清爽很多。

2.3 字段映射与数据校验细节

字段映射是我觉得注册数据集最脏的环节。原始数据里字段名五花八门,常见的有question/answer、prompt/completion、context/response,甚至同一个 jsonl 里不同行的字段还不一样。我处理的流程是:先写一个normalize_messages函数,把各种别名统一成messages,然后把 role 和 content 都检查一遍。

校验的重点有三个:

  • content 不能为 None,也不能是空字符串。空指令会让模型学到“无输入也输出”,副作用很大。
  • 对话轮数要合理。单轮简单任务一条 user 一条 assistant 就行,多轮数据要确保最后一条是 assistant,不要以 user 结尾。
  • 字段类型要一致。有些 jsonl 里 content 是对象而不是字符串,预处理直接报错。

我还会抽一小批样本,走一遍 tokenizer 的apply_chat_template,看看拼出来的 prompt 是否自然。这一步极其推荐,因为很多数据问题不是结构错误,而是拼出来根本不像人话,模型训练出来自然也不会像人话。

注册完数据集后,先别急着训练。用框架的预处理模块生成一批 token id,检查input_ids和labels的长度是否在合理范围内,以及-100忽略位是否只出现在需要被忽略的位置。这一步能拦下大量“训练时 loss 异常”的隐性问题。

3. 动态数据增强训练:每个 epoch 的数据不完全一样

3.1 大模型训练为什么也需要数据增强

很多做 CV 的朋友对数据增强很熟,图像翻转、裁剪、颜色抖动一套一套的,但一到 NLP 建模就默认“文本是离散的,不能随便改”。我在做领域微调时发现,如果训练数据里某些指令的表达方式高度雷同,模型很容易过拟合到“记忆答案”而不是“学会任务”。动态数据增强就是让同一份样本在每个 epoch 被读取时,有一定概率产生一个语义不变但表达有差异的版本,相当于在有限数据集上扩大了训练分布。

它的本质是让模型看到更多“同义不同形”的输入,增强泛化能力。对于 LLM 微调,增强的重点应该放在 user 侧的指令改写、前缀补充、句式转换;assistant 侧的答案尽量不要动,因为答案一改,标签含义就变了,模型会学到错误映射。

3.2 在 ms-swift 数据管线里做动态增强的实作方式

这里注意一个核心问题:datasets.Dataset.map默认是静态的,map 完结果会被缓存住,下次 epoch 再读还是同一份数据。所以动态增强不能简单挂在map上,需要在__getitem__层面实现“每次读取时都重新增强”。

我喜欢写一个轻量包装类:

import numpy as np class DynamicAugDataset: def __init__(self, base_dataset, aug_fn, aug_prob=0.3): self.base = base_dataset self.aug_fn = aug_fn self.aug_prob = aug_prob def __len__(self): return len(self.base) def __getitem__(self, index): item = self.base[index] if np.random.random() < self.aug_prob: item = self.aug_fn(item) return item

然后训练时把这个包装类传给框架。这样每个 epoch 重新遍历时,同一序号对应的样本可能已经通过aug_fn产生了新版本,而且每次读都不同,真正做到动态。

aug_fn的写法要克制。举例来说,我想让模型更适应“用户会在问题里加一些背景描述”的场景,那就在 user 内容前以一定概率插入“结合以下背景信息回答:”这类引导句,或者简单替换 prompt 模板里的措辞,比如“请回答”换成“请说明”。这种增强不改变答案,风险最低。

如果你想做更激进的增强,比如同义词替换、句式重排,一定确保答案与新输入仍然匹配。一个典型坑是:你把问题里的“违约金”替换成“赔偿金”,答案里还在用“违约金”做解释,这就会让模型学到错误的词汇对齐关系。所以我对 assistant 内容基本保持不动,最多做标点符号统一化。

3.3 动态增强的边界与训练稳定性

动态增强不是越多越好。增强概率太高,模型可能始终在一个飘忽不定的数据分布上训练,loss 曲线会神经质般抖动。我实践下来的经验:aug_prob设在 0.2-0.4 之间比较合适,并且增强方式要稳定,不要又做同义词替换又做句子 shuffle 又做长度截断,多种扰动叠加会让样本离原始分布太远。

还有一个不可忽视的坑:增强后的样本长度会变化。如果你的max_length是 2048,增强前样本只有 1500 token,增强后可能涨到 2200 token,然后被截断,答案尾部被切掉,标签错位。所以做增强之后,最好再对样本做一次长度校验,超长的样本要么重新裁剪到安全长度,要么跳过增强。

另外,如果 loss 曲线在动态增强后出现周期性跳变,不要直接怀疑增强逻辑。先确认是不是没有设置随机种子。分布式训练时每个 rank 的随机状态不同,np.random.random()在不同进程里会产生不同样本版本,这本身不是问题,但如果你希望实验可复现,就要在数据加载器里设置全局 seed,并且保证增强函数使用可复现的随机流。

4. 新增 token 与回归训练:词表不是想加就加

4.1 什么时候需要新增 token

新增 token 最典型的场景是领域分词太碎。比如法律、医疗、代码领域会有一批固定术语,默认 tokenizer 可能把它们切成十几个子词,既浪费序列长度,又让模型难以建立稳定的语义映射。把这些术语作为整体 token 加入词表后,模型可以用一个 token 表示一个完整概念,训练和推理效率都会变好。

还有一些场景是加入特殊控制符号,比如给表格数据加<cell_start>、<cell_end>,给文档加<doc_title>,或者给多模态任务加<image>。这类 token 不是自然语言,而是结构标记,模型需要学会它们的起止位置和语义。

但新增 token 是有代价的。每加一个 token,embedding 矩阵就会多一行,LM head 也会多一列,模型参数总量和显存占用都会变大。更关键的是,新增 token 没有任何预训练知识,它的 embedding 一开始是随机初始化的,必须通过训练让它学习到合理语义,否则推理时一旦触发这些 token,模型表现会很怪。

4.2 核心操作:add_tokens 与 resize_model_embeddings

在 ms-swift 或 transformers 里,操作路径很直接:

from transformers import AutoTokenizer, AutoModelForCausalLM tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct", trust_remote_code=True) model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-7B-Instruct", trust_remote_code=True) new_tokens = [ "<law_contract>", "<law_tort>", "<law_criminal>", "违约金条款", "不可抗力条款", ] num_added = tokenizer.add_tokens(new_tokens, special_tokens=True) model.resize_token_embeddings(len(tokenizer)) print(f"added {num_added} tokens, new vocab size: {len(tokenizer)}")

special_tokens=True会让这些 token 进入 special token 逻辑,在解码时不参与正常文本合并。但注意,special token 和普通新词的处理不完全一样。结构标记类 token 适合走 special token;而像“违约金条款”这种领域词汇,我倾向按普通 token 加入,也就是special_tokens=False,这样它可以在文本序列里被当成正常词使用。

resize_token_embeddings会自动扩展 embedding 矩阵,但新扩展出来的行是随机初始化的,这个随机初始化的方差如果太大,可能让训练一开始就出现 loss 波动。有些实现里可以用已有 token 的 embedding 均值或小随机正态分布去初始化新 token,实践下来更稳。比如:

import torch embedding = model.get_input_embeddings() init_std = 0.02 with torch.no_grad(): embedding.weight[-num_added:] = torch.randn(num_added, embedding.weight.size(-1)) * init_std

如果你加了 tokenizer 却忘记resize,最常见的问题是加载模型后前向时 embedding 索引越界,报IndexError。反向的坑是:resize之后保存模型,但再次加载旧 checkpoint 时没同步加载新 tokenizer,词表 id 错位,生成的文本里全是莫名其妙的 token。

4.3 回归训练:如何让新 token 学会又不让旧能力退化

新增 token 之后的训练,我一般叫它“回归训练”。不是把新 token 随便训练一下就完事,而是要保证两个目标:新 token 被触发时能给出合理表示;旧 token 的语义不被新数据冲垮。后者就是灾难性遗忘问题,尤其在只拿领域数据训练时很致命。

我的回归训练方案分三步:

第一步,准备混合训练集。新任务数据不要占 100%,至少混入 20%-30% 的通用指令数据,让模型继续看到熟悉的表达方式。如果领域数据和通用数据风格差异很大,比如全是法律文书,我会再加一部分通用问答数据,比例可以到 40%,防止模型“只懂法律、不懂人话”。

第二步,分阶段训练。我倾向于先用一小段训练让新 token embedding 从随机状态收敛,这个阶段可以用相对小的学习率,比如 1e-5 到 2e-5,避免随机初始化向量对原有模型输出产生过大扰动。之后再做正式的 LoRA 微调,让模型学会在具体任务里使用这些新 token。

第三步,回归验证。训练前先记录一组 baseline 指标,包括通用能力指标、领域任务指标、token 命中率。训练后再跑同一套评测,对比旧能力掉没掉。如果掉了,优先降低学习率、增加通用数据比例,而不是盲目加迭代轮数。

这里还要说一个新手特别容易忽略的点:新增 token 的 embedding 在 LoRA 训练时往往并不会被更新。因为 LoRA 默认只作用在 attention 的 q/k/v/o 等线性层上,embedding 和 lm_head 都不在其中。如果你想让新 token 参与 LoRA 训练,要么改用全参数微调,要么在训练脚本里显式把新增 embedding 的requires_grad打开,并且用自定义 Trainer 控制优化器参数分组。我在实际项目里是先短跑一小段全参数微调,让新 token embedding 稳定,然后切 LoRA 做下游任务训练,效果比单跑 LoRA 稳定很多。

5. 改模型结构与自定义 loss:框架不是限制,是接口

5.1 改模型结构的常见动机与切入点

聊到“改模型结构”,先要分清是哪种改法。最常见的三种:

一是加一个 side adapter 或 cross-attention 层,用来把额外信息源送入主模型。比如在医疗场景中,要把化验单结构化数据与文本一起输入,可以在 decoder 层之间加一个融合模块。

二是换输出头。把原本的 LM head 替换成领域定制的分类头、双塔头、或者多标签头。

三是调整模型内部的局部结构,比如把 MLP 换成门控结构、把标准 attention 改成稀疏 attention。

在 ms-swift 里做这类改动,我的建议是不要直接改框架源码,而是通过 transformers 的模型类机制去扩展。先基于原模型类写一个子类,重写forward方法,再通过模型的from_pretrained加载原始权重。改动点越集中越好,避免破坏原始层之间的跳连和缓存逻辑。

如果你只是想在模型输出上多接一个小模块,优先考虑把它做成一个独立网络,放在主模型之外,而不是把主模型 forward 改得乱七八糟。这样训练时主模型参数可以冻结或 LoRA,附加模块单独更新,整体稳定性好很多。

5.2 自定义 loss 的标准写法和接入方式

ms-swift 的训练器继承自 transformers 的 Trainer,所以最干净的自定义 loss 方法是重写compute_loss方法。下面是一个带辅助 loss 的示例:

import torch import torch.nn as nn from transformers import Trainer class CustomTrainer(Trainer): def __init__(self, aux_loss_weight=0.1, *args, **kwargs): super().__init__(*args, **kwargs) self.aux_loss_weight = aux_loss_weight def compute_loss(self, model, inputs, return_outputs=False, **kwargs): 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) ce_loss = loss_fct( shift_logits.view(-1, shift_logits.size(-1)), shift_labels.view(-1) ) # 例如:鼓励模型在指定 token 位置输出高置信度 aux_loss = self.compute_auxiliary_loss(logits, labels) total_loss = ce_loss + self.aux_loss_weight * aux_loss return (total_loss, outputs) if return_outputs else total_loss

接入 ms-swift 的方式,就是把自定义 Trainer 类传给训练入口。如果你用sft_train,可以看下它是否暴露trainer_cls参数;如果不暴露,退一步的做法是不要死磕框架封装,直接用 transformers 的Trainer配合 ms-swift 生成的数据集来跑训练。框架的意义是帮我们省去数据、评估、checkpoint 管理的重复工作,不是把我们的训练目标锁死。

自定义 loss 写得再漂亮,也得先跑通数值稳定性。我最开始加了一个辅助 loss,结果主 loss 一直降,总 loss 却涨,因为辅助 loss 的尺度和主 loss 差了一个数量级。解决方式是给辅助 loss 加权重,并且观察两个 loss 分量的曲线。

5.3 自定义 loss 后如何调试和验证

调试自定义 loss 有一个很实用的技巧:先构造一个极小数据集,比如 8 条样本,然后刻意让模型过拟合这 8 条。如果 loss 能顺利降到很低,前向和反向的基本链路就是通的;如果在这个小数据上都振荡、发散,问题一定在 loss 实现或梯度流动上,跟数据量无关。

还有一个必须做的检查:shift_logits和shift_labels的最后一维是否对齐。很多 loss 异常都出在序列错位上。比如 labels 比 logits 长一位,或者 -100 掩码位置不对,CrossEntropyLoss 会默默忽略一堆 token,导致 loss 数值看起来正常但模型其实没学到东西。

如果要改compute_loss的返回值,务必注意return_outputs对 Trainer 后续逻辑的影响。某些版本里 Trainer 会根据return_outputs判断是否把输出传给评估回调,写错会导致 val loss 显示为 None。我的建议是保持标准返回格式,只在 loss 层面做扩展,不要改变输出元组结构。

6. 常见问题与排查技巧实录

6.1 高频坑速查表

这里把我在实际训练中遇到的高频问题整理成一张表,方便你照着排查。

问题现象可能原因解决思路
新增 token 后训练报IndexError: index out of range in selfembedding 未 resize调model.resize_token_embeddings(len(tokenizer))
加载 checkpoint 后新 token 全部变未知tokenizer 没和新模型一起保存保存时同时保存 tokenizer,加载时用同一个 tokenizer 文件
loss 为 nan学习率过大、数据里有 None 或空标签先降到 1e-5 试跑,检查数据清洗逻辑
多卡训练时断点不生效或重复命中每个 rank 都执行了同一段代码调试阶段限制单卡,或手动 attach 到指定进程
VSCode 调试显示找不到源代码本地路径与服务器路径不一致配置pathMappings或直接在服务器目录打开工作区
动态增强后同一 epoch 的数据没变化用了静态Dataset.map改成__getitem__层的包装类,或每次重新 map
自定义 loss 训练指标正常但文本效果差标签错位或 ignore_index 设置错误可视化一个 batch 的 input_ids 和 labels 对齐情况
领域数据训练后通用能力大幅下降灾难性遗忘混入通用数据,降低学习率,做回归训练

6.2 给新手的操作顺序建议

如果你第一次接触 ms-swift,我不建议一上来就把这些能力全叠上。先把最小闭环跑通:加载一个官方 demo 数据集,用默认参数训练一个小模型。然后换成自己的数据,熟悉注册和预处理。等这一步稳定了,再折腾新增 token、动态增强这些进阶操作,每加一个能力就做一次回归验证。

我个人的经验是:小数据验证永远优先。所谓小数据,我一般取 200-500 条覆盖各种边角情况的样本,目标是验证代码链路而不是刷指标。在这个阶段把数据字段、tokenizer、loss 数值稳定性全部调好,再上全量训练。这样即使全量训练出了问题,你也能确定问题大概率出在规模相关的地方,而不是基础链路。

改模型结构和自定义 loss 这两件事,更要放在最后做。它们涉及对模型类、训练循环内部机制的深入理解,没有前置的调试经验,出了问题你会分不清是框架 bug、数据问题还是自己的代码问题。

写在最后的小体会

这套链路跑下来,我个人最深的体会是“新增 token 的回归训练”比想象中重要。很多人加完 token 之后只盯着领域指标,看到领域任务涨了就觉得大功告成,结果通用能力悄悄掉了不少。后来我养成一个习惯:每次改动模型结构、loss 或 tokenizer,都要跑一遍固定回归集,哪怕只是几十条精心挑选的样本。这个小习惯帮我拦下了至少三次肉眼发现不了的能力退化。

另外,自定义 loss 并不是越复杂越好。大部分领域微调任务,标准交叉熵 loss 已经够用,新增辅助 loss 时一定要用权重约束住它的影响力。如果辅助 loss 的贡献超过主 loss,你实际上是在优化另一个目标,模型最终行为会变得不可控。

最后想补充一个操作性建议:ms-swift 和 transformers 的版本升级偶尔会改接口,网上很多旧教程会失效。遇到报错先看版本,再用python -c "import swift; print(swift.__version__)"确认当前环境版本,很多“玄学问题”其实是版本混用导致的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询