1. 为什么单卡微调值得认真对待
1.1 从“跑不起来”到“跑得动”的认知转变
很多人第一次接触大模型微调,脑子里浮现的画面是机房里的八卡A100集群,觉得这事跟自己手头的设备没什么关系。我一开始也是这么想的,直到有一次手头只有一个任务:让一个几B参数的小模型学会按照特定格式输出结构化的设备巡检报告。租云卡按小时计费,跑一轮实验调参加上数据清洗,账单很快就上去了。后来我换了个思路,把模型规模压下来,把数据质量提上去,用一张消费级显卡硬是把这件事做成了。
单卡微调的核心逻辑不是“用更少的卡做同样的事”,而是“用更少的卡做更聚焦的事”。你不需要让模型学会所有知识,只需要让它在某个垂直场景里表现稳定。这个认知转变很关键,它决定了你后面所有的技术选型:模型选多大、数据怎么构造、训练参数怎么设、推理怎么部署。
昇思 MindSpore 在这条路径上有它自己的优势。它的图算融合和自动并行机制,在单卡场景下能把显存利用得比较充分,而且 MindSpore 的mindformers套件对大模型微调做了比较完整的封装,很多底层细节不用自己从头写。这篇文章就是把我自己从零搭建一套“单卡微调加推理”流程的完整过程拆开来讲,包括环境怎么配、数据怎么处理、参数怎么调、推理怎么接,以及中间踩过的那些坑。
适合读这篇内容的人,我大致分三类:一是手头只有一张卡但想跑通全流程的开发者;二是想用 MindSpore 做垂直场景模型定制的算法工程师;三是对大模型微调感兴趣、想找一个能实际动手跑起来的最小闭环的学生或爱好者。不管你是哪一类,下面的内容都可以直接照着操作,我会把每一步的意图和参数依据都讲清楚。
1.2 单卡微调的现实边界在哪里
先把预期管理做好。单卡微调不是万能的,它有明确的边界。以目前主流的消费级显卡为例,24GB 显存是一个比较常见的档位。在这个显存条件下,全量微调一个 7B 参数的模型基本不现实,因为光是模型权重加优化器状态就远超显存容量。但如果你用 LoRA 或者 QLoRA 这类参数高效微调方法,把可训练参数降到原来的百分之一甚至更低,再配合梯度检查点和混合精度训练,7B 级别的模型是可以跑起来的。
这里有一个简单的估算方法。假设模型有 N 个参数,用 FP16 存储权重需要 2N 字节,用 FP32 存储优化器状态(以 Adam 为例)需要 8N 字节,再加上梯度 2N 字节,全量微调的总显存需求大约是 12N 字节。7B 参数就是 84GB 左右,单卡显然放不下。换成 LoRA 之后,可训练参数可能只有几十M,优化器状态和梯度都只针对这部分参数,显存占用就降到了模型权重加少量额外开销的水平。7B 模型 FP16 权重约 14GB,加上激活值和中间计算,24GB 显存刚好够用,但 batch size 只能设得很小,通常就是 1 到 2。
所以单卡微调的关键词是“参数高效”和“显存精打细算”。你需要在模型规模、序列长度、batch size、梯度累积步数这几个变量之间找到一个平衡点。这个平衡点没有标准答案,取决于你的具体任务和硬件条件,但后面的章节我会给出一个经过实测的参考配置,你可以在此基础上调整。
2. 环境搭建与工具链选型
2.1 昇思 MindSpore 安装的版本选择
MindSpore 的安装方式有好几种,pip 安装、conda 安装、源码编译。对于单卡微调这个场景,我建议直接用 pip 安装官方发布的版本,省去编译的麻烦。版本选择上,要和你显卡的 CUDA 版本匹配。MindSpore 目前对 CUDA 11.6 和 11.8 的支持比较成熟,如果你用的是较新的显卡驱动,建议选 CUDA 11.8 对应的版本。
安装命令大致是这样的:
pip install mindspore==2.3.0 -i https://pypi.tuna.tsinghua.edu.cn/simple这里指定版本号是有必要的,因为mindformers套件对 MindSpore 的版本有依赖关系,版本不匹配会出现一些奇怪的报错。我试过用最新版的 MindSpore 配旧版的 mindformers,结果在加载模型权重的时候一直报 shape 不匹配,排查了半天才发现是版本问题。
安装完之后,用下面这段代码验证一下:
import mindspore as ms print(ms.__version__) print(ms.context.get_context("device_target"))如果输出是GPU并且版本号和你安装的一致,说明基础环境没问题。如果输出是CPU,那说明 CUDA 没配好,需要检查显卡驱动和 CUDA 环境变量。
注意:MindSpore 的 GPU 版本对驱动版本有最低要求,安装前先确认你的驱动版本是否满足。驱动太旧的话,即使 CUDA 装对了,MindSpore 也可能识别不到显卡。
2.2 mindformers 套件的获取与配置
mindformers是 MindSpore 生态里专门做大模型训练和推理的套件,里面已经封装好了常见模型的网络结构、数据预处理、训练流程和推理接口。单卡微调用这个套件可以省掉大量重复代码。
获取方式是从代码仓库克隆对应版本的源码:
git clone https://gitee.com/mindspore/mindformers.git cd mindformers git checkout r1.3 pip install -e .这里git checkout到对应的发布分支很重要,主分支的代码可能还在开发中,接口不稳定。pip install -e .是开发模式安装,这样你修改套件里的配置文件后不用重新安装就能生效。
安装完成后,进入research或者examples目录,找到对应模型的微调脚本。以常见的几个模型为例,目录结构大概是这样的:
mindformers/ research/ llama2/ run_llama2.py llama2_config.yaml qwen/ run_qwen.py qwen_config.yaml每个模型目录下都有一个训练入口脚本和一个配置文件。配置文件里定义了模型结构、训练超参、数据路径、并行策略等所有关键信息。单卡微调的核心工作就是改这个配置文件。
2.3 依赖库的版本对齐
除了 MindSpore 和 mindformers 本身,还有一些依赖库需要注意版本。比如numpy的版本不能太高,某些版本和 MindSpore 的算子有兼容性问题。sentencepiece是处理 tokenizer 用的,版本太旧会导致分词结果异常。protobuf的版本也要注意,太新的版本可能导致模型权重加载失败。
我整理了一个经过实测的依赖版本组合,可以作为参考:
| 依赖库 | 推荐版本 | 说明 |
|---|---|---|
| mindspore | 2.3.0 | GPU 版本,CUDA 11.8 |
| mindformers | r1.3 | 对应 MindSpore 2.3 |
| numpy | 1.24.4 | 避免 2.x 版本的兼容问题 |
| sentencepiece | 0.1.99 | 分词器依赖 |
| protobuf | 3.20.3 | 权重加载依赖 |
| transformers | 4.35.0 | 部分模型配置参考 |
这些版本不是绝对的,但如果你遇到一些莫名其妙的报错,先检查一下这几个库的版本是否在这个范围内。
3. 数据准备与格式转换
3.1 微调数据的构造原则
单卡微调的数据量不需要很大,但质量要求很高。我见过很多人拿几万条脏数据去微调,结果模型学了一堆噪声,输出变得乱七八糟。对于垂直场景的微调,几百到几千条高质量数据往往就能看到明显效果。
数据构造的核心原则是“输入输出对齐”。你要让模型学会的是“给定这样的输入,输出那样的结果”,所以每一条数据都必须是一个完整的输入输出对。以设备巡检报告为例,输入是设备运行参数和巡检记录,输出是结构化的报告文本。数据格式可以是 JSONL,每行一个样本:
{"instruction": "根据以下设备参数生成巡检报告", "input": "设备编号:A-102,温度:78℃,振动:4.2mm/s,电流:12.5A", "output": "巡检报告:设备A-102运行状态异常,温度偏高,建议检查散热系统。"}这种 instruction-input-output 的三段式结构是微调数据最常见的格式。instruction 是任务描述,input 是具体输入,output 是期望输出。在训练时,通常只对 output 部分计算损失,instruction 和 input 作为条件输入。
数据质量比数量重要得多。我自己的经验是,500 条精心构造的数据,效果往往好过 5000 条随手收集的数据。构造数据时要注意几点:输出格式要统一,不要一会儿用中文标点一会儿用英文标点;输出内容要准确,不要有事实性错误;输入要有一定的多样性,覆盖不同的参数组合和场景。
3.2 数据格式转换与 tokenizer 适配
原始数据准备好之后,需要转换成 MindSpore 能读取的格式。mindformers 支持多种数据格式,最常用的是 MindRecord 和 TFRecord。对于单卡微调,我建议先用一个简单的文本格式,然后在训练脚本里做实时 tokenize,这样调试起来比较方便。
具体做法是准备一个 JSONL 文件,然后在配置文件里指定数据路径和预处理方式。mindformers 的配置文件里有一个data_loader部分,可以这样配置:
data_loader: type: CommonDataLoader dataset_dir: "./data" dataset_name: "finetune_data" batch_size: 1 max_seq_length: 1024 tokenizer: type: LlamaTokenizer vocab_file: "./tokenizer.model"这里max_seq_length设成 1024 是一个比较稳妥的选择。设得太长会显著增加显存占用,设得太短又会截断重要信息。你可以先统计一下自己数据里输入输出对的长度分布,取一个覆盖 95% 样本的值。
tokenizer 的选择要和模型匹配。如果你用的是 Llama 架构的模型,就用 LlamaTokenizer;如果是 Qwen 架构,就用对应的 QwenTokenizer。用错 tokenizer 会导致分词结果完全错误,模型训练出来的效果会非常差。
提示:在正式训练之前,先写一个小脚本把几条数据过一遍 tokenizer,看看分词结果是否符合预期。特别是中文数据,有些 tokenizer 对中文的分词粒度很粗,一个词可能被拆成好几个 token,这会显著增加序列长度。
3.3 数据集的划分与验证集的作用
单卡微调的数据量不大,但仍然需要划分训练集和验证集。我通常按 9:1 的比例划分,如果数据量特别少(比如只有两三百条),可以按 8:2 划分。验证集的作用不是评估模型泛化能力,而是监控训练过程是否过拟合。
在配置文件里可以这样指定:
train_dataset: "./data/train.jsonl" eval_dataset: "./data/eval.jsonl"训练过程中每隔一定步数跑一次验证集,观察 loss 的变化。如果训练 loss 持续下降但验证 loss 开始上升,说明模型开始过拟合了,这时候应该停止训练或者减小学习率。
我自己的经验是,单卡微调很容易过拟合,因为数据量少、模型参数量大。所以验证集的监控非常重要,不要等到训练完了才发现模型已经过拟合了。通常训练 3 到 5 个 epoch 就能看到比较明显的效果,再多就容易过拟合。
4. 微调配置与参数调优
4.1 LoRA 微调的原理与参数设置
LoRA 的全称是 Low-Rank Adaptation,中文叫低秩适配。它的核心思想是在模型的某些权重矩阵旁边加一个低秩的旁路,训练时只更新这个旁路,原始权重保持冻结。这样做的好处是可训练参数量大幅减少,显存占用和训练时间都显著降低。
在 mindformers 里配置 LoRA 微调,需要在配置文件里加上pet_config部分:
pet_config: pet_type: lora lora_rank: 8 lora_alpha: 16 lora_dropout: 0.05 target_modules: ["q_proj", "v_proj"]这里几个参数的含义需要解释一下。lora_rank是低秩矩阵的秩,决定了旁路的容量。秩越大,可训练参数越多,拟合能力越强,但过拟合风险也越高。对于单卡微调,我建议从 8 开始试,如果效果不够再增加到 16 或 32。lora_alpha是缩放因子,通常设为 rank 的两倍。lora_dropout是 dropout 概率,用来防止过拟合,数据量少的时候可以设大一点,比如 0.1。
target_modules指定在哪些权重矩阵上加 LoRA。最常见的做法是在 attention 的 query 和 value 投影矩阵上加,也就是q_proj和v_proj。有些实践也会在 key 和 output 投影上加,但那样可训练参数会增多。对于单卡场景,我建议先只加 q 和 v,效果不够再扩展。
LoRA 的一个关键优势是推理时可以合并回原始权重。训练完成后,把 LoRA 旁路的权重加到原始权重上,推理时就完全看不出和全量微调的区别,也不会增加推理延迟。mindformers 提供了合并权重的脚本,后面推理部分会讲到。
4.2 训练超参的选取与显存优化
训练超参里最关键的几个是学习率、batch size、梯度累积步数和训练轮数。单卡微调的学习率通常比全量微调大一些,因为可训练参数少,需要更大的步长才能有效更新。我常用的配置是学习率 1e-4 到 5e-4,配合 cosine 衰减策略。
batch size 受显存限制,24GB 显存跑 7B 模型 LoRA 微调,batch size 通常只能设 1 或 2。为了保持训练稳定性,需要用梯度累积来模拟更大的 batch。比如设gradient_accumulation_steps: 8,实际等效 batch size 就是 8。
learning_rate: 2e-4 lr_schedule: cosine warmup_steps: 100 batch_size: 1 gradient_accumulation_steps: 8 epochs: 5 optimizer: adamw weight_decay: 0.01显存优化方面,有几个开关可以打开。梯度检查点(gradient checkpointing)用计算换显存,可以把激活值的显存占用降低很多,代价是训练速度慢一些。混合精度训练(AMP)用 FP16 或 BF16 存储激活值和梯度,也能显著降低显存。这两个开关在配置文件里通常是这样的:
gradient_checkpointing: true amp_level: "O2" compute_dtype: "float16"amp_level设为 O2 表示使用混合精度,compute_dtype设为 float16 表示计算时用半精度。如果你的显卡支持 BF16,也可以设成 bfloat16,数值稳定性更好。
我实测下来,7B 模型在 24GB 显存上,开启梯度检查点和混合精度,batch size 设为 1,序列长度 1024,显存占用大约在 20GB 左右,还有一定余量。如果序列长度增加到 2048,显存就会比较紧张,需要进一步降低 batch size 或者缩短序列长度。
4.3 训练过程的监控与中断恢复
训练启动之后,需要监控 loss 曲线和显存占用。mindformers 默认会输出训练日志,包括每步的 loss、学习率、耗时等信息。我习惯把日志重定向到文件,方便后续分析:
python run_llama2.py --config llama2_config.yaml > train.log 2>&1 &然后可以用tail -f train.log实时查看训练进度。重点关注几个指标:loss 是否在下降、下降速度是否合理、有没有出现 NaN、显存占用是否稳定。如果 loss 突然变成 NaN,通常是学习率太大或者数据里有异常样本,需要检查数据和调整学习率。
训练中断恢复也是单卡微调需要考虑的问题。因为训练时间可能比较长,中途可能因为各种原因中断。mindformers 支持 checkpoint 保存和恢复,在配置文件里设置保存间隔:
save_checkpoint_steps: 500 keep_checkpoint_max: 3这样每 500 步保存一次,最多保留 3 个最新的 checkpoint。如果训练中断了,可以从最新的 checkpoint 恢复:
python run_llama2.py --config llama2_config.yaml --load_checkpoint ./checkpoints/llama2_500.ckpt注意:恢复训练时,优化器状态和训练步数也需要恢复,否则学习率调度会从头开始。mindformers 的 checkpoint 里包含了这些信息,但需要确认配置文件里的
load_checkpoint路径指向的是完整的 checkpoint 文件。
5. 推理部署与效果验证
5.1 LoRA 权重合并与模型导出
训练完成后,得到的是 LoRA 旁路的权重文件。这个文件不能直接用于推理,需要先和原始模型权重合并。mindformers 提供了合并脚本,通常在tools目录下:
python tools/merge_lora.py \ --base_model ./base_model.ckpt \ --lora_model ./lora_model.ckpt \ --output ./merged_model.ckpt合并的原理很简单,就是把 LoRA 的旁路权重按缩放因子加到原始权重上。合并后的模型和全量微调的模型在结构上完全一致,推理时不需要任何额外处理。
合并完成后,可以把模型导出成 MindIR 格式,这样推理时加载更快,也不依赖训练时的代码环境:
python tools/export.py \ --config ./config.yaml \ --checkpoint ./merged_model.ckpt \ --output ./exported_model导出的 MindIR 文件可以在推理引擎里直接加载,适合部署到生产环境。
5.2 推理脚本的编写与流式输出
推理脚本的核心是加载模型、构造输入、生成输出。mindformers 提供了MindFormerPipeline接口,封装了常见的推理流程:
from mindformers import pipeline pipe = pipeline( task="text_generation", model="llama2_7b", checkpoint="./merged_model.ckpt", max_length=512, do_sample=True, temperature=0.7, top_k=50, top_p=0.9 ) result = pipe("根据以下设备参数生成巡检报告:设备编号A-102,温度78℃") print(result)这里几个生成参数需要解释一下。temperature控制输出的随机性,值越大输出越多样,值越小输出越确定。对于结构化输出任务,建议设小一点,比如 0.3 到 0.7。top_k和top_p是采样策略,top_k表示只从概率最高的 k 个 token 里采样,top_p表示从累积概率达到 p 的 token 集合里采样。这两个参数通常设一个就行,我习惯用top_p。
流式输出对于交互式应用很重要,可以让用户看到逐字生成的效果。mindformers 的 pipeline 支持流式输出:
for token in pipe.stream("根据以下设备参数生成巡检报告:设备编号A-102,温度78℃"): print(token, end="", flush=True)流式输出的实现原理是每次生成一个 token 就返回,而不是等整个序列生成完再返回。这对于长文本生成场景可以显著提升用户体验。
5.3 推理效果的评估与迭代
推理效果评估不能只看一两个例子,需要构造一个测试集,覆盖不同的输入场景。我通常从验证集里抽 20 到 30 条数据,人工检查输出是否符合预期。评估的维度包括:格式是否正确、内容是否准确、有没有重复或遗漏、语言是否流畅。
如果发现效果不理想,可以从几个方向排查。首先是数据质量,检查训练数据里有没有错误的标注或者格式不一致的情况。其次是训练轮数,可能训练不够或者过拟合了。再次是 LoRA 的秩,可能秩太小导致拟合能力不足。最后是推理参数,调整 temperature 和 top_p 可能会改善输出质量。
我自己的经验是,单卡微调的效果很大程度上取决于数据质量。同样的模型和参数,用精心构造的 500 条数据训练,效果明显好过用随手收集的 2000 条数据。所以在数据构造上多花时间是值得的。
6. 常见问题与排查技巧
6.1 显存不足的排查与解决
显存不足是单卡微调最常见的问题。报错信息通常是Out of memory或者CUDA out of memory。排查思路是从大到小逐个检查显存占用项。
首先检查模型权重的显存占用。7B 模型 FP16 权重约 14GB,如果加载模型后就占用了超过 20GB,说明可能加载了 FP32 权重或者有额外的缓存。可以在配置文件里确认compute_dtype和param_init_type是否设成了 float16。
其次检查激活值的显存占用。激活值和序列长度、batch size 成正比。如果序列长度设成了 2048,可以降到 1024 试试。如果 batch size 设成了 2,可以降到 1。
然后检查优化器状态的显存占用。LoRA 微调时优化器状态只针对可训练参数,占用很小。但如果误开了全量微调,优化器状态会占用大量显存。
最后检查有没有内存泄漏。MindSpore 在反复加载模型时可能会有显存碎片,可以在脚本开头设置ms.set_context(mempool_block_size="1GB")来优化显存分配。
我整理了一个显存不足的排查清单:
| 排查项 | 检查方法 | 解决方法 |
|---|---|---|
| 模型权重精度 | 查看配置文件 compute_dtype | 改为 float16 |
| 序列长度 | 查看 max_seq_length | 降低到 1024 或 512 |
| batch size | 查看 batch_size | 降低到 1 |
| 梯度检查点 | 查看 gradient_checkpointing | 设为 true |
| 混合精度 | 查看 amp_level | 设为 O2 |
| 显存碎片 | 观察多次运行后的显存 | 设置 mempool_block_size |
6.2 训练 loss 异常的排查
Loss 异常通常表现为 loss 不下降、loss 震荡、loss 变成 NaN。每种情况的原因不同,排查方法也不同。
Loss 不下降,可能是学习率太小、数据有问题、或者模型没有正确加载预训练权重。先检查学习率是否在合理范围,然后检查数据里有没有大量重复样本或者错误标注。最后确认预训练权重是否加载成功,可以打印模型的部分权重看看是否和预训练权重一致。
Loss 震荡,通常是学习率太大或者 batch size 太小。可以降低学习率,或者增大梯度累积步数来等效增大 batch size。如果数据里有长度差异很大的样本,也可能导致 loss 震荡,可以考虑按长度分桶。
Loss 变成 NaN,通常是数值溢出。检查学习率是否太大,检查数据里有没有异常值,检查混合精度的设置是否合适。如果用的是 FP16,可以换成 BF16 试试,BF16 的数值范围更大,不容易溢出。
提示:训练初期 loss 在 2 到 3 左右是正常的,随着训练进行会逐渐下降到 1 以下。如果 loss 一直在 5 以上不下降,说明模型可能没有学到东西,需要检查数据和配置。
6.3 推理输出不符合预期的排查
推理输出不符合预期,可能表现为格式错误、内容重复、答非所问。排查思路是从训练和推理两个环节分别检查。
格式错误,通常是训练数据里格式不统一导致的。检查训练数据的输出部分,确保所有样本的格式一致。如果训练数据里有的用中文标点有的用英文标点,模型就会学混。
内容重复,通常是推理参数设置不当。降低 temperature,减小 top_k 或 top_p,可以减少重复。如果重复很严重,可能是模型过拟合了,需要减少训练轮数或者增加数据量。
答非所问,通常是训练数据里输入输出不对齐。检查数据里有没有输入和输出不匹配的样本。另外,如果 instruction 部分写得太模糊,模型可能无法理解任务意图,需要把 instruction 写得更明确。
我自己的经验是,推理效果不好时,先回头检查训练数据,十有八九是数据的问题。数据质量上去了,推理效果自然就好。
7. 单卡微调的扩展思路
7.1 从单卡到多卡的平滑过渡
单卡微调跑通之后,如果任务需要更大的模型或者更多的数据,可以考虑扩展到多卡。MindSpore 的自动并行机制让单卡到多卡的过渡比较平滑,主要改动在配置文件的并行策略部分。
单卡配置里通常是parallel_config: data_parallel: 1, model_parallel: 1。扩展到多卡时,可以增加数据并行度或者模型并行度。数据并行是把 batch 切分到多张卡上,每张卡持有完整的模型副本。模型并行是把模型切分到多张卡上,每张卡持有部分模型。
对于单卡微调的场景,如果只是数据量增大,优先考虑数据并行。如果模型太大单卡放不下,才考虑模型并行。MindSpore 的自动并行可以根据配置自动推导切分策略,不需要手动指定每一层的切分方式。
7.2 推理性能的进一步优化
单卡推理的性能优化有几个方向。一是模型量化,把 FP16 权重转成 INT8,推理速度可以提升一倍左右,精度损失通常在可接受范围内。MindSpore 提供了量化工具,可以对导出的 MindIR 模型做量化。
二是算子融合,MindSpore 的图算融合机制会自动把一些相邻算子合并,减少 kernel 启动开销。可以在推理时开启ms.set_context(graph_kernel_flags="--enable_graph_kernel=1")来启用。
三是批处理推理,如果有多个请求同时到达,可以把它们拼成一个 batch 一起推理,提高显卡利用率。但要注意 batch 太大会增加延迟,需要根据实际场景权衡。
7.3 持续迭代与数据飞轮
模型上线之后不是终点,而是新的起点。用户的真实输入会暴露出训练数据没有覆盖的场景,这些场景可以作为新的训练数据来源。把线上推理的输入输出记录下来,人工筛选出高质量的部分,加入训练集重新微调,模型效果会逐步提升。
这个循环就是数据飞轮。单卡微调的好处是迭代成本低,一次微调可能只需要几个小时,可以快速验证新数据的效果。我自己的做法是每周收集一批线上数据,筛选后加入训练集,每月做一次重新微调。这样模型能持续适应新的场景,保持较好的效果。
最后再分享一个小技巧:在构造训练数据时,可以故意加入一些边界情况的样本,比如输入为空、输入特别长、输入包含特殊字符等。这样模型在面对异常输入时表现会更稳定,不会轻易输出乱七八糟的内容。这个技巧在实际部署中非常有用,能减少很多线上问题。