☰
ms-swift深度定制微调实战:数据增强、token扩充与回归训练
2026/10/1 23:04:35 网站建设 项目流程

接手这个项目的时候,我盯着需求清单看了半天——ms-swift 框架训练、vscode 调试、注册数据集、动态数据增强、新增 token、回归训练、改模型结构、自定义 loss。这不是让我"跑通一个微调脚本",而是要把一套训练链路里能改的地方几乎都改一遍。这类需求在真实项目里其实很常见:模型底座不想换,但业务侧什么都想定制。这篇文章就是我把这条链路完整走一遍的记录,包括每个环节怎么选型、怎么落地、以及哪些地方一旦踩进去就会消耗你一整天。

分享对象是已经在用 ms-swift 或类似框架做过基础微调、现在需要做深度定制的人。如果只是跑跑官方命令,你会觉得标题里一半的内容用不上;一旦进入业务定制阶段,下面这些细节全都躲不开。

1. 需求盘点与框架选型:这些定制化功能为什么能攒到一篇里

1.1 接到需求时我列出的"改动清单"

标题里的八个关键词,翻译成技术语言是这样的:

  • ms-swift 框架训练:整个实验的底座,所有改动都跑在这个框架的管线里。
  • vscode 调试:需要能钻进框架内部看数据流、梯度、loss 的具体数值,而不是靠 print 猜。
  • 注册数据集:业务数据不是通用开源格式,要让它能被 swift 的 data 管线正确读取和加工。
  • 动态数据增强训练:数据在训练过程中实时变换,而不是静态文件里一份样本训到底。
  • 新增 token:业务里有模型词表覆盖不了的专有符号或领域词。
  • 回归训练:加新能力的同时,不能让模型忘掉之前已经学会的东西。
  • 改模型结构:在不动预训练权重的前提下,把模型某些部位的输入输出按业务需求改造。
  • 自定义 loss:训练目标需要加辅助信号,标准交叉熵不够用。

这些需求放在一起,指向一个结论:我不需要一个"傻瓜式微调工具",我需要一个"可以拆开改、改完还能拼回去"的训练框架。而且所有改动必须可回归、可验证,不然模型训完到底好不好,完全说不清楚。

1.2 选 ms-swift 而不是裸 Trainer 的原因

很多人遇到定制需求的第一反应是自己写 Trainer。我不否认这是条路,但大部分时候没有必要,而且容易把时间耗在重复造轮子上。

ms-swift 的核心价值,我认为有三点:

  • 数据规范统一:对话格式、模板渲染、多模态字段处理都有统一约定,注册一个自定义数据集只需要改一处,不用自己维护一整套预处理脚本。
  • 训练方式可切换:全参、LoRA、QLoRA 这类切换是内置的,同一个数据集可以在不同训练策略间快速横跳,做对比实验非常方便。
  • 改造成本可控:它底层本质还是 Hugging Face Trainer 那一套,所以重写 compute_loss、换模型组件这类操作,技术风险和排查路径都是透明的。

选型时还有一层现实考量:团队里不是所有人都会写训练逻辑,ms-swift 让做数据的人和做模型的人可以解耦。数据同学把数据集注册好,模型同学专注改模型和 loss,两边不用抢同一个脚本。

1.3 版本与环境的优先级

这个坑我必须放在最前面说:ms-swift 的迭代速度很快,不同小版本的命令入口、参数名都有差异。我见过最痛苦的场景是照着教程敲命令,结果报参数不存在,查了半天发现是版本对不上。

我的建议是三步走:

  1. 建一个干净的虚拟环境,只装当前项目需要的依赖。
  2. 记录 transformers、ms-swift、tokenizers 三者的确切版本号,写进 requirements.txt。
  3. 任何网上搜到的代码片段,先确认人家用的版本号和你的差距,再决定是否照搬。

这一步做扎实了,后面的 vscode 调试才会有意义。版本不锁死,今天能跑明天不能跑,debug 半天发现不是代码问题是环境问题,非常消耗耐心。

2. VSCode 远程调试:跑通之前先把"眼睛"装好

2.1 为什么必须用调试器而不是 print

