上次帮一个做法律问答的朋友调模型,他一开始直接全参微调,数据量不大,训了一整晚,验证集准确率反而掉了两个点。后来我帮他把 embedding 层和前 10 层 Transformer Block 全部冻住,只留下最后 4 层加输出头去训练,第二天早上指标回来了,还上涨了不少。这个经历让我意识到,很多人对“微调模型指定层参数”这件事没有概念,以为微调就是整盘接受、从头改到尾,但真正高效的场景里,指定层微调往往才是那根最该拔的杠杆。
这篇内容不局限于某个特定框架,我会把微调模型指定层参数的原理、实操代码、选层经验和避坑方法都讲清楚,覆盖 PyTorch 原生流程、HuggingFace Trainer 和 LLaMA-Factory 这类常用大模型微调工具。适合正在做大模型微调、迁移学习,或者被显存和算力卡住的朋友,读完至少你能自己动手实现“只训某些层,其余层全部冻结”。
1. 为什么会想“只微调某些层”:三种典型场景
1.1 领域迁移时最怕的“学新忘旧”
预训练模型本质上是一个在超大语料上长大的“通才”,它肚子里装了很多通用语法、常识推理和世界知识。当我们把它迁移到某个具体领域,比如法律、医疗、金融,真正需要它改变的是“表达方式和判断偏好”,而不是重新认识世界。全参微调相当于让这个通才把所有知识全部重写一遍,数据量不够时,轻则过拟合,重则灾难性遗忘——原来会做通用对话的能力被冲淡,新领域又没学好。
我自己做过一个对比实验:同一份财报问答数据集,全参微调和只调最后 6 层加 LayerNorm,在测试集上的得分几乎持平,但后者的训练时间只有前者的三分之一,过拟合程度也更低。原因不复杂,底层和中层编码的通用语言结构,你要它们改变反而容易破坏已有特征表达,冻结它们就是给模型“圈了一块重建区”,只允许它在靠近输出端的地方调整决策逻辑。
1.2 显存不够?冻结层是成本最低的减负方案之一
很多人的第一反应是上 LoRA 或在有限算力下硬扛全参微调。但如果你已经有现成的全参微调管线,只是想快速把模型改造成某个领域版本,冻结大部分层是一个几乎零成本、不需要引入额外依赖的思路。
算一笔账可能会改变你的认知。以 7B 参数的模型为例,用 AdamW 优化器全参微调时,除了模型本身的 FP16/BF16 权重,还要在显存里维护 FP32 的主权重副本、一阶动量、二阶动量。每参数字节数大概是:2 字节权重(BF16)+ 4 字节 FP32 主权重 + 4 字节动量 + 4 字节方差,也就是约 14 字节。7B 参数算下来光优化器状态和主权重副本就要接近 98GB 显存。如果你冻结 80% 的参数,这部分开销直接降到原来的 20% 左右,对单卡微调来说这是质的差别。
另外,分布式训练里优化器状态的同步通信量也会大幅下降。所以很多人在显存不够时第一反应是换更小模型,其实先冻结层往往能省下更多的资源开销。
1.3 指定层微调在微调技术谱系里的位置
现在说到大模型微调,大家讨论最多的三个词是提示工程、RAG 检索、模型微调。很多初学者会混淆:提示词不改变权重,RAG 用外部检索补充知识,而模型微调是把领域规律写进权重里。指定层微调就处在“全参微调”和“参数高效微调”之间。
全参微调更新所有参数,数据量和算力要求最高;LoRA、Adapter 这类参数高效微调只更新额外加的少量参数,但需要引入新依赖和额外的合并逻辑;指定层微调则是一种“次全参”方案——权重主体还是原来的结构,只是冻结一部分参数、保留另一部分更新。它比 LoRA 更容易调试,因为你能精确控制模型里哪些模块被改变,也更容易解释最终效果的差异来源。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 层参数微调的原理:先弄清参数在哪、怎么控制更新
2.1 Transformer 模型的参数分布与命名习惯
你要指定层,首先得知道层在代码里长什么样。以常见的 LLaMA 结构为例,用 HuggingFace Transformers 加载后,用 model.named_parameters() 打印出来的参数名大致是这个形态:
text复制model.embed_tokens.weight
model.layers.0.input_layernorm.weight
model.layers.0.self_attn.q_proj.weight
model.layers.0.self_attn.k_proj.weight
model.layers.0.self_attn.v_proj.weight
model.layers.0.self_attn.o_proj.weight
model.layers.0.mlp.gate_proj.weight
model.layers.0.mlp.up_proj.weight
model.layers.0.mlp.down_proj.weight
model.layers.0.post_attention_layernorm.weight
model.layers.1.input_layernorm.weight
...
model.norm.weight
lm_head.weight
BERT 类模型结构有些不同,但核心规律一致:
text复制bert.embeddings.word_embeddings.weight
bert.embeddings.position_embeddings.weight
bert.encoder.layer.0.attention.self.query.weight
bert.encoder.layer.0.attention.self.key.weight
bert.encoder.layer.0.attention.self.value.weight
bert.encoder.layer.0.attention.output.dense.weight
bert.encoder.layer.0.intermediate.dense.weight
bert.encoder.layer.0.output.dense.weight
bert.pooler.dense.weight
classifier.weight
看到这些名字,你就能理解“指定层”和“指定模块”的本质区别。说“指定层”的时候,我们通常指 encoder.layer 或 model.layers 后面的数字编号,比如 model.layers.23 表示第 24 层 Transformer Block;说“指定模块”的时候,指 q_proj、mlp、layernorm 这类内部子模块。实际冻结时两种方式都会用到。
2.2 requires_grad = False 到底发生了什么
在 PyTorch 里控制参数是否参与训练,核心就是 requires_grad 这个布尔属性。把它设为 False,Autograd 引擎在反向传播时就不会为这个参数计算梯度,优化器自然也就不会更新它。
一个常见误解是:把中间层冻结后,梯度流经过这一层时就断了,前面的层也收不到信号。实际上完全不是这样。requires_grad=False 影响的只是“这个参数自己要不要更新”,但它依然可以作为梯度传播的管道,把后面层的误差信号继续传回前面的可训练层。换句话说,你可以冻结某个中间层,但让更靠近输入端的层继续训练。
这和“完全停止计算”是两码事。如果你希望某个层的前向输出完全固定,可以把这层放到 torch.no_grad() 上下文里,或者单独把这个子模块设为 eval() 模式。日常指定层微调中,我们绝大多数时候只冻结参数更新,不做这种额外操作。
2.3 模型整体模式对冻结层的影响
这里有一个容易被忽略的细节:即使你冻结了某些层,只要整个模型处于 model.train() 状态,所有层里的 Dropout 仍然会随机置零,BatchNorm(如果模型里有)里的统计量也会继续更新。对 Transformer 类模型来说,LayerNorm 本身没有 BatchNorm 那种 batch 级统计量依赖,但 Dropout 的影响依然存在。
所以如果你预期“冻结层输出完全不变”,这个预期可能是错的。冻结层的权重不变,但它的前向结果在训练模式下仍可能是随机的,因为 Dropout 还在工作。如果你真的需要冻结层完全表现为纯推理形态,需要在 forward 过程中单独处理这些层,比较麻烦。这也是为什么大多数场景下大家接受一个更宽松的设定:冻结只是“不更新权重”,而不是“完全锁死行为”。
3. 指定层的三种落地姿势:从纯 PyTorch 到 Trainer 再到大模型工具
3.1 姿势一:纯 PyTorch 按名称或索引冻结,并用参数组控制学习率
最直接的操作就是遍历 named_parameters,用参数名判断哪些该保留。我习惯封装成一个函数,这样在多个实验里复用:
python复制def freeze_except(model, keep_keywords):
for name, param in model.named_parameters():
keep = any(keyword in name for keyword in keep_keywords)
param.requires_grad = keep
比如你只想训练最后两层和分类头:
python复制freeze_except(model, keep_keywords=["layer.9", "layer.10", "classifier"])
这里要注意 layer.9 会同时匹配到 layer.9.xxx 和 layer.90.xxx 这些名字,因为字符串匹配是按子串来的。如果你用的是层数较多的模型,建议写得更精确,比如 "layer.9." 后面带个点,或者直接依赖 model.bert.encoder.layer[-2:] 这类型号:
python复制for layer in model.bert.encoder.layer[-2:]:
for param in layer.parameters():
param.requires_grad = True
for param in model.classifier.parameters():
param.requires_grad = True
冻结之后,优化器建议按参数组拆分,给不同层配不同学习率。这个设计比“只冻结”“只解冻”更精细,实际效果也更好:
python复制optimizer = torch.optim.AdamW([
{"params": [p for n, p in model.named_parameters() if "classifier" in n], "lr": 5e-5},
{"params": [p for n, p in model.named_parameters() if "layer.9" in n or "layer.10" in n], "lr": 1e-5},
], lr=1e-5, weight_decay=0.01)
注意,参数组里如果某个参数在多个组出现,PyTorch 会直接报错或者产生不可预期的更新结果,所以写筛选条件时最好保证每组之间是互斥的。另外,如果你完全不传某个可训练参数,它就不会出现在优化器里,也就永远不会被更新。
3.2 姿势二:在 HF Trainer 流程中植入冻结逻辑
很多人在用 transformers.Trainer 做微调,Trainer 本身没有暴露一个“指定层微调”的高级参数,但这不代表做不了。最简单的做法是在 Trainer 初始化之前冻结参数:
python复制from transformers import AutoModelForSequenceClassification, Trainer, TrainingArguments
model = AutoModelForSequenceClassification.from_pretrained("bert-base-uncased", num_labels=2)
for name, param in model.named_parameters():
if not any(k in name for k in ["layer.9", "layer.10", "classifier"]):
param.requires_grad = False
trainer = Trainer(model=model, args=training_args, ...)
trainer.train()
Trainer 在构建优化器时会遍历模型参数并跳过 requires_grad=False 的参数,所以不需要额外配置。如果你担心代码复杂,还可以用回调函数在训练开始时做冻结:写一个 TrainerCallback,在 on_train_begin 里执行上面这段冻结逻辑,保证不管别人怎么改代码,进入训练后状态是确定的。
这里有个小坑:有些教程会在冻结后调用 model.eval(),千万记得这是错的。eval() 会把所有层切到推理模式,Dropout 和 BatchNorm 全都不按训练逻辑走,可训练层的输出统计量也会变化,模型效果会大打折扣。冻结参数和设置 train()/eval() 是两个独立的控制维度,千万别混为一谈。
3.3 姿势三:LLaMA-Factory 里怎么指定训练模块
如果你用 LLaMA-Factory 微调 LLaMA/Qwen 这类大模型,它内置的 Freeze 微调方案就是“指定层微调”的工程化封装。在 LLaMA-Factory 的 YAML 配置文件里,几个关键参数组合起来的逻辑很直接:
yaml复制finetuning_type: full
freeze_trainable_layers: 6
trainable_modules: all
freeze_trainable_layers 代表从模型尾部倒数保留多少层参与训练,其他层全部冻结。比如设成 6,就只训练最后 6 个 Transformer Block 和输出头,前面全部冻结。trainable_modules 则可以更细粒度地指定训练哪些模块名,比如你想只训练注意力层里的 q_proj 和 v_proj,可以写成:
yaml复制finetuning_type: full
freeze_trainable_layers: 4
trainable_modules: q_proj,v_proj
这个参数组合的实际效果是:在保留最后 4 层可训练的前提下,只更新其中的 q_proj 和 v_proj。如果你想要“所有可训练层里的全部模块都更新”,就设 trainable_modules: all。实际使用时,先打印一下 model.named_parameters() 的命名规律,再填 trainable_modules 会少走很多弯路,因为不同模型对投影矩阵的命名略有差异。
这种方式的好处是,不需要你手动写循环冻结代码,LLaMA-Factory 已经处理好了层与层之间的关系。坏处是灵活性不如你直接修改模型,适用于常规训练流程;如果要做“第 10 层和第 20 层同时训练、其他层冻结”这种跳跃式指定,建议还是回到 PyTorch 原生管线。
3.4 微调完别忘了验证:如何确认冻结真的生效
冻结逻辑写完后,第一件事不是急着训练,而是验证“该冻的冻住没有,该训的训上没有”。最简单的方式:
python复制total_params = sum(p.numel() for p in model.parameters())
trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"总参数量: {total_params:,}")
print(f"可训练参数量: {trainable_params:,} ({trainable_params / total_params:.2%})")
如果设置保留最后 4 层,打印出来可训练参数量应该远小于总量。如果发现跟预期差很多,先检查是不是 freeze_except 里的关键词写得太宽或太窄。更严谨的验证方法是在训练循环里打个断点,对冻结层打印 param.grad,如果它是 None,说明确实没有梯度计算。
4. 该选哪几层:不同层对下游任务的影响对比
4.1 输入侧:embedding 层,动还是不动
Embedding 层是把 token 映射成向量的第一站,参数量通常非常惊人,一个大词表可能就有上亿参数。很多人在指定层微调时会顺手把 embedding 冻结,我基本上也建议默认冻结。
理由有两个:第一,embedding 层承载的更多是“词和词之间的初始关系”,这种关系在预训练阶段已经被充分建模,领域数据通常很难提供足够信号去优化它;第二,它的参数量太大,放开训练不仅容易过拟合,还会大幅增加优化器状态显存。
但也有例外。如果你在处理一个专业领域,比如法律文书,里面大量术语和缩写是预训练语料中很少出现的,embedding 层对这些 token 的表示可能不够好。这时候可以把 embedding 层的学习率压得很低,比如 1e-6,并且只在前一半训练轮次放开,后面再冻结,让新词的表示有一定适配空间,但又不会破坏全局词向量结构。
4.2 输出侧:head 层是性价比之王
模型输出头(分类头、回归头或者 LLaMA 的 lm_head)直接决定最终输出空间。如果你的任务改变了,比如原来是通用文本分类,现在要分 5 类情感,那 head 层几乎必须更新,否则输出维度都对不上。
即使输出维度不变,我也强烈建议至少把 head 层纳入可训练范围。它离最终损失最近,梯度信号最强,更新它带来的收益最直接。很多“只调最后几层”的实验里,真正拉高指标的主力就是 head 层加最后 2~4 层 Transformer Block。我自己做项目时,基本默认留 head 层和最后一层,把它们设为相对高一点的学习率,其他层按冻结或低学习率处理。
4.3 中间层:领域知识到底藏在哪里
中间层是情况最复杂的一块。经验上,靠近输入的底层更多在做通用语法特征和浅层搭配,这在 NLP 中已经被很多可视化工作验证过;靠近输出的顶层则更贴近任务语义,负责把隐藏表示“翻译成”当前任务需要的决策信号。所以领域适配时,从尾部往前解冻通常是正确方向,而不是从头部开始。
具体的解冻范围取决于你的数据量。数据量少,比如几千条标注样本,解冻最后 1~2 层就够了;数据量中等,比如几万条,解冻最后 1/4 层可能更好;如果数据量达到十几万条甚至更多,再考虑逐步扩大范围到一半层。
这个判断背后的逻辑并不玄学:可训练参数量多,模型复杂度高,数据量不够时一定会过拟合。指定层微调本质上就是用一个可调旋钮控制模型复杂度,让模型容量刚好承接数据中的有效信息。
4.4 LayerNorm 层:参数少但不能忽视
LayerNorm 层在 Transformer 里虽然参数量小,但它的作用是调整激活值的尺度和偏移,对最终输出分布的影响非常大。很多实验里,单独解冻全部 LayerNorm 层就能带来明显效果提升,这在领域风格迁移和对话语气调整任务里尤其明显。
所以我的建议是:即使你其他层全都冻结,也尽量把 LayerNorm 的 weight 和 bias 留成可训练。开销很小,收益却很稳定。注意模型参数名里像 input_layernorm、post_attention_layernorm、final_layernorm 这类都算,如果你的代码里还有 BatchNorm 层,那就更要注意——BatchNorm 的统计量更新和冻结参数之间的交互是一个容易翻车的点,本文第 5 章会专门说。
5. 关键参数配置与避坑经验
5.1 学习率、批次大小和梯度裁剪怎么调
指定层微调的学习率跟全参微调不太一样。全参微调时,每个参数平均分摊到的更新信号相对小,学习率通常可以给到 3e-5 ~ 5e-5。但指定层微调后,可训练参数少,同样一个 batch 的梯度都集中作用在少数参数上,实际更新的“强度”反而更大。所以我的经验是:head 层学习率用 3e-5 ~ 5e-5,中间层用 1e-5 ~ 2e-5,embedding 层如果解冻,给 1e-6 左右。同时开启 warmup 和 cosine 衰减,让训练前期稳定、后期收敛。
梯度裁剪也建议开启,max_grad_norm=1.0 是个比较稳的默认值。冻结部分层后,梯度在回传过程中的范数分布会变得和全参训练不一致,有时候你会看到某个 step 的 loss 突然爆掉,大概率是梯度爆炸。梯度裁剪对这种问题的兜底效果非常好。
批次大小方面,冻结层节省的显存可以让你有更大余地去调大 batch size,但也不要盲目拉满。正常的做法是先按显存余量调到合理范围,然后观察 loss 曲线是否平稳。如果数据本身很小,过大的 batch size 会加剧过拟合,指定层微调的优势反而发挥不出来。
5.2 冻结层与混合精度、分布式训练的组合问题
用混合精度训练时,冻结层的权重本身还在前向中参与计算,但不会获得梯度。这种情况下,如果某个冻结层的数值范围比较大,可能导致前向输出产生 inf 或 NaN,进而污染后续可训练层的梯度。这类问题往往不会在训练一开始出现,而是在某个数据批次触发后才暴露,排起来比较头疼。
我建议在训练前期用几十个 step 做 smoke test,观察 loss 和梯度范数是否正常。如果出现异常,优先检查:可训练层是否有参数变成 NaN、输入数据是否混入了异常值、冻结层是否有数值溢出的可能。必要时在 forward 里对可疑层输出做 clip,或者调低学习率重试。
分布式训练里,冻结层虽然不更新,但参数仍然在每个 rank 上占用存储。像 PyTorch FSDP 这类分片技术默认也会对所有参数做分片管理,冻结层未必能自动跳过状态同步。如果你的显存评估是基于“冻结后应该省很多”,一定要先看实际优化器状态,而不是凭感觉判断。
5.3 共享权重的坑:tie_weights 必须一起处理
很多因果语言模型会把 lm_head 和 embedding 层的权重绑定在一起,也就是 lm_head.weight 实际上和 embed_tokens.weight 是同一个 tensor 的不同视图。这种情况下,你只设置“解冻 lm_head、冻结 embed_tokens”是无效的,因为它们指向同一个底层存储,一个 requires_grad=True,另一个也会跟着变为 True。
遇到这种情况,要么把两者一起解冻,要么在前处理阶段先解开权重共享,比如在配置里设 tie_word_embeddings=False,再重新初始化 lm_head。解绑会改变模型结构,可能会影响最终效果,所以如果只是做下游任务适配,更推荐直接解冻 embedding 层并给它一个极低学习率,避免共享权重导致意料之外的联动更新。
用 HuggingFace 模型时,可以用 model.config.tie_word_embeddings 检查是否开启绑定。这个检查应该写进你的冻结脚本里,算是基本防御。
5.4 验证冻结的终极手段:梯度观察法
如果你想确认冻结真正生效,最可靠的方式不是看 requires_grad,而是看反向传播之后梯度是否存在。因为某些库或自定义代码可能会在前向过程中临时修改参数的 requires_grad 状态,导致你在训练前检查的结果和实际结果不一致。
写一个小钩子,在 backward() 后检查关键层:
python复制model.zero_grad()
loss = model(**batch).loss
loss.backward()
for name, param in model.named_parameters():
if "layer.0" in name and param.grad is not None:
print(f"警告: {name} 仍然有梯度,冻结未生效")
正常情况冻结层的 grad 应该是 None。如果发现冻结层有梯度,检查是不是有自定义逻辑重新开放了 requires_grad,或者某个参数被多个模块共享导致梯度流入了不该更新的位置。
5.5 训练结束后还要看冻结层的“恢复”问题
还有一个常见操作需要提醒:训练结束保存模型后,有些流程会做“全模型推理测试”,这时候要把整个模型切到 eval(),并记得用 model.state_dict() 保存所有层的权重,包括冻结层。冻结层的权重虽然没更新,但依然属于模型的一部分,保存时一个都不能少。
另外,如果你准备把指定层微调后的模型继续做后续对齐或二次微调,建议先记录这次哪些层被冻结过。下一次继续解冻更多层时,之前的冻结层会从原始预训练权重开始更新,而不是从当前状态继续。这个细节很容易被忽略,但直接决定二次微调的效果预期。
6. 一些选层和调参的个人经验补充
现在很多微调框架把“指定层”包装得越来越透明,但我觉得工程师心里还是应该有一张地图:数据量少时优先动 head 和 LayerNorm;数据量中等时往前扩展到最后 1/4 层;数据量充足时再考虑逐步放开更多层;embedding 层默认不动,除非词表覆盖严重不足。
我在实际项目里的做法是,先冻结到一个相对保守的范围,也就是最后 2~4 层加全部 LayerNorm,训练几个 epoch 看效果,再逐步扩大解冻范围。如果你每次都从全参微调开始试,很容易浪费大量算力在“寻找问题出在哪”这件事上。指定层微调最大的价值,其实是把模型调整变成一门可以精准控制变量的实验,而不是一场笼统的赌博。
最后再分享一个小习惯:每次做指定层微调实验,我都会把可训练参数名清单写入一个文件存档。这样模型效果好的时候,我能回到这份清单复盘到底是哪几层在起作用;效果差的时候,也能快速排除是否是冻结策略出了问题。时间久了,你对自己常用模型的“性格”会有非常清晰的感觉,选层的时候甚至不用看代码就能猜个八九不离十。
