Qwen2.5-VL多模态微调超参数深度解析(上):训练流程与优化策略的量化机制分析
上篇文章通过系统化的对比实验,回答了多模态微调中的三个基础问题:“选什么方法”(LoRA/Full/Freeze/OFT对比)、“用什么配置”(60+组超参数组合)、“达到什么效果”(ROUGE-L/BLEU-4指标)。这些结果为后续工作奠定了实验基线,但也留下了更深层的疑问:为何相同方法在不同数据集上表现迥异?为何某些超参数的微小调整会引发性能剧变?如何在未见过的任务上快速找到最优配置?
本文是对前文实验数据的二次挖掘与机理解析。我们将关注点从"做了什么实验"转移到"实验揭示了什么规律",实验视角从"结果对比"转向"过程解析"。本篇聚焦于适用于所有微调方法的通用训练流程超参数,包括训练轮数、批处理策略、学习率调度、梯度管理、精度控制等核心维度。
通过对这些超参数在llava这一通用大样本数据集和sign这一专业小样本数据集上的训练曲线、资源消耗、性能边界的深度剖析,结合对不同版本LLamafactory源代码对应部分的解读以及运行时日志的比较分析,我们揭示超参数调整背后的作用机制与普适规律。本文提供损失函数分析、资源-性能的量化权衡、以及可复用的调优决策框架,旨在将前文的经验性发现升华为具有迁移能力的工程知识,帮助实践者从海量实验数据中提炼规律,在面对新任务时能够快速定位关键参数、预判调整方向、缩短迭代周期。
文章目录
- 不同超参对应损失下降曲线及测试效果比较
- 一、训练流程与优化相关超参数
- 1. 训练轮数(Epoch)
- llava数据集-LoRA
- llava数据集-Full
- sign数据集-LoRA
- sign数据集-Freeze
- sign数据集-Oft
- 2. 学习率(Learning Rate)
- llava数据集-LoRA
- llava数据集-Freeze
- 3. 梯度裁剪(Max Grad Norm)
- llava数据集-LoRA
- sign数据集-LoRA
- 4. 梯度累积步数(Gradient Accumulation Steps)
- llava数据集-LoRA
- sign数据集-LoRA
- 5. 每卡训练批大小(Per Device Train Batch Size)
- llava数据集-LoRA
- sign数据集-LoRA
- 二、模型精度与数值计算相关设置
- 1. 计算精度类型(dtype)
- llava数据集-LoRA
- llava数据集-Full
- llava数据集-Freeze
- sign数据集-LoRA
- 2. 量化等级(Quantization Bit)
- llava数据集-LoRA
- sign数据集-LoRA
- 三、序列与上下文长度相关参数
- 1. 最大序列截断长度(cutoff_len)
- llava数据集-LoRA
- sign数据集-LoRA
- 2. 序列打包策略(packing / neat_packing)
- llava数据集-LoRA
- sign数据集-LoRA
- 四、视觉输入与多模态相关参数
- 1. 图像最大像素限制(image_max_pixels)
- llava数据集-LoRA
- llava数据集-Full
- sign数据集-LoRA
- 2. 视觉编码器冻结策略(freeze_vision_tower)/ 多模态投影器冻结策略(freeze_multi_modal_projector)
- llava数据集-LoRA
- llava数据集-Full
- sign数据集-LoRA
- 五、冻结训练(Freeze)相关策略
- 1. 可训练层数(freeze_trainable_layers)
- llava数据集-Freeze
- sign数据集-Freeze
- 六、Prompt 与对话建模相关参数
- 1. 是否训练 Prompt(train_on_prompt)
- 2. 是否屏蔽历史对话(mask_history)
- 3. 词表调整(resize_vocab)
- 4. LLaMA-Pro 扩展参数训练(use_llama_pro)
- 5. 综合实验
- llava数据集-LoRA
- sign数据集-LoRA
- 总结——如何根据任务快速选择超参数
🎉进入大模型应用与实战专栏 | 🚀查看更多专栏内容

不同超参对应损失下降曲线及测试效果比较
一、训练流程与优化相关超参数
1. 训练轮数(Epoch)
参数意义:Epoch 是指整个训练集被模型完整遍历一次。它直接决定模型看到训练样本的次数,从而影响拟合程度与过拟合风险。
在实验中的影响:
- 大样本(llava):LoRA 在 3→6 epoch 有小幅提升,过多 epoch(如 30)反而出现性能下降,说明在大样本/通用任务上容易出现“过训练”或学习率/正则配置不匹配的情况。
- 小样本(sign):更多 epoch 带来显著改进(3→15 epoch 性能大幅上升),说明小样本场景下需要更长训练以充分拟合,但也有过拟合边界(在非常长训练后可能性能波动)。
- 方法相关性:Full/Freeze/OFT 的最佳 epoch 不同;Full 更容易在少量 epoch 就饱和或过拟合(取决于学习率与正则),Freeze/OFT 在小样本上可以利用更多 epoch 获得收益。
llava数据集-LoRA
| 训练轮数 | 训练时间 | 训练显存 | 保存模型大小 | 推理显存 | ROUGE-L | BLEU-4 |
|---|---|---|---|---|---|---|
| 3(默认) | 0:05:07 | 9912MiB | 58M | - | 23.46 | 9.45 |
| 6 | 0:10:37 | 9912MiB | 58M | - | 23.74 | 10.06 |
| 30 | 0:56:28 | 9912MiB | 58M | - | 22.47 | 9.75 |
llava数据集-Full
| 训练轮数 | 训练时间 | 训练显存 | 保存模型大小 | 推理显存 | ROUGE-L | BLEU-4 |
|---|---|---|---|---|---|---|
| 3(默认) | 0:19:47 | 22086MiB | 6.99GB | - | 23.43 | 10.79 |
| 6 | 0:38:58 | 22086MiB | 6.99GB | - | 23.06 | 10.24 |

sign数据集-LoRA
| 训练轮数 | 训练时间 | 训练显存 | 保存模型大小 | 推理显存 | ROUGE-L | BLEU-4 |
|---|---|---|---|---|---|---|
| 3(默认) | 0:04:45 | 17554MiB | 58M | 8453MiB | 79.72 | 67.89 |
| 6 | 0:09:37 | 17554MiB | 58M | 8453MiB | 80.95 | 69.06 |
| 8 | 0:11:28 | 17554MiB | 58M | 8453MiB | 83.39 | 72.80 |
| 10 | 0:15:17 | 17554MiB | 58M | 8453MiB | 83.91 | 73.32 |
| 15 | 0:22:21 | 17554MiB | 58M | 8453MiB | 94.33 | 90.27 |
| 20 | 0:31:09 | 17554MiB | 58M | 8453MiB | 92.37 | 87.01 |

sign数据集-Freeze
| 训练轮数 | 训练时间 | 训练显存 | 保存模型大小 | 推理显存 | ROUGE-L | BLEU-4 |
|---|---|---|---|---|---|---|
| 3(默认) | 0:03:53 | 18780MiB | 7.28GB | - | 82.15 | 70.77 |
| 15 | 0:16:25 | 18780MiB | 7.28GB | - | 93.10 | 87.79 |

sign数据集-Oft
| 训练轮数 | 训练时间 | 训练显存 | 保存模型大小 | 推理显存 | ROUGE-L | BLEU-4 |
|---|---|---|---|---|---|---|
| 3(默认) | 0:06:32 | 17660MiB | 50MB | 8593MiB | 85.89 | 76.67 |
| 10 | 0:20:46 | 17660MiB | 50MB | 8593MiB | 93.11 | 87.33 |
| 15 | 0:30:47 | 17660MiB | 50MB | 8593MiB | 92.98 | 87.33 |