微调脚本的链条很长:数据加载 → tokenize → collate → forward → loss → backward。大部分问题发生在数据流转环节,比如某个字段变成 None、某些样本的 labels 没对齐、某个自定义模块的输出维度对不上。用 print 去追踪这种问题,你得在代码里插十几处,每跑一次要重启整个训练,光等数据加载就能等火。

vscode 调试的好处是可以在任意位置停下来,查看变量、回溯调用栈、甚至临时修改内存里的值再继续跑。对训练脚本而言,最有价值的断点位置是这三处:

  • dataset 预处理回调里:看每一原始样本到底被加工成了什么样子。
  • collate 之后:看一个 batch 的 input_ids、labels 形状是否和模型要求匹配。
  • compute_loss 里:看 logits 和 labels 的关系是否正确。

2.2 launch.json 关键配置

我通常不直接调试 swift 的命令行入口,而是写一个薄薄的入口脚本,里面调用 swift 的训练接口,让 vscode 的 launch 模式指向这个脚本。这样断点位置更可控,也能在调用前设置一些自定义逻辑。

launch.json 的配置参考:

{ "version": "0.2.0", "configurations": [ { "name": "Swift Train Debug", "type": "debugpy", "request": "launch", "program": "${workspaceFolder}/train_debug.py", "console": "integratedTerminal", "justMyCode": false, "env": { "CUDA_VISIBLE_DEVICES": "0", "WANDB_MODE": "offline" }, "args": [ "--model", "Qwen/Qwen2.5-7B-Instruct", "--train_type", "lora", "--dataset", "my_dataset:data/train.jsonl" ] } ] }

对应的 train_debug.py 长这样:

from swift.llm import sft_main, sft_args if __name__ == "__main__": args = sft_args() # 在这里打断点,检查参数有没有被正确解析 sft_main(args)

这里有两个关键配置要解释。

第一,"justMyCode": false必须设置。默认情况下调试器只进你自己写的代码,不会进入 swift 和 transformers 内部。但很多问题恰恰埋在框架源码里,不进去看永远找不到根因。关掉这个限制,你才能在任何第三方库的断点处停下来。

第二,CUDA_VISIBLE_DEVICES在 env 里固定。调试时通常只需要一张卡,避免多卡环境下多个进程抢设备,把问题复杂化。

2.3 调试多进程与 DDP 的取舍

如果你直接用torchrun --nproc_per_node=4启动,vscode 的 launch 模式会非常尴尬:它会同时启动 4 个 Python 进程,调试器要决定 attach 到哪一只,而且每个进程都断下来会让人崩溃。

我通常的策略是:

  • 单卡调试逻辑:所有自定义代码先用CUDA_VISIBLE_DEVICES=0跑通,这里只看正确性,不看速度。
  • 确认逻辑无误后,再退出调试模式,用多卡正常跑训练。
  • 如果问题只在多卡下出现(比如梯度同步、batch size 差异),用 attach 模式单独连 rank 0,而不是 launch 全部进程。

调试用的数据集也一定要缩小,几十条样本足够暴露逻辑问题,不需要全量数据。全量数据是训练时才该用上的东西。

2.4 调试中的杂项:账号 token 失效这类干扰

VSCode 的 Remote-SSH 调试本身很稳定,但有一类问题经常让人误判环境坏了:扩展商店或账号同步报token exchange failed、failed to refresh token这类错误。多数情况下这只是 vscode 自身的登录态问题,和你的训练环境没有任何关系,不代表代码有问题。

处理方法是:关掉无关扩展、把工作区信任目录检查一遍、必要时删掉本地的 vscode 缓存目录重新登录。调试核心功能不受影响,不要在这种问题上卡太久。

另外 Python 解释器一定要指向虚拟环境里的 Python,而不是系统 Python。你可以在 vscode 右下角看到当前解释器路径,如果是/usr/bin/python那肯定不对,训练进程读的包和你 vscode 里看到的包都会对不上。

3. 注册数据集:从业务原始文本到 swift 可读的规范格式

3.1 理解两套字段约定:messages 与 conversations

