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

先说结论:通用大模型在垂直领域真的不够用。

我手上的场景是给一个内部技术文档系统做智能问答。直接用Qwen2.5-7B-Instruct的API测试了200条真实问题,发现几个典型问题:

  • 对内部术语理解偏差,比如把“灰度发布”解释成图像处理里的灰度
  • 回答风格太“官方”,用户想要的是简洁的步骤式回答
  • 经常编造不存在的接口名和参数

全量微调7B模型?我只有一张RTX 4090(24G),fp16全量微调需要约60G显存,不现实。所以LoRA是唯一选择。更进一步,为了在24G显存上跑更大的batch size,我用了QLoRA(4-bit量化+LoRA)。

这篇文章会完整记录:数据怎么准备、参数怎么设、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
datasets: 2.20.0

重点提醒:bitsandbytespeft的版本要匹配,否则会出现4-bit量化后LoRA权重无法加载的问题。我一开始用peft 0.10.0 + bitsandbytes 0.41.0,直接报ValueError: Cannot merge LoRA weights into 4-bit model。升级到上面这组版本后解决。

另外,transformers 4.44以上对Qwen2.5的支持更完整,建议不要低于4.40。

三、方案设计:QLoRA + 指令微调

3.1 模型选择

基座模型:Qwen2.5-7B-Instruct。选它原因:中文能力强、7B规模适中、社区LoRA示例多。

3.2 微调策略

  • 量化:4-bit NF4,双量化(double quant),计算类型bf16
  • LoRA:r=16, lora_alpha=32, target_modules=["q_proj","k_proj","v_proj","o_proj","gate_proj","up_proj","down_proj"]
  • dropout:0.05
  • 训练方式:指令微调,只对answer部分计算loss(prompt部分mask掉)

为什么r=16?我试过r=8和r=32。r=8时loss降到0.9就下不去了,r=32时显存多占1.2G但效果提升不明显。r=16是性价比最高的。

3.3 数据格式

我用了3200条内部问答对,格式为Alpaca风格:

{
  "instruction": "如何配置灰度发布规则?",
  "input": "",
  "output": "1. 登录控制台,进入\"发布管理\"。\n2. 选择目标服务,点击\"新建规则\"。\n3. 设置灰度比例(建议5%-10%)。\n4. 指定灰度用户标签或IP段。\n5. 保存并启用。"
}

数据划分:训练集3000条,验证集200条。注意:验证集不要和训练集有重复问题,否则loss曲线会骗你。

四、核心实现:代码与配置

4.1 加载4-bit模型和tokenizer

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

model_name = "Qwen/Qwen2.5-7B-Instruct"

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

tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token

model = AutoModelForCausalLM.from_pretrained(
    model_name,
    quantization_config=bnb_config,
    device_map="auto",
    trust_remote_code=True,
    torch_dtype=torch.bfloat16
)

model = prepare_model_for_kbit_training(model)

lora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    target_modules=["q_proj","k_proj","v_proj","o_proj","gate_proj","up_proj","down_proj"],
    lora_dropout=0.05,
    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,000,000 || trainable%: 0.264

可训练参数只有2000万,占0.264%,显存压力小很多。

4.2 数据预处理与Trainer配置

from datasets import load_dataset
from transformers import TrainingArguments, Trainer, DataCollatorForSeq2Seq

dataset = load_dataset("json", data_files={"train": "train.json", "validation": "val.json"})

def format_example(example):
    prompt = f"user\n{example['instruction']}\n{example['input']}\nassistant\n"
    answer = example['output'] + tokenizer.eos_token
    return {"prompt": prompt, "answer": answer}

def tokenize_fn(example):
    prompt_ids = tokenizer(example["prompt"], add_special_tokens=False)["input_ids"]
    answer_ids = tokenizer(example["answer"], add_special_tokens=False)["input_ids"]
    input_ids = prompt_ids + answer_ids
    labels = [-100] * len(prompt_ids) + answer_ids
    return {"input_ids": input_ids, "labels": labels, "attention_mask": [1]*len(input_ids)}

train_ds = dataset["train"].map(format_example).map(tokenize_fn, remove_columns=dataset["train"].column_names)
val_ds = dataset["validation"].map(format_example).map(tokenize_fn, remove_columns=dataset["validation"].column_names)

training_args = TrainingArguments(
    output_dir="./qwen2.5-7b-lora",
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,
    num_train_epochs=3,
    learning_rate=2e-4,
    lr_scheduler_type="cosine",
    warmup_ratio=0.03,
    logging_steps=10,
    save_strategy="epoch",
    evaluation_strategy="epoch",
    bf16=True,
    optim="paged_adamw_8bit",
    report_to="none",
    save_total_limit=2,
    gradient_checkpointing=True
)

trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=train_ds,
    eval_dataset=val_ds,
    data_collator=DataCollatorForSeq2Seq(tokenizer, padding=True, return_tensors="pt")
)

trainer.train()

关键参数解释:
- per_device_train_batch_size=4 + gradient_accumulation_steps=4 = 有效batch size 16
- learning_rate=2e-4:LoRA常用学习率,比全量微调大10倍左右
- optim="paged_adamw_8bit":省显存,24G卡上必开
- gradient_checkpointing=True:用时间换显存,训练速度慢约20%但显存降30%

五、踩坑与优化:我遇到的三个真实问题

坑1:显存溢出(OOM)

第一次跑,batch_size=8,直接OOM。报错CUDA out of memory. Tried to allocate 2.3 GiB

解决:batch_size降到4,开gradient_checkpointing,用paged_adamw_8bit。最终显存峰值21.3G,稳了。

坑2:loss震荡不收敛

前500步loss从1.82降到1.1,然后开始震荡,在1.0-1.3之间跳。

原因:学习率太大(我一开始用了5e-4)。改成2e-4,加warmup_ratio=0.03,震荡消失。

坑3:推理时输出重复

微调后模型有时会重复输出同一句话。检查发现是训练数据里有些answer结尾没有明确终止符。

解决:在answer末尾强制加tokenizer.eos_token,并在推理时设置repetition_penalty=1.1

六、效果数据:loss曲线与推理对比

6.1 loss曲线

训练3个epoch,共2250步(3000条 / 16有效batch * 3 = 562步?实际因为梯度累积,每4步更新一次,约563次更新,但logging每10步记录一次,共约56个点)。

实际记录:
- 第1个epoch结束:train_loss=1.24, eval_loss=1.18
- 第2个epoch结束:train_loss=0.81, eval_loss=0.79
- 第3个epoch结束:train_loss=0.63, eval_loss=0.67

eval_loss略高于train_loss但差距很小,没有过拟合。

6.2 推理效果对比

用同一批20个测试问题,对比微调前后:

指标 微调前 微调后
术语准确率 62% 91%
回答简洁度(人工评分1-5) 2.8 4.3
编造接口名次数 7次 1次
平均响应长度 187字 96字

具体例子:

问题:“灰度发布怎么回滚?”

微调前回答:“灰度发布是一种逐步将新版本推送给部分用户的方式。回滚通常需要……(省略200字,包含不存在的rollback-gray接口)”

微调后回答:“1. 进入发布管理。2. 找到对应灰度规则。3. 点击回滚,选择回滚到上一稳定版本。4. 确认后5分钟内生效。”

6.3 推理代码

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

base_model = AutoModelForCausalLM.from_pretrained(
    "Qwen/Qwen2.5-7B-Instruct",
    torch_dtype=torch.bfloat16,
    device_map="auto"
)
model = PeftModel.from_pretrained(base_model, "./qwen2.5-7b-lora/checkpoint-563")
model = model.merge_and_unload()  # 合并LoRA权重,推理更快

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

def ask(question):
    messages = [{"role": "user", "content": question}]
    text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
    inputs = tokenizer(text, return_tensors="pt").to(model.device)
    outputs = model.generate(
        **inputs,
        max_new_tokens=256,
        temperature=0.3,
        repetition_penalty=1.1,
        do_sample=True
    )
    return tokenizer.decode(outputs[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True)

print(ask("灰度发布怎么回滚?"))

注意:merge_and_unload()会把LoRA权重合并进基座模型,推理速度比不合并快约15%,但显存占用会回到fp16的14G左右。如果显存紧张,可以不合并,直接加载LoRA。

七、总结

这次LoRA微调7B模型的实践,核心结论就几条:

  1. QLoRA + LoRA r=16 在24G显存上完全可行,训练3小时(约2250步)即可收敛。
  2. 数据质量比数量重要。我一开始用8000条噪声数据,效果还不如清洗后的3200条。
  3. 学习率2e-4、warmup 3%、cosine调度是7B LoRA的甜点区。
  4. 一定要在验证集上看loss,不要只看训练loss。
  5. 推理时记得加repetition_penalty,否则微调后容易复读。

最终模型在内部测试集上术语准确率从62%提升到91%,回答长度缩短一半,编造接口的问题基本消失。如果你也想微调7B模型,这套配置可以直接抄作业。

有问题的欢迎评论区交流,我看到都会回。