上机实验:Qwen2.5-1.5B 的 LoRA 微调
在单张 A100-40G 上用 LLaMA-Factory 或 TRL 对 Qwen2.5-1.5B 做 LoRA 微调,跑通完整 SFT 流程
实验概述
本实验让你第一次完整走完 SFT 流水线:从数据准备到 LoRA 微调,再到推理对比和简单评估。为了让所有同学在单张 A100-40G(或等价)上都能复现,我们选择一个轻量组合:
| 项目 | 详情 |
|---|---|
| 基座模型 | Qwen/Qwen2.5-1.5B(不是 -Instruct) |
| 数据集 | 500-1000 条小规模指令数据(可用 llamafactory/alpaca_gpt4_zh 的子集) |
| 方法 | LoRA(BF16,r=32,α=64) |
| 工具栈 | 两条路线二选一:路线 A:LLaMA-Factory(YAML 配置) 或 路线 B:TRL(Python 代码) |
| 预计时间 | A100-40G 上约 15-25 分钟训练;T4 下约 45 分钟 |
GPU 要求:推荐 A100-40G / RTX 4090 / 3090。T4-16G 需降级到 QLoRA + 更小的 seq_len。训练前先跑 nvidia-smi 确认 GPU 可用。
实验步骤
步骤 1:环境配置(10 分钟)
安装依赖(两条路线都需要):
# 建议用 conda 或 venv 新建环境
pip install "torch>=2.2" --index-url https://download.pytorch.org/whl/cu121
# 路线 A:LLaMA-Factory
pip install llamafactory
# 路线 B:TRL
pip install "transformers>=4.45" "peft>=0.13" "trl>=0.12" \
"datasets>=2.19" "accelerate>=0.34" "bitsandbytes" \
"sentencepiece" "protobuf"验证环境:
import torch, transformers, peft, trl
print("torch:", torch.__version__)
print("transformers:", transformers.__version__)
print("peft:", peft.__version__)
print("trl:", trl.__version__)
print("CUDA available:", torch.cuda.is_available())
if torch.cuda.is_available():
print("GPU:", torch.cuda.get_device_name(0))步骤 2:数据准备(15 分钟)
下载并裁剪一个小规模指令数据集(500-1000 条)。
from datasets import load_dataset
# 以 Alpaca-GPT4 中文版为例
dataset = load_dataset("llamafactory/alpaca_gpt4_zh", split="train")
print(f"Total: {len(dataset)}")
# 抽样 1000 条
small_ds = dataset.shuffle(seed=42).select(range(1000))
# 转换为 messages 格式(SFT 通用)
def to_messages(example):
user = example["instruction"]
if example.get("input", "").strip():
user += "\n" + example["input"]
return {
"messages": [
{"role": "user", "content": user},
{"role": "assistant", "content": example["output"]},
]
}
small_ds = small_ds.map(to_messages, remove_columns=small_ds.column_names)
# 过滤过短 / 过长
def ok(ex):
u, a = ex["messages"][0]["content"], ex["messages"][1]["content"]
if not u.strip() or not a.strip():
return False
if len(a) < 20 or len(u) + len(a) > 4000:
return False
return True
small_ds = small_ds.filter(ok)
print(f"After filter: {len(small_ds)}")
# 划分 95/5
split = small_ds.train_test_split(test_size=0.05, seed=42)
train_ds, eval_ds = split["train"], split["test"]
# 存到本地 jsonl
train_ds.to_json("data/train.jsonl", force_ascii=False)
eval_ds.to_json("data/eval.jsonl", force_ascii=False)
print(f"Train: {len(train_ds)}, Eval: {len(eval_ds)}")加分项:用你自己课程里感兴趣的领域数据(如北大研究生手册问答、课程 FAQ)替代 Alpaca。数据量 500 条也能看出明显风格变化——这就是 LIMA 那篇论文的核心洞见。
步骤 3:训练配置(5 分钟)
在 configs/qwen2_5_lora_sft.yaml 写入:
### 模型
model_name_or_path: Qwen/Qwen2.5-1.5B
trust_remote_code: true
### 方法
stage: sft
do_train: true
finetuning_type: lora
lora_rank: 32
lora_alpha: 64
lora_target: q_proj,k_proj,v_proj,o_proj,gate_proj,up_proj,down_proj
lora_dropout: 0.05
### 数据集
dataset: my_alpaca_zh # 见下方 data/dataset_info.json
template: qwen
cutoff_len: 2048
max_samples: 1000
overwrite_cache: true
preprocessing_num_workers: 4
### 输出
output_dir: saves/qwen2.5-1.5b/lora/sft
logging_steps: 10
save_steps: 200
overwrite_output_dir: true
### 训练
per_device_train_batch_size: 4
gradient_accumulation_steps: 4
learning_rate: 2.0e-4
num_train_epochs: 3
lr_scheduler_type: cosine
warmup_ratio: 0.1
bf16: true
gradient_checkpointing: true
ddp_timeout: 180000000
### 评估
val_size: 0.05
per_device_eval_batch_size: 4
eval_strategy: steps
eval_steps: 100在 data/dataset_info.json 注册数据集:
{
"my_alpaca_zh": {
"file_name": "train.jsonl",
"formatting": "sharegpt",
"columns": {
"messages": "messages"
},
"tags": {
"role_tag": "role",
"content_tag": "content",
"user_tag": "user",
"assistant_tag": "assistant"
}
}
}把下列代码保存为 train.py:
import torch
from datasets import load_dataset
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import LoraConfig, get_peft_model
from trl import SFTTrainer, SFTConfig
# ===== 1. 模型 =====
model_name = "Qwen/Qwen2.5-1.5B"
tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
model = AutoModelForCausalLM.from_pretrained(
model_name,
torch_dtype=torch.bfloat16,
device_map="auto",
trust_remote_code=True,
)
# ===== 2. LoRA =====
lora_config = LoraConfig(
r=32,
lora_alpha=64,
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()
# ===== 3. 数据 =====
train_ds = load_dataset("json", data_files="data/train.jsonl", split="train")
eval_ds = load_dataset("json", data_files="data/eval.jsonl", split="train")
# ===== 4. 训练配置 =====
sft_config = SFTConfig(
output_dir="./qwen2.5-1.5b-sft",
num_train_epochs=3,
per_device_train_batch_size=4,
per_device_eval_batch_size=4,
gradient_accumulation_steps=4,
learning_rate=2e-4,
lr_scheduler_type="cosine",
warmup_ratio=0.1,
bf16=True,
gradient_checkpointing=True,
gradient_checkpointing_kwargs={"use_reentrant": False},
max_length=2048,
packing=False,
logging_steps=10,
eval_strategy="steps",
eval_steps=100,
save_strategy="steps",
save_steps=200,
save_total_limit=2,
report_to="none",
seed=42,
assistant_only_loss=True, # 只在 assistant token 上算损失
)
# ===== 5. 启动 =====
trainer = SFTTrainer(
model=model,
args=sft_config,
train_dataset=train_ds,
eval_dataset=eval_ds,
processing_class=tokenizer,
)
train_result = trainer.train()
trainer.save_model("./qwen2.5-1.5b-sft/final")
tokenizer.save_pretrained("./qwen2.5-1.5b-sft/final")
print(f"\n=== Training finished ===")
print(f"Train loss: {train_result.training_loss:.4f}")
print(f"Runtime: {train_result.metrics['train_runtime']:.0f}s")
print(f"Throughput: {train_result.metrics['train_samples_per_second']:.2f} samples/s")步骤 4:开始训练(15-25 分钟)
llamafactory-cli train configs/qwen2_5_lora_sft.yaml也可以直接启 WebUI:
llamafactory-cli webui
# 浏览器打开 http://localhost:7860,在 UI 里选择同样的配置python train.py期望看到的日志:
trainable params: 18,464,768 || all params: 1,561,174,016 || trainable%: 1.1827
[100/570] loss=1.248 eval_loss=1.203 lr=1.85e-4
[200/570] loss=1.105 eval_loss=1.094 lr=1.55e-4
[300/570] loss=1.021 eval_loss=1.056 lr=1.12e-4
[400/570] loss=0.962 eval_loss=1.034 lr=0.65e-4
[500/570] loss=0.928 eval_loss=1.028 lr=0.19e-4损失解读:
- train_loss 持续下降是正常的
- eval_loss 也应同步下降或保持平稳
- 如果 eval_loss 在中途开始上升,说明过拟合了——减少 epoch 或增大 lora_dropout
步骤 5:推理对比(10 分钟)
用同一组 prompts 对比 base 模型和微调后的 LoRA 模型:
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel
base_name = "Qwen/Qwen2.5-1.5B"
adapter_dir = "./qwen2.5-1.5b-sft/final"
tokenizer = AutoTokenizer.from_pretrained(adapter_dir, trust_remote_code=True)
def load(name, adapter=None):
m = AutoModelForCausalLM.from_pretrained(
name, torch_dtype=torch.bfloat16, device_map="auto",
trust_remote_code=True,
)
if adapter:
m = PeftModel.from_pretrained(m, adapter)
m.eval()
return m
def chat(model, prompt, max_new=256):
messages = [{"role": "user", "content": prompt}]
text = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
inputs = tokenizer(text, return_tensors="pt").to(model.device)
with torch.no_grad():
out = model.generate(
**inputs,
max_new_tokens=max_new,
do_sample=True,
temperature=0.7,
top_p=0.9,
pad_token_id=tokenizer.eos_token_id,
)
return tokenizer.decode(
out[0][inputs.input_ids.shape[1]:],
skip_special_tokens=True,
)
# ===== 测试 prompts =====
prompts = [
"用三句话介绍量子计算。",
"写一个 Python 函数计算斐波那契数列。",
"请以 JSON 格式列出中国四大发明。",
"小明有 15 个苹果,给了小红 3 个,又买了 7 个,现在有多少个?",
"用 Markdown 表格对比 Python 和 Java 的三个优缺点。",
]
# ===== 基座 =====
base = load(base_name)
print("===== BASE =====")
for p in prompts:
print(f"\n[Q] {p}")
print(f"[A] {chat(base, p)}")
del base
torch.cuda.empty_cache()
# ===== SFT =====
sft = load(base_name, adapter=adapter_dir)
print("\n===== SFT =====")
for p in prompts:
print(f"\n[Q] {p}")
print(f"[A] {chat(sft, p)}")期望观察:
| 维度 | Base 模型 | SFT 模型 |
|---|---|---|
| 指令跟随 | 经常只做补全,不回答 | 明确作答、结构清晰 |
| 格式遵循 | 几乎忽视"用 JSON / 表格"的要求 | 倾向于按要求输出 |
| 终止 | 常常不停下 | 更自然地结束 |
| 语气 | 像在续写文本 | 像在对话 |
步骤 6:简单评估(15 分钟)
定性:在 eval.jsonl 或自己准备的 20 条测试问题上人工打分(1-5 分):
- 指令遵循:是否做了用户要求的事
- 格式遵守:是否用了要求的 JSON / 表格 / 代码块
- 事实性:回答的事实对不对
- 流畅度:语言自然吗
定量(加分项):用 LLM-as-Judge(参见第 8 讲)让 GPT-4 / Claude 对 base 和 SFT 的输出做双盲打分:
# 伪代码
judge_prompt = """你是一位严格的评委。下面给出同一个问题和两份回答 A/B。
请从【指令遵循、格式、事实性、流畅度】四个维度 1-5 打分,并选出胜者。
问题:{question}
A:{ans_a}
B:{ans_b}
"""记录胜率(SFT 相对 base 的 win-rate),比如 72% 意味着 500 轮里 SFT 赢了 360 轮。
加分实验
实验 1:换一个超参
修改一个关键超参,重新训练,对比损失曲线和推理质量:
lora_rank:32 → 8 / 128learning_rate:2e-4 → 2e-5 / 1e-3num_train_epochs:3 → 1 / 10
实验 2:换一个领域
把 Alpaca 换成你自己领域的 500 条问答(课程 FAQ、研究生手册、实验室代码注释),看看模型能否学会你的领域术语和回答风格。
实验 3:LLaMA-Factory vs TRL
用同一份数据,两条路线各跑一次,对比:
- 跑通的时间(写代码 / 调 YAML 用了多久)
- 训练速度
- 最终损失
交付物清单
完成实验后提交:
- 训练损失曲线(
training_loss.png) - 推理对比表:5-10 个 prompts 的
basevsSFT输出对比 - 简单评估结果:人工打分表 或 LLM-as-Judge 的胜率
- 一页书面分析(约 500 字),讨论:
- 哪些维度(指令跟随 / 格式 / 风格)变化最明显?
- 哪些维度基本没变化?为什么?
- 你调整的一个超参对结果有什么影响?
- 你观察到 LoRA 训练的哪些"坑"?
时间预估(A100-40G,1000 条数据,3 epoch):
- 环境 + 数据:~25 分钟
- 配置 + 训练:~20-30 分钟
- 推理对比:~10 分钟
- 评估:~15 分钟
- 总计:约 80-90 分钟
下一步
完成本实验后,你已经具备了独立做一次 LoRA 微调的能力。如果你希望更进一步:
- 本课程第 7 讲(Agent):把你微调的模型挂进一个 Agent 框架,看它在多步工具调用里的表现
- 姊妹课程 大语言模型后训练:在 SFT 基础上继续做 DPO / GRPO,学习完整的后训练流水线
- 第 8 讲(LLM as Judge):把你的 SFT 模型做系统化的自动评估