1. 问题背景:为什么我放弃了全参微调

上个月接了个活,要给公司内部的知识库做一个代码助手,需要让模型理解我们特定的框架和API。一开始我天真地想着全参微调Qwen2.5-7B,结果刚把训练脚本跑起来,一看显存监控——23.5G/24G,直接顶着上限跑,batch size只能设为1,一个epoch要跑11个小时。

更离谱的是,训练到第3个epoch,loss开始震荡,然后直接OOM了。检查发现是某个batch的序列长度超过了最大长度,导致激活值暴涨。

迫不得已,我转向了LoRA(Low-Rank Adaptation)和它的升级版QLoRA。这篇文章就是记录整个实践过程,包括数据怎么准备、参数怎么配、遇到了什么坑、最终效果如何。

2. 环境与版本

先贴一下我的实验环境,版本号很重要,因为PEFT和transformers的API变动比较快:

# 硬件
GPU: NVIDIA RTX 3090 24GB
CPU: AMD Ryzen 9 5900X
内存: 64GB DDR4

# 软件
Python: 3.10.12
PyTorch: 2.1.2+cu118
transformers: 4.38.2
peft: 0.8.2
bitsandbytes: 0.42.0
datasets: 2.16.1
accelerate: 0.26.1
trl: 0.7.11

特别提醒:bitsandbytes的版本和CUDA版本必须严格匹配,我之前用0.41.0版本在CUDA 11.8上会报CUDA Setup failed错误,升级到0.42.0才解决。

3. 方案设计:LoRA vs QLoRA怎么选

LoRA的核心思想是冻结预训练权重,只训练注入的low-rank矩阵。对于7B模型,LoRA通常只训练0.1%-0.5%的参数。QLoRA更进一步,先把模型量化到4bit(NF4格式),然后再注入LoRA适配器。

我做了一组对比测试:

方案 显存占用 训练速度 (steps/s) 可训练参数
全参微调 23.5GB 0.42 7.6B (100%)
LoRA (fp16) 14.8GB 0.68 4.2M (0.055%)
QLoRA (4bit) 6.1GB 0.55 4.2M (0.055%)

最终选了QLoRA,原因很简单:显存占用降了快4倍,训练速度只慢了20%左右(主要是4bit反量化的开销)。而且QLoRA论文里提到,4bit量化+NF4+Double Quantization的组合,效果几乎无损。

LoRA的目标模块我选的是q_proj, k_proj, v_proj, o_proj,也就是attention层的所有线性层。有人会只选q_projv_proj,但实验下来加k_projo_proj能提升1.5%左右的BLEU分数,代价是显存增加0.3G,值得的。

4. 核心实现:数据准备与训练配置

4.1 数据准备

我的任务是对代码生成模型进行指令微调,数据格式采用Alpaca风格:

{
  "instruction": "使用MyFrame框架实现一个Redis缓存工具类",
  "input": "要求包含连接池配置和异常处理",
  "output": "```java\npublic class RedisCacheUtil {\n    // ... 完整的工具类代码\n}\n```"
}

数据清洗的几个关键点:
- 去重:用MinHash去重后从50k条降到38k条
- 过滤:删除output部分长度小于50的样本(太短没有训练价值)
- 长度控制:样本tokenize后长度超过2048的直接丢弃,避免padding浪费

加载和预处理代码:

from datasets import load_dataset
from transformers import AutoTokenizer

dataset = load_dataset("json", data_files="train.jsonl", split="train")

tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct")
tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = "right"  # 关键:padding_side要设为right

def preprocess_function(examples):
    """构造指令微调的训练样本"""
    texts = []
    for inst, inp, out in zip(examples["instruction"], examples["input"], examples["output"]):
        if inp and len(inp.strip()) > 0:
            prompt = f"user\n{inst}\n{inp}\nassistant\n"
        else:
            prompt = f"user\n{inst}\nassistant\n"
        texts.append(prompt + out + "")

    tokenized = tokenizer(texts, truncation=True, max_length=2048, padding=False)
    # labels和input_ids相同,但把prompt部分设为-100(忽略loss)
    tokenized["labels"] = tokenized["input_ids"].copy()
    return tokenized

tokenized_dataset = dataset.map(preprocess_function, batched=True, remove_columns=dataset.column_names)

这里有个坑:Qwen2.5的chat模板用的是,如果直接用apply_chat_template会多一层system prompt,反而影响效果。我手动拼了模板。

4.2 训练配置

使用PEFT库配置LoRA,配合transformers的Trainer:

from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from transformers import AutoModelForCausalLM, BitsAndBytesConfig, TrainingArguments, Trainer
import torch

# 4bit量化配置
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_use_double_quant=True,
    bnb_4bit_compute_dtype=torch.float16
)

# 加载量化模型
model = AutoModelForCausalLM.from_pretrained(
    "Qwen/Qwen2.5-7B-Instruct",
    quantization_config=bnb_config,
    device_map="auto",
    trust_remote_code=True
)

# 准备k-bit训练(冻结量化层,转为fp16)
model = prepare_model_for_kbit_training(model, use_gradient_checkpointing=True)

# LoRA配置
lora_config = LoraConfig(
    r=16,                # LoRA秩
    lora_alpha=32,       # 缩放因子,通常为r的2倍
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM"
)

model = get_peft_model(model, lora_config)
model.print_trainable_parameters()  # 输出: trainable params: 4,194,304 || all params: 7,657,431,040 || trainable%: 0.0548%

