一、问题背景:为什么放弃全参数微调

上一周,我接到一个需求:对内部工单系统做意图分类(12个类别),数据量约3.2万条。老板说“用开源大模型微调一下就行”,但我心里清楚——单卡A100 40GB的机器只有一台,还在被隔壁推荐组占用。全参数微调7B模型,即便用AdamW + ZeRO Stage 2,也需要至少60GB显存(实测ChatGLM2-6B在seq_len=512下,全参微调batch_size=8即OOM)。

于是转向PEFT(Parameter-Efficient Fine-Tuning)。这里不讨论Adapter和Prefix-Tuning,只说LoRA(Low-Rank Adaptation)和它的进阶版QLoRA。我的核心决策路径:

  • LoRA:冻结原模型,只训练低秩矩阵A和B(维度r=8/16/32),可训练参数占比约0.1%-0.5%。
  • QLoRA:在LoRA基础上,将基础模型量化为4-bit NF4格式,进一步降低显存和显存带宽占用。

我的目标很明确:在单张RTX 3090(24GB)上完成微调,且推理时不能有质量崩塌。

二、环境与版本(血的教训:版本必须锁死)

以下是我最终稳定运行的组合,任何一个版本不匹配都会引发诡异错误(比如bitsandbytes在CUDA 12.1下无法加载4-bit模型):

# 核心依赖版本
torch==2.1.0+cu118
transformers==4.36.2
peft==0.7.1
bitsandbytes==0.41.3
datasets==2.16.1
accelerate==0.26.1

注意bitsandbytes 0.41.3对应CUDA 11.8,如果使用CUDA 12.x,需要源码编译或换用0.43.0+版本。另外,transformers 4.36.2 支持 BitsAndBytesConfignf4 数据类型,低于4.30的版本不支持。

三、方案设计:LoRA rank和target_modules的选择

3.1 模型选择

THUDM/chatglm2-6b为例(虽然它是6B,但实际参数量约6.2B,且内部有32个Transformer层)。为什么不用Llama-2-7B?因为中文业务数据量不大,且ChatGLM2的tokenizer对中文更友好(词表大小65024 vs 32000)。

3.2 LoRA配置

from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training

# 关键参数说明
lora_config = LoraConfig(
    r=16,                 # 低秩矩阵维度,我最终选16(8欠拟合,32过拟合且显存+1.2G)
    lora_alpha=32,        # 缩放因子,一般设为r/2或2*r,这里取2*r
    target_modules=["query_key_value"],  # ChatGLM2的注意力层是合并的QKV
    lora_dropout=0.05,    # 防止过拟合,0.1也行
    bias="none",          # 不训练bias
    task_type="CAUSAL_LM",
)

关于target_modules的坑:ChatGLM2的注意力层实现中,QKV是合并成一个query_key_value线性层的,而不是分开的q_proj, k_proj, v_proj(那是Llama的结构)。如果你用Llama-2系列,需要改为["q_proj", "k_proj", "v_proj", "o_proj"]

3.3 QLoRA量化配置

from transformers import BitsAndBytesConfig

bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,                # 4-bit量化
    bnb_4bit_quant_type="nf4",        # NormalFloat,比fp4精度更高
    bnb_4bit_use_double_quant=True,   # 双量化:量化常数的二次量化
    bnb_4bit_compute_dtype=torch.bfloat16,  # 计算时反量化为bf16
)

四、核心实现:数据准备与训练循环

4.1 数据准备(重点:指令格式对齐)

我最初的错误:直接使用text: label的简单拼接,导致loss下降慢且最终F1只有0.62。后来参考ChatGLM2官方微调格式,改为:

def build_prompt(sample):
    # 业务工单样本:sample["content"]是工单描述,sample["category"]是意图类别
    prompt = f"""对以下工单内容进行意图分类,类别:报销咨询、账号问题、故障报修、投诉建议、其他。

工单内容:{sample['content']}

分类结果:{sample['category']}"""
    return prompt

# 使用tokenizer处理,注意padding和truncation
def tokenize_function(examples):
    texts = [build_prompt({"content": c, "category": l}) for c, l in zip(examples["content"], examples["label"])]
    tokenized = tokenizer(
        texts,
        padding="max_length",
        truncation=True,
        max_length=512,
        return_tensors="pt"
    )
    # 关键:把label设置成input_ids的拷贝,且将prompt部分的loss屏蔽
    labels = tokenized["input_ids"].clone()
    # 这里简单起见,不屏蔽prompt部分(实际业务中建议屏蔽,否则模型会学习生成prompt)
    tokenized["labels"] = labels
    return tokenized

我踩的坑:如果不将labels设为input_ids的拷贝,训练时loss会为NaN(因为HuggingFace默认计算交叉熵时,label=-100的地方被忽略,而你没设置label,它会用input_ids当label,但此时padding部分也是-100?实际上不设置会报错)。

4.2 训练配置(重点:gradient_checkpointing和batch_size的平衡)

from transformers import TrainingArguments, Trainer

training_args = TrainingArguments(
    output_dir="./chatglm2-lora-16",
    num_train_epochs=3,
    per_device_train_batch_size=8,      # 24G显存下,QLoRA+gradient_ckpt可以到16
    gradient_accumulation_steps=2,      # 实际batch_size = 8*2 = 16
    gradient_checkpointing=True,        # 必须开启,省约40%显存
    optim="paged_adamw_8bit",           # 8-bit优化器,省显存且稳定
    logging_steps=50,
    save_steps=500,
    learning_rate=2e-4,                 # LoRA一般用1e-4~5e-4,比全参大10倍
    lr_scheduler_type="cosine",
    warmup_ratio=0.03,
    bf16=True,                          # 如果显卡不支持bf16,用fp16
    max_grad_norm=1.0,
    report_to="tensorboard",
)

