做文本分类、意图识别、情感判断这类任务,这两年最常被问到的一句话就是“手上有个预训练Transformer模型,怎么把它微调成我自己业务能用的样子”。微调预训练Transformer模型这件事,说难不难,说简单也真不简单:跑通一个demo可能半小时,但要让它在你自己的数据上稳定收敛、指标可信、上线不翻车,中间隔着数据清洗、显存估算、参数选择、过拟合控制一堆坎。我自己从最早拿单卡硬啃全参微调,到后来习惯用参数高效的方式做快速迭代,踩过的坑基本能覆盖入门阶段的绝大多数困惑。这篇内容就是把这些经验摊开讲:它适合刚接触微调、手里有一批标注数据、想在自己机器上跑出第一个可用模型的同学,也适合已经跑通过demo但指标总不理想的从业者。下面所有代码和参数都以常见开源框架的通用写法为准,模型路径和字段名按你自己的实际情况替换即可。
1. 路线判断:微调预训练Transformer到底在解决什么问题
1.1 三条落地路线的取舍逻辑
很多人一上来就问“用哪个学习率”,其实更该先问的是“我到底要不要微调”。摆在面前通常有三条路:提示工程、全参微调、参数高效微调。提示工程不改模型权重,靠构造输入让模型输出想要的结果,成本最低,但对输出格式、领域术语、边界 case 的控制力很弱,遇到“必须输出固定标签”的分类任务基本没戏。全参微调把所有参数都放开训练,效果上限高,但对显存和数据的胃口都大,小数据集上极容易过拟合,训完之后模型对通用能力的保持也会变差。参数高效微调只训练一小部分附加参数,比如低秩适配器,显存占用小、训练快、可以给不同业务各存一份适配器,缺点是极端复杂的任务上效果可能略逊于全参。
我一般的判断顺序是这样:如果任务只是“把已有能力引导出来”,先试提示工程;如果标注数据在几千条以上、任务边界清晰、机器也够,再考虑全参微调;如果是入门练手、数据量不大、只有一张消费级显卡,直接上参数高效微调,跑通链路、验证数据质量,等确认方向对了再决定要不要加码。这个顺序能帮你避免一个常见误区:拿着一张 8GB 显存的卡硬上全参微调,最后 OOM 报错折腾一整天,其实换成低秩适配器半小时就能看到第一版结果。
还有一点值得说清楚:微调不是“让模型学会新知识”的唯一手段,它更擅长的是“把模型已有的表示调整到你的任务分布上”。所以如果你的数据里全是模型预训练阶段没见过的专有名词、内部黑话,光靠微调效果有限,得先解决词表和领域语料的问题;反过来,如果任务本身和预训练目标很接近,比如情感分类、意图识别,微调的性价比就非常高。
1.2 预训练Transformer的知识到底存在哪
理解这一点,后面很多参数选择就顺了。预训练Transformer的“知识”主要藏在两处:一是嵌入层和注意力层的权重,负责把词映射成向量、建立词与词的关系;二是前馈层的权重,负责做非线性变换。微调时,分类任务通常会在模型顶部接一个小的分类头,把 pooled 输出映射到标签空间。全参微调是这些部分一起动;低秩适配器的思路则是在注意力层的特定投影矩阵旁边挂一个小型低秩矩阵,前向时把原输出和适配器输出相加,反向传播只更新适配器。
这解释了一个常见现象:为什么低秩适配器只改注意力层的 query、value 投影就能有效果。因为这两个投影决定了“当前词该关注哪些词”以及“被关注的信息怎么聚合”,对分类、抽取这类任务来说,调整注意力模式往往比调整整个前馈层更直接。我第一次做低秩微调时把 target_modules 设成全部线性层,结果参数变多、训练变慢,效果却没比只改 query、value 好多少,后来就固定用后者做基线,特殊任务再扩大范围。
1.3 入门场景的典型需求盘点
入门阶段最常见的任务大概有这么几类:短文本分类,比如评论情感、工单类型;句子对任务,比如语义相似度、是否重复;序列标注,比如实体抽取;还有生成式任务,比如摘要、改写。前两类最适合拿来练手,因为数据构造简单、评估指标直观、训练轮数少。序列标注对对齐要求高,生成式任务对解码参数敏感,建议放在第二轮再碰。
我建议第一个项目就选二到五类的短文本分类,数据量控制在每条类别几百到两千条,文本长度先截断到 128 或 256。原因很实际:这个规模在一张普通显卡上十几分钟就能跑完一轮,你能快速经历“数据准备—训练—评估—调参”的完整闭环,建立起对 loss 曲线和指标的手感。等你能稳定把验证集 F1 从 0.7 推到 0.85 以上,再去碰更复杂的任务,心里就有底了。
2. 动手前的资产盘点与环境准备
2.1 硬件显存怎么估算
显存不够是入门阶段最高频的拦路虎,提前算一笔账能省很多时间。粗略估算公式是:显存 ≈ 参数量 × 每参数字节数 × 放大系数,放大系数主要来自激活值和临时缓冲区。全参微调用混合精度加 Adam 类优化器时,每个参数大约要占 16 到 20 字节:权重本身 2 到 4 字节、梯度 2 到 4 字节、优化器的一阶和二阶动量各 4 字节。一个 1.1 亿参数的小模型,光这些就接近 1.8 到 2.2GB,再算上批次激活值,实际往往要 4 到 6GB。7B 量级的模型全参微调,权重加梯度加优化器状态轻松超过 80GB,单卡基本不用想。
低秩适配器就友好得多,因为主干权重冻结,不存梯度和优化器状态,只有适配器参数和激活值占大头。7B 模型用 16 位加载、低秩适配器、批次为一的训练,显存大概在 16 到 24GB 区间,具体取决于序列长度和是否开梯度检查点。小模型就更宽松,8GB 卡跑 1 亿参数级别的低秩微调完全够用。
| 场景 | 参数量 | 训练方式 | 粗略显存需求 | 适用硬件 |
|---|---|---|---|---|
| 小模型分类 | 1 亿级 | 全参微调 | 4 到 6GB | 单卡 8GB 可跑 |
| 小模型分类 | 1 亿级 | 低秩适配器 | 2 到 4GB | 单卡 6GB 可跑 |
| 中等模型 | 10 亿级 | 全参微调 | 24GB 以上 | 单卡 40GB 较稳 |
| 中等模型 | 10 亿级 | 低秩适配器 | 6 到 10GB | 单卡 12GB 可跑 |
| 大模型 | 70 亿级 | 全参微调 | 80GB 以上 | 多卡或高显存卡 |
| 大模型 | 70 亿级 | 低秩适配器 | 16 到 24GB | 单卡 24GB 可跑 |
这张表是经验值,实际会因框架版本、序列长度、批次大小浮动。我的习惯是先按表估一个上限,然后用最小批次跑一步,看显存峰值,再逐步加批次。别一上来就设批次 32,OOM 之后反复猜,效率很低。
2.2 模型与框架版本选型
入门阶段不建议追最新最大的模型,选一个参数量适中、社区资料多、分词器稳定的基座就行。中文任务优先选在中文语料上继续预训练过的版本,英文或双语任务选通用版本即可。框架层面,Transformers 生态加参数高效微调库是目前最省心的组合,训练器封装好了大部分流程,你只需要关心数据和超参。
版本上有一个坑要提醒:训练框架、参数高效微调库、加速库三者版本不匹配时,会出现各种莫名其妙的报错,比如适配器加载不上、混合精度训练报类型错误。我的做法是新建一个干净环境,先用 pip 装一个经过验证的组合,把版本号记在项目说明里,之后不要随便升级。曾经有一次我为了用某个新特性升级了加速库,结果原来能跑的脚本全部报错,回退版本才恢复,白白浪费半天。
2.3 数据集格式与清洗要点
数据质量决定微调上限,这句话在入门阶段尤其成立。我见过太多人模型结构调了半天,最后发现是标注不一致。建议统一用 JSON Lines 格式,一行一条,字段明确:
{"text": "这个快递三天还没到,客服也联系不上", "label": 0} {"text": "东西还行,包装有点简陋", "label": 1} {"text": "收到就用上了,比预期好很多", "label": 2}标签用整数,另外维护一份 id 到名称的映射表,避免训练和推理时标签错位。清洗上重点做四件事:去重,完全重复和近似重复都要处理,否则验证集指标会虚高;去空值和超短样本,长度小于三五个字的样本信息量太低;检查标签分布,类别严重不平衡时要么补数据,要么在损失函数里加权重;人工抽查边界样本,尤其是那些标注员自己都犹豫的,统一标准比多标一百条更有价值。
注意:训练集和验证集必须从同一批清洗后的数据里按比例切分,不能拿早期数据和后期数据分别做训练和验证,否则时间分布差异会让指标失真。
3. 从零跑通一次微调:代码与参数逐行拆解
3.1 加载模型与分词器
先确定模型标识,可以是本地目录,也可以是模型仓库里的名称。分词器和模型要来自同一份权重,混用会导致词表对不上。分类任务用带分类头的模型类,标签数量通过 num_labels 指定。
import torch from transformers import AutoTokenizer, AutoModelForSequenceClassification MODEL_NAME = "你的预训练模型路径或标识" NUM_LABELS = 3 tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME) model = AutoModelForSequenceClassification.from_pretrained( MODEL_NAME, num_labels=NUM_LABELS, ignore_mismatched_sizes=True, ) device = "cuda" if torch.cuda.is_available() else "cpu" model.to(device)ignore_mismatched_sizes 这个参数很关键。预训练模型原始的分类头通常是针对预训练任务的,标签数和你的任务不一样,加载时形状对不上会报错,加上它就会重新初始化分类头。重新初始化的部分需要正常的学习率训练,这也是为什么微调时分类头往往比主干收敛得快。
分词阶段要统一截断长度和填充方向。编码器类模型一般用右侧填充,但有些模型要求左侧填充,训练和推理必须一致。我建议封装一个预处理函数:
MAX_LEN = 256 def preprocess(batch): return tokenizer( batch["text"], truncation=True, max_length=MAX_LEN, padding="max_length", return_tensors=None, )max_length 的选择要看文本长度分布。先统计一下训练集长度的 95 分位数,取一个略大于它的值,既能覆盖绝大多数样本,又不浪费显存。256 对短文本通常够用,长文档任务再往 512 调,但注意显存和计算量是随长度增长的。
3.2 数据管道的搭建
用数据加载库读 JSON Lines,然后做映射和划分。这里有个细节:先划分再处理,还是先处理再划分?我的习惯是先按原始数据划分,避免同一条样本的增强版本同时出现在训练和验证里。
from datasets import load_dataset raw = load_dataset( "json", data_files={"train": "data/train.jsonl", "validation": "data/val.jsonl"}, ) encoded = raw.map( lambda x: tokenizer( x["text"], truncation=True, max_length=MAX_LEN, padding="max_length", ), batched=True, ) encoded = encoded.rename_column("label", "labels") encoded.set_format(type="torch", columns=["input_ids", "attention_mask", "labels"])列名必须改成 labels,训练器默认按这个名字取标签。如果任务带 token 级别的标签,比如序列标注,还要处理子词对齐,入门阶段先不碰。数据管道搭好后,随机抽几条解码出来看一眼,确认文本、标签、注意力掩码都对得上,这一步能提前发现大部分低级错误。
3.3 训练配置与关键参数
训练参数里最影响结果的是学习率、批次大小、训练轮数和预热比例。全参微调的学习率一般在 1e-5 到 5e-5 之间,低秩适配器因为只训练少量参数,学习率可以放大到 1e-4 到 3e-4。批次大小受显存限制,显存不够时用梯度累积把等效批次补上来,比如单步批次 8、累积 4 步,等效批次就是 32。训练轮数对小数据集两到四轮通常够,多了容易过拟合。预热比例取总步数的百分之五到十,让学习率从零缓慢爬升,训练更稳。
from transformers import TrainingArguments training_args = TrainingArguments( output_dir="./output", learning_rate=2e-5, per_device_train_batch_size=16, per_device_eval_batch_size=32, gradient_accumulation_steps=1, num_train_epochs=3, warmup_ratio=0.1, weight_decay=0.01, logging_steps=50, eval_strategy="epoch", save_strategy="epoch", load_best_model_at_end=True, metric_for_best_model="f1", fp16=torch.cuda.is_available(), seed=42, )fp16 在支持混合精度的卡上能明显降显存、提速,但如果训练中出现损失变成 NaN,先关掉它排查,确认是数值问题还是数据问题。评估和保存都按轮进行,配合 load_best_model_at_end 自动保留验证指标最好的那一版,避免最后一轮过拟合导致效果回退。
3.4 用低秩适配器把显存压下来
如果全参微调显存吃紧,就换低秩适配器。核心配置是秩 r、缩放系数 alpha、丢弃率和目标模块。r 越大表达能力越强,参数也越多,入门从 8 开始试,不够再往 16、32 调。alpha 一般设成 r 的两倍。目标模块要按模型结构来,编码器类模型通常挂到 query 和 value 上,解码器类模型挂到 q_proj、v_proj 上。
from peft import LoraConfig, get_peft_model, TaskType lora_config = LoraConfig( task_type=TaskType.SEQ_CLS, r=8, lora_alpha=16, lora_dropout=0.05, target_modules=["query", "value"], bias="none", ) model = get_peft_model(model, lora_config) model.print_trainable_parameters()打印出来的可训练参数占比通常在百分之一到百分之三之间,这就是显存友好的原因。训练流程和全参微调完全一样,训练器不需要改。训完之后保存适配器权重,加载时先加载基座再挂适配器。如果要把适配器合并回基座用于部署,调用合并方法再保存,得到一个完整的模型目录,推理侧就不依赖适配器库了。
3.5 训练过程监控与保存
启动训练后重点盯三样东西:训练损失、验证指标、显存峰值。训练损失正常应该在前几十步快速下降,然后趋于平缓;如果一直在高位震荡,多半是学习率太大或数据有问题。验证指标和训练损失同步看,训练损失降但验证指标不升,就是过拟合的信号。显存峰值可以用显卡监控工具看,接近上限时提前降批次或截断长度。
from transformers import Trainer trainer = Trainer( model=model, args=training_args, train_dataset=encoded["train"], eval_dataset=encoded["validation"], tokenizer=tokenizer, compute_metrics=compute_metrics, ) trainer.train() trainer.save_model("./output/final") tokenizer.save_pretrained("./output/final")compute_metrics 里接上准确率、F1 等指标,分类任务用宏平均 F1 更稳妥,尤其是类别不平衡时。保存时模型和分词器一起存,避免推理阶段找不到词表。我习惯在保存目录里再放一份训练参数和数据的版本说明,过两周回来复盘时能对上是哪一版数据和哪一组超参。
4. 训练过程中的监控、评估与调参
4.1 指标怎么看才不被误导
准确率在类别不平衡时会骗人。假如验证集里九成是负类,模型全猜负类也能有九成准确率,但业务上完全没用。所以分类任务我至少看三个数:宏平均 F1、每类召回率、混淆矩阵。宏平均 F1 对少数类更敏感,每类召回率能告诉你模型在哪一类上漏得最多,混淆矩阵能看出具体混淆方向,比如中性样本被大量判成负面,说明边界定义模糊,需要回去看标注标准。
还有一个容易被忽略的点:验证集太小的时候,指标波动会很大。几百条的验证集,一两条样本翻转就能让 F1 变动一两个点,这时候别急着根据单次结果调参,多跑几个随机种子取平均,或者做交叉验证,结论才可靠。
4.2 学习率与批次的联动调整
学习率和批次是联动的。批次增大时,梯度噪声变小,学习率可以适当放大;批次减小时,学习率要相应调小,否则训练容易震荡。经验上,批次翻倍,学习率可以乘 1.5 到 2,但这只是起点,最终还是要看损失曲线。我通常先固定批次,用学习率做小范围网格,比如 1e-5、2e-5、5e-5 各跑一轮短训练,看哪条损失曲线下降最顺,再在最优值附近微调。
短训练的判断方法是只跑几百步就停,比较前几百步的损失下降速度和稳定性。这个方法能快速筛掉明显不合适的学习率,比每组都跑完整轮省很多时间。低秩适配器的学习率范围整体上移一个量级,同样先用短训练筛。
4.3 过拟合的识别与压制
过拟合的典型表现是训练损失持续下降、验证损失先降后升、验证指标在某一轮之后不再提升甚至回落。小数据集上这个拐点往往来得很早,可能一两轮就出现。压制手段按性价比排序:先加数据或做数据增强,这是最根本的;其次降低训练轮数、用早停;再就是加权重衰减、提高丢弃率、冻结部分底层参数。
对低秩适配器来说,还可以减小秩、降低 alpha,减少可训练容量。全参微调时,冻结嵌入层和底部几层也是常用做法,因为底层学的是通用语言特征,小任务没必要动。我自己的习惯是先把早停和权重衰减配好,如果还过拟合,再考虑冻结层,最后才动模型结构。
4.4 灾难性遗忘的应对
全参微调在小数据上还有一个副作用:模型在目标任务上变好了,但通用能力明显退化,换个任务就不好使了。这在多业务共用一个基座时很要命。应对思路有两个方向:一是控制微调强度,少训几轮、冻结底层、用较小的学习率;二是用参数高效微调,主干不动,天然避免大范围遗忘,每个业务存一份适配器,互不干扰。
如果必须全参微调又担心遗忘,可以在训练数据里混入一部分通用语料做回放,比例不用高,百分之五到十就能缓解不少。这个方法我在多任务场景试过,代价是训练时间略增,但换来的是模型在新任务上的泛化更稳。
5. 常见问题排查实录与避坑清单
5.1 损失不降或剧烈震荡
损失完全不降,先查三个地方:标签有没有错位、数据有没有打乱、分词结果对不对。标签错位最常见,比如训练时把 label 列名改错,或者 id 映射表和实际标注反了,模型学的是一个随机映射,损失自然下不去。数据没打乱时,如果数据按类别排序,每个批次全是同一类,梯度方向单一,训练会非常不稳定。分词问题则表现为输入几乎全是填充符,解码几条看看就能发现。
损失震荡则优先怀疑学习率过大。把学习率降一个量级再跑几百步对比,如果曲线明显变平滑,就是学习率的问题。另外批次太小也会导致震荡,适当加批次或开梯度累积。混合精度下的数值溢出也会造成偶发 NaN,关掉混合精度跑一小段就能定位。
5.2 显存溢出(OOM)的降级顺序
OOM 报错时按这个顺序降级,对效果影响从小到大:先减批次,用梯度累积补等效批次;再减序列长度,从 512 降到 256 甚至 128;然后开梯度检查点,用时间换显存;接着换低秩适配器;最后才考虑换更小的基座模型。每一步改完都重新跑一步看显存峰值,别一次改好几项,否则不知道是哪一项起的作用。
注意:开了梯度检查点之后训练会变慢百分之二十到三十,这是正常代价,别以为是环境出了问题。
还有一个隐蔽的显存杀手是评估阶段的批次。训练批次设得很小,评估批次却设得很大,结果在评估时 OOM。评估不需要反向传播,显存占用小,但也不能无脑放大,按训练批次的两倍左右设置比较稳。
5.3 指标虚高与数据泄漏
验证指标高得不正常,比如一上来就 0.98,先怀疑数据泄漏。常见来源包括:训练集和验证集有重复样本、同一条文本的不同改写版本分到了两边、用全量数据做了分词或特征统计再切分、以及验证集参与了口令选择。排查方法是去掉完全重复样本后重跑,再人工抽查几十条验证样本,看是否在训练集里见过近似内容。
另一个来源是标签泄漏,某些字段和标签强相关,比如工单文本里带了处理结果字段,模型直接学捷径。检查方法是把特征逐个剔除做消融,看指标掉多少,掉得异常多的字段就要警惕。
5.4 训练指标好但线上效果差
这是分布差异问题。训练数据往往来自历史积累,线上请求的用词、长度、话题分布都可能不同。表现是离线 F1 很高,上线后误判明显。应对办法是尽量让训练数据贴近真实请求分布,比如按时间采样而不是按方便采样;上线前用一批新鲜样本做一次盲测;推理时的截断长度、填充方向、标签映射必须和训练完全一致,这几项不一致造成的效果下降最冤枉也最常见。
5.5 常见问题速查表
| 现象 | 优先排查 | 常用处理 |
|---|---|---|
| 损失不下降 | 标签错位、数据未打乱、分词异常 | 核对列名与映射、开启打乱、解码抽查 |
| 损失震荡或 NaN | 学习率过大、批次过小、混合精度溢出 | 降学习率、加批次或累积、关闭混合精度 |
| 显存溢出 | 批次、序列长度、优化器状态 | 减批次、截断长度、梯度检查点、低秩适配器 |
| 验证指标虚高 | 重复样本、数据泄漏、标签泄漏 | 去重、重新切分、字段消融 |
| 过拟合 | 训练轮数过多、容量过大 | 早停、权重衰减、冻结底层、减小秩 |
| 通用能力退化 | 全参微调强度过大 | 少训轮数、冻结底层、改用适配器、混入通用语料 |
| 线上效果差 | 分布差异、预处理不一致 | 按真实分布采样、统一预处理、上线前盲测 |
把这张表贴在项目文档里,遇到问题先对号入座,能省掉大量盲目试错的时间。最后再分享一个我自己的习惯:每次微调都固定随机种子、记录数据版本和完整超参,哪怕是一次失败的实验也记下来。入门阶段真正值钱的不是某组“神奇参数”,而是你对“什么改动会导致什么现象”的直觉,这个直觉只能靠一次次有记录的实验积累出来。