2. 学习率(Learning Rate)
参数意义: 控制参数每一步更新的步长,是影响收敛速度与稳定性的关键超参。
在本实验中的影响:
-
在 LoRA 上,把 lr 从 5e-5 提高到 1e-4 能带来明显提升(更快逼近更优解);继续增大到过大值会导致不稳定或震荡。
-
Freeze/Full 模式对学习率更敏感(Full 尤其需要较小或渐进式调度以避免发散或数值问题)。
实践建议: 用对数刻度(如 1e-5 → 1e-3)做粗调;首选带 warmup 的调度器(线性/余弦);LoRA 可以相对使用较大的 lr,Full 微调先保守选择较小 lr 并观察验证曲线。
llava数据集-LoRA
| 学习率 | 训练时间 | 训练显存 | 保存模型大小 | 推理显存 | ROUGE-L | BLEU-4 |
|---|---|---|---|---|---|---|
| 5e-5(默认,epoch=3) | 0:05:07 | 9912MiB | 58M | - | 23.46 | 9.45 |
| 1e-4(epoch=6) | 0:10:29 | 9912MiB | 58M | - | 24.30 | 11.22 |
| 2e-4(epoch=6) | 0:11:05 | 9912MiB | 58M | - | 24.01 | 11.05 |
llava数据集-Freeze
| 学习率 | 训练时间 | 训练显存 | 保存模型大小 | 推理显存 | ROUGE-L | BLEU-4 |
|---|---|---|---|---|---|---|
| 5e-5(默认) | 0:03:24 | 12446MiB | 7.28GB | - | 21.79 | 7.57 |
| 1e-4 | 0:03:28 | 12446MiB | 7.28GB | - | 21.61 | 7.32 |
3. 梯度裁剪(Max Grad Norm)
最大梯度范数(Max Gradient Norm)主要用于梯度裁剪(Gradient Clipping),其核心作用包括:
- 防止梯度爆炸:限制梯度的最大值,避免参数更新过大导致训练不稳定
- 稳定训练过程:使损失函数下降更平滑,减少震荡
- 提高训练鲁棒性:对学习率的选择更加宽容
梯度裁剪的基本原理是:当梯度的L2范数超过设定的阈值时,按比例缩放梯度,使其范数等于该阈值。
梯度范数计算:
∥ g ∥ = ∑ i g i 2 |mathbf{g}| = sqrt{sum_{i} g_i^2} ∥g∥=i∑gi2
其中 g mathbf{g} g 是所有参数梯度组成的向量。
梯度裁剪公式:
g c l i p p e d = { g if ∥ g ∥ ≤ max_norm max_norm ∥ g ∥ ⋅ g if ∥ g ∥ > max_norm mathbf{g}_{clipped} = egin{cases} mathbf{g} & ext{if } |mathbf{g}| leq ext{max_norm} rac{ ext{max_norm}}{|mathbf{g}|} cdot mathbf{g} & ext{if } |mathbf{g}| > ext{max_norm} end{cases} gclipped={g∥g∥max_norm⋅gif ∥g∥≤max_normif ∥g∥>max_norm
参数更新:
θ t + 1 = θ t − η ⋅ g c l i p p e d heta_{t+1} = heta_t - eta cdot mathbf{g}_{clipped} θt+1=θt−η⋅gclipped
其中 η eta η 是学习率, θ heta θ 是模型参数。
从loss曲线可以发现,梯度裁剪阈值过小时训练收敛不足,过大时效果略有下降,而中等值时能够达到最佳的训练效果。
| Max Gradient Norm | Loss变化 | 下降幅度 |
|---|---|---|
| 很小(1e-5附近) | 4.5 → 3.4 | 1.1 |
| 中等值 | 4.5 → 0 | 4.5 |
| 较大值(10附近) | 4.5 → 0.5 | 4.0 |
这是因为:
1. 极小值(1e-5)时:梯度被严重限制
- 实际梯度很可能大于1e-5,被大幅裁剪
- 每步更新的步长 = 学习率 × 1e-5(最大)
- 参数更新过于保守,收敛极慢
- 类似于使用了一个极小的有效学习率
2. 中等值时:达到最优效果
- 梯度裁剪阈值合适,既防止了梯度爆炸,又不过度限制
- 在训练初期,梯度较大时被适当裁剪,保持稳定
- 在训练后期,梯度自然变小,裁剪不起作用
- 能够顺利收敛到全局最优或良好的局部最优
3. 较大值(10)时:轻微限制
- 只有在梯度特别大时才触发裁剪
- 仍然起到一定的保护作用,但约束较松
- 可能在训练过程中出现一些震荡,导致最终loss略高于中等值情况
- 或者模型陷入了一个稍差的局部最优
在实验中的影响:
- 极小阈值(例如
1e-5)会把梯度过度压缩,等效于把有效学习率降得很低,导致收敛缓慢或未收敛。 - 中等阈值(例如 0.1–1.0)通常在多数实验中能得到最佳折中:既防止极端更新又不过度抑制学习。
- 过大阈值(例如 10)基本不裁剪,只在极端情况下触发,可能允许短期震荡,导致最终 loss 略高于中等值情形。
llava数据集-LoRA
| 最大梯度范数 | 训练时间 | 训练显存 | 保存模型大小 | 推理显存 | ROUGE-L | BLEU-4 |
|---|---|---|---|---|---|---|
| 0.5 | 0:10:29 | 9912MiB | 58M | - | 24.16 | 10.81 |
| 1.0(默认,epoch=6,lr=1e-4) | 0:10:29 | 9912MiB | 58M | - | 24.30 | 11.22 |
| 2.0 | 0:10:29 | 9912MiB | 58M | - | 24.09 | 10.77 |
sign数据集-LoRA
| 最大梯度范数 | 训练时间 | 训练显存 | 保存模型大小 | 推理显存 | ROUGE-L | BLEU-4 |
|---|---|---|---|---|---|---|
| 1e-5 | 0:23:07 | 17554MiB | 58M | 8453MiB | 80.06 | 68.21 |
| 0.1 | 0:23:33 | 17554MiB | 58M | 8453MiB | 94.36 | 90.39 |
| 0.5 | 0:25:44 | 17554MiB | 58M | 8453MiB | 94.39 | 90.27 |
| 1.0(默认,epoch=15) | 0:22:21 | 17554MiB | 58M | 8453MiB | 94.33 | 90.27 |
| 2 | 0:22:39 | 17554MiB | 58M | 8453MiB | 94.36 | 90.39 |
| 10 | 0:23:35 | 17554MiB | 58M | 8453MiB | 88.13 | 80.44 |

4. 梯度累积步数(Gradient Accumulation Steps)
梯度累积的主要作用是牺牲训练时间,换取更小的显存占用,同时获得大batch的训练效果在有限的GPU内存条件下模拟更大的批次大小(batch size),从而:
- 在内存受限时训练大模型
- 获得更大batch size带来的训练稳定性
- 减少梯度估计的方差
- 避免因batch size过小导致的训练不稳定
梯度累积通过将一个大batch拆分成多个小batch,分别计算梯度并累加,最后统一更新参数。
设累积步数为
K
K
K,每个小batch大小为
b
b
b,则有效batch size为
B
=
K
×
b
B = K imes b
B=K×b
标准方式(大batch):
∇
θ
=
1
B
∑
i
=
1
B
∇
θ
L
(
x
i
,
θ
)
abla_ heta = rac{1}{B}sum_{i=1}^{B}
abla_ heta mathcal{L}(x_i, heta)
∇θ=B1i=1∑B∇θL(xi,θ)
梯度累积方式:
∇
θ
=
1
K
∑
k
=
1
K
[
1
b
∑
j
=
1
b
∇
θ
L
(
x
k
,
j
,
θ
)
]
=
1
B
∑
i
=
1
B
∇
θ
L
(
x
i
,
θ
)
abla_ heta = rac{1}{K}sum_{k=1}^{K}left[rac{1}{b}sum_{j=1}^{b}
abla_ heta mathcal{L}(x_{k,j}, heta)
ight] = rac{1}{B}sum_{i=1}^{B}
abla_ heta mathcal{L}(x_i, heta)
∇θ=K1k=1∑K[b1j=1∑b∇θL(xk,j,θ)]=B1i=1∑B∇θL(xi,θ)
两者在数学上等价。
从结果上看,梯度越大loss下降越缓慢,应该跟batch的表现是非常相似的。但是梯度累积步数的增大,并不会带来显存的同步增加,可以用下面的代码来说明。
首先,训练时显存的主要组成
总显存 = 模型参数 + 优化器状态 + 梯度 + 激活值(中间结果) + 当前batch数据
具体占用:
| 组成部分 | 占用大小 | 是否随累积步数变化 |
|---|---|---|
| 模型参数 | 固定(如7B模型≈28GB) | ❌ 不变 |
| 优化器状态 | 2×参数(Adam) | ❌ 不变 |
| 梯度缓存 | 1×参数 | ❌ 不变(只累加) |
| 激活值 | 与batch size成正比 | ❌ 不变(每次只处理小batch) |
| 输入数据 | 与batch size成正比 | ❌ 不变(每次只加载小batch) |
标准训练(batch_size=32)
# 一次性处理32个样本
batch = data[0:32] # 显存:存32个样本的激活值
loss = model(batch)
loss.backward()
optimizer.step()
显存峰值: 需要存储32个样本的所有中间激活值
梯度累积(batch_size=4, steps=8, 有效batch=32)
optimizer.zero_grad()
for i in range(8): # 累积8次
mini_batch = data[i*4:(i+1)*4] # 每次只处理4个样本
loss = model(mini_batch) / 8
loss.backward() # 梯度累加到参数的.grad属性
# 前向激活值在backward后自动释放!
optimizer.step() # 一次性更新
显存峰值: 只需要存储4个样本的激活值(每次循环后释放)
在实验中的影响:
- 在资源受限场景下,使用累积(如 8)能在不 OOM 的情况下获得稳定的大 batch 效果,实验中 llava 和 sign 的默认设置均以累积步数 8 达到较好平衡。
- 过大累积(例如 16)在某些场景会降低性能(可能与优化器/学习率/梯度噪声有关)并增加训练时间。
实践建议: 优先用小的 per-device batch(1–2)配合累积(4–8);把有效 batch size 作为调优目标,并与学习率(线性缩放规则)共同调整。
llava数据集-LoRA
| 梯度累积 | 训练时间 | 训练显存 | 保存模型大小 | 推理显存 | ROUGE-L | BLEU-4 |
|---|---|---|---|---|---|---|
| 4 | 0:10:30 | 9912MiB | 58M | - | 24.14 | 11.14 |
| 8(默认,epoch=6,lr=1e-4) | 0:10:29 | 9912MiB | 58M | - | 24.30 | 11.22 |
| 16 | 0:10:32 | 9916MiB | 58M | - | 23.59 | 10.24 |

sign数据集-LoRA
| 梯度累积 | 训练时间 | 训练显存 | 保存模型大小 | 推理显存 | ROUGE-L | BLEU-4 |
|---|---|---|---|---|---|---|
| 1 | 0:24:35 | 17478MiB | 58M | 8453MiB | 93.08 | 87.54 |
| 2 | 0:25:35 | 17500MiB | 58M | 8453MiB | 93.16 | 87.79 |
| 4 | 0:23:50 | 17526MiB | 58M | 8453MiB | 83.91 | 73.32 |
| 8(默认,epoch=15) | 0:22:21 | 17554MiB | 58M | 8453MiB | 94.33 | 90.27 |
| 16 | 0:23:20 | 17666MiB | 58M | 8453MiB | 83.86 | 73.28 |

5. 每卡训练批大小(Per Device Train Batch Size)
参数意义: 单个 GPU/设备每次前向反向传递处理的样本数,直接影响激活内存与吞吐。
在实验中的影响:
- 小批次(如 1)配合累积步数,在 LoRA 与 Sign 上表现最好且最稳健;大批次在某些配置下会引发 OOM 或性能下降(Sign 在 batch=2/4 出现显存暴涨或 OOM)。
- 较大的 per-device batch 会缩短总训练时间(更高并行效率),但对数值稳定性与泛化有时不利。
实践建议: 能用累积就尽量将 per-device batch 保持较小(1–2),以获得跨不同硬件的一致性;若硬件允许且观察到更好泛化,可尝试放大并同步调节学习率。
llava数据集-LoRA
| 批处理大小 | 训练时间 | 训练显存 | 保存模型大小 | 推理显存 | ROUGE-L | BLEU-4 |
|---|---|---|---|---|---|---|
| 1(默认,epoch=6,lr=1e-4) | 0:10:29 | 9912MiB | 58M | - | 24.30 | 11.22 |
| 2 | 0:06:02 | 11656MiB | 58M | - | 23.68 | 10.60 |
| 4 | 0:04:06 | 15174MiB | 58M | - | 23.41 | 9.90 |
sign数据集-LoRA
| 批处理大小 | 训练时间 | 训练显存 | 保存模型大小 | 推理显存 | ROUGE-L | BLEU-4 |
|---|---|---|---|---|---|---|
| 1(默认,epoch=15) | 0:22:21 | 17554MiB | 58M | 8453MiB | 94.33 | 90.27 |
| 2 | 0:28:13 | 27597MiB | 58M | 8453MiB | 80.95 | 69.06 |
| 4 | OOM | - | - | - | - | - |
二、模型精度与数值计算相关设置
1. 计算精度类型(dtype)
参数意义: 指模型训练时使用的浮点格式(fp32/fp16/bf16/pure_bf16 等),影响数值范围、溢出的概率与显存占用。
**在实验中的影响:**不同微调方式在占用显存上对于精度类型的敏感度是不一样的:LoRA对精度类型不敏感,因为可训练参数极少,精度影响可忽略;而Full微调对精度类型极度敏感,因为全部参数可训练,精度影响被放大;
- 浮点数格式对比表
| 格式 | 全称 | 总位数 | 指数位 | 尾数位 | 数值范围 | 精度 |
|---|---|---|---|---|---|---|
| fp32 | Float32 | 32位 | 8位 | 23位 | ±3.4×10³⁸ | 最高 |
| fp16 | Float16 | 16位 | 5位 | 10位 | ±65,504 | 中等 |
| bf16 | BFloat16 | 16位 | 8位 | 7位 | ±3.4×10³⁸ | 较低 |
- 训练参数选项对比表
| 参数 | 含义 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| fp32 | 32位全精度 | • 精度最高 • 训练最稳定 | • 显存占用大 • 计算速度慢 | 显存充足时使用 |
| fp16 | 16位半精度 | • 广泛硬件支持 • 速度快 • 显存节省50% | • 易溢出 • 需要loss scaling • 数值范围小 | 显存紧张 + 旧硬件 |
| bf16 | Brain Float16 | • 不易溢出 • 训练稳定 • 速度快 • 显存节省50% | • 需要新硬件 • 精度略低于fp16 | 显存紧张 + 新硬件 (推荐) |
| pure_bf16 | 纯bf16模式 | • 性能最优 • 显存占用最小 | • 精度最低 • 需谨慎测试 | 追求极致性能 |
- 硬件支持对比
| 格式 | 支持的GPU |
|---|---|
| fp32 | 所有GPU |
| fp16 | NVIDIA Volta (V100) 及以上 |
| bf16 | NVIDIA Ampere (A100) 及以上、H100 |
推荐选择流程
有新硬件(A100/H100)?
├─ 是 → bf16 (首选)
└─ 否 → 显存够吗?
├─ 是 → fp32
└─ 否 → fp16
llava数据集-LoRA
| 计算类型 | 训练时间 | 训练显存 | 保存模型大小 | 推理显存 | ROUGE-L | BLEU-4 |
|---|---|---|---|---|---|---|
| bf16(默认,epoch=6,lr=1e-4) | 0:10:29 | 9912MiB | 58M | - | 24.30 | 11.22 |
| fp16 | 0:10:01 | 9912MiB | 58M | - | 24.55 | 11.22 |
| fp32 | 0:10:08 | 9878MiB | 58M | - | 24.21 | 11.18 |
| pure_bf16 | 0:10:29 | 9878MiB | 58M | - | 24.36 | 11.00 |
llava数据集-Full
| 计算类型 | 训练时间 | 训练显存 | 保存模型大小 | 推理显存 | ROUGE-L | BLEU-4 |
|---|---|---|---|---|---|---|
| bf16(默认) | 0:19:47 | 22086MiB | 6.99GB | - | 23.43 | 10.79 |
| fp16 | 0:18:58 | 17902MiB | 6.99GB | - | 23.75 | 10.66 |
| fp32 | OOM | - | - | - | - | - |
| pure_bf16 | error | - | - | - | - | - |
llava数据集-Freeze
| 计算类型 | 训练时间 | 训练显存 | 保存模型大小 | 推理显存 | ROUGE-L | BLEU-4 |
|---|---|---|---|---|---|---|
| bf16(默认,freeze_trainable_layers=2) | 0:03:24 | 12446MiB | 7.28GB | - | 21.79 | 7.57 |
| pure_bf16(freeze_trainable_layers=2) | 0:03:24 | 10698MiB | 6.99GB | - | 21.18 | 6.66 |
| fp16(freeze_trainable_layers=8) | 0:05:34 | 20888MiB | 8.14GB | - | 23.18 | 8.91 |
| fp32(freeze_trainable_layers=8) | error | - | - | - | - | - |
| pure_bf16(freeze_trainable_layers=8) | 0:04:20 | 14242MiB | 6.99GB | - | 22.26 | 7.86 |

sign数据集-LoRA
| 计算类型 | 训练时间 | 训练显存 | 保存模型大小 | 推理显存 | ROUGE-L | BLEU-4 |
|---|---|---|---|---|---|---|
| bf16(默认,epoch=15) | 0:22:21 | 17554MiB | 58M | 8453MiB | 94.33 | 90.27 |
| fp16 | 0:25:13 | 17606MiB | 58M | 8453MiB | 94.36 | 90.39 |
| fp32 | 0:25:52 | 17696MiB | 58M | 8453MiB | 90.48 | 83.75 |
| pure_bf16 | 0:25:08 | 17696MiB | 58M | 8453MiB | 94.33 | 90.27 |

2. 量化等级(Quantization Bit)
参数意义: 将模型权重从高精度浮点缩减到低比特整数(例如 8bit/4bit),以节省内存与存储。
在实验中的影响:
- 量化能显著降低加载与推理所需内存,但训练时会增加时间开销(频繁反量化/重建临时 FP16/BF16 张量带来额外计算)。
- 对小样本数据集,量化有时带来“正则化”效果(可降低过拟合),实测 Sign 数据集在某些 4-bit 配置上性能反而更好;但量化实现与硬件差异会导致异常显存行为(实验中华为服务器的 4-bit 导致显存增大)。
- 训练/推理端量化不一致会显著降低效果(建议训练与部署保持量化策略一致或谨慎验证)。
实践建议: 优先在推理端量化以节省部署成本;若在训练中使用 QLoRA/4-bit 风格方案,务必验证实现的分页与反量化开销,并在小样本任务上试验是否有正则化收益。
下面做一个更详细的说明:
量化是将模型参数从高精度浮点数(如FP32/FP16)映射到低精度整数(如INT8/INT4)的过程,目的是在可接受的精度损失范围内,大幅降低模型的内存占用和计算开销。
对于原始浮点权重 W ∈ R W in mathbb{R} W∈R,量化过程可表示为:
W q = round ( W − Z S ) W_q = ext{round}left(rac{W - Z}{S} ight) Wq=round(SW−Z)
其中:
- W q W_q Wq: 量化后的整数值
- S S S: 缩放因子(Scale),计算公式为 S = W m a x − W m i n 2 n − 1 S = rac{W_{max} - W_{min}}{2^n - 1} S=2n−1Wmax−Wmin
- Z Z Z: 零点偏移(Zero-point)
- n n n: 量化位数(8-bit时 n = 8 n=8 n=8,4-bit时 n = 4 n=4 n=4)
推理时需将量化值恢复为浮点数:
W
^
=
S
⋅
W
q
+
Z
hat{W} = S cdot W_q + Z
W^=S⋅Wq+Z
理论量化误差范围:
∣
W
−
W
^
∣
≤
S
2
=
W
m
a
x
−
W
m
i
n
2
n
+
1
−
2
|W - hat{W}| leq rac{S}{2} = rac{W_{max} - W_{min}}{2^{n+1} - 2}
∣W−W^∣≤2S=2n+1−2Wmax−Wmin
理论显存占用:
Memory
=
Param_Count
×
Bit_Width
8
×
10
9
GB
ext{Memory} = rac{ ext{Param_Count} imes ext{Bit_Width}}{8 imes 10^9} ext{ GB}
Memory=8×109Param_Count×Bit_Width GB
- FP16: 每个参数占用 2 bytes
- INT8: 每个参数占用 1 byte → 节省50%显存
- INT4: 每个参数占用 0.5 bytes → 节省75%显存
下面是一个实际的计算示例:
假设我们有一个神经网络层的部分权重向量:
W = [2.5, -1.3, 0.8, 3.7, -0.5, 1.2, -2.1, 0.3] # FP32浮点数
Step 1: 确定量化范围
找出权重的最小值和最大值:
W_min = -2.1
W_max = 3.7
Step 2: 计算缩放因子(Scale)
INT8的表示范围是 [-128, 127] (有符号8位整数)
S = W m a x − W m i n 2 8 − 1 = 3.7 − ( − 2.1 ) 255 = 5.8 255 = 0.02275 S = rac{W_{max} - W_{min}}{2^8 - 1} = rac{3.7 - (-2.1)}{255} = rac{5.8}{255} = 0.02275 S=28−1Wmax−Wmin=2553.7−(−2.1)=2555.8=0.02275
Step 3: 计算零点偏移(Zero-point)
Z = − round ( W m i n S ) = − round ( − 2.1 0.02275 ) = − round ( − 92.31 ) = 92 Z = - ext{round}left(rac{W_{min}}{S} ight) = - ext{round}left(rac{-2.1}{0.02275} ight) = - ext{round}(-92.31) = 92 Z=−round(SWmin)=−round(0.02275−2.1)=−round(−92.31)=92
Step 4: 执行量化
对每个权重值应用量化公式:
W q = round ( W S ) + Z W_q = ext{round}left(rac{W}{S} ight) + Z Wq=round(SW)+Z
逐个计算:
| 原始值 W | 计算过程 | 量化值 W_q | 验证范围 |
|---|---|---|---|
| 2.5 | round(2.5/0.02275) + 92 = round(109.89) + 92 | 202 | ⚠️ 需裁剪到127 |
| -1.3 | round(-1.3/0.02275) + 92 = round(-57.14) + 92 | 35 | ✓ |
| 0.8 | round(0.8/0.02275) + 92 = round(35.16) + 92 | 127 | ✓ |
| 3.7 | round(3.7/0.02275) + 92 = round(162.64) + 92 | 255 | ⚠️ 需裁剪到127 |
| -0.5 | round(-0.5/0.02275) + 92 = round(-21.98) + 92 | 70 | ✓ |
| 1.2 | round(1.2/0.02275) + 92 = round(52.75) + 92 | 145 | ✓ |
| -2.1 | round(-2.1/0.02275) + 92 = round(-92.31) + 92 | 0 | ✓ |
| 0.3 | round(0.3/0.02275) + 92 = round(13.19) + 92 | 105 | ✓ |
裁剪到有效范围 (Clipping):
W_q[3] = min(max(255, -128), 127) = 127 # 3.7被裁剪
最终INT8量化结果:
W_q_int8 = [127, 35, 127, 127, 70, 127, 0, 105] # 注意:部分值超出范围已裁剪
Step 5: 反量化验证
从量化值恢复浮点数:
W ^ = ( W q − Z ) × S hat{W} = (W_q - Z) imes S W^=(Wq−Z)×S
验证计算:
| 量化值 W_q | 反量化计算 | 恢复值 W ^ hat{W} W^ | 原始值 W | 误差 |
|---|---|---|---|---|
| 127 | (127-92)×0.02275 | 0.796 | 2.5 | -1.704 |
| 35 | (35-92)×0.02275 | -1.297 | -1.3 | +0.003 |
| 127 | (127-92)×0.02275 | 0.796 | 0.8 | -0.004 |
| 127 | (127-92)×0.02275 | 0.796 | 3.7 | -2.904 ⚠️ 严重失真 |
| 70 | (70-92)×0.02275 | -0.501 | -0.5 | -0.001 |
| 127 | (127-92)×0.02275 | 0.796 | 1.2 | -0.404 |
| 0 | (0-92)×0.02275 | -2.093 | -2.1 | +0.007 |
| 105 | (105-92)×0.02275 | 0.296 | 0.3 | -0.004 |
问题分析: 由于值3.7超出量化范围,被强制裁剪到127,导致严重精度损失(-2.904)
总体来看,量化等级越小,loss下降越慢。量化引入的噪声可能起到类似Dropout的作用,在小样本数据集上可能防止过拟合。下面是一些实验数据总结。
通用规律(LLaVA数据体现):
- 随着量化等级加深(None -> 8bit -> 4bit),训练显存呈显著下降趋势。在 LLaVA 数据集中,4bit 相比 None 节省了约 48.5% 的显存(9912MiB -> 5096MiB)。这是量化最直接的收益,允许在有限硬件上跑更大的 batch size。
- 两个数据集均显示:量化等级越高,训练时间越长。LLaVA:None (10min) < 4bit (16min) < 8bit (23min)。Sign:None (25min) < 8bit (1h 24m) < 4bit (3h 03m)。【在训练过程中,模型权重虽然是 4-bit/8-bit 存储,但在前向传播和反向传播计算梯度时,必须实时反量化回 FP16/BF16 进行运算。这个频繁的“解压”过程(Dequantization overhead)显著增加了计算耗时。对于 Sign 这样的小样本数据集,原本训练很快,量化导致的相对时间成本激增非常明显(增加了 6 倍时间,跟华为服务器有关)。】
- 当训练和推理量化精度不匹配时(如 8bit 训,4bit 推),模型会遭受严重的精度崩塌。例如,全链路 4-bit(训+推)的效果甚至优于全链路 FP16(None)。这可能是因为对于小样本数据集,量化引入的噪声起到了“正则化”(Regularization)的作用,防止了模型过拟合,从而在测试集上表现更好。
- LoRA模型大小均为 58M (LLaVA) 或 29M (Sign),且不随量化等级变化。这是因为 LoRA 训练的是附加的低秩矩阵(Adapter),而非基座模型(Base Model)。基座模型无论是否量化,其本身不被保存,被保存的仅仅是 LoRA 权重(通常保存为 FP16 或 FP32)。因此,量化并不减小 LoRA 适配器文件的大小,它减小的是基座模型加载到内存时的大小。
异常现象(Sign数据体现):
在 Sign 数据集中,4bit 训练显存(22553MiB)反而远高于 None(17408MiB)和 8bit(13834MiB)。这通常不是量化本身的问题,而是实现机制的问题,猜测与华为服务器有关。
- 该实验中的 4-bit 实现(如早期的 bitsandbytes QLoRA)可能未开启分页优化(Paged Optimizers),或者在计算梯度时需要将 4-bit 权重反量化为 FP16/BF16 进行计算,产生的临时激活值(Activation Memory)在特定长序列(Sign 数据集可能包含长文本或多帧特征)下导致显存激增。
llava数据集-LoRA
| 量化等级 | 训练时间 | 训练显存 | 保存模型大小 | 推理显存 | ROUGE-L | BLEU-4 |
|---|---|---|---|---|---|---|
| none(默认,epoch=6,lr=1e-4) | 0:10:29 | 9912MiB | 58M | - | 24.30 | 11.22 |
| 8 | 0:23:17 | 6530MiB | 58M | 7928MiB | 23.90 | 10.89 |
| 4 | 0:16:02 | 5096MiB | 58M | 7928MiB | 24.09 | 10.76 |

sign数据集-LoRA
| 训练量化等级 | 推理量化等级 | 训练时间 | 训练显存 | 保存模型大小 | 推理显存 | ROUGE-L | BLEU-4 |
|---|---|---|---|---|---|---|---|
| none(默认) | none | 0:25:01 | 17408MiB | 29M | 8453MiB | 94.45 | 90.43 |
| 8bit | none | 1:24:45 | 13834MiB | 29M | 8421MiB | 94.36 | 90.39 |
| 8bit | 8bit | 1:24:45 | 13834MiB | 29M | 8221MiB | 94.09 | 89.93 |
| 8bit | 4bit | 1:24:45 | 13834MiB | 29M | 7112MiB | 88.58 | 81.08 |
| 4bit | none | 3:03:27 | 22553MiB | 29M | 8423MiB | 94.36 | 90.39 |
| 4bit | 8bit | 3:03:27 | 22553MiB | 29M | 8225MiB | 90.51 | 83.92 |
| 4bit | 4bit | 3:03:27 | 22553MiB | 29M | 7113MiB | 95.06 | 91.53 |

三、序列与上下文长度相关参数
1. 最大序列截断长度(cutoff_len)
参数意义:训练时单个输入(source+target)允许的最大 token 长度。影响上下文覆盖能力与内存占用。
在实验中的影响:
- 过短的 cutoff_len(例如 2048)可能丢失长上下文信息,导致生成质量下降(loss 增大、指标下降)。
- 过长的 cutoff_len 会增加单样本的激活内存占用与计算时间,但并非总能带来显著性能提升(4096→8192 对 llava 的提升有限)。
实践建议: 根据任务所需上下文长度选择:短文本任务可用 2048 或更低以节省资源;多轮对话/长说明类任务优先 4096 或 8192(若硬件允许)。
相关代码见src/llamafactory/data/processor/supervised.py,不同版本的实现一致。损失函数曲线基本一致。
for turn_idx, (source_ids, target_ids) in enumerate(encoded_pairs):
if total_length >= self.data_args.cutoff_len:
break
source_len, target_len = infer_seqlen(
len(source_ids), len(target_ids), self.data_args.cutoff_len - total_length
)
source_ids = source_ids[:source_len]
target_ids = target_ids[:target_len]
total_length += source_len + target_len
- 如果总长度超过
cutoff_len,停止添加更多对话 infer_seqlen:智能分配剩余长度给 source 和 target
llava数据集-LoRA
| 截断长度 | 训练时间 | 训练显存 | 保存模型大小 | 推理显存 | ROUGE-L | BLEU-4 |
|---|---|---|---|---|---|---|
| 2048 | 0:11:12 | 9912MiB | 58M | - | 22.12 | 7.39 |
| 4096(默认,epoch=6,lr=1e-4) | 0:10:29 | 9912MiB | 58M | - | 24.30 | 11.22 |
| 8192 | 0:11:09 | 9912MiB | 58M | - | 24.26 | 10.87 |
sign数据集-LoRA
| 截断长度 | 训练时间 | 训练显存 | 保存模型大小 | 推理显存 | ROUGE-L | BLEU-4 |
|---|---|---|---|---|---|---|
| 2048 | error | - | - | - | - | - |
| 4096(默认,epoch=15) | 0:22:21 | 17554MiB | 58M | 8453MiB | 94.33 | 90.27 |
| 8192 | 0:25:56 | 17554MiB | 58M | 8453MiB | 94.36 | 90.39 |

2. 序列打包策略(packing / neat_packing)
参数意义: 把多个短样本拼接成一个长序列以提升 GPU 利用率(packing),neat_packing 控制 attention mask 的处理方式。
在实验中的影响:
- packing 在不同数据集上表现不一致:对 LLaVA(通用大样本)有时会使 loss 变大变慢,而对 Sign(专业小样本)可能大幅降低 loss。原因可能与样本长度分布、attention 实现(flash_attn 对 mask 的敏感性)以及打包后标签/位置编码的影响有关。
- neat_packing(不同 mask id)在某些实现或硬件上会触发效率或兼容性问题(实验中部分配置报错)。
实践建议: 对于短样本高度同质的数据集,尝试 packing 往往能提升吞吐与样本多样性;对长序列或依赖精确位置的任务,优先使用不打包或谨慎启用 neat_packing 并做充分验证。
SFT Packing详解里已经给出了很好的介绍,我们可以看看这两个版本中代码的实现有无差异。分别进入两个LLamafactory的src/llamafactory/data/processor/supervised.py目录下,重点查看PackedSupervisedDatasetProcessor的实现,发现它们的实现是完全一样的:
@dataclass
class PackedSupervisedDatasetProcessor(SupervisedDatasetProcessor):
def preprocess_dataset(self, examples: dict[str, list[Any]]) -> dict[str, list[Any]]:
# TODO: use `position_ids` to achieve packing
# build inputs with format ` X1 Y1 X2 Y2 `
# and labels with format ` ... Y1 ... Y2 `
valid_num = 0
batch_input_ids, batch_labels, batch_images, batch_videos, batch_audios = [], [], [], [], []
lengths = []
length2indexes = defaultdict(list)
for i in range(len(examples["_prompt"])):
if len(examples["_prompt"][i]) % 2 != 1 or len(examples["_response"][i]) != 1:
logger.warning_rank0(
"Dropped invalid example: {}".format(examples["_prompt"][i] + examples["_response"][i])
)
continue
input_ids, labels = self._encode_data_example(
prompt=examples["_prompt"][i],
response=examples["_response"][i],
system=examples["_system"][i],
tools=examples["_tools"][i],
images=examples["_images"][i] or [],
videos=examples["_videos"][i] or [],
audios=examples["_audios"][i] or [],
)
length = len(input_ids)
if length > self.data_args.cutoff_len:
logger.warning_rank0(f"Dropped lengthy example with length {length} > {self.data_args.cutoff_len}.")
else:
lengths.append(length)
length2indexes[length].append(valid_num)
batch_input_ids.append(input_ids)
batch_labels.append(labels)
batch_images.append(examples["_images"][i] or [])
batch_videos.append(examples["_videos"][i] or [])
batch_audios.append(examples["_audios"][i] or [])
valid_num += 1
model_inputs = defaultdict(list)
knapsacks = greedy_knapsack(lengths, self.data_args.cutoff_len)
for knapsack in knapsacks:
packed_input_ids, packed_attention_masks, packed_position_ids, packed_labels = [], [], [], []
packed_images, packed_videos, packed_audios = [], [], []
for i, length in enumerate(knapsack):
index = length2indexes[length].pop()
packed_input_ids += batch_input_ids[index]
packed_position_ids += list(range(len(batch_input_ids[index]))) # NOTE: pad_to_multiple_of ignore this
packed_labels += batch_labels[index]
packed_images += batch_images[index]
packed_videos += batch_videos[index]
packed_audios += batch_audios[index]
if self.data_args.neat_packing:
packed_attention_masks += [i + 1] * len(batch_input_ids[index]) # start from 1
else:
packed_attention_masks += [1] * len(batch_input_ids[index])
if len(packed_input_ids) < self.data_args.cutoff_len + 1: # avoid flash_attn drops attn mask
pad_length = self.data_args.cutoff_len - len(packed_input_ids) + 1
packed_input_ids += [self.tokenizer.pad_token_id] * pad_length
packed_position_ids += [0] * pad_length
packed_labels += [IGNORE_INDEX] * pad_length
if self.data_args.neat_packing:
packed_attention_masks += [0] * pad_length
else:
packed_attention_masks += [1] * pad_length # more efficient flash_attn
if len(packed_input_ids) != self.data_args.cutoff_len + 1:
raise ValueError("The length of packed example should be identical to the cutoff length.")
model_inputs["input_ids"].append(packed_input_ids)
model_inputs["attention_mask"].append(packed_attention_masks)
model_inputs["position_ids"].append(packed_position_ids)
model_inputs["labels"].append(packed_labels)
model_inputs["images"].append(packed_images or None)
model_inputs["videos"].append(packed_videos or None)
model_inputs["audios"].append(packed_audios or None)
return model_inputs
处理流程:
- 样本编码 (135-162行)
- 编码所有有效样本
- 过滤超长样本
- 按长度建立索引 length2indexes
- 贪心背包打包 (165行)
- 使用 greedy_knapsack 算法将样本打包
- 目标:最大化利用 cutoff_len 空间
- 序列拼接 (166-180行)
- 将多个样本的 input_ids、labels、多模态数据拼接
- 生成 position_ids(每个样本从0开始)
- attention_mask 处理:
- neat_packing=True: 每个样本用不同的 mask ID(1, 2, 3…)
- neat_packing=False: 全部为1(更高效的 flash_attn)
- 填充 (182-190行)
- 填充到 cutoff_len + 1 长度
- 避免 flash_attn 丢弃 attention mask
- 填充部分标签为 IGNORE_INDEX
- 验证和输出 (192-201行)
- 验证长度正确性
- 输出打包后的模型输入
当设置这类参数的时候,LLamafactory的后台日志会出现Converting format of dataset (num_proc=16),在给出的生成样本示例中,你可以看到非常长一眼看不到头的ids和label,很直观的多样本拼接,这里packing和neat_packing输出的结果是完全一致的。


llava数据集-LoRA
| 序列打包配置 | 训练时间 | 训练显存 | 保存模型大小 | 推理显存 | ROUGE-L | BLEU-4 |
|---|---|---|---|---|---|---|
| 默认(不打包,epoch=6,lr=1e-4) | 0:10:29 | 9912MiB | 58M | - | 24.30 | 11.22 |
| packing=true | 0:03:09 | 17296MiB | 58M | - | 22.23 | 8.04 |
| packing=true + neat_packing=true | 0:03:47 | 17616MiB | 58M | - | 23.44 | 9.63 |

sign数据集-LoRA
| 序列打包配置 | 训练时间 | 训练显存 | 保存模型大小 | 推理显存 | ROUGE-L | BLEU-4 |
|---|---|---|---|---|---|---|
| 默认(不打包,epoch=15,lora_rank=4) | 0:25:01 | 17408MiB | 29M | 8453MiB | 94.45 | 90.43 |
| packing=true | 0:30:49 | 17664MiB | 29M | 8453MiB | 93.11 | 87.70 |
| neat_packing=true | error | - | - | - | - | - |

四、视觉输入与多模态相关参数
1. 图像最大像素限制(image_max_pixels)
参数意义: 训练前对图像进行缩放的阈值(以像素总数计),决定输入图像的分辨率上限。
在实验中的影响:
- 较小像素上限(如 64×64)会造成信息丢失,显著拉高 loss;默认 768×768 在多模态任务中提供了较优的视觉特征与成本折中,进一步增大到 1024×1024 收益有限且代价更高。
- 图像尺寸的增大会直接影响前向计算、显存与训练时间。
实践建议: 以任务对视觉细节的需求为准:视觉理解/细粒度识别类任务选择较高像素(≥768²);只需粗略视觉线索的任务可降低像素以节省计算。
原理说明:
- 图像预处理阶段:在数据加载时,所有输入图像都会根据
image_max_pixels进行尺寸调整 - 自适应缩放:如果图像像素总数超过限制,会按比例缩小至满足要求;低于
image_min_pixels则放大
相关代码在src/llamafactory/data/mm_plugin.py,两个版本的实现均一致:
def _preprocess_image(
self, image: "ImageObject", image_max_pixels: int, image_min_pixels: int, **kwargs
) -> "ImageObject":
r"""Pre-process a single image."""
if (image.width * image.height) > image_max_pixels:
resize_factor = math.sqrt(image_max_pixels / (image.width * image.height))
width, height = int(image.width * resize_factor), int(image.height * resize_factor)
image = image.resize((width, height))
if (image.width * image.height) < image_min_pixels:
resize_factor = math.sqrt(image_min_pixels / (image.width * image.height))
width, height = int(image.width * resize_factor), int(image.height * resize_factor)
image = image.resize((width, height))
if image.mode != "RGB":
image = image.convert("RGB")
return image
代码要点:
- 使用
math.sqrt计算缩放因子,确保等比例调整宽高 - 缩放操作在数据加载阶段完成,影响后续所有训练步骤
- 强制转换为RGB模式,保证输入格式统一
llava数据集-LoRA
| 图像最大像素 | 训练时间 | 训练显存 | 保存模型大小 | 推理显存 | ROUGE-L | BLEU-4 |
|---|---|---|---|---|---|---|
| 64×64 | 0:10:03 | 9614MiB | 58M | - | 23.77 | 10.42 |
| 768×768(默认,epoch=6,lr=1e-4) | 0:10:29 | 9912MiB | 58M | - | 24.30 | 11.22 |
| 1024×1024 | 0:10:31 | 9912MiB | 58M | - | 24.10 | 11.01 |

llava数据集-Full
| 图像最大像素 | 训练时间 | 训练显存 | 保存模型大小 | 推理显存 | ROUGE-L | BLEU-4 |
|---|---|---|---|---|---|---|
| 64×64 | 0:18:26 | 21920MiB | 6.99GB | - | 23.36 | 10.41 |
| 768×768(默认) | 0:19:47 | 22086MiB | 6.99GB | - | 23.43 | 10.79 |
sign数据集-LoRA
| 图像最大像素 | 训练时间 | 训练显存 | 保存模型大小 | 推理显存 | ROUGE-L | BLEU-4 |
|---|---|---|---|---|---|---|
| 768×768(默认,epoch=15,lora_rank=4) | 0:25:01 | 17408MiB | 29M | 8453MiB | 94.45 | 90.43 |
| 512×512 | 0:10:12 | 16994→25844MiB | 29M | 8453MiB | 83.77 | 73.11 |
| 64×64 | 0:10:03 | 16027MiB | 29M | 8453MiB | 81.44 | 69.79 |

2. 视觉编码器冻结策略(freeze_vision_tower)/ 多模态投影器冻结策略(freeze_multi_modal_projector)
参数意义: 分别控制是否冻结预训练视觉编码器(Vision Tower)和图像–文本对齐的投影层(Projector),决定这些模块是否参与反向传播。
在实验中的影响:
- 冻结视觉编码器可大幅节省显存与计算并保护预训练表征,能在小样本上提高稳定性;
- 从结果来看,loss曲线基本是一致的,但是不冻结视觉编码器会让效果变得较差。总体上这两个参数的调整不会带来什么收益;
- 对于需要改动视觉特征分布的场景(风格差异大、域移)可考虑解冻 projector 或部分视觉层;否则推荐默认冻结视觉塔、只微调 projector/上层。
llamafactory 中 Freeze vision tower 和 Freeze multi-modal projector 参数配置讲得不错,这两个参数控制多模态模型中不同组件的可训练性,是控制训练成本和防止灾难性遗忘的关键机制。
架构组成:
- Vision Tower(视觉编码器):通常是预训练的CLIP ViT模型,负责将图像编码为特征向量
- Multi-modal Projector(多模态投影器):连接视觉编码器和语言模型的小型MLP,负责特征空间对齐
冻结策略的理论依据:
- 预训练知识保护:Vision Tower已在大规模图像-文本对上预训练,冻结可防止在小数据集上过拟合
- 计算资源节约:视觉编码器参数量大(ViT-Large约300M参数),冻结可减少70%以上的显存占用
- 训练稳定性:冻结底层特征提取器,只微调上层对齐模块,训练过程更稳定
相关代码在src/llamafactory/model/model_utils/visual.py,两个版本的实现均一致:
def get_forbidden_modules(config: "PretrainedConfig", finetuning_args: "FinetuningArguments") -> set[str]:
r"""Freeze vision tower and language model for VLM full/freeze tuning."""
model_type = getattr(config, "model_type", None)
forbidden_modules = set()
if model_type in COMPOSITE_MODELS:
if finetuning_args.freeze_vision_tower:
vision_model_keys = COMPOSITE_MODELS[model_type].vision_model_keys
logger.info_rank0(f"Set vision model not trainable: {vision_model_keys}.")
forbidden_modules.update(vision_model_keys)
if finetuning_args.freeze_multi_modal_projector:
projector_key = COMPOSITE_MODELS[model_type].projector_key
logger.info_rank0(f"Set multi model projector not trainable: {projector_key}.")
forbidden_modules.add(projector_key)
if finetuning_args.freeze_language_model:
language_model_keys = COMPOSITE_MODELS[model_type].language_model_keys
logger.info_rank0(f"Set language model not trainable: {language_model_keys}.")
forbidden_modules.update(language_model_keys)
return forbidden_modules
代码要点:
forbidden_modules集合记录不可训练的模块名称- 通过
COMPOSITE_MODELS字典查找模型的各组件键名
llava数据集-LoRA
| 冻结配置 | 训练时间 | 训练显存 | 保存模型大小 | 推理显存 | ROUGE-L | BLEU-4 |
|---|---|---|---|---|---|---|
| 默认(冻结视觉编码器+冻结多模态投影器,epoch=6,lr=1e-4) | 0:10:29 | 9912MiB | 58M | - | 24.30 | 11.22 |
| freeze_vision_tower=false | 0:15:51 | 10118MiB | 79M | - | 24.06 | 10.86 |
| freeze_multi_modal_projector=false | 0:10:19 | 9912MiB | 58M | - | 24.38 | 10.86 |
llava数据集-Full
| 冻结配置 | 训练时间 | 训练显存 | 保存模型大小 | 推理显存 | ROUGE-L | BLEU-4 |
|---|---|---|---|---|---|---|
| 默认(冻结视觉编码器+冻结多模态投影器) | 0:19:47 | 22086MiB | 6.99GB | - | 23.43 | 10.79 |
| freeze_vision_tower=false | 0:34:58 | 19766MiB | 6.99GB | - | 23.28 | 10.33 |
| freeze_multi_modal_projector=false | 0:42:35 | 22290MiB | 6.99GB | - | 23.28 | 10.44 |
sign数据集-LoRA
| 冻结配置 | 训练时间 | 训练显存 | 保存模型大小 | 推理显存 | ROUGE-L | BLEU-4 |
|---|---|---|---|---|---|---|
| 默认(冻结视觉编码器+冻结多模态投影器,epoch=15,lora_rank=4) | 0:25:01 | 17408MiB | 29M | 8453MiB | 94.45 | 90.43 |
| freeze_vision_tower=false | 0:44:05 | 17864MiB | 40M | 8453MiB | 92.91 | 88.00 |
| freeze_multi_modal_projector=false | 0:24:26 | 17409MiB | 29M | 8453MiB | 94.39 | 90.35 |

五、冻结训练(Freeze)相关策略
1. 可训练层数(freeze_trainable_layers)
参数意义:freeze_trainable_layers参数控制在Freeze微调模式下,从模型底层或顶层选择多少层进行训练。这是一种介于Full微调和LoRA之间的折中策略。
在实验中的影响:
- 训练较少层(2 层)成本低但上限有限;增大可训练层数(8 层等)能提高表现但显存、时间增长明显;
层级训练的理论基础:
- 特征层次性:深度神经网络底层提取通用特征(边缘、纹理),顶层提取任务特定特征
- 迁移学习原理:预训练模型的底层特征通常可直接复用,顶层需针对新任务调整
参数说明:
freeze_trainable_layers > 0:训练最后n层(top-down策略)freeze_trainable_layers < 0:训练最前n层(bottom-up策略,罕用)- 配合
freeze_trainable_modules指定训练哪些模块(如attention、mlp)
相关代码在src/llamafactory/model/adapter.py,两个版本的代码实现一致
def _setup_freeze_tuning(
model: "PreTrainedModel",
finetuning_args: "FinetuningArguments",
is_trainable: bool,
cast_trainable_params_to_fp32: bool,
) -> None:
if not is_trainable:
return
logger.info_rank0("Fine-tuning method: Freeze")
if hasattr(model.config, "text_config"): # composite models
config = getattr(model.config, "text_config")
else:
config = model.config
num_layers = (
getattr(config, "num_hidden_layers", None)
or getattr(config, "num_layers", None)
or getattr(config, "n_layer", None)
)
if not num_layers:
raise ValueError("Current model does not support freeze tuning.")
if finetuning_args.use_llama_pro:
if num_layers % finetuning_args.freeze_trainable_layers != 0:
raise ValueError(
f"`num_layers` {num_layers} should be "
f"divisible by `num_layer_trainable` {finetuning_args.freeze_trainable_layers}."
)
stride = num_layers // finetuning_args.freeze_trainable_layers
trainable_layer_ids = range(stride - 1, num_layers + stride - 1, stride)
elif finetuning_args.freeze_trainable_layers > 0: # fine-tuning the last n layers if num_layer_trainable > 0
trainable_layer_ids = range(max(0, num_layers - finetuning_args.freeze_trainable_layers), num_layers)
else: # fine-tuning the first n layers if num_layer_trainable < 0
trainable_layer_ids = range(min(-finetuning_args.freeze_trainable_layers, num_layers))
hidden_modules = set()
non_hidden_modules = set()
for name, _ in model.named_parameters():
if ".0." in name:
hidden_modules.add(name.split(".0.")[-1].split(".")[0])
elif ".1." in name: # MoD starts from layer 1
hidden_modules.add(name.split(".1.")[-1].split(".")[0])
if re.search(r".d+.", name) is None:
non_hidden_modules.add(name.split(".")[-2]) # remove weight/bias
trainable_layers = []
for module_name in finetuning_args.freeze_trainable_modules:
if module_name != "all" and module_name not in hidden_modules:
raise ValueError(
"Module {} is not found, please choose from {}".format(module_name, ", ".join(hidden_modules))
)
for idx in trainable_layer_ids:
trainable_layers.append(".{:d}.{}".format(idx, module_name if module_name != "all" else ""))
if finetuning_args.freeze_extra_modules:
for module_name in finetuning_args.freeze_extra_modules:
if module_name not in non_hidden_modules:
raise ValueError(
"Module {} is not found, please choose from {}".format(module_name, ", ".join(non_hidden_modules))
)
trainable_layers.append(module_name)
model_type = getattr(model.config, "model_type", None)
if not finetuning_args.freeze_multi_modal_projector and model_type in COMPOSITE_MODELS:
trainable_layers.append(COMPOSITE_MODELS[model_type].projector_key)
forbidden_modules = get_forbidden_modules(model.config, finetuning_args)
for name, param in model.named_parameters():
if any(trainable_layer in name for trainable_layer in trainable_layers) and not any(
forbidden_module in name for forbidden_module in forbidden_modules
):
if cast_trainable_params_to_fp32:
param.data = param.data.to(torch.float32)
else:
param.requires_grad_(False)
logger.info_rank0("Set trainable layers: {}".format(",".join(trainable_layers)))
代码实现关键:根据 freeze_trainable_layers 确定可训练层
这里有三种策略:
策略A:LLaMA-Pro 模式
if finetuning_args.use_llama_pro:
if num_layers % finetuning_args.freeze_trainable_layers != 0:
raise ValueError(...)
stride = num_layers // finetuning_args.freeze_trainable_layers
trainable_layer_ids = range(stride - 1, num_layers + stride - 1, stride)
示例:假设 num_layers=32,freeze_trainable_layers=8
stride = 32 // 8 = 4trainable_layer_ids = range(3, 36, 4)→[3, 7, 11, 15, 19, 23, 27, 31]- 效果:均匀间隔地选择层进行训练
策略B:训练最后 N 层(
freeze_trainable_layers > 0)
elif finetuning_args.freeze_trainable_layers > 0:
trainable_layer_ids = range(max(0, num_layers - finetuning_args.freeze_trainable_layers), num_layers)
示例:num_layers=32,freeze_trainable_layers=4
trainable_layer_ids = range(28, 32)→[28, 29, 30, 31]- 效果:只训练模型的后 4 层
策略C:训练最前 N 层(
freeze_trainable_layers < 0)
else: # fine-tuning the first n layers if num_layer_trainable < 0
trainable_layer_ids = range(min(-finetuning_args.freeze_trainable_layers, num_layers))
示例:num_layers=32,freeze_trainable_layers=-4
trainable_layer_ids = range(4)→[0, 1, 2, 3]- 效果:只训练模型的前 4 层
llava数据集-Freeze
| 可训练层数 | 训练时间 | 训练显存 | 保存模型大小 | 推理显存 | ROUGE-L | BLEU-4 |
|---|---|---|---|---|---|---|
| 2(默认,dtype=pure_bf16) | 0:03:24 | 10698MiB | 6.99GB | - | 21.18 | 6.66 |
| 8(dtype=pure_bf16) | 0:04:20 | 14242MiB | 6.99GB | - | 22.26 | 7.86 |
| 2(dtype=bf16) | 0:03:24 | 12446MiB | 7.28GB | - | 21.79 | 7.57 |
| 8(dtype=bf16) | 0:05:31 | 20888MiB | 8.14GB | - | 22.31 | 8.30 |

sign数据集-Freeze
| 可训练层数 | 训练时间 | 训练显存 | 保存模型大小 | 推理显存 | ROUGE-L | BLEU-4 |
|---|---|---|---|---|---|---|
| 2(默认,epoch=15) | 0:16:25 | 18780MiB | 7.28GB | - | 93.10 | 87.79 |
| 4 | 0:18:40 | 20649MiB | 7.57GB | - | 92.99 | 87.47 |
| 8 | 0:18:54 | 25918MiB | 8.14GB | - | 92.99 | 87.50 |

六、Prompt 与对话建模相关参数
1. 是否训练 Prompt(train_on_prompt)
参数意义: 决定是否在训练时对“用户输入(prompt)”部分计算 loss 并进行参数更新。
在实验中的影响:
False(默认):模型只学习生成回复,能减少过拟合、提高泛化;True:模型同时学习 prompt 的分布,可能在需要固定问法或专用话术的场景有用,但容易记忆训练集 prompt 导致泛化差(实验中 loss曲线变很大并且降不下来,可能是因为模型记住了训练集prompt的固定格式,无法泛化)。
实践建议: 默认 False;只有在确实希望模型模仿或约束特定 prompt 风格时考虑 True(并配合强正则与验证)。
模式比较:
- 默认模式的优势:
- 避免模型浪费容量记忆训练集中的具体问题
- 专注学习如何根据各种问题生成恰当回复
- 减少过拟合风险,提高泛化能力
- 全序列训练的适用场景:
- Prompt格式需要严格遵循特定模板
- 需要学习特定的提问方式(如客服话术)
- 联合优化prompt理解和response生成(罕见)
相关代码见src/llamafactory/data/processor/supervised.py,不同版本的实现一致
class SupervisedDatasetProcessor(DatasetProcessor):
def _encode_data_example(
self,
prompt: list[dict[str, str]],
response: list[dict[str, str]],
system: Optional[str],
tools: Optional[str],
images: list["ImageInput"],
videos: list["VideoInput"],
audios: list["AudioInput"],
) -> tuple[list[int], list[int]]:
messages = self.template.mm_plugin.process_messages(prompt + response, images, videos, audios, self.processor)
input_ids, labels = self.template.mm_plugin.process_token_ids(
[], [], images, videos, audios, self.tokenizer, self.processor
)
encoded_pairs = self.template.encode_multiturn(self.tokenizer, messages, system, tools)
total_length = len(input_ids) + (1 if self.template.efficient_eos else 0)
if self.data_args.mask_history:
encoded_pairs = encoded_pairs[::-1] # high priority for last turns
for turn_idx, (source_ids, target_ids) in enumerate(encoded_pairs):
if total_length >= self.data_args.cutoff_len:
break
source_len, target_len = infer_seqlen(
len(source_ids), len(target_ids), self.data_args.cutoff_len - total_length
)
source_ids = source_ids[:source_len]
target_ids = target_ids[:target_len]
total_length += source_len + target_len
if self.data_args.train_on_prompt:
source_label = source_ids
elif self.template.efficient_eos:
source_label = [self.tokenizer.eos_token_id] + [IGNORE_INDEX] * (source_len - 1)
else:
source_label = [IGNORE_INDEX] * source_len
if self.data_args.mask_history and turn_idx != 0: # train on the last turn only
target_label = [IGNORE_INDEX] * target_len
else:
target_label = target_ids
if self.data_args.mask_history: # reversed sequences
input_ids = source_ids + target_ids + input_ids
labels = source_label + target_label + labels
else:
input_ids += source_ids + target_ids
labels += source_label + target_label
if self.template.efficient_eos:
input_ids += [self.tokenizer.eos_token_id]
labels += [self.tokenizer.eos_token_id]
return input_ids, labels
train_on_prompt 的标签处理
if self.data_args.train_on_prompt:
source_label = source_ids
elif self.template.efficient_eos:
source_label = [self.tokenizer.eos_token_id] + [IGNORE_INDEX] * (source_len - 1)
else:
source_label = [IGNORE_INDEX] * source_len
三种模式对比:
| 模式 | source_label | 含义 |
|---|---|---|
train_on_prompt=True | source_ids | 计算用户输入的损失,模型学习用户的提问方式 |
efficient_eos=True | [eos] + [IGNORE]* | 只对第一个 token 计算损失(通常是 EOS) |
| 默认模式 | [IGNORE]* | 完全忽略用户输入,只学习助手回复 |
IGNORE_INDEX(通常是 -100):PyTorch 的 CrossEntropyLoss 会自动忽略这些位置。
下面是train_on_prompt设后和未设置的日志截图对比,其区别主要在于设置后label_ids包含了完整的prompt和response部分的token IDs,而默认情况下label_ids中prompt部分被标记为-100。因此设置后模型会在整个序列(包括用户输入的prompt部分)上计算loss并进行梯度更新,默认情况下loss计算和梯度更新只针对模型生成的response部分。
因此,train_on_prompt=False是更常见的微调设置,因为我们通常只想让模型学习如何生成好的回答,而不需要"学习"用户的提问方式。设置为-100的token会在loss计算中被忽略,这样可以避免模型浪费容量去记忆训练集中的具体问题,而是专注于学习如何根据各种问题生成恰当的回复。这种方式能提高微调效率,减少过拟合风险。


2. 是否屏蔽历史对话(mask_history)
参数意义: 在多轮对话训练中是否只对最后一轮 assistant 的回复计算 loss(将历史回复标为 IGNORE)。
在实验中的影响:
True:避免历史轮次稀释梯度、节省序列长度预算,聚焦当前回复质量(从结果来看,loss曲线无变化,可能是因为历史信息冗余);False:训练所有回复,适合需要整体对话一致性或训练模型保持多轮策略的场景。
实践建议: 对话任务以回应质量为主时启用 mask_history=True;若目标是训练连贯的多轮策略或评价多轮一致性则保留 False。
多轮对话训练的挑战:
- 序列长度膨胀:n轮对话的总长度是单轮的n倍,容易超过max_length限制
- 梯度稀释:所有轮次的梯度平均分配,关键信息(通常在最后一轮)的学习效率降低
- 重复信息:早期轮次的回复可能包含重复或无关信息,干扰训练
mask_history的作用机制:
- mask_history=False(默认):所有轮次的response都参与loss计算
- mask_history=True:只有最后一轮response参与loss计算,历史轮次被标记为-100
相关代码见上一部分的src/llamafactory/data/processor/supervised.py,不同版本的实现一致。
关键1:mask_history 的反转逻辑
if self.data_args.mask_history:
encoded_pairs = encoded_pairs[::-1] # high priority for last turns
作用:
- 反转对话顺序,优先处理最后的对话轮次
- 原因:当序列超长需要截断时,保留最近的对话,丢弃早期对话
示例:
# 原始:[(turn1_user, turn1_assistant), (turn2_user, turn2_assistant), (turn3_user, turn3_assistant)]
# 反转后:[(turn3_user, turn3_assistant), (turn2_user, turn2_assistant), (turn1_user, turn1_assistant)]
关键2:mask_history 的标签掩码
if self.data_args.mask_history and turn_idx != 0: # train on the last turn only
target_label = [IGNORE_INDEX] * target_len
else:
target_label = target_ids
逻辑:
- 如果
mask_history=True且turn_idx != 0(不是第一轮):target_label = [IGNORE_INDEX] * target_len→ 忽略这轮助手回复的损失
- 否则:
target_label = target_ids→ 计算这轮助手回复的损失
关键理解:
- 由于前面反转了顺序,
turn_idx=0实际是最后一轮对话 - 因此,
mask_history=True时,只训练最后一轮对话的助手回复
关键3:mask_history 的反向序列拼接
if self.data_args.mask_history: # reversed sequences
input_ids = source_ids + target_ids + input_ids
labels = source_label + target_label + labels
else:
input_ids += source_ids + target_ids
labels += source_label + target_label
mask_history=True:从后往前拼接(因为已反转)mask_history=False:正常顺序拼接
实际案例对比
案例 1:3 轮对话,默认设置
train_on_prompt=False, mask_history=False
Turn 1: User: "你好" → Assistant: "你好!有什么可以帮你的?"
Turn 2: User: "天气" → Assistant: "今天天气晴朗"
Turn 3: User: "谢谢" → Assistant: "不客气!"
编码结果:
input_ids: [你好] [你好!有什么可以帮你的?] [天气] [今天天气晴朗] [谢谢] [不客气!]
labels: [IGN] [你好!有什么可以帮你的?] [IGN] [今天天气晴朗] [IGN] [不客气!]
(IGN = IGNORE_INDEX)
效果:模型学习所有助手回复,忽略用户输入。
案例 2:同样对话,
train_on_prompt=True
train_on_prompt=True, mask_history=False
编码结果:
input_ids: [你好] [你好!有什么可以帮你的?] [天气] [今天天气晴朗] [谢谢] [不客气!]
labels: [你好] [你好!有什么可以帮你的?] [天气] [今天天气晴朗] [谢谢] [不客气!]
效果:模型同时学习用户输入和助手回复(类似语言建模)。
案例 3:同样对话,
mask_history=True
train_on_prompt=False, mask_history=True
处理流程:
- 反转:
[(Turn3), (Turn2), (Turn1)] - 遍历时:
turn_idx=0(Turn3):保留标签turn_idx=1(Turn2):掩码标签turn_idx=2(Turn1):掩码标签
编码结果:
input_ids: [谢谢] [不客气!] [天气] [今天天气晴朗] [你好] [你好!有什么可以帮你的?]
labels: [IGN] [不客气!] [IGN] [IGN IGN IGN] [IGN] [IGN IGN IGN IGN IGN IGN]
效果:
- 只训练最后一轮助手回复(“不客气!”)
- 历史对话作为上下文,但不计算损失
- 优势:避免模型过拟合历史对话,聚焦当前回复质量
3. 词表调整(resize_vocab)
参数意义:resize_vocab参数控制是否根据数据集中的新token动态调整词表大小,并将embedding层和LM head设为可训练。
**在实验中的影响:**从loss曲线上看,llava数据集无变化,sign数据集整体变得极小,训练时间暴增,显存占用翻倍,模型体积膨胀,然后性能还下降,这说明token在预训练词表中已充分覆盖,扩展词表无益反而引入噪声。
**背景分析:**词表(Vocabulary)是语言模型的"字典",将文本token映射为整数ID。预训练模型通常有固定词表(如LLaMA的32000词、GPT的50257词)。当遇到词表外(Out-of-Vocabulary, OOV)的token时,tokenizer会:
-
分解为子词:使用BPE/WordPiece将未知词拆分为已知子词
- 例如:
"COVID-19"→["CO", "VID", "-", "19"](4个token) - 如果在词表中:
"COVID-19"→["COVID-19"](1个token)
- 例如:
-
映射为UNK:无法分解时标记为
token- 导致语义信息完全丢失
-
字符级降级:极端情况下降级到字符级编码
- 序列长度爆炸,效率极低
词表扩展的必要性:
- 新token问题:数据集可能包含预训练词表中不存在的token(如专业术语、新词、特殊符号)
- UNK token的弊端:未识别token会被映射为
[UNK],导致信息丢失 - 表达能力提升:扩展词表可以精确表示新概念,避免用近似token组合
深层问题:
- Embedding层:词表大小(V)× 隐藏维度(D)的矩阵(如32000×4096 = 1.3亿参数)
- LM Head层:隐藏维度(D)× 词表大小(V)的输出投影矩阵(同样1.3亿参数)
- 扩展词表意味着这两个巨型矩阵同时增大
相关代码在src/llamafactory/model/adapter.py,两个版本的代码实现一致
if model_args.resize_vocab and finetuning_args.additional_target is None:
input_embeddings = model.get_input_embeddings()
output_embeddings = model.get_output_embeddings()
module_names = set()
for name, module in model.named_modules():
if module in [input_embeddings, output_embeddings]:
module_names.add(name.split(".")[-1])
finetuning_args.additional_target = module_names
logger.warning_rank0("Vocab has been resized, add {} to trainable params.".format(",".join(module_names)))
具体的执行过程在src/llamafactory/model/model_utils/embedding.py
if model_args.resize_vocab:
resize_embedding_layer(
model,
tokenizer,
new_special_tokens_config=getattr(model_args, "_special_token_descriptions", None),
init_special_tokens=model_args.init_special_tokens,
)
def resize_embedding_layer(
model: "PreTrainedModel",
tokenizer: "PreTrainedTokenizer",
new_special_tokens_config: Optional[dict] = None,
init_special_tokens: str = "noise_init",
) -> None:
r"""Resize token embeddings and initialize new tokens.
Args:
model: The model to resize
tokenizer: The tokenizer (used to get target vocab size)
new_special_tokens_config: Optional dict with token descriptions for semantic initialization
init_special_tokens: Initialization method ('noise_init', 'desc_init', 'desc_init_w_noise')
"""
if is_deepspeed_zero3_enabled():
import deepspeed # type: ignore
params = [model.get_input_embeddings().weight]
if model.get_output_embeddings() is not None and not model.config.tie_word_embeddings:
params.append(model.get_output_embeddings().weight)
context_maybe_zero3 = deepspeed.zero.GatheredParameters(params, modifier_rank=0)
else:
context_maybe_zero3 = nullcontext()
with context_maybe_zero3:
current_embedding_size = model.get_input_embeddings().weight.size(0)
if len(tokenizer) > current_embedding_size:
if getattr(model, "quantization_method", None):
raise ValueError("Cannot resize embedding layers of a quantized model.")
if not isinstance(model.get_output_embeddings(), torch.nn.Linear):
raise ValueError("Current model does not support resizing embedding layers.")
model.resize_token_embeddings(len(tokenizer), pad_to_multiple_of=64)
with context_maybe_zero3:
new_embedding_size = model.get_input_embeddings().weight.size(0)
num_new_tokens = new_embedding_size - current_embedding_size
logger.info_rank0(
f"Resizing embeddings: {current_embedding_size} -> {new_embedding_size} (+{num_new_tokens} tokens)"
)
# Initialize input embeddings
_initialize_embeddings(
model.get_input_embeddings().weight.data,
num_new_tokens,
init_special_tokens,
new_special_tokens_config,
tokenizer,
model,
)
# Initialize output embeddings if not tied
if model.get_output_embeddings() is not None and not model.config.tie_word_embeddings:
_initialize_embeddings(
model.get_output_embeddings().weight.data,
num_new_tokens,
init_special_tokens,
new_special_tokens_config,
tokenizer,
model,
)
model.config.vocab_size = new_embedding_size
logger.info_rank0(f"Resized token embeddings from {current_embedding_size} to {new_embedding_size}.")
这段代码的核心目标是安全高效地调整模型的词表大小并智能初始化新增的token嵌入。整个过程首先要处理DeepSpeed Zero-3分布式训练场景下的参数分片问题,通过GatheredParameters上下文管理器将分散在多个GPU上的嵌入层参数临时聚合到主进程,这样才能正确获取完整的嵌入层尺寸。在确认tokenizer的词表确实大于当前模型嵌入层后,代码会进行两项关键的前置检查:拒绝对已量化的模型进行调整(因为量化后的张量结构已被压缩重组,无法简单扩展),同时确保输出层是标准的Linear层(resize_token_embeddings的内部实现依赖这个假设)。
通过这些检查后,代码调用resize_token_embeddings并将新尺寸对齐到64的倍数,这个对齐策略能显著提升GPU矩阵运算效率,虽然会创建一些额外的未使用token位置,但带来的性能收益远超这点内存开销。调整完成后最关键的步骤是初始化新增的token嵌入,这里支持三种策略:纯随机噪声、基于文本描述的语义初始化、以及描述加噪声的混合方式。描述初始化是个巧妙的设计,它将新token的语义描述文本(比如"表示医疗对话中患者发言的标记")先用tokenizer编码,再通过现有的嵌入层获取这些描述token的平均嵌入向量,作为新token的初始表示,这样新token从训练开始就携带了语义信息而不是完全随机的噪声,能大幅加速收敛。
代码还细致处理了权重共享的情况,当输入嵌入层和输出投影层的权重是tied的(即共享同一份参数以节省内存),只需初始化输入层就会自动影响输出层,避免重复操作。最后更新model.config.vocab_size确保配置与实际嵌入层大小保持一致,这对后续的模型保存、加载和推理都很重要。整个流程在DeepSpeed的上下文管理器保护下完成,保证了分布式训练环境中参数修改的正确性和一致性。
代码要点:
- 自动检测模型的embedding层名称(不同架构可能不同)
- 将embedding层添加到
additional_target,与LoRA参数一起训练 - 只在未手动指定
additional_target时自动添加(避免覆盖用户配置)
调整前:
tokenizer vocab size: 32000
model.embed_tokens.weight.shape: [32000, 4096]
model.lm_head.weight.shape: [32000, 4096]
调整后:
tokenizer vocab size: 32010 # 添加了 10 个新 token
model.embed_tokens.weight.shape: [32010, 4096] # 扩展了 10 行
model.lm_head.weight.shape: [32010, 4096] # 扩展了 10 行
4. LLaMA-Pro 扩展参数训练(use_llama_pro)
LLaMA-Pro 是一种高效的模型扩展方法,来自论文 “LLaMA-Pro: Progressive LLaMA with Block Expansion”。
参数意义:use_llama_pro参数启用LLaMA-Pro的块扩展训练策略,这是一种介于全量微调和参数高效微调之间的新型方法。
**在实验中的影响:**从loss曲线上看,llava数据集loss下降变慢且更加不稳定,训练时间减少,显存占用增加。
LLaMA-Pro的核心思想:
- 块复制扩展:在预训练模型的基础上插入额外的Transformer层
- 选择性训练:只训练新插入的层,原有层保持冻结
- 容量扩张:在不破坏预训练知识的前提下,为新任务增加模型容量
与传统方法的区别:
- vs LoRA:LoRA在原有层内注入低秩矩阵,LLaMA-Pro是增加新层
- vs Freeze:Freeze训练原模型的部分层,LLaMA-Pro训练新增的层
- vs Full:Full训练所有参数,LLaMA-Pro只训练扩展部分
相关代码在src/llamafactory/model/adapter.py,两个版本的代码实现一致.
针对freeze:
if finetuning_args.use_llama_pro:
if num_layers % finetuning_args.freeze_trainable_layers != 0:
raise ValueError(
f"`num_layers` {num_layers} should be "
f"divisible by `num_layer_trainable` {finetuning_args.freeze_trainable_layers}."
)
stride = num_layers // finetuning_args.freeze_trainable_layers
trainable_layer_ids = range(stride - 1, num_layers + stride - 1, stride)
假设:
num_layers = 32(原始模型层数)freeze_trainable_layers = 8(要训练的层数)
计算过程:
stride = 32 // 8 = 4
trainable_layer_ids = range(4-1, 32+4-1, 4)
= range(3, 35, 4)
= [3, 7, 11, 15, 19, 23, 27, 31]
视觉化:
原始32层模型:
[0][1][2][3][4][5][6][7][8][9][10][11]...[29][30][31]
✓ ✓ ✓ ✓
(训练) (训练) (训练) (训练)
每隔 stride=4 层选择一层进行训练
为什么是 stride - 1 开始?这是一个巧妙的索引设计:
# 如果从 0 开始:range(0, 32, 4) = [0, 4, 8, 12, 16, 20, 24, 28]
# 如果从 stride-1 开始:range(3, 35, 4) = [3, 7, 11, 15, 19, 23, 27, 31]
对比两种方案:
| 起始位置 | 选择的层 | 特点 |
|---|---|---|
| 从 0 开始 | [0, 4, 8, ..., 28] | 包含第一层,不包含最后一层 |
从 stride-1 开始 | [3, 7, 11, ..., 31] | 包含最后一层,更均匀分布 |
为什么选择后者?
- 最后一层通常最重要(靠近输出头)
- 均匀分布在整个模型中
- 避免训练 embedding 层(第 0 层)
针对lora,只对特定层添加适配器:
def find_expanded_modules(model: "PreTrainedModel", target_modules: list[str], num_layer_trainable: int) -> list[str]:
r"""Find the modules in the expanded blocks to apply lora."""
num_layers = getattr(model.config, "num_hidden_layers", None)
if not num_layers:
raise ValueError("Model was not supported.")
if num_layers % num_layer_trainable != 0:
raise ValueError(
f"`num_layers` {num_layers} should be divisible by `num_layer_trainable` {num_layer_trainable}."
)
stride = num_layers // num_layer_trainable
trainable_layer_ids = range(stride - 1, num_layers + stride - 1, stride)
trainable_layers = [f".{idx:d}." for idx in trainable_layer_ids]
module_names = []
for name, _ in model.named_modules():
if any(target_module in name for target_module in target_modules) and any(
trainable_layer in name for trainable_layer in trainable_layers
):
module_names.append(name)
logger.info_rank0("Apply lora to layers: {}.".format(",".join(map(str, trainable_layer_ids))))
return module_names
if finetuning_args.use_llama_pro:
target_modules = find_expanded_modules(model, target_modules, finetuning_args.freeze_trainable_layers)
5. 综合实验
llava数据集-LoRA
| 其他参数配置 | 训练时间 | 训练显存 | 保存模型大小 | 推理显存 | ROUGE-L | BLEU-4 |
|---|---|---|---|---|---|---|
| 默认(epoch=6,lr=1e-4) | 0:10:29 | 9912MiB | 58M | - | 24.30 | 11.22 |
| train_on_prompt=true(学习提示词) | 0:10:40 | 9912MiB | 58M | - | 23.37 | 9.87 |
| mask_history=true(不学习历史对话) | 0:11:07 | 9912MiB | 58M | - | 23.95 | 11.00 |
| resize_vocab=true(更改词表大小) | 0:34:09 | 23382MiB | 2.4GB | - | 23.92 | 10.84 |
| use_llama_pro=true(仅训练块扩展后的参数) | 0:05:54 | 12144MiB | 58M | - | 22.71 | 8.74 |

sign数据集-LoRA
| 其他参数配置 | 训练时间 | 训练显存 | 保存模型大小 | 推理显存 | ROUGE-L | BLEU-4 |
|---|---|---|---|---|---|---|
| 默认(epoch=15,lora_rank=4) | 0:25:01 | 17408MiB | 29M | 8453MiB | 94.45 | 90.43 |
| train_on_prompt=true(学习提示词) | 0:23:58 | 17408MiB | 29M | 8453MiB | 80.76 | 68.67 |
| mask_history=true(不学习历史对话) | 0:23:40 | 17408MiB | 29M | 8453MiB | 94.45 | 90.35 |
| resize_vocab=true(更改词表大小) | 0:24:39 | 26854MiB | 2403MB | 11129MiB | 93.11 | 87.70 |
| use_llama_pro=true(仅训练块扩展后的参数) | OOM | - | - | - | - | - |

总结——如何根据任务快速选择超参数
-
先看资源与目标:
- 显存充足、追求最稳定效果 →
bf16/fp32、较小 lr、适度 epoch。 - 显存受限或需要部署优化 → 优先
fp16/bf16+ 量化(推理)或 QLoRA(训练)+ 梯度累积。 - 选择微调方法(影响优先级):
- LoRA:首选(当目标是快速迭代、显存受限、保留基础模型时)。对 dtype、量化不敏感。
- Full:若数据量大且想全面微调模型能力,且显存/时间足够。对 lr、dtype 非常敏感。
- Freeze/OFT:适合中小样本或需要冻结大部分模型只训练少量层的场景。
- 显存充足、追求最稳定效果 →
-
按任务规模分层决策:
- 大样本通用任务(LLaVA 类):少量 epoch(3–6),中等 lr(1e-5–1e-4,视方法而定),cutoff_len ≥ 4096(若有长上下文),packing 等视实现与硬件再测。
- 小样本/专业任务(Sign 类):多轮 epoch(10–20),较小或稳健 lr(1e-6–1e-4,LoRA 可偏高),可尝试量化带来的正则化效应,packing 有时能提升样本利用。
-
调参优先级(从高到低):
-
dtype / 量化 / batch(资源约束决定其余)
2.学习率(对收敛影响最大)
3.训练轮数(与任务样本量直接相关)
4.梯度裁剪&累积步数(稳定性与有效 batch)
5.cutoff_len、packing、image_max_pixels等(影响输入信息量与计算成本) -
具体规则与检查点:
- 每次改大有效 batch(或累积)同时按线性缩放或重调 lr。
- 若训练震荡 → 降 lr 或收紧 grad clip。
- 若收敛过慢且验证损失无下降 → 增加 epoch 或略增 lr(小步)。
- 量化/packing/更改 cutoff_len 等会改变训练-推理一致性,改动后务必在验证集上做 end-to-end 验证。