关键数字:以下是同等设置下的显存实测(batch_size=8, seq_len=512, 梯度检查点开启):

方案 显存占用 训练速度 (samples/s)
全参数微调 (fp16) 23.8GB (OOM) 无法训练
LoRA (fp16) 18.5GB 9.2
QLoRA (4-bit) 6.2GB 7.6

QLoRA速度下降约18%,但显存节省66%。如果你用batch_size=16+梯度累积2,QLoRA可以压到5.5GB左右,甚至能在消费级显卡(2080Ti 11GB)上跑。

4.3 训练启动代码

from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import prepare_model_for_kbit_training
from datasets import load_dataset

# 加载4-bit模型
model = AutoModelForCausalLM.from_pretrained(
    "THUDM/chatglm2-6b",
    quantization_config=bnb_config,
    device_map="auto",          # 自动分配层到GPU
    trust_remote_code=True,     # ChatGLM2需要
)

# 冻结+准备kbit训练
model = prepare_model_for_kbit_training(model)
model.gradient_checkpointing_enable()

# 注入LoRA
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()  # 输出:trainable params: 8,388,608 || all params: 6,245,275,648 || trainable%: 0.1343

# 数据集加载与训练
dataset = load_dataset("json", data_files="train.jsonl")
train_dataset = dataset["train"].map(tokenize_function, batched=True)
trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=train_dataset,
)
trainer.train()

五、踩坑与优化:两次OOM和一次loss不降

5.1 第一次OOM:pad_token未设置

我用padding="max_length"时,tokenizer没有pad_token,会默认用eos_token(ChatGLM2的eos是`),但这样导致attention_mask无法正确区分pad和真实内容。**报错信息**:RuntimeError: CUDA out of memory`(其实不是显存不足,而是padding导致序列长度计算错误,但报错很迷惑)。

解决:显式设置tokenizer.pad_token = tokenizer.eos_token,并在数据集中过滤掉长度超过512的样本。

5.2 第二次OOM:gradient_checkpointingprepare_model_for_kbit_training的兼容问题

开启gradient_checkpointing后,显存从18GB降到11GB,但训练几个step后突然OOM。排查发现是prepare_model_for_kbit_training会调用model.enable_input_require_grads(),而gradient_checkpointing_enable()需要在其之后调用。正确顺序:

model = prepare_model_for_kbit_training(model)
model.gradient_checkpointing_enable()  # 必须在prepare之后

5.3 loss不降的原因:学习率过大

初始learning_rate=5e-4,loss在0.1附近震荡不下降。查看tensorboard发现梯度范数在0.5-1之间波动,但loss下不去。后来将lr=2e-4并配合warmup_ratio=0.03,loss在前200步从2.3降到0.8。经验:LoRA的lr过大容易导致低秩矩阵震荡,过小(<1e-5)则收敛极慢,建议在1e-4~3e-4之间做网格搜索。

六、效果数据:rank=8与rank=32的对比

在验证集(4000条)上的结果:

配置 可训练参数 显存 F1 (宏平均) 推理延迟 (ms/条)
LoRA r=8 419万 5.8GB 0.71 45ms
LoRA r=16 839万 6.2GB 0.83 47ms
LoRA r=32 1678万 7.1GB 0.80 50ms
全参微调 62亿 OOM - -

推理效果对比(同一测试样本):

原模型(未微调):
工单内容:我的报销单被驳回两次了,系统提示发票模糊,但我重新传了三次还是一样。
分类结果:其他(错误)

LoRA r=16微调后:
工单内容:我的报销单被驳回两次了,系统提示发票模糊,但我重新传了三次还是一样。
分类结果:报销咨询(正确)

loss曲线(tensorboard截图描述):从初始2.34快速下降,500步后到0.6,之后平缓下降至0.31(3个epoch)。注意:我观察到loss在epoch 2结束时出现轻微上升(过拟合迹象),如果业务数据更少,建议加early stopping或增大dropout到0.1。

七、总结与建议

  1. QLoRA是消费级显卡微调7B模型的可行解,显存占用约6GB,3090/4090甚至2080Ti都能跑。
  2. LoRA rank的选择不是越大越好,我的业务中r=16最优,r=32反而过拟合到0.80。建议从r=8开始,逐步加。
  3. 数据格式比参数更重要,指令格式不匹配会导致模型压根不学习“分类”这个任务。
  4. 版本锁死bitsandbytes + transformers的组合极易因CUDA版本不匹配报错,建议用Docker或venv复现。
  5. 最后,推理时务必合并LoRA权重model = model.merge_and_unload()),否则部署时还需要加载Adapter,且推理速度会下降约10%。

如果你在微调中遇到“loss下降但F1不高”的情况,优先检查label是否设置正确(比如prompt部分是否被错误计入loss)。祝各位微调顺利,少踩OOM的坑。


附录:完整可用的训练脚本(伪代码,已脱敏)已放在我的GitHub仓库中,需要的同学可以评论区留言。如果对你有帮助,请点个赞让更多被OOM困扰的兄弟看到。