ms-swift 对对话数据有自己的一套格式要求。核心是:一条样本就是一段多轮对话,里面对话的每一轮都要标注角色和内容。我经历过两个主要版本的字段风格,旧版本常见conversations,里面每条记录用from和value;新版本更贴近 ChatML 风格,用messages,里面每条记录用role和content。

新版风格的 jsonl 长这样:

{"messages": [{"role": "system", "content": "你是一个专业的合同审核助手。"}, {"role": "user", "content": "请审阅这份合同第三条。"}, {"role": "assistant", "content": "合同第三条存在以下风险点:……"}]}

注册前要先去查你当前版本的模板要求。swift 会调用模板做输入渲染,不是说你给一段 jsonl 它就能正确训练。最稳妥的办法是在调试环境里打印一条加载后的样本,看 model_input 到底组装成了什么字符串。

3.2 注册数据集的实际操作方式

最简单的注册方式是把 jsonl 路径直接传给--dataset:

swift train --model Qwen/Qwen2.5-7B-Instruct --dataset /path/to/my_data.jsonl

如果你的数据需要更精细的配置——比如指定模板、设置数据集分组比例、命中缓存路径——建议用 dataset_info.json 统一管理。这个文件会告诉 swift 哪些路径对应哪个数据集别名,以及该用什么格式解析。

{ "my_business_data": { "dataset_path": "./data/my_data.jsonl", "format": "chatml", "columns": { "messages": "messages" } } }

注册完之后训练命令里的--dataset就可以写成my_business_data,而这个文件本身可以通过 git 管理,和代码一起走版本控制。数据集注册成功与否的验证方法,是训练日志里会出现数据集的样本数量统计。如果数量对不上,就用调试方式检查注册逻辑。

3.3 校验与自检:一眼看穿的坏数据

数据质量决定了微调的天花板。这个环节我建议做三件套:

  • 角色顺序检查:多轮对话中第一条系统/用户消息一定是 human 或 system 角色,assistant 回复跟在后面。角色乱序会导致模板拼接出来的指令和回答关系错乱。
  • 空白与 token 溢出检查:某条 content 是空字符串、或者超长导致截断后只剩一半语义,这类样本会悄悄污染训练。
  • 打印抽样验证:用调试器在 dataset 加载处打断点,随机抽 5 条样本,人工读一遍渲染后的训练文本是否自然。

我还习惯写一个一次性校验脚本,统计每轮对话的平均轮数、最大长度、空样本占比。这些统计指标看起来简陋,但比任何高级的数据分析工具都管用——数据有问题时,它们最先发出异常信号。

4. 动态数据增强训练:尽量别做"一次性增广副本"

4.1 动态增强 VS 离线增强

很多团队的默认做法是:离线把数据增强一遍,比如把一条训练样本复制成 5 条变体,合并成一个大文件再训。这样做的最大问题在于:增强后的数据被固定下来了,每个 epoch 看到的都是同样的变体,模型很容易对着这些固定模式过拟合。

动态数据增强的思路完全不同:每一条样本被取出来用于训练的那一刻,才决定要不要做变换、做什么变换。同一份原始数据,第一个 epoch 可能以 A 变体参与训练,第二个 epoch 可能是 B 变体。这样模型每次看到的输入分布都在微变,变相扩大了有效训练集,而且不需要额外磁盘空间。

实现成本并没有想象中高,关键是在 dataset 管线里做文章。

4.2 在 dataset.map 里实现随机增强

Hugging Face 的 dataset 对象有.map()方法,可以在样本级别做变换。训练时对这个方法传入的是一个函数,函数内部通过随机数决定是否增强。

import random def augment_example(example): if random.random() < 0.3: content = example["messages"][0]["content"] # 简单同义词替换,这里是示意 content = content.replace("总结", "概括") example["messages"][0]["content"] = content return example dataset = dataset.map(augment_example, load_from_cache_file=False)

需要注意两个细节:

  • load_from_cache_file=False很关键。默认情况下 map 会把处理后的数据集缓存下来,如果函数里带随机性,第二次运行时加载的是缓存而不是重新调用函数,动态增强就会失效。
  • 随机种子只能在每个进程启动时固定,不要在增强函数内部反复 seed,否则同一进程内不同 batch 的随机模式可能退化成伪随机序列,影响多样性。

