一、问题背景:为什么我要微调一个7B模型

先说结论:通用大模型在垂直领域经常“一本正经地胡说八道”,而全量微调7B模型对个人开发者来说显存门槛太高。我手上的任务是做一个企业内部知识问答助手,涉及大量内部术语、产品代号和固定话术。直接拿Qwen2.5-7B-Instruct做few-shot,回答准确率只有54%左右,而且经常把内部术语解释成通用含义。

全量微调7B需要至少8张A100 80G,我只有一张4090 24G。于是LoRA和QLoRA成了唯一现实的选择。这篇文章就是把我从数据准备到推理对比的完整流程写下来,包含具体参数、loss曲线和踩过的坑。

二、环境与版本

环境版本这东西,不写清楚就是耍流氓。我的配置如下:

  • GPU:RTX 4090 24G(单卡)
  • CUDA:12.1
  • PyTorch:2.3.1+cu121
  • transformers:4.44.2
  • peft:0.12.0
  • bitsandbytes:0.43.3
  • trl:0.9.6
  • accelerate:0.33.0
  • Python:3.10.14

安装命令大致如下,bitsandbytes一定要和CUDA版本匹配,否则4-bit量化会报错:

pip install torch==2.3.1 --index-url https://download.pytorch.org/whl/cu121
pip install transformers==4.44.2 peft==0.12.0 trl==0.9.6 accelerate==0.33.0
pip install bitsandbytes==0.43.3 datasets==2.20.0

三、方案设计:QLoRA + LoRA,r=16

核心思路是:基座模型用4-bit NF4量化加载,冻结全部参数,只在注意力层的q_proj、k_proj、v_proj、o_proj和MLP层的gate_proj、up_proj、down_proj上挂LoRA适配器。这样可训练参数只有约4200万,占7B的0.6%左右。

关键参数选择:
- LoRA rank r=16,alpha=32,dropout=0.05
- 目标模块:q/k/v/o/gate/up/down 全部挂上,实测比只挂q/v效果好3-5个点
- 量化:load_in_4bit=True,bnb_4bit_quant_type="nf4",bnb_4bit_compute_dtype=torch.bfloat16
- 学习率:2e-4,cosine调度,warmup_ratio=0.03
- batch_size:per_device=2,gradient_accumulation=8,等效batch=16
- 训练轮数:3 epoch
- 最大长度:1024

为什么用QLoRA而不是纯LoRA?因为纯LoRA加载fp16的7B模型需要约14G显存,加上激活值和优化器状态,24G很紧张。4-bit量化后模型只占约4.5G,训练峰值18.6G,留有余量。

四、核心实现

4.1 数据准备

数据格式我采用Alpaca风格的instruction/input/output,1.2万条,按9:1划分训练和验证。关键点是每条数据都套用Qwen2.5的chat template,而不是自己拼字符串。否则推理时格式对不上,效果会崩。

from datasets import load_dataset
from transformers import AutoTokenizer

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

def format_sample(sample):
    messages = [
        {"role": "system", "content": "你是企业内部知识助手,只根据已知信息回答。"},
        {"role": "user", "content": sample["instruction"] + "\n" + sample.get("input", "")},
        {"role": "assistant", "content": sample["output"]},
    ]
    text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False)
    return {"text": text}

dataset = load_dataset("json", data_files="train.jsonl", split="train")
dataset = dataset.map(format_sample, remove_columns=dataset.column_names)
split = dataset.train_test_split(test_size=0.1, seed=42)

4.2 模型加载与LoRA配置

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

bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True,
)

model = AutoModelForCausalLM.from_pretrained(
    model_path,
    quantization_config=bnb_config,
    device_map="auto",
    trust_remote_code=True,
)
model = prepare_model_for_kbit_training(model)
model.gradient_checkpointing_enable()

lora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
    target_modules=["q_proj","k_proj","v_proj","o_proj",
                    "gate_proj","up_proj","down_proj"],
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# trainable params: 42,467,328 || all params: 7,658,.,,, || trainable%: 0.5546

4.3 训练配置

用trl的SFTTrainer最省事,它自动处理packing和loss计算。

from transformers import TrainingArguments
from trl import SFTTrainer

training_args = TrainingArguments(
    output_dir="./qwen2.5-7b-lora",
    per_device_train_batch_size=2,
    per_device_eval_batch_size=2,
    gradient_accumulation_steps=8,
    num_train_epochs=3,
    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,
    optim="paged_adamw_8bit",
    gradient_checkpointing=True,
    report_to="none",
    max_grad_norm=0.3,
)

trainer = SFTTrainer(
    model=model,
    args=training_args,
    train_dataset=split["train"],
    eval_dataset=split["test"],
    dataset_text_field="text",
    max_seq_length=1024,
    packing=False,
)
trainer.train()
trainer.model.save_pretrained("./qwen2.5-7b-lora-final")

五、踩坑与优化

坑一:loss不下降,一直在1.8附近震荡。原因是学习率设成了1e-5,对LoRA来说太小。改成2e-4后第200步就开始明显下降。

坑二:eval loss比train loss还低。一开始以为数据泄漏,后来发现是dropout在eval时关闭导致,属于正常现象,不用慌。

坑三:4-bit量化后保存adapter,推理时加载报错。必须用PeftModel.from_pretrained加载adapter,并且基座也要用同样的bnb_config加载,否则dtype不匹配。

坑四:packing=True时loss曲线很漂亮但推理效果差。因为packing把多条短样本拼在一起,attention mask处理不当会跨样本污染。改成packing=False后效果正常。

优化点:把max_grad_norm从1.0降到0.3,训练更稳;开启double quant,显存再省0.4G;target_modules全挂比只挂q/v在验证集上高4.2个点。

六、效果数据

训练3个epoch,共约2250步,耗时3小时18分,显存峰值18.6G。loss从初始1.87降到0.62,eval loss从1.79降到0.68,没有过拟合。

推理效果对比,我用200条人工标注的测试集:

指标 基座模型 LoRA微调后
领域问答准确率 54% 89%
术语解释正确率 61% 93%
格式合规率 72% 98%
平均响应长度 187字 142字

实际对话例子:问“X7模块的告警阈值是多少”,基座回答“通常建议设置在80%左右”(错误,通用猜测),微调后回答“X7模块默认告警阈值为CPU 75%、内存85%,可在配置文件alert.yaml中修改”(正确,来自训练数据)。

推理速度方面,4-bit基座+LoRA adapter,单条512 token输出约2.3秒,比fp16全量快约40%,因为量化权重加载更快。

七、总结

LoRA/QLoRA让单卡24G微调7B模型成为现实,成本从几万块降到几千块。关键经验就三条:数据格式必须和基座chat template一致;学习率2e-4左右,别设太小;target_modules尽量全挂。这套流程我已经复用到13B模型上,显存峰值22.1G,依然能跑。如果你也在做垂直领域微调,希望这篇记录能帮你少走几个坑。