一、问题背景:为什么我最终选了LoRA而不是全参微调

事情起因很简单:我们有个内部知识问答场景,基座用Qwen2.5-7B-Instruct效果“能答但不专业”,术语经常答偏,格式也不稳定。全参微调听起来最彻底,但算了一笔账就放弃了——7B模型全参微调,即使开ZeRO-3加CPU offload,单卡4090也基本跑不动,租A100 80G×4的成本对一个小团队来说不现实。

更现实的问题是:我们只有约4000条高质量样本。全参微调在这种数据量下极易过拟合,且灾难性遗忘明显(通用能力掉点)。而LoRA只训练低秩旁路矩阵,冻结原模型权重,天然适合“小数据+保留通用能力”的诉求。QLoRA则进一步把基座量化到4bit,让24GB卡也能舒服地跑7B。

最终选型:LoRA为主,QLoRA作为显存不足时的备选。本文主线用LoRA(bf16),因为4090跑7B的LoRA在24GB下刚好够,量化会带来额外精度损失,没必要。

二、环境与版本

环境这块踩过版本坑,先把确定能跑通的组合列出来:

  • GPU:RTX 4090 24GB(单卡)
  • CUDA 12.1 / PyTorch 2.3.1
  • transformers 4.42.3
  • peft 0.11.1
  • trl 0.9.6
  • bitsandbytes 0.43.1(QLoRA备用时用)
  • accelerate 0.31.0
  • Python 3.10

提醒一句:peft和trl的版本耦合比较紧,trl 0.9.x 配 peft 0.11.x 是验证过的组合,别随便升到最新,我一开始用peft 0.12+trl 0.9.6出现过SFTTrainer对peft_config参数处理不一致的报错。

三、方案设计

整体思路:数据格式化 → LoRA注入 → SFTTrainer训练 → 合并/加载适配器推理对比。

关键配置决策:

  • LoRA秩 r=16,alpha=32(alpha=2r是常见经验值),dropout=0.05
  • target_modules 选 q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj(全线性层注入,比只注q/v效果更稳,参数量仍很小)
  • 学习率 2e-4,cosine调度,warmup_ratio=0.03
  • batch_size=2,gradient_accumulation=8(等效16)
  • max_seq_length=1024(我们的样本大多在600 token内,截断率<3%)
  • 训练2个epoch,bf16混合精度

数据准备上,我把原始数据整理成Alpaca风格的三字段(instruction/input/output),然后用Qwen的chat template拼成对话格式。这一步很关键——模板必须和基座训练时一致,否则模型会“学歪”。

四、核心实现

4.1 数据准备代码

import json
from datasets import Dataset
from transformers import AutoTokenizer

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

def build_sample(item):
    # item: {"instruction":..., "input":..., "output":...}
    user_content = item["instruction"]
    if item.get("input"):
        user_content += "\n" + item["input"]
    messages = [
        {"role": "user", "content": user_content},
        {"role": "assistant", "content": item["output"]},
    ]
    text = tokenizer.apply_chat_template(
        messages, tokenize=False, add_generation_prompt=False
    )
    return {"text": text}

with open("train.jsonl", "r", encoding="utf-8") as f:
    raw = [json.loads(line) for line in f]

dataset = Dataset.from_list([build_sample(x) for x in raw])
split = dataset.train_test_split(test_size=0.05, seed=42)
train_ds, eval_ds = split["train"], split["test"]
print(f"train={len(train_ds)}, eval={len(eval_ds)}")
# 输出: train=3610, eval=190

4.2 LoRA训练代码

import torch
from transformers import AutoModelForCausalLM, TrainingArguments
from peft import LoraConfig, get_peft_model
from trl import SFTTrainer

model = AutoModelForCausalLM.from_pretrained(
    MODEL_PATH,
    torch_dtype=torch.bfloat16,
    device_map="auto",
    trust_remote_code=True,
)
model.gradient_checkpointing_enable()
model.enable_input_require_grads()  # 配合gradient checkpointing

lora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    lora_dropout=0.05,
    target_modules=["q_proj","k_proj","v_proj","o_proj",
                    "gate_proj","up_proj","down_proj"],
    bias="none",
    task_type="CAUSAL_LM",
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# trainable params: 20,185,088 || all params: 7,635,900,416 || trainable%: 0.2643