4.3 哪些增强操作适合 LLM 微调

不是所有 CV 领域的增强思路都适合语言模型。我在这个项目里验证过几类操作,最终留下的是:

  • 指令改写:在保留语义的前提下更换问题句式。"请总结这段内容"改成"把这段内容的核心要点提炼一下"。适合提升模型对指令多样性的鲁棒性。
  • 关键词替换:对业务领域内的同义词做替换。注意只能替换不影响语义的词,实体名这类信息绝对不能动。
  • 随机丢弃或替换标点:适合提升模型对输入噪声的容忍度,但比例要低,不然把格式都打乱了。

不建议做的是回译式增强,成本高而且引入语义漂移的风险很难控制。增强概率我一般控制在 0.2 到 0.4 之间。增强太猛,模型学到的分布会偏离真实业务场景;增强太弱,效果又趋近于没有。

动态增强还要配合一个意识:增强是正则化手段,不是数据量的替代品。原始数据本身如果只有几百条,增强救不了根本问题,还是得回到数据采集和标注环节想办法。

5. 新增 token 的完整流程与三个经典坑

5.1 什么时候值得扩词表

新增 token 的本质是往模型词表里添加一个或多个全新的词元,并扩展 embedding 矩阵。这个操作不是免费的:每加一个 token,模型总参数量会增加一点,训练时要多学一组 embedding 向量。

什么场景值得加?我遇到的最典型场景是业务里有固定的专有符号或领域缩写,比如公司内部的单据编号格式ORD-2024-XXXX、特殊的计量单位、特定系统的代码片段。这类 token 本身是完整语义单元,如果不用一个独立 token 表示,分词器会把它切得七零八落,模型每次都要重新学这些碎片之间的组合关系。

反之,如果只是一个普通词语,分词器本身大概率能通过子词覆盖,这时候强行加 token 反而降低效率,不做为好。

5.2 标准操作流程

核心思路是:先加 token,再让模型的 embedding 矩阵和输出头跟上新的词表长度。

from transformers import AutoModelForCausalLM, AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct") model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-7B-Instruct") # 第一步:新增 token,返回实际新增的数量 added_num = tokenizer.add_tokens(["<ORD_ID>", "<SPECIAL_UNIT>"]) # 第二步:resize 模型 embedding,包括 lm_head model.resize_token_embeddings(len(tokenizer)) # 第三步:用较小标准差随机初始化新增部分 import torch std = 0.02 input_embeddings = model.get_input_embeddings().weight.data output_embeddings = model.get_output_embeddings().weight.data new_weight = torch.randn(added_num, input_embeddings.size(1)) * std input_embeddings[-added_num:] = new_weight.clone() output_embeddings[-added_num:] = new_weight.clone()

第三步很多人会省略,直接依赖resize_token_embeddings内部的处理。但实际测试下来,如果不手动控制初始化尺度,新增 embedding 的初始值会导致训练初期 loss 出现很大震荡。设置一个较小的std值,让新增部分的向量初始分布接近原有 embedding 的量级,训练会稳定很多。

5.3 坑一:embedding 随机初始化导致生成崩

我自己踩过最迷惑的一个坑是:新增 token 之后,微调阶段 loss 正常下降,但推理时一用到新增 token,输出就开始胡言乱语。

查下来发现原因是新增 embedding 的初始值方差过大,和模型原有的 embedding 空间不在一个尺度上。模型在微调时被迫花大量时间去调整这些离群向量,微调步数不够时,这些向量仍然停留在不合理的区域。手动设置较小的初始化标准差之后,这个问题基本消失。

5.4 坑二:resize 之后 tied weight 没同步

部分模型会把输入 embedding 和输出投影头共享权重,也就是 weight tying。resize_token_embeddings本身会同步处理这种绑定关系,但如果你在 resize 之后手动修改了 input embedding 却没有同步更新 output embedding,就会导致模型读进去的向量和吐出来的 logits 计算不是一套权重的结果。

手动初始化时,最安全的做法是像上面示例一样,分别 get 到两个 embedding 再朝同一个方向赋值。改完之后可以打印一下两个矩阵新行是否完全相等,确认绑定没有失效。

