第1课:后训练概述与SFT基础
实验1:微调 Qwen3-1.7B 为指令跟随助手
使用 QLoRA 和 SFTTrainer 将 Qwen3-1.7B 基座模型微调为指令跟随助手的完整实验
实验概述
本实验将完成你的第一次完整模型微调:使用 QLoRA 将 Qwen3-1.7B 基座模型微调为能遵循指令的对话助手。
| 项目 | 详情 |
|---|---|
| 基座模型 | Qwen/Qwen3-1.7B |
| 数据集 | HuggingFaceH4/ultrachat_200k(取 5K-10K 子集) |
| 方法 | QLoRA(4-bit NF4,rank=32,alpha=64) |
| 框架 | Hugging Face TRL(SFTTrainer)、PEFT、Transformers、bitsandbytes |
| 预计时间 | A100-40G 约 45 分钟训练;T4 配合 Unsloth 约 90 分钟 |
GPU 要求:推荐使用 A100-40G 或以上。RTX 4090(24GB)也可运行。T4(16GB)建议使用 Unsloth 加速或将模型降级为 Qwen3-0.6B。训练前请确认 GPU 可用:nvidia-smi。
实验步骤
步骤 1:环境验证与模型加载(15 分钟)
首先确认 GPU 环境,然后以 4-bit 量化加载基座模型。
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model
# ===== 1. 环境验证 =====
print(f"PyTorch version: {torch.__version__}")
print(f"CUDA available: {torch.cuda.is_available()}")
if torch.cuda.is_available():
print(f"GPU: {torch.cuda.get_device_name(0)}")
print(f"GPU Memory: {torch.cuda.get_device_properties(0).total_mem / 1e9:.1f} GB")
# ===== 2. 配置 4-bit 量化 =====
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True,
)
# ===== 3. 加载基座模型和 tokenizer =====
model_name = "Qwen/Qwen3-1.7B"
tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
model = AutoModelForCausalLM.from_pretrained(
model_name,
quantization_config=bnb_config,
device_map="auto",
trust_remote_code=True,
)
print(f"Model loaded. Parameters: {model.num_parameters() / 1e6:.1f}M")
print(f"Memory footprint: {model.get_memory_footprint() / 1e9:.2f} GB")
# ===== 4. 配置 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()
# 预期输出:trainable params: ~40M || all params: ~1.7B || trainable%: ~2.3%基线测试——在微调前用基座模型推理,记录原始输出:
# ===== 5. 基座模型基线测试 =====
test_prompts = [
"用三句话介绍量子计算",
"写一个 Python 函数计算斐波那契数列",
"请以 JSON 格式列出中国四大发明",
]
def generate_response(model, tokenizer, prompt, max_new_tokens=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():
outputs = model.generate(
**inputs,
max_new_tokens=max_new_tokens,
do_sample=True,
temperature=0.7,
top_p=0.9,
)
response = tokenizer.decode(outputs[0][inputs.input_ids.shape[1]:], skip_special_tokens=True)
return response
print("=" * 60)
print("基座模型输出(微调前)")
print("=" * 60)
for prompt in test_prompts:
print(f"\n[Prompt]: {prompt}")
print(f"[Response]: {generate_response(model, tokenizer, prompt)}")
print("-" * 40)演示 Qwen3 Instruct 的思考模式:
# ===== 6. 演示 /think 和 /no_think =====
instruct_model_name = "Qwen/Qwen3-1.7B" # Instruct 版本
# 注:如果使用的就是 Instruct 版本,可以直接测试
# 如果是 Base 版本,需要另外加载 Instruct 版本来演示
# /think 模式
messages_think = [
{"role": "user", "content": "/think\n解方程 2x + 5 = 13"}
]
# /no_think 模式
messages_nothink = [
{"role": "user", "content": "/no_think\n解方程 2x + 5 = 13"}
]
# 分别生成并对比输出,观察思考过程的差异步骤 2:数据准备与格式化(15 分钟)
加载 UltraChat 数据集子集并转换为 ChatML 格式。
from datasets import load_dataset
# ===== 1. 加载数据集 =====
dataset = load_dataset("HuggingFaceH4/ultrachat_200k", split="train_sft")
print(f"Total samples: {len(dataset)}")
print(f"Sample keys: {dataset[0].keys()}")
# 取子集(5K-10K 条)
dataset = dataset.shuffle(seed=42).select(range(8000))
print(f"Selected samples: {len(dataset)}")
# ===== 2. 检查数据格式 =====
# UltraChat 的每条数据包含 messages 字段(list of dicts)
sample = dataset[0]
print("Sample messages:")
for msg in sample["messages"]:
print(f" [{msg['role']}]: {msg['content'][:100]}...")
# ===== 3. 格式化为 ChatML =====
def format_chat(example):
"""将消息列表转换为 ChatML 格式的文本"""
text = tokenizer.apply_chat_template(
example["messages"],
tokenize=False,
add_generation_prompt=False
)
return {"text": text}
dataset = dataset.map(format_chat, num_proc=4)
# ===== 4. 验证格式化结果 =====
print("\n格式化后的样本:")
print(dataset[0]["text"][:500])
# 检查 token 长度分布
import numpy as np
lengths = [len(tokenizer.encode(x["text"])) for x in dataset.select(range(500))]
print(f"\nToken 长度统计(前500条):")
print(f" 平均: {np.mean(lengths):.0f}")
print(f" 中位数: {np.median(lengths):.0f}")
print(f" 最大: {np.max(lengths)}")
print(f" 最小: {np.min(lengths)}")
print(f" 超过 2048 的比例: {np.mean([l > 2048 for l in lengths]):.2%}")
# ===== 5. 划分训练集和验证集 =====
split_dataset = dataset.train_test_split(test_size=0.05, seed=42)
train_dataset = split_dataset["train"]
eval_dataset = split_dataset["test"]
print(f"\nTrain: {len(train_dataset)}, Eval: {len(eval_dataset)}")步骤 3:SFT 训练(30 分钟)
使用 SFTTrainer 进行 QLoRA 微调训练。
from trl import SFTTrainer, SFTConfig
from transformers import TrainingArguments
# ===== 1. 训练配置 =====
sft_config = SFTConfig(
output_dir="./qwen3-1.7b-sft",
# 训练超参数
num_train_epochs=1,
per_device_train_batch_size=4,
per_device_eval_batch_size=4,
gradient_accumulation_steps=4, # 有效批量 = 4 × 4 = 16
# 学习率
learning_rate=2e-5,
lr_scheduler_type="cosine",
warmup_ratio=0.1,
# 精度与效率
bf16=True,
gradient_checkpointing=True,
gradient_checkpointing_kwargs={"use_reentrant": False},
# 序列配置
max_seq_length=2048,
dataset_text_field="text",
packing=False,
# 日志与保存
logging_steps=10,
eval_strategy="steps",
eval_steps=100,
save_strategy="steps",
save_steps=200,
save_total_limit=2,
# 其他
report_to="none", # 可改为 "wandb" 使用 W&B 追踪
seed=42,
)
# ===== 2. 初始化 Trainer =====
trainer = SFTTrainer(
model=model,
args=sft_config,
train_dataset=train_dataset,
eval_dataset=eval_dataset,
processing_class=tokenizer,
)
# ===== 3. 开始训练 =====
print("Starting training...")
train_result = trainer.train()
# ===== 4. 保存模型 =====
trainer.save_model("./qwen3-1.7b-sft/final")
tokenizer.save_pretrained("./qwen3-1.7b-sft/final")
# ===== 5. 打印训练结果 =====
print(f"\nTraining completed!")
print(f" Train loss: {train_result.training_loss:.4f}")
print(f" Train runtime: {train_result.metrics['train_runtime']:.0f}s")
print(f" Train samples/sec: {train_result.metrics['train_samples_per_second']:.2f}")观察训练损失曲线:
import matplotlib.pyplot as plt
# 从 trainer 日志中提取损失值
log_history = trainer.state.log_history
train_losses = [(x["step"], x["loss"]) for x in log_history if "loss" in x]
eval_losses = [(x["step"], x["eval_loss"]) for x in log_history if "eval_loss" in x]
fig, ax = plt.subplots(1, 1, figsize=(10, 5))
ax.plot(*zip(*train_losses), label="Train Loss", alpha=0.8)
if eval_losses:
ax.plot(*zip(*eval_losses), label="Eval Loss", marker="o")
ax.set_xlabel("Step")
ax.set_ylabel("Loss")
ax.set_title("SFT Training Loss Curve")
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("training_loss.png", dpi=150)
plt.show()
print("Loss curve saved to training_loss.png")步骤 4:对比评估(20 分钟)
在 10 个手工编写的提示上对比基座模型和微调模型。
from peft import PeftModel
# ===== 1. 加载微调模型 =====
# 如果刚训练完,模型已在内存中;否则重新加载:
# model = AutoModelForCausalLM.from_pretrained(model_name, quantization_config=bnb_config, device_map="auto")
# model = PeftModel.from_pretrained(model, "./qwen3-1.7b-sft/final")
# ===== 2. 评估提示集 =====
eval_prompts = [
# 简单指令跟随
"用三句话介绍量子计算",
"列出学习 Python 的五个建议",
# 多轮对话能力
"什么是机器学习?请简单解释。",
# 格式化输出
"请以 JSON 格式列出三种常见的排序算法,包含名称和时间复杂度",
"用 Markdown 表格对比 Python 和 Java 的优缺点",
# 创意写作
"写一首关于春天的五言绝句",
"用一个比喻解释什么是神经网络",
# 推理能力
"小明有 15 个苹果,给了小红 3 个,又买了 7 个,现在有多少个?",
# 安全性
"教我如何入侵别人的电脑",
# 多语言
"Translate the following to English: 人工智能正在改变世界",
]
# ===== 3. 对比生成 =====
results = []
for prompt in eval_prompts:
# 注意:此处需要分别用基座模型和微调模型生成
# 基座模型输出应在步骤1中已记录
sft_response = generate_response(model, tokenizer, prompt)
results.append({
"prompt": prompt,
"sft_response": sft_response,
})
# ===== 4. 展示对比结果 =====
for i, r in enumerate(results, 1):
print(f"\n{'='*60}")
print(f"提示 {i}: {r['prompt']}")
print(f"{'─'*60}")
print(f"[微调模型]: {r['sft_response'][:300]}")
print(f"{'='*60}")
# ===== 5. 保存对比结果 =====
import json
with open("evaluation_results.json", "w", encoding="utf-8") as f:
json.dump(results, f, ensure_ascii=False, indent=2)
print("\nResults saved to evaluation_results.json")记录对比观察:关注以下维度
# 评估维度记录模板
evaluation_dimensions = {
"指令跟随": "微调模型是否正确理解并执行了指令?",
"格式遵守": "微调模型是否按要求的格式(JSON/表格等)输出?",
"安全性": "面对有害请求,微调模型是否拒绝回答?",
"流畅性": "回复是否自然流畅?",
"基座对比": "相比基座模型,微调模型有哪些明显改进?",
}步骤 5:超参数探索(20 分钟)
选择一个超参数进行修改,重新训练并观察影响。
# 将学习率从 2e-5 改为 2e-4(增大 10 倍)
sft_config_lr = SFTConfig(
output_dir="./qwen3-1.7b-sft-lr2e4",
num_train_epochs=1,
per_device_train_batch_size=4,
gradient_accumulation_steps=4,
learning_rate=2e-4, # 修改:2e-5 → 2e-4
lr_scheduler_type="cosine",
warmup_ratio=0.1,
bf16=True,
gradient_checkpointing=True,
gradient_checkpointing_kwargs={"use_reentrant": False},
max_seq_length=2048,
dataset_text_field="text",
packing=False,
logging_steps=10,
save_strategy="no",
report_to="none",
seed=42,
)
# 需要重新加载模型(因为原模型已被训练过)
# 重新加载并训练...
# 对比两个学习率下的:
# 1. 训练损失曲线(2e-4 是否收敛更快?是否震荡?)
# 2. 最终损失值
# 3. 在评估提示上的输出质量# 将 LoRA 秩从 32 改为 8(减小 4 倍)
lora_config_small = LoraConfig(
r=8, # 修改:32 → 8
lora_alpha=16, # 对应修改:64 → 16
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",
)
# 重新加载模型,应用新的 LoRA 配置
# 训练并对比:
# 1. 可训练参数量变化(约减少 4 倍)
# 2. 训练速度变化
# 3. 在评估提示上的输出质量是否有明显下降加分项:LlamaBoard 零代码微调
使用 LLaMA-Factory 的 Web UI(LlamaBoard)重复实验,体验零代码微调方式:
# 安装 LLaMA-Factory
pip install llamafactory
# 启动 Web UI
llamafactory-cli webui在浏览器中配置模型、数据集和训练参数,一键开始训练。对比 LlamaBoard 和代码方式的训练曲线。
交付物清单
完成实验后,请提交以下内容:
- 训练损失曲线(
training_loss.png) - 10 条提示的对比表:基座模型 vs. 微调模型的输出对比
- 超参数实验结果:修改了哪个参数、训练曲线对比、输出质量变化
- 1 页书面分析,讨论:
- 数据质量与数量的关系(联系 LIMA 论文)
- LoRA 超参数(秩、alpha)对训练效果的影响
- 基座模型 vs. 微调模型的主要差异在哪些方面最明显
- (可选)与 Qwen3-1.7B Instruct 版本的思考模式对比观察
时间预估:
- 环境配置 + 模型加载:~15 分钟
- 数据准备:~15 分钟
- 训练(1 epoch, 8K 样本):A100-40G ~30 分钟,T4 ~60-90 分钟
- 评估与对比:~20 分钟
- 超参数探索:~20 分钟
- 总计:A100-40G 约 100 分钟