前阵子接手了一个微调任务,目标是把开源基座模型适配到某个垂直领域上,整个工程涉及ms-swift框架训练、vscode调试、自定义数据集注册、动态数据增强、新增token、回归训练、改模型结构、自定义loss这一整套动作。项目前前后后跑了两个多星期,其中一半时间花在排查问题和做对照实验上,真正改训练代码的时间反而没想象中多。趁记忆还热乎,我把这条完整路径里所有实际操作过的细节整理出来,包括每一步为什么要这么做、怎么做、踩过哪些坑,给后面要动类似工程的人一个可以直接照着抄的参考。
这次不是入门扫盲,而是面向已经会用ms-swift跑默认微调、想进一步做深度定制的人。如果你正好在折腾大规模模型微调,想通过vscode把训练过程看清楚,或者遇到了新增token后效果不对、自定义loss不起作用这类问题,这篇内容应该能帮你省下不少试错时间。
1. 整体设计与方案选型
1.1 ms-swift框架到底解决什么问题
ms-swift这个框架,简单说就是把“基座模型加载、tokenizer处理、数据集预处理、LoRA/QLoRA训练、推理验证”这一串动作封装成了统一入口。过去用transformers加peft自己搭训练脚本,光处理多轮对话的attention_mask就要写不少代码,验证集切分、日志落盘、断点续训全部要自己维护。ms-swift把这些常规工作从重复造轮子变成了填参数,降低了微调入门门槛。
真正让我愿意在项目里用它,不只是命令行方便,而是它把训练主链路和数据处理拆得比较干净。你可以在多数场景下靠swift sft跑通默认实验,然后在需要定制的地方切入源码。这个项目的复杂度主要不在基座本身,而在垂直领域数据、词表扩展和loss改造,ms-swift恰好在这些位置保留了足够的自定义空间,不用绕开框架重新搭一套。
1.2 这次微调任务的整体链路
这次任务的目标是把开源基座适配到垂直场景。整体链路做下来大致是:先注册自定义数据集,再把领域词塞进tokenizer,接着调整模型结构以适配输入输出,然后设计动态数据增强来扩充训练样本多样性,最后在训练中加入自定义loss,整个过程中回归训练不停穿插。
这个顺序其实是有讲究的。数据格式和词表是第一步,因为它们决定了后续所有环节的输入形态。模型结构改动应该尽可能早,因为改结构必然影响权重加载,需要靠回归训练验证。自定义loss放在最后,是因为它依赖前面数据流和输出层的稳定状态。如果把顺序打乱,出了问题往往分不清是数据、结构还是loss引入的。项目管理的角度上,这种安排也能让每次改动都有明确的验证点,不至于把所有变量混在一起。
2. VSCode调试环境搭建
2.1 launch.json配置与断点踩坑
ms-swift跑训练的时候很多人习惯直接命令行一把梭,但工程一复杂,还是得依赖断点调试。vscode在这里的优势是轻量,配合Remote-SSH可以直接在服务器上开发,不用把数据拷到本地。最基础的一步是装好Python扩展和debugpy,然后在.vscode/launch.json里写一个调试配置。
{ "version": "0.2.0", "configurations": [ { "name": "Python: Debug Swift SFT", "type": "debugpy", "request": "launch", "program": "${workspaceFolder}/train.py", "args": [ "--model", "Qwen/Qwen2.5-7B-Instruct", "--dataset", "my_dataset", "--train_type", "lora" ], "env": { "CUDA_VISIBLE_DEVICES": "0" }, "justMyCode": false, "console": "integratedTerminal" } ] }justMyCode这个参数很关键。默认情况下debugpy只进你自己的代码,不会进入site-packages里的库函数。但微调排障经常要进入ms-swift内部,看数据怎么被处理、loss在哪个环节算的,所以必须把它设成false。否则你明明在transformers的源码里打了断点,程序却直接跑过去了,很容易误以为断点不生效。
另一个容易踩的坑是Python解释器选错。vscode左下角或者命令面板里选的解释器必须和你训练用的conda环境完全一致,否则debugpy起的是另一套环境,import的库版本对不上,断点位置全乱。最好在终端里先which python确认路径,再去vscode里手动选中同一个解释器。
2.2 分布式训练的调试姿势
多卡训练时调试会麻烦很多,torchrun会拉起多个进程,debugpy默认只attach一次,这导致断点可能在某个子进程里执行,其他进程在干等,整个调试体验非常痛苦。我的做法是:分布式训练阶段不直接用vscode的launch模式,而是先把nproc_per_node改成1,用单卡把整个训练流程跑一遍,把所有逻辑问题清干净,然后再切回多卡做真实训练。
如果必须在多进程环境下调试,可以用debugpy的--listen方式从外部attach。具体做法是在训练入口文件里加一段环境变量判断,当检测到DEBUG_ATTACH时,在进程启动后等待外部调试器连接。这样你可以在vscode里配置attach模式,手动指定端口,然后单独attach到第0号进程上。不过这个操作比较繁琐,除非遇到特别棘手的多卡一致性问题,否则不推荐在日常开发中频繁使用。
单卡调试时还有一个小技巧,就是把训练步数压到最小,比如只跑两三个step就退出,然后用vscode的断点停在第一个step上,检查输入batch的shape、dtype、数据分布是否符合预期。数据流没问题再放开step数,这样能避免在训练中途才发现数据已经变形。
2.3 调试视图的实用技巧
vscode的调试控制台和watch面板在训练任务里非常实用。watch面板里可以添加model.config.hidden_size、len(tokenizer)、inputs['input_ids'].shape这些表达式,断点触发时直接看到当前状态,不用反复print。
调试控制台还能临时执行代码,比如在断点处手动执行tokenizer.decode(inputs['input_ids'][0]),把token序列翻译回文本,检查数据预处理有没有把内容搞乱。这类操作在排查数据集接入问题时效率极高,比一个个打印变量值直观得多。
我个人习惯是在DataLoader输出的位置打一个断点,因为训练环节里最隐蔽的问题往往不是模型结构,而是数据形状和标签错位。标签错位这种问题在代码review阶段根本看不出来,但断点下一看labels和input_ids的shift关系,立刻就能定位。vscode的调试面板还能查看变量类型和嵌套结构,对排查dict格式的数据特别顺手。
3. 注册数据集与动态数据增强
3.1 数据集注册机制
ms-swift的数据集处理有一个注册机制,你可以把自定义数据集挂载到框架的DATASET_MAPPING里,之后命令行直接用数据集名字加载,不用再写重复的读取代码。自定义数据集的第一步是整理成框架能识别的格式,常见的是JSONL,每行一个样本,多轮对话则用messages数组表示。
{"messages": [{"role": "user", "content": "介绍下这个产品的使用方法"}, {"role": "assistant", "content": "第一步先连接电源,第二步启动设备。"}]}注册函数用装饰器方式挂在框架里,大概长这样:
from swift.llm import register_dataset @register_dataset('my_dataset') def get_my_dataset(path_name): samples = [] with open(path_name, 'r', encoding='utf-8') as f: for line in f: samples.append(json.loads(line)) return Dataset.from_list(samples)注册完之后,命令行直接写--dataset my_dataset就能用。这比每次传文件路径要干净很多,也方便同一个数据集在多个实验任务里复用。注册时务必检查messages中的角色字段只能是system、user、assistant这三种,其他自定义角色在大多数模型上训练会有问题。
这里有个容易忽略的细节:注册函数的返回数据类型要和框架预期一致,ms-swift内部会继续对样本做template拼接和token化。如果返回的是Dataset对象,要注意内部字段名别自定义得太随意,最稳妥的做法是参照官方内置数据集的字段结构来组织。
3.2 动态数据增强的实现思路
数据增强在NLP微调里没有图像领域那么好做,改字、换词、回译都有可能改变语义,尤其是问答类样本,答案一旦被增强改动,模型就学到错误映射。所以在微调场景下,我倾向于只对user部分做增强,assistant的answer保持原样,这样既能增加指令多样性,又不会破坏答案正确性。
常见的增强方法包括同义词替换、口语化改写、插入多余语气词、调整语序等。离线增强最直接的缺点就是磁盘占用膨胀,比如原本10万条数据,扩增5倍就是50万条,存储和预处理时间都是负担。动态数据增强的做法是把增强逻辑放到Dataset.__getitem__里,每次读取样本时按概率决定是否做一次变换,相当于每一轮epoch看到的训练数据都不完全一样。
import random class AugmentedDataset(Dataset): def __init__(self, base_samples, augment_fn, p=0.3): self.base_samples = base_samples self.augment_fn = augment_fn self.p = p def __getitem__(self, idx): sample = self.base_samples[idx] if random.random() < self.p: sample = self.augment_fn(sample) return sample def __len__(self): return len(self.base_samples)这种方案内存开销小,实现也不复杂。关键是你传入augment_fn的时候要保证它只修改messages里的user角色,assistant内容完全不碰。这个改造对数据加载性能有一点影响,因为每次读取都会执行一次Python函数,但相比磁盘扩容带来的IO压力,这点CPU开销通常可以忽略。
3.3 增强比例与防过拟合设计
增强比例不是越大越好。我日常用的概率在0.2到0.5之间,超过0.5会让训练数据变得过于“花哨”,模型反而抓不住稳定的指令模式。具体比例要看你原始数据的规模和质量,数据少的时候可以适当提高增强概率,数据多就压低,把增强当成补充手段而不是主力。
还有一点要注意的是增强后的样本长度。模型训练时有max_length限制,如果增强函数在user文本里掺入太多额外内容,导致整体序列超长,就会触发截断,严重时可能把answer截掉,直接破坏样本。所以在增强函数里最好加上长度检查,如果超过阈值就跳过本次增强,返回原始样本。
另一个防过拟合的设计是把增强函数设计成可复现的。也就是说,每次随机操作都传入当前epoch的种子,这样既能在每个epoch生成不同样本,又能在复现实验时保证同样的顺序。这个特性对后续回归训练尤其重要,后面会详细说。
4. 新增Token与词表扩展
4.1 什么时候必须新增token
大模型微调不是所有场景都需要动词表,但有几个典型场景避不开。比如垂直领域里频繁出现的复合术语,基座tokenizer可能会拆成好几个subword,不仅占长度,模型也未必能学到这个术语的整体语义。再比如业务里需要模型输出带格式指令,像[Start]、[End]这样的结构标记,用普通文本表达容易被误生成,塞进词表里变成独立token反而稳定很多。
新增token还有一个好处是给模型提供“锚点”。例如希望模型在回答开头统一加上特定符号,如果这个符号是tokenizer里的独立token,模型在生成时更容易被约束住。这个技巧在稳定输出格式上比规则后处理要自然得多,因为它是模型在训练中自己学的,不是靠解码时硬扣。
不过新增token并不是越多越好。每加一个token,embedding矩阵和lm_head都会对应扩大,参数量增加,训练负担也会上升。而且基座预训练时没有见过这些token,如果初始化没做好,模型需要额外时间去适应它们。所以我的原则是能不加就不加,确实有明确的结构性需求才动词表。
4.2 扩展词表的完整操作
在ms-swift里扩展词表本质上就是operate在tokenizer和model两个对象上。先加载模型和tokenizer,然后调用add_tokens,最后用resize_token_embeddings让模型词表维度跟tokenizer保持一致。
from transformers import AutoModelForCausalLM, AutoTokenizer model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-7B-Instruct") tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct") special_tokens = ["[DOMAIN_START]", "[DOMAIN_END]"] tokenizer.add_tokens(special_tokens) model.resize_token_embeddings(len(tokenizer), pad_to_multiple_of=128)pad_to_multiple_of=128这个参数建议设置,因为很多硬件的tensor core对维度有对齐要求,词表大小对齐到128的倍数后,embedding矩阵的计算效率会更高。如果你的原始词表大小加上新增token后已经是128的倍数,那就不会真正加padding,模型配置里的vocab_size也不会受影响。
还有一个容易漏的地方:保存模型时必须把tokenizer一起保存,否则训练完以后加载模型,词表对不上,生成的时候UNKtoken会满天飞。我用的是tokenizer.save_pretrained(output_dir)配合model.save_pretrained(output_dir),保证tokenizer配置和模型权重在同一个目录下。
4.3 新token的embedding初始化策略
新增token对应的embedding默认是随机初始化的,这点在实际训练里会造成明显震荡。尤其是你希望模型快速学会某个领域术语,结果token随机初始化,前面几百步基本是在把噪声掰正。更靠谱的做法是让新token的embedding从语义相近的已有token初始化。
比如新增的[DOMAIN_START],可以找几个含义接近的词,把它们embedding的平均值赋给这个新token,再在这个基础上做小幅随机扰动。
import torch new_ids = tokenizer.convert_tokens_to_ids(special_tokens) anchor_ids = tokenizer.convert_tokens_to_ids(["领域", "专业", "开始"]) with torch.no_grad(): for nid in new_ids: anchor_embedding = model.get_input_embeddings().weight.data[anchor_ids].mean(dim=0) model.get_input_embeddings().weight.data[nid] = anchor_embedding这样初始化之后,新token从一开始就落在语义比较合理的位置,训练起来稳定很多。需要注意的是,如果你的模型开启了tie_word_embeddings,也就是lm_head的权重和embedding共享同一份参数,那么只用get_input_embeddings修改是不够的,输出层的权重也会跟着变,因为它们是同一个tensor,不需要额外处理。如果没有tie,那lm_head也需要做同样的初始化,否则模型在输出这个token时的概率分布依然不对。
新增token之后还有个大坑:LoRA默认不训练embedding层。如果你的微调方式走的LoRA,新增token对应的embedding大概率不会被更新,模型等于在用一个随机初始化的向量硬学。这种情况下有两个选择,一是把embedding层加入LoRA的target modules,二是对这个项目改用全参微调。如果你只是想小成本微调,建议把embedding加进可训练模块,效果会比只训attention好很多。
5. 回归训练与模型结构修改
5.1 回归训练为什么不可省
回归训练就是每次改完代码或模型结构之后,用一套固定的数据和参数重新跑一遍短训练,验证改动没有破坏原有能力。很多人觉得这多余,但实际经验告诉我,模型微调工程里“改一处炸全部”的情况太常见了。比如你改了attention实现,原本的loss计算逻辑没动,但因为输出维度对不上,导致整个forward挂掉。这种问题在长训练任务里可能跑到一半才暴露,白白浪费几十个小时。
回归训练要做成可重复的固定流程。首先固定全局随机种子,包括Python的random、numpy、torch,缺一不可。其次固定数据顺序,Dataset的shuffle操作需要明确seed。最后固定训练步数,比如只跑3到5步,每步打印loss和梯度范数,和改动前的记录对比。
我一般会把回归训练脚本单独放在regression/目录下,里面包含固定的模型版本、数据集版本、参数配置文件。每次改动以后只跑这个脚本,对比loss曲线和几个关键指标是否在合理范围内。这个习惯刚开始会觉得麻烦,但项目推进到中期,改动频繁的时候,它能帮你把每个变更的因果链条理清楚。
5.2 改模型结构的几种常见场景
微调场景下真正从零改模型结构的情况很少,更多是在基座基础上做局部扩展。最常见的一种是加分类头或者新模块,典型场景是模型既要做对话生成,又要输出某种结构化标签,这时可以在基础模型上挂一个线性层,输出维度对应标签数。
第二种是替换已有的子模块,比如把MHA换成GQA或者MQA。不少开源基座本身已经支持GQA配置,你只需要在config里改num_key_value_heads并保证对应的权重结构匹配即可。对于不支持的模型,就需要改模型代码,替换attention模块的forward逻辑。
第三种是改变模型的输入输出范式,比如希望模型能接受图像特征,那就需要加一个视觉编码器接口,再通过投影层把图像特征映射到文本空间。这种改动范围最大,需要同时调整数据pipline和模型架构。无论哪种情况,改完之后都要确认两点:一是forward跑通,二是loss计算还能对齐,后者很容易被忽略。
5.3 结构修改后的权重加载与校验
改完模型结构后加载基座权重,最常遇到的报错就是key不匹配。transformers在加载时如果设置了strict=False,会打印missing和unexpected keys。missing表示新模型里有但权重文件里没有的层,unexpected反之。看到missing keys是正常的,因为那是你新增的模块,通常会被随机初始化;如果unexpected keys很多,说明你改动了已有层的命名或者结构,这时候要特别小心。
比如你把attention里的某个线性层重命名了,预训练权重就找不到了,模型会随机初始化这一层,推理效果立刻崩。排查方法是用脚本对比变更前后的state_dict keys,把所有差异列出来,逐个人工确认哪些是预期的新增层,哪些是意外的结构破坏。
回归训练在这里的作用就是验证权重是否正确加载。如果新增模块是随机初始化,训练初期loss可能下降得比较慢,但不会出现极端异常值。如果看到loss从第一步开始就远高于正常水平,或者干脆不下降,大概率是权重加载出了问题,优先检查config和模型代码的兼容性。
6. 自定义Loss的挂载与实现
6.1 从哪一层切入loss最合理
自定义loss在微调框架里有三个切入层面,选哪个取决于你要改的东西。第一层是模型内部,直接在forward返回值里改loss,适合那些标准交叉熵不满足需求的情况。第二层是Trainer的compute_loss,这一层能拿到model输出和原始输入,适合做对比学习、focal loss这类需要灵活控制样本权重的场景。第三层是训练循环外层,在step结束时额外算一个辅助loss再累加,适合给主训练目标加约束项。
ms-swift底层用的是transformers的Trainer体系,所以大多数情况下第二层是最好用的切入面。你可以继承一个自定义Trainer,重写compute_loss,在里面既保留原来的语言模型loss,又加自己的辅助loss。这种做法侵入性小,不影响ms-swift的调用链。
如果你需要的是中间层loss,那就必须在模型内部改forward了。比如希望在模型倒数第二层的hidden states上增加一个对齐约束,这时只能在模型代码里把hidden states导出来,在loss计算时做额外操作。这个方案实现成本高,调试难度也大,建议仅在确实需要的时候才去做。
6.2 一个可落地的自定义loss示例
这里给一个比较常规的示例。比如你想给loss加上asymmetric loss因子,让模型更关注那些已经做错的样本,实现思路是继承默认的Seq2SeqTrainer,覆写compute_loss。
import torch import torch.nn as nn from transformers import Seq2SeqTrainer class AsymmetricLossTrainer(Seq2SeqTrainer): def __init__(self, gamma=1.0, alpha=0.5, *args, **kwargs): super().__init__(*args, **kwargs) self.gamma = gamma self.alpha = alpha def compute_loss(self, model, inputs, return_outputs=False): outputs = model(**inputs) logits = outputs.logits labels = inputs.get("labels") if labels is not None: shift_logits = logits[..., :-1, :].contiguous() shift_labels = labels[..., 1:].contiguous().view(-1) vocab_size = shift_logits.size(-1) log_probs = torch.log_softmax(shift_logits.view(-1, vocab_size), dim=-1) true_log_probs = log_probs.gather(1, shift_labels.unsqueeze(1)).squeeze(1) pt = torch.exp(true_log_probs) weights = self.alpha * (1 - pt) ** self.gamma loss = -(weights * true_log_probs).mean() else: loss = outputs.loss return (loss, outputs) if return_outputs else loss这里关键点在于shift_logits和shift_labels的错位处理。因果语言模型的标准损失是每个位置的输出预测下一个token,所以必须把logits和labels错开一位,这一步做错了loss就是乱的。继续用AsymmetricLossTrainer的方式注册到训练流程里,就比直接改模型代码控制起来方便很多,可调整的风险也小。
6.3 loss调试心得
自定义loss最大的坑是数值不稳定。focal或者asymmetric这类loss里有幂运算和指数运算,当模型对某个样本预测得过于自信时,pt接近1,(1-pt)^gamma接近0,梯度可能会消失,导致部分样本完全不被学习。反过来,如果模型预测概率极低,pt接近0,loss可能会被拉得很大,出现NaN。
我习惯的做法是先在样本维度打印loss的分布,看看不同难度样本的loss值区间在哪里。如果发现极端值,可以用label smoothing或者在下界上加一个小epsilon来稳定数值。另外自定义loss的初始值也需要关注,如果比默认交叉熵高出很多倍,模型可能在第一步就发生梯度爆炸,这种情况下需要把自定义loss乘一个缩放系数,让它和主loss保持在同一个量级。
还有一个心得,就是自定义loss写完之后,先在小规模数据上跑一次对比实验,分别用默认loss和自定义loss训练同样的数据,看loss曲线和生成结果是否有合理差异。如果两个loss训练出来的结果没有任何差别,很可能是你的自定义loss没有被真正调用,这种情况在ms-swift里发生过,原因往往是训练入口加载的Trainer类没有替换成自定义的那个。
7. 常见问题与排查技巧实录
7.1 问题速查表
| 现象 | 常见原因 | 排查思路 |
|---|---|---|
| vscode断点完全不停 | debugpy版本问题或解释器选错 | 确认launch.json的type为debugpy,解释器与训练环境一致,justMyCode设为false |
| 自定义数据集加载报错 | JSON格式或字段名不符合要求 | 先用脚本单独读取几行,检查messages结构和角色字段 |
| 新增token后生成大量UNK | 保存模型时漏了tokenizer | 同时保存model和tokenizer,加载时用同一个目录 |
| 新token训练后无效果 | LoRA没训练embedding层 | 把embedding加入target modules或改为全参微调 |
| 自定义loss训练结果为NaN | 概率计算出现极端值或学习率过大 | 减小学习率,loss加epsilon下限,打印loss分布 |
| 回归训练loss和之前差异大 | 随机种子或数据顺序未固定 | 固定random/numpy/torch种子,固定Dataloader的shuffle行为 |
| 改模型结构后加载权重报错 | key命名不匹配 | 用strict=False加载并打印missing/unexpected keys,逐个确认 |
7.2 一条建议:把回归当成流水线的一部分
最容易被忽略的事情就是回归训练和日常训练共用一套训练脚本,改完代码后直接拿全量数据跑,跑完看结果,发现有问题也不知道是哪一步引入的。更稳妥的方式是维护一个独立的回归脚本,放到CI里或者每次改动后手动跑一遍,用极少的步数验证forward、loss、梯度这三个环节都正常。
我在这个项目里收获最大的一点体会是,微调工程的复杂度不在某一个单一环节,而在改动的组合效应。单独加一个token没问题,单独改loss也没问题,同时做就会出现各种指标下降。每次只引入一个改变,然后回归验证,比一次性堆好几个改动高效得多。如果你也在做类似的模型定制工作,我建议把上面这套流程沉淀成自己的模板脚本,下次接到新任务就能直接复用,省去大量重复的踩坑时间。