5.5 坑三:lora 训练时新 embedding 根本没参与更新

这个坑最隐蔽。如果你用 LoRA 训练,默认情况下只有被 LoRA 适配器作用的线性层参与更新,embedding 层不在其中。于是词表已经加上了新 token、embedding 矩阵也 resize 了,但训练过程中新增的向量纹丝不动,模型当然永远学不会这个 token。

解决办法有两条路:一是训练时加上--include_embeddings,让 embedding 层也进入可训练范围;二是改用全参数微调。如果只能 LoRA,还必须多留个心眼,检查训练后新增 embedding 的权重是否真的发生了变化,别想当然以为跑了就有结果。

另外,训练结束保存模型时,tokenizer 的扩展词表必须和模型放在同一个目录。tokenizer 没保存、或者保存的不是扩展后的版本,推理加载时词表长度不一致,直接报错。

6. 回归训练:别让新能力建立在旧能力废墟上

6.1 回归训练的本质

微调最尴尬的事情是:新任务指标进步了,旧任务的能力却倒退了。这就是灾难性遗忘。回归训练不是一种特殊算法,而是一种训练策略加验证机制:在引入新数据、新 token、新 loss 的同时,持续监控模型在旧任务上的表现,一旦发现旧能力下滑超过阈值,就回头调整数据比例或超参。

这个环节在标题里单独出现,说明它不是附加题,而是每个定制化项目都必须带上的一环。

6.2 回归集怎么建最省力

回归集不需要很大,但必须有代表性。我的做法是:从旧任务的测试集或线上真实日志里抽 200 到 500 条,覆盖旧任务的主要场景和边界情况。这些样本一旦定下来就固定不动,不能随训练动态变化,否则无法跨版本对比。

回归集建好后,每次训练结束都用同一个评估脚本跑一遍。评估指标按任务类型选:生成类看 BLEU、ROUGE 或者人工抽检;分类类看准确率;对话类看指令遵循能力,可以人工打分。没有固定指标的任务,就用抽样看输出质量。

6.3 混合比例与学习率的实证经验

回归训练的常见做法是把旧数据和新数据混合。我的一手经验是:

  • 新数据、旧数据的混合比例一般在 1:1 到 1:3 之间。旧数据太少,防遗忘效果不明显;旧数据太多,新任务学的又不够。
  • 学习率要比纯新任务训练低一些。同样的 LoRA 配置,纯新任务可以用 1e-4,带回归混合后我会降到 5e-5 甚至 2e-5。学习率越低,模型在新任务上的拟合越慢,但对旧权重的冲击也越小。
  • 每训练一个 epoch 就做一次回归评估。旧任务指标如果出现明显下降,不要硬着头皮继续训,停下来分析数据比例或训练步数。

回归训练不是一次性的。新增 token、改模型结构、换 loss 这些操作都会对旧能力产生影响,所以每做一次改动,都要配合一轮回归验证。没有回归数据的微调实验,等于闭着眼开车。

7. 改模型结构与自定义 loss:最后两道硬菜

7.1 加 head 而不是改主干

很多需求进场时是"能不能把模型结构改一下"。真上来就改主干——比如调整 self-attention 的层数、替换激活函数——风险极大,预训练权重基本废掉,整个项目要从头开始训。我的原则是:能不动主干绝不动主干。

大多数定制需求其实只需要在模型特定位置加一个轻量模块。举例:想在标准生成模型之外加一个"判断本轮回答质量"的评分头,常规做法是在最后一层 hidden state 上接一个线性层作为分类 head。这类操作可以基于 transformers 的AutoModelForCausalLM包一层组合模型,也可以注册一个临时的 head 模块挂在 lm_head 旁边。

import torch.nn as nn class QualityHead(nn.Module): def __init__(self, hidden_size, num_labels=1): super().__init__() self.layer = nn.Linear(hidden_size, hidden_size) self.out = nn.Linear(hidden_size, num_labels) def forward(self, hidden_states): return self.out(torch.relu(self.layer(hidden_states)))

然后通过修改模型 forward 或者在 trainer 里手动调用 hidden states 来接入。这类改动对预训练权重基本零损害,回归训练也轻松很多。