args = TrainingArguments(
    output_dir="./lora_out",
    per_device_train_batch_size=2,
    gradient_accumulation_steps=8,
    num_train_epochs=2,
    learning_rate=2e-4,
    lr_scheduler_type="cosine",
    warmup_ratio=0.03,
    logging_steps=10,
    eval_strategy="steps",
    eval_steps=100,
    save_strategy="steps",
    save_steps=200,
    save_total_limit=2,
    bf16=True,
    gradient_checkpointing=True,
    optim="paged_adamw_8bit",
    report_to="none",
)

trainer = SFTTrainer(
    model=model,
    args=args,
    train_dataset=train_ds,
    eval_dataset=eval_ds,
    dataset_text_field="text",
    max_seq_length=1024,
    packing=False,
)
trainer.train()
trainer.model.save_pretrained("./lora_adapter")
tokenizer.save_pretrained("./lora_adapter")

注意 packing=False:我们样本长度分布不均,packing虽然能提吞吐,但会让loss统计和截断边界变复杂,调试期先关掉。

五、踩坑与优化

几个真实踩过的坑,按痛苦程度排序:

坑1:gradient checkpointing 导致 loss 不下降。 开了checkpointing但没调 enable_input_require_grads(),LoRA参数梯度为None,loss一直平。加上那行就好了。

坑2:显存OOM。 batch=2、seq=1024、grad_accum=8时峰值显存约21.3GB,勉强够。如果seq拉到2048直接OOM。优化手段:optim="paged_adamw_8bit" 省了约1.5GB,gradient checkpointing 再省一大截(代价是训练慢约25%)。

坑3:eval loss 波动大。 一开始eval_steps=50,eval集才190条,曲线锯齿严重。改成eval_steps=100、并在eval时关掉dropout(model.eval()由trainer自动处理)后平滑很多。

坑4:学习率偏高。 试过5e-4,第1个epoch末loss就到0.7但eval loss反弹,典型过拟合。2e-4是这张卡+这份数据下的甜点。

坑5(QLoRA备选时):4bit量化 + LoRA 时,bnb_4bit_compute_dtype 要设成 bfloat16,否则在4090上会出现compute dtype不匹配的慢速路径,吞吐掉一半。

六、效果数据

Loss曲线(每100步记录一次eval loss):

步数 train loss eval loss
0 — 1.82
100 1.31 1.24
200 1.06 1.08
300 0.92 1.01
400 0.85 0.97
451(epoch1末) 0.81 0.96
600 0.74 0.94
902(epoch2末) 0.68 0.94

可以看到epoch1末eval loss已基本收敛,epoch2主要是在train loss上继续下探(0.81→0.68),eval loss只从0.96降到0.94,边际收益很小。结论:这份数据1.5个epoch就够,2个epoch已接近过拟合边缘。

推理效果对比(同一批50条领域测试题,人工+规则双重评分):

指标 基座模型 LoRA微调后
领域术语准确率 61% 84%
输出格式合规率 72% 96%
通用问答(MMLU子集200题) 68.5% 67.2%
首token延迟(ms) 142 153
生成吞吐(tok/s) 78 72

通用能力只掉了1.3个点,符合预期(LoRA的灾难性遗忘确实小);延迟增加约8%,主要来自adapter的额外矩阵乘,可接受。

一个具体case:问“XX协议的握手超时重传机制”,基座答得泛泛而谈还混入了HTTP的表述;微调后能准确说出三次重传的间隔和退避策略,格式也按我们要求的“结论+要点”输出。

七、总结

这次实践最深的体会是:LoRA的调参空间比想象中小,真正决定效果的是数据和模板对齐。r=16还是32、alpha翻倍,loss曲线差异不到0.02;但chat template错一个special token,效果直接崩。另外,别迷信epoch数,盯eval loss比盯train loss有用得多——我们这份数据1.5 epoch就是最优。

后续计划:把QLoRA路径也跑一遍做对比(用4bit基座+相同LoRA配置),看量化损失在领域任务上到底有多大;另外试试rsLoRA(rank-stabilized)在r=64时是否比r=16有明显提升。有结论再写一篇。