training_args = TrainingArguments(
    output_dir="./qwen-lora-checkpoints",
    num_train_epochs=3,
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,  # 实际batch = 4 * 4 = 16
    learning_rate=2e-4,
    lr_scheduler_type="cosine",
    warmup_ratio=0.03,
    logging_steps=10,
    save_steps=500,
    eval_strategy="steps",
    eval_steps=200,
    fp16=True,             # 混合精度训练
    gradient_checkpointing=True,
    optim="paged_adamw_8bit",  # 8bit优化器省显存
    report_to="tensorboard",
    remove_unused_columns=False,
)

trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=tokenized_dataset,
    eval_dataset=eval_dataset,
    tokenizer=tokenizer,
)

trainer.train()

这段代码跑下来,显存峰值6.1GB,每秒约0.55步(每步16个样本)。一个epoch大概需要38k/16/0.55 = 4318秒 ≈ 1.2小时,3个epoch共3.6小时,比全参微调的33小时快了将近10倍。

5. 踩坑与优化

5.1 坑1:loss不下降,一直在3.5左右徘徊

训练到第200步,loss卡在3.5左右纹丝不动,这明显不对劲。排查后发现是prepare_model_for_kbit_training之后没有设置model.config.use_cache = False。在gradient checkpointing开启时,use_cache=True会导致缓存冲突,loss计算混乱。

model.config.use_cache = False  # 训练时必须关掉

5.2 坑2:eval loss和train loss差距巨大

训练2个epoch后,train loss降到1.1,但eval loss还是2.8。检查发现是数据预处理时,length大于2048的样本被丢弃后,eval数据里有些超长样本被truncate了,导致输入不完整。解决方法是把数据集的max_length从2048提到2560,同时把batch size从4降到3。

5.3 优化:学习率调度

一开始用linear调度,loss尾部波动很大。换成cosine + warmup_ratio=0.03之后,收敛曲线平滑了很多。最终best checkpoint是在epoch 2.4(step 3420)时得到的,后续的epoch反而有轻微过拟合。

6. 效果数据:Loss曲线与推理对比

6.1 Loss曲线

训练过程的loss曲线呈典型的"快速下降-平台期-缓慢收敛"形态:

  • Step 0-200:loss从3.8下降到2.1(快速学习阶段)
  • Step 200-1000:2.1 → 1.4(平台期)
  • Step 1000-3420:1.4 → 1.05(缓慢收敛)
  • Step 3420之后:loss在1.05-1.15之间震荡(轻微过拟合)

eval loss在step 3400达到最低1.18,之后反弹到1.35,所以最终选择了step 3420的checkpoint。

6.2 推理效果对比

我拿微调前后的模型在100条代码生成测试集上做了对比:

指标 基座模型 (Qwen2.5-7B-Instruct) QLoRA微调后
BLEU-4 28.6 31.8 (+3.2)
代码通过率 (单元测试) 42% 67% (+25%)
领域API使用正确率 68.4% 91.7% (+23.3%)
生成平均长度 342 tokens 358 tokens
响应延迟 (单卡推理) 89ms 91ms

看具体例子,基座模型调我们的内部框架API时,经常把方法名搞错,比如getRedisConn写成getRedisConnection。微调后这类错误基本消失了。

还有一个有意思的点:基座模型在生成代码时经常忘记加@Override注解,微调后这个问题的出现率从34%降到7%。

6.3 推理代码

微调后的模型保存的是LoRA适配器,推理时需要合并或加载适配器:

from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer, GenerationConfig
import torch

base_model = AutoModelForCausalLM.from_pretrained(
    "Qwen/Qwen2.5-7B-Instruct",
    torch_dtype=torch.float16,
    device_map="auto"
)
model = PeftModel.from_pretrained(base_model, "./qwen-lora-checkpoints/checkpoint-3420")
model = model.merge_and_unload()  # 合并LoRA权重,加速推理

tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct")

prompt = "user\n用MyFrame实现定时任务调度\nassistant\n"
inputs = tokenizer(prompt, return_tensors="pt").to("cuda")

with torch.no_grad():
    outputs = model.generate(
        **inputs,
        max_new_tokens=512,
        temperature=0.3,
        top_p=0.9,
        do_sample=True
    )

print(tokenizer.decode(outputs[0], skip_special_tokens=True))

合并后的模型大小约14GB(fp16),在3090上单次推理延迟91ms,和基座模型几乎没有差异。

7. 总结与建议

这次QLoRA微调实践,最终以6.1GB显存、3.6小时训练时间,换来了代码通过率25个百分点的提升。经验总结如下:

  1. 首选QLoRA而非LoRA:在7B这个量级,QLoRA的显存优势实在太明显。如果你有A100这样的80GB卡,LoRA也够用,但QLoRA不会带来明显的效果损失。

  2. LoRA秩r的选择:我试过r=8/16/32,r=16效果最好。r=8欠拟合,r=32过拟合且训练时间增加60%。

  3. 数据质量远重要于数据量:38k条高质量数据的效果,比之前用100k条爬虫数据提升了一倍。

  4. 别迷信"多训几个epoch":我实验里epoch 2.4的checkpoint明显优于epoch 3,建议用early stopping。

  5. 注意Qwen2.5的chat模板:手动拼模板比用apply_chat_template更可控,特别是要做多轮对话时。

最后吐槽一句:transformers库更新太快,每次升级都可能有breaking change。建议锁定版本,别追新。如果你也遇到CUDA extension不兼容的问题,先查版本矩阵,别急着重装驱动。

如果你们也在做类似的LLM微调,欢迎交流。下一篇打算写写RAG和微调怎么结合,效果比单用任何一种都好不少。