7.2 替换或包装 attention 层要注意什么

有些场景必须改 attention,比如换成某种稀疏注意力、加长距离依赖偏置。这时候可以做局部替换而不是重写主干。

用model.get_submodule()定位到目标层,然后用自定义 Module 把它包一层。需要注意三点:

  • 自定义 Module 的输出维度和原始层完全一致,否则后续层会链式报错。
  • 要保证 state_dict 的 key 和原模型一致,否则加载 checkpoint 时权重对不上。
  • DeepSpeed 或 FSDP 这类分布式训练插件对动态注入的 Module 会有兼容性问题,改造前先在小规模环境里验证能不能跑通。

这类改动对调试能力的要求很高,必须配合前面的 vscode 调试环境,在第 7.1 节提到的 hidden state 检查点上验证数据形状。

7.3 重写 compute_loss 落地自定义 loss

自定义 loss 是最常见的深度定制需求。很多人一上来就抄个 asymmetric loss、focal loss 的公式,但微调场景真正需要的是一个能够"辅助标准任务约束"的补充项。

标准做法是继承 Trainer,覆写compute_loss方法:

import torch import torch.nn.functional as F from transformers import Trainer class CustomTrainer(Trainer): def compute_loss(self, model, inputs, return_outputs=False): outputs = model(**inputs) logits = outputs.logits labels = inputs.get("labels") # 标准交叉熵:手工 shift shift_logits = logits[..., :-1, :].contiguous() shift_labels = labels[..., 1:].contiguous() ce_loss = F.cross_entropy( shift_logits.view(-1, shift_logits.size(-1)), shift_labels.view(-1), ignore_index=-100 ) # 自定义辅助 loss:比如鼓励最后一层 hidden state 的余弦相似度与标签一致 hidden = outputs.hidden_states[-1] aux_loss = self.custom_auxiliary_loss(hidden, labels) loss = ce_loss + 0.1 * aux_loss return (loss, outputs) if return_outputs else loss

常见的辅助 loss 设计思路有这么几类:

  • 关键词覆盖约束:鼓励生成结果里出现目标关键词,用 negative log-likelihood 或覆盖率作为惩罚。
  • 长度控制惩罚:生成过长或过短时加惩罚项,改善输出冗长问题。
  • 句间一致性约束:对多轮对话的隐状态做平滑性约束,让模型行为更稳定。

自定义 loss 要小步验证。一个已知有效的做法是把辅助 loss 先设成 0.01 这样的极小权重,跑几十步确认主 loss 行为正常,再逐步加大。权重过大会导致训练目标被带偏,loss 曲线直接起飞。

7.4 自检手段:梯度检查与过拟合测试

改完模型结构和 loss 之后,不要急着上全量训练。先用小样本集调通流程,再确认改动是否真的在按预期工作。

我每次做这类改动都会做三层自检:

  • 单样本过拟合测试:用一条样本反复训练几十步,观察 loss 是否持续下降。如果单样本都拟合不动,说明模型结构和 loss 设计有 bug。
  • 梯度数值检查:用torch.autograd.gradcheck或者手动跑一个简单样例,对比数值梯度和反向传播梯度。这个操作能抓出大多数自定义 Module 的维度错位问题。
  • NaN/Inf 监控:训练前几十步打印每个参数的梯度范数。如果某层梯度突然变成 NaN,优先检查自定义模块里有没有除零操作或未初始化的维度。

自检环节看起来费时间,但比起训了半天才发现 loss 一直下不来,这点时间成本完全是节省。


整个链路走下来,我的体会是:这类"全家桶"定制需求真正难的从来不是某一个功能,而是它们叠加在一起时如何保持可控。框架选型能降低第一步的成本,vscode 调试能让你看清楚每一步发生了什么,数据增强和自定义 loss 决定了模型能不能学到业务真正要的东西,回归训练则确保所有改动不会拆了东墙补西墙。最后分享一个小习惯:每次改动模型结构或 loss 之前,我都会先跑一个 50 条数据的最小实验,确认改动没有把基础训练流程破坏掉,再一次做增量修改。这个习惯帮我躲掉了至少三次大返工。

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

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

立即咨询