AI参数高效微调折腾手记

前一阵子在做一个垂域知识库问答项目时,遇到个现实问题:基础模型用的是 7B 参数量级,单张 24G 显卡勉强能装下,但要微调时就彻底不行了。

全量微调的问题有几个方面:

  1. 显存占用高:除了模型权重,优化器状态(Adam 的两个动量)和梯度都要占一份。

背景与限制

先说清楚当时的限制条件,避免后面有人问"为什么不用 4×A100":

  • 单机单卡环境,GPU 只有 24G 显存
  • 目标是让模型掌握行业术语和写作风格,不是从零训练
  • 数据量大概 5K 条高质量问答对,不算多也不算少
  • 训练时间有限,不能等上一周再看到结果

这些条件其实挺典型的,很多中小企业或个人开发者都是这个级别。全量微调在资源足够时确实效果最好,但现实就是这样。

为什么需要 PEFT

全量微调的问题有几个方面:

  1. 显存占用高:除了模型权重,优化器状态(Adam 的两个动量)和梯度都要占一份。对于 7B 模型,仅模型权重就占 14G(fp16),加上优化器状态和梯度,总共可能要 50G+ 显存

  2. 训练慢:所有参数都要参与前向和反向传播,计算量大

  3. 存储开销大:每个微调任务都要保存一份完整模型权重

PEFT 的思路很简单:大部分参数保持冻结,只训练一小部分参数或引入少量可训练适配器。这样既能保持模型能力,又能大幅降低资源需求。

不同微调方法的显存占用对比:全量微调需要 50GB,而 PEFT 方法仅需 16-20GB,24GB 单卡即可应对

这张图直接说明了问题:全量微调的显存需求是 PEFT 的 2-3 倍,这也是为什么在资源有限时 PEFT 几乎是唯一可行方案。

主要 PEFT 方法对比

当时看了几种主流方法,各有特点。

LoRA(Low-Rank Adaptation)

LoRA 的想法是:在权重矩阵的更新量上做低秩分解。假设原始权重是 $W$,训练时只更新 $\Delta W$,LoRA 把 $\Delta W$ 分解成两个小矩阵 $A$ 和 $B$ 的乘积:$\Delta W = BA$。

具体实现时,在前向传播中这样计算:

$$W_{new} = W + BAx$$

其中 $A \in \mathbb{R}^{r \times d}$,$B \in \mathbb{R}^{d \times r}$,$r$ 是秩,通常取 4、8、16 这些小值。

graph LR subgraph 原始权重 W[权重矩阵 W] end subgraph LoRA 分支 A[矩阵 A<br/>r x d] B[矩阵 B<br/>d x r] end x[输入 x] --> W x --> A --> B --> Mul[乘以缩放因子 α/r] W --> Add[相加] Mul --> Add Add --> y[输出] style W fill:#e1f5e1 style A fill:#fff4e1 style B fill:#fff4e1

这个方法的好处是:训练时只优化 $A$ 和 $B$,参数量极少;推理时可以把 $BA$ 融合进 $W$,不增加推理开销。

Prefix Tuning

Prefix Tuning 的思路是在每层注意力机制前面加一段可训练的"前缀向量"。这些前缀向量会影响模型对输入的处理方式,相当于在内部"打标签"。

但这个方法有个问题:前缀向量要占用 KV Cache 的空间,推理时没优势。另外调参比较玄学,前缀长度少了不够用,多了又占资源。

Adapter

Adapter 是在 Transformer 层之间插入小型神经网络模块,通常是两层 MLP,中间有个瓶颈层。原始层权重冻结,只训练这些 Adapter。

Adapter 架构示意:在冻结的 Transformer 层之间插入可训练的 Adapter 模块

设计上挺合理,但问题也很明显:推理时每一层都要多做一次前向传播,延迟增加明显。对实时场景不友好。

其他方法

还有 Prompt Tuning(只优化 prompt 嵌入)、LoRA 变体(LoRA+、DoRA)、AdaLoRA(动态调整秩)等,但核心思想都是"少改一点,多学一点"。

实际选择与实现

综合考虑后,选了 LoRA 作为主要方案。理由简单粗暴:

  • 推理零开销,这点对线上服务很重要
  • 参数量可控,显存压力小
  • 实现成熟,社区支持好
  • 调参相对直观

环境配置

先装必要依赖,这里用 HuggingFace 的 PEFT 库:

pip install peft transformers datasets accelerate bitsandbytes

代码里这样配置 LoRA:

