再谈微调:LoRA 指令微调 Qwen2--从零到简历项目的踩坑实录
写在前面
前段时间写了一篇关于大模型微调的文章,立下了解决配置和数据问题的flag,现在圆满解决了。
这篇文章记录我使用 LoRA 对 Qwen2-0.5B 大模型进行指令微调,提升其代码生成与指令跟随能力的过程,哪些方法一次跑通,哪些坑调了两天才爬出来。如果你也在折腾微调,希望这篇能节省你几天时间。
同时与之前的 LoRA 程序相比,这回的更完善,更像是一个项目。除了数据更丰富以外,训练过程也有优化,还增加了多轮对话与前后对比。
整体架构
先看项目结构:
A_LoRA_project/
├── train_lora.py # 训练脚本
├── compare.py # 微调前后对比测试
├── inference.py # 交互式推理
├── code_alpaca_20k.json # 20K 条指令数据(来自 CodeAlpaca)
└── loss_curve.png # 训练 loss 曲线
训练流程如下:
20K 条指令数据
│
▼
Qwen2-0.5B + LoRA(rank=8,7 个 target modules)
│
▼
训练 5 个 epoch(约 4 小时)
│
▼
对比测试:Base vs LoRA 同一 prompt 输出
LoRA 配置
这次用的配置:
| 参数 | 值 |
|---|---|
| 基座模型 | Qwen2-0.5B(5 亿参数) |
| 微调方法 | LoRA |
| rank | 8 |
| lora_alpha | 32 |
| target_modules | q_proj / k_proj / v_proj / o_proj / gate_proj / up_proj / down_proj |
| 可训练参数 | ~420 万(占 0.8%) |
| 冻结参数 | 99.2% |
| 训练数据 | CodeAlpaca 中文数据集(20,000 条) |
| 训练轮数 | 5 epoch / 6,260 步 |
| 硬件 | RTX 5060(8GB 显存) |
| 训练用时 | 约 4 小时 |
训练过程:Loss 曲线
先放训练结果的一张图,方便有个直观感受:

Loss 从 1.00 降到 0.48,在个人显卡上跑了 4 小时。
训练一共跑了 6260 步,loss 整体从 1.00 降到 0.52,最佳在 step 5250(0.482)。图中曲线未做平滑处理。
图中有一个有意思的现象:每训练一个epoch(1250步),loss 便会出现一次阶梯式下降,统计验证——边界区域(前后各 50 步)的平均 loss 下降为 0.076,而随机相同长度区间的平均下降仅为 0.0005。这不是视觉假象。
我的有效 batch size = 8 × 2(gradient accumulation)= 16,所以 20000 ÷ 16 = 1250 步跑完一遍全部数据,正好一个 epoch。如果采用的是stepLR 调度器,那么这种阶梯式下降是正常的,因为它在进入下一个 epoch 时会收束学习率,导致 loss 大幅下降,但我的训练使用的是线性衰减调度器。 按预期,loss 曲线图应该近似于一条曲线。为此我查找了部分资料,也询问了 AI,得到如下结果,目前暂无法验证真伪,希望有读者可以帮忙解答:
epoch 边界的下降不能用 StepLR 解释(学习率是均匀线性衰减的,没有按 epoch 跳变)。因此猜测在训练中每经过一个 epoch,模型多学了一轮,loss 自然整体低于上一个 epoch,虽然学习率是线性衰减,但在完成一个 epoch 的学习后,学习的结果才能被大模型使用,因此产生了图片中的跳变。
当然这只是个人的猜测,代码我会放到 GitHub 欢迎大家复现讨论。
训练的关键参数快照:
| 位置 | 步数 | Loss | 学习率 |
|---|---|---|---|
| 开始 | 10 | 1.00 | 2.0e-4 |
| 第 1 个 epoch 结束 | ~1250 | 0.75 | 1.6e-4 |
| 第 2 个 epoch 结束 | ~2500 | 0.72 | 1.2e-4 |
| 第 3 个 epoch 结束 | ~3750 | 0.65 | 8.0e-5 |
| 第 4 个 epoch 结束 | ~5000 | 0.58 | 4.0e-5 |
| 训练结束 | 6260 | 0.52 | 3.2e-8 |
第一步:训练脚本
train_lora.py 的核心逻辑只有几段:
加载数据集
我从 GitHub 下载了 CodeAlpaca 的 code_alpaca_20k.json,每一条数据包含 instruction、input、output 三个字段。加载后格式化成 Qwen2 的对话模板:
# 格式化 Qwen2 的 chat template
def format_instruction(example):
if inputs:
prompt = f"<|im_start|>user\n{instruction}\n{inputs}<|im_end|>\n<|im_start|>assistant\n"
else:
prompt = f"<|im_start|>user\n{instruction}<|im_end|>\n<|im_start|>assistant\n"
full_text = prompt + output + "<|im_end|>"
return {"prompt": prompt, "full_text": full_text}
配置 LoRA
lora_config = LoraConfig(
r=8,
lora_alpha=32,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"],
lora_dropout=0.1,
bias="none",
task_type=TaskType.CAUSAL_LM,
)
model = get_peft_model(model, lora_config)
这里 target_modules 覆盖了 Qwen2 的全部线性投影层。你可能会问为什么选这 7 个而不是别的?LoRA 论文的实验表明,作用在 attention 和 FFN 的投影层效果最好。
踩坑记录
坑 1:数据集从哪里来
一开始用 HuggingFace 的 datasets.load_dataset("shibing624/code-alpaca-chinese"),但在国内网络环境下频繁超时。换成 HF 镜像站,又提示数据集不存在。
最后从 GitHub 仓库 sahil280114/codealpaca 下载了 code_alpaca_20k.json,本地文件读取,不依赖网络。
这里一个教训:国内做微调,先把数据准备好再开始写代码。训练时发现数据下不来,非常打断节奏。
坑 2:模型路径与 HuggingFace 仓库名校验
程序里写了 MODEL_NAME = "Qwen/Qwen2-0.5B"。结果每次一运行就去 HuggingFace 下载,而模型明明就在隔壁目录。
改成本地相对路径 ../Qwen2-0.5B/Qwen/Qwen2-0___5B 后,又报了新错:HFValidationError: Repo id must use alphanumeric chars...
后来发现原因在于 HuggingFace 的 from_pretrained() 在加载模型前会先校验字符串格式。Windows 路径中的点号(.vscode)在这道校验里被认为不合法,直接拒绝了。
最终方案:用 file 定位到脚本所在目录,拼出绝对路径,再加 local_files_only=True 强制从本地加载。
坑 3:EOS Token 提前截断(最隐蔽,耗时最久)
训练完兴奋地跑对比测试,结果:
# Base 模型:
def is_palindrome(s):
s = s.lower()
return s == s[::-1]
# LoRA 模型(被截断):
def is_palindrome(s):
s = s.lower().replace(' ', '').replace(
一开始怀疑模型没训练好,去看 adapter 权重没问题。然后怀疑是 batch size 太大,调小了重训还是一样的。最后一级一级排查到生成参数。
根因:Qwen2 的 EOS token ID(151643)同时也是 <|im_end|>。训练数据中每段回答都以 <|im_end|> 结尾,所以模型学到的模式是:写几句代码 → 输出 <|im_end|> → 结束。
推理时,模型过早输出了这个 token,生成器发现 EOS token 后立即停止。所以回答总是被截断。
试过的无效方案:
| 方案 | 结果 |
|---|---|
eos_token_id=-1 强制不停止 | 产生了大量重复内容 |
repetition_penalty | 改善不明显 |
| 解析输出时手动过滤 `< | im_end |
最终方案:
改用 do_sample=False(贪婪解码),没有随机采样后,模型在代码任务上的 EOS 行为稳定了许多,能在完整生成代码后再输出结束 token。
不过这个方案并不是通用的——对需要多样性的任务(对话、创意写作)还是需要用采样的。只是在代码生成这种确定性强、追求完整度的场景下,贪婪解码是更好的选择。
坑 4:对比测试脚本设计
最初我写了两个函数分别加载 Base 和 LoRA 模型:
base_model = load_base() # 一个模型实例
lora_model = load_lora() # 另一个模型实例
但这样两份模型同时驻留在 GPU 上,8GB 显存很快就满了。而且两个加载时间点不同,模型状态不完全一致,对比结果不够精确。
后来改用 PeftModel.disable_adapter_layers() / enable_adapter_layers() 在同一个模型实例上切换:
model.disable_adapter_layers() # → Base 输出
base_ans = generate(prompt)
model.enable_adapter_layers() # → LoRA 输出
lora_ans = generate(prompt)
这样省了一半显存,而且 Base 和 LoRA 的差异只来自 adapter 本身,没有其他干扰因素。
坑5:PyTorch安装版本
在上一期提到过,尽管我是N卡,但是在进行训练时只能使用 CPU,这是因为虽然安装命令里指定了 --index-url https://download.pytorch.org/whl/cu128,但 pip 的全局配置文件已设置了清华镜像源,优先级高于命令行参数。最终 pip 从清华源下载了默认的 CPU 版 torch,CUDA 指定没有生效。
解决:卸载后用官方命令重新安装,不再走镜像。
微调前后对比
最终用 5 组相同 prompt 做了对比测试:
测试 1:回文函数
🔴 Base:
def is_palindrome(s):
s = s.lower()
return s == s[::-1]
🟢 LoRA:
def is_palindrome(s):
s = s.lower().replace(' ', '').replace(',', '').replace(':', '')
return s == s[::-1]
LoRA 的处理更健壮——会主动去除标点和空格。
测试 2:二分查找
🔴 Base: Base 写到 `if arr[mid] == target:\n retu` 处截断
🟢 LoRA: 完整可运行的实现,包含边界判断和返回值
这是差距最大的一题。Base 模型甚至没写完一个完整的函数体。
测试 3:计算器
🔴 Base: 4 个独立函数(add/subtract/multiply/divide)
🟢 LoRA: 统一接口 calculator(num1, num2, operator),更简洁
测试 4:RAG 概念解释
🔴 Base: RAG 说成"提高搜索性能的技术"+ 跑偏到数学题
🟢 LoRA: 概念仍不正确但不再跑偏
两个模型对 RAG 的解释都是错的。0.5B 太小了,装不下这类知识性概念。如果要做知识型任务,建议用 7B 以上模型。
测试 5:FastAPI 接口
🔴 Base: 列出 7 条"应该包含以下功能"——全是文字,没有代码
🟢 LoRA: 生成可运行的路由定义和视图函数
Base 像是项目经理写的需求文档,LoRA 才是工程师写出的代码。
总结对比
| 测试 | Base | LoRA | 结论 |
|---|---|---|---|
| 回文函数 | 基础实现 | 更健壮 | LoRA ✅ |
| 二分查找 | 被截断 | 完整可运行 | LoRA ✅ |
| 计算器 | 4 个独立函数 | 统一接口 | LoRA ✅ |
| RAG | 错误+跑偏 | 错误但不跑偏 | 都不行 |
| FastAPI | 文字描述 | 实际代码 | LoRA ✅ |
总结与心得
几个比较深的体会:
LoRA 在模型训练上十分有效。 0.5B 的基座、0.8% 的可训练参数、4 小时训练,就能在代码生成上看到明显改进。对大模型微调来说,性价比很高。
EOS token 是微调中最隐蔽的陷阱之一。 Qwen2 的 <|im_end|> 对话 token 和 EOS token 共用同一个 ID,这个设计在推理时会导致模型过早截断。理解 tokenizer 的底层行为比调生成参数更重要。
贪婪解码在代码任务中优于采样。 代码生成追求的是正确性和完整性,而不是多样性。do_sample=False 可以大幅减少 EOS 提前截断的问题。但在需要创意的任务中还是有区别的。
对比测试要公平。 同一模型实例切换 adapter 比加载两个独立模型更精确也更省显存。
数据量决定了微调的上限。 一开始用 80 条数据试跑时效果很差(loss 0.81),20K 数据的版本(loss 0.52)才有明显的对比差距。微调前先把数据准备好,效果会事半功倍。