返回博客列表

再谈微调:LoRA 指令微调 Qwen2--从零到简历项目的踩坑实录

2026-06-19阅读约 3 分钟LoRA实战踩坑

写在前面

前段时间写了一篇关于大模型微调的文章,立下了解决配置和数据问题的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
rank8
lora_alpha32
target_modulesq_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学习率
开始101.002.0e-4
第 1 个 epoch 结束~12500.751.6e-4
第 2 个 epoch 结束~25000.721.2e-4
第 3 个 epoch 结束~37500.658.0e-5
第 4 个 epoch 结束~50000.584.0e-5
训练结束62600.523.2e-8

第一步:训练脚本

train_lora.py 的核心逻辑只有几段:

加载数据集

我从 GitHub 下载了 CodeAlpaca 的 code_alpaca_20k.json,每一条数据包含 instructioninputoutput 三个字段。加载后格式化成 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 才是工程师写出的代码。

总结对比

测试BaseLoRA结论
回文函数基础实现更健壮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)才有明显的对比差距。微调前先把数据准备好,效果会事半功倍。