from peft import LoraConfig, get_peft_model
from transformers import AutoModelForCausalLM, BitsAndBytesConfig

# 4bit 量化,进一步降低显存占用
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_compute_dtype=torch.float16,
    bnb_4bit_use_double_quant=True,
)

model = AutoModelForCausalLM.from_pretrained(
    "model-path",
    quantization_config=bnb_config,
    device_map="auto"
)

# LoRA 配置
lora_config = LoraConfig(
    r=16,  # 秩,越大表达能力越强,但参数量也多
    lora_alpha=32,  # 缩放因子,通常设为 2×r
    target_modules=["q_proj", "v_proj"],  # 只微调注意力层
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM"
)

model = get_peft_model(model, lora_config)

这里有个小坑:target_modules 选择很重要。只微调 q_projv_proj 是常见做法,但有些模型架构不同,需要改成 ["q_proj", "k_proj", "v_proj", "o_proj"] 或其他组合。第一次训练时要检查模型实际结构。

训练参数

训练时主要调整这些参数:

training_args = TrainingArguments(
    output_dir="./output",
    num_train_epochs=3,
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,
    learning_rate=2e-4,
    warmup_steps=100,
    logging_steps=10,
    save_steps=500,
    fp16=True,
    optim="paged_adamw_32bit",  # 显存不够时用这个
)

gradient_accumulation_steps=4 意味着每 4 个 batch 才更新一次参数,相当于 batch size 是 16。这是个常用技巧,用时间换空间。

踩坑记录

实际跑起来后,遇到了几个值得记一下的问题。

1. rank 太小效果差

刚开始设了 $r=4$,参数量确实很少,但训练完后模型输出几乎没有变化,还是原始模型的调性。调到 $r=16$ 后才明显感受到风格变化。

这里有个规律可以参考:任务越简单,rank 可以越小;需要大幅改写风格或学习新知识,rank 要适当大。但大到一定程度(比如超过 64)后,收益就不明显了,反而增加训练时间。

LoRA rank 选择:从效果和参数量两个维度看,rank=16-32 通常是性价比最高的区间

2. 学习率过大导致不稳定

LoRA 的参数量少,一开始用了全量微调常用的 1e-4,结果训练到一半 loss 直接爆炸。降到 2e-4 后才稳定,但还是要配合梯度裁剪。

trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=train_dataset,
    data_collator=data_collator,
    max_grad_norm=0.3,  # 梯度裁剪
)

3. 数据质量比数量重要

刚开始贪多,把所有能找到的领域数据都灌进去,结果模型学会了些奇怪的表述。后来人工清洗了一遍,只保留高质量样本,效果反而好了。

这是个老生常谈的问题,但在 PEFT 场景下尤其明显——因为本身可训练参数就少,如果数据质量不行,那点参数很快就学偏了。

4. 推理时忘合并权重

LoRA 训练完后得到的是适配器权重,推理前要合并回原始模型,否则推理时多一次计算,而且可能没适配好。

from peft import PeftModel

# 加载基础模型
base_model = AutoModelForCausalLM.from_pretrained("model-path")

# 加载 LoRA 适配器
model = PeftModel.from_pretrained(base_model, "checkpoint-dir")

# 合并权重
merged_model = model.merge_and_unload()

# 保存合并后的模型
merged_model.save_pretrained("merged-model")

效果对比

最后对比一下几种方法的实际表现(以 7B 模型为例):

方法可训练参数量显存占用训练时间效果
全量微调7B~50G最好
LoRA (r=16)~40M~16G接近全量
Adapter~60M~20G中等接近全量
Prefix Tuning~30M~18G中等较差

这里的效果评估是主观的,主要看领域问答的准确性和风格适配程度。LoRA 在这个任务上基本能达到全量微调 90% 的效果,但资源需求只有 1/3。

写在后面

PEFT 不是银弹,但在资源有限时确实是个实用方案。它解决的核心问题是:在模型能力保持和资源消耗之间找个平衡点。

实践中,选哪个方法要考虑具体场景。如果推理延迟敏感,LoRA 优先;如果训练资源更紧,可以考虑更激进的方法;如果效果要求极高,那就只能上全量微调,或者换更贵的硬件。

最后提醒一点:PEFT 的参数量少了,调参时反而更要小心,稍微偏一点模型就学不到东西。多观察训练曲线,早点发现异常,能省不少时间折腾。

版权声明: 本文首发于 指尖魔法屋-AI参数高效微调折腾手记https://blog.thinkmoon.cn/post/383-ai-peft-full-efficient-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!