模型微调示例:油菜品种信息查询助手
在 Apple Silicon(M 系列芯片)上,用 MLX-LM 对一个开源大模型做 LoRA 微调, 把它变成一个"油菜品种信息查询"垂域助手,并对比微调前后的效果。 本文记录了从环境准备、数据构造、微调到评测的完整过程,以及踩过的几个版本坑。
概述与背景
目标:构建一个能回答"某油菜品种生育期多长、含油量多少、适合种在哪里、抗什么病"等问题的垂域大模型。
为什么用 MLX-LM:Apple Silicon 的 CPU / GPU / 神经网络引擎共享同一块物理内存(统一内存),MLX 利用这一点做到"零拷贝",模型可以大到吃满整机内存,而不受独立显存限制。因此可以在没有独立显卡的 Mac 上,用接近 NumPy 的写法做 GPU/神经网络引擎加速的训练与推理。MLX-LM 是其在语言模型方向的开源实现,提供 generate / lora(微调)/ fuse(合并)等命令。
基座模型选择:mlx-community/Qwen2.5-3B-Instruct-4bit(约 2GB、中文能力强、16GB 内存即可跑,且是 MLX 格式,开箱即用)。
微调方式:LoRA(低秩适配)。只训练注入的一小部分低秩参数,基座权重冻结,显存占用小、训练快、训完权重只有几十 MB,且能直接合并回基座分发。
环境准备
需要 macOS + Apple Silicon(M1 及以上),Python ≥ 3.9。本文所用环境为 conda mldl 环境。
安装必要的依赖包:
| |
验证版本:
下载模型
新版的 Hugging Face 客户端把 huggingface-cli 整合进了 hf 命令。用 hf download 把模型拉到本地目录:
--local-dir把权重直接落到./models/Qwen2.5-3B-Instruct-4bit,之后所有命令的--model都指这个本地路径,不用每次从 Hugging Face 联网拉取。HF_HUB_DISABLE_XET=1关闭 HF 的 Xet/CAS 大文件传输层,规避偶发的401 Unauthorized下载错误。
下载完成后验证目录里有 config.json、*.safetensors、tokenizer* 即可。
先跑一次推理,确认链路通:
| |
- 提示词含 33 个 token,模型生成了 100 个 token 作为回复
- token 输入速度 62.431 tokens/sec,输出速度 84.512 tokens/sec
- 峰值内存使用 1.872 GB
注:
python -m mlx_lm.generate(点号写法)已被官方弃用,控制台命令用mlx_lm generate即可。
准备数据集
内容构成:
- 覆盖 14 个真实油菜品种:中油杂19、华油杂62、沣油737、川油36、秦优10号、中油杂2号、秦优7号、浙油50、油研10号、中双11号、华油杂9号、中油杂39、青杂2号、青杂(春油菜)等。
- 每品种生成 8 类定向查询:生育期、含油量、适种区域、育种单位/审定、抗性、产量、双低品质、综合介绍。
- 跨品种 推荐/对比 类:高油、早熟,长江中游/下游,春油菜区,机收等。
- 概念解释 类:双低、甘蓝型、三系杂交、CMS、芥酸、硫苷、菌核病、硼肥等。
- 1 条 “知识边界如实拒答” 样本,训练模型"不知道就说不知道"。
数据量:train 86 条 / valid 18 条 / test 18 条(小数据集常见约 7:1.5:1.5 划分)。
data 目录下包含 train.jsonl、valid.jsonl 和 test.jsonl 3 个文件,单条记录如下:
| |
三个文件的角色与用法
| 文件 | 条数 | 角色 | 何时用 | 作用 |
|---|---|---|---|---|
train.jsonl | 86 | 训练集 | 全程 | 模型从这里面"学"参数(更新 LoRA 权重) |
valid.jsonl | 18 | 验证集 | 训练中途反复用 | 监控泛化、决定早停、调超参(如 rank / 学习率) |
test.jsonl | 18 | 测试集 | 训练结束后只用一次 | 报告最终真实效果,模拟"考试" |
🔑 铁律:
test.jsonl在训练和调参阶段绝对不能碰。一旦你拿 test 结果去改 rank、降学习率、 或决定多训几轮,test 就"偷看了答案",不再是泛化能力的代表,退化为第二个 valid。 正确链路:train → 用 valid 反复调/早停 → 定稿后,才用 test 评一次出报告。

模型微调
创建 LoRA 配置文件 lora_config.yaml:
执行微调:
关键参数说明:
--data ./data:MLX-LM 会自动读取该目录下train.jsonl/valid.jsonl/test.jsonl。--num-layers 8:只对模型顶层若干层加 LoRA。层数越多拟合越强但越易过拟合;3B 模型常取 8–16。--mask-prompt:只对"回答"部分算 loss,避免模型去模仿提问。--iters 110:按步数训练。iters = 轮数 × (训练样本 / batch)=5 × (86/4)≈ 110 步。- 训练过程会打印 loss,loss 不再下降即可停;想看曲线可加
--report-to wandb。
训练产出的 LoRA 权重保存在 ./adapters/(通常仅几十 MB)。
可选增强(按需加进命令):
--steps-per-eval 20(每 20 步在 valid 上验证,做早停监控)、--save-every 50(每 50 步存一次)、--grad-checkpoint(内存吃紧时开,省内存但略慢)。
评估微调后的模型
为什么不用 mlx_lm evaluate
在 0.31.3 中,mlx_lm evaluate 已被重写为 lm-evaluation-harness 驱动,只认 --tasks(hellaswag / mmlu / gsm8k 等公开学术基准),既不支持 --data(喂自定义 jsonl),也不支持 --adapter-path(评 LoRA)。因此:
它适合做的是通用能力体检(如 mlx_lm evaluate --tasks hellaswag arc_challenge --limit 100),
而不是评测你的领域 test.jsonl。要评"基座 vs 微调"在自定义测试集上的表现,得用程序化方式。
评测脚本 eval.py
eval.py 直接调用 MLX-LM 的程序化 API:
load(model, adapter_path=...)加载基座或带 LoRA 的模型;- 用
tokenizer.apply_chat_template(..., add_generation_prompt=True)构建提示,与mlx_lm generate的模板完全一致, 避免评测 / 推理不一致; - 指标在脚本内纯 Python 自实现(字符级 ROUGE-L / tokenF1 / ExactMatch),零额外依赖,无需
rouge_score; - 读取
messages格式(兼容prompt/completion简单格式),逐条生成并打分,明细写入 JSON 便于排查。
评测结果
基座模型
| |
微调模型
| |
结果对比表
| 指标 | 基座 (base) | 微调 (LoRA) | 提升 |
|---|---|---|---|
| ROUGE-L | 0.277 | 0.730 | +163% |
| tokenF1 | 0.383 | 0.807 | +111% |
| ExactMatch | 0.000 | 0.111 | +0.111(18 题命中 1 题) |

结论
- LoRA 微调带来显著提升:ROUGE-L 从 0.277 跃升到 0.730(+163%)、tokenF1 从 0.383 升到 0.807(+111%), 说明模型对油菜品种问答的"表述结构与关键信息覆盖"已大幅贴合标准答案,微调方向正确有效。
- 绝对质量仍有空间:ExactMatch 仅 0.111(18 题仅 1 题完全一致),且基座 tokenF1 已 0.383,
说明模型"见过字"但离"一字不差"还差一截。后续可增大训练轮数 / 数据量、降低学习率抑制过拟合,
或在
eval.py中加关键字段抽取核对(含油量、适种区域、生育期逐字段比对)来严判领域事实正确性。
评测指标详解
ROUGE-L 与 tokenF1 都是**表面重叠度(surface overlap)**指标——只比"预测答案"和"标准答案"在字面上的重合程度,不判断语义对错。区别在"怎么算重合"。
- ROUGE-L(最长公共子序列,顺序敏感)
- 在预测和 gold 里找 LCS,字符可不全连着但相对顺序要对;
- 召回 R = LCS 长度 / gold 长度,精度 P = LCS 长度 / 预测长度,ROUGE-L = 2·R·P / (R+P);
- 把句子换个语序,LCS 变短、分数掉;范围 0~1,越高越好;
- 适合衡量"表述结构 / 流畅度"像不像标准答案(摘要、长回答等顺序敏感任务)。
- tokenF1(字符集合交集,顺序无关)
- 把预测和 gold 各看成字符集合,算集合 F1;
- R = 共同字符数 / gold 字符数,P = 共同字符数 / 预测字符数,tokenF1 = 2·R·P / (R+P);
- 完全不在乎顺序,只看"字覆盖没覆盖";范围 0~1,越高越好;
- 适合衡量"关键信息字覆盖没覆盖"(抽取 / 短答案,如"适种区域"“含油量数值"有没有出现)。
- ExactMatch(精确匹配)
- 去空白后预测与 gold 完全一致的比例,最严格;领域抽取 / 短答案类任务的"一字不差"判据。
⚠️ 三者都只量"形似”,量不了"事实真"。例如预测把"含油量 45%“写成"含油量 54%“只差一字, ROUGE-L / tokenF1 都会很高,但事实错了。领域上线前需配人工或字段级核查兜底。
下图用同一组重排示例(gold=适宜黄淮长江下游,pred=适宜长江下游黄淮)直观对比两个指标:
ROUGE-L 因顺序被打乱而扣分,tokenF1 因字全覆盖而满分。

踩坑记录
① 下载报 401 Unauthorized ... cas-server.xethub.hf.co(Xet/CAS 传输层 bug)
新版 HF 用 Xet 大文件传输,公开模型也会偶发 401。加 HF_HUB_DISABLE_XET=1 关掉它,回退普通 HTTPS 下载即可。
若仍 401,清掉半截缓存:rm -rf ~/.cache/huggingface/hub/models--mlx-community--Qwen2.5-3B-Instruct-4bit 再下。
Qwen2.5-3B-Instruct-4bit 是公开模型,正常无需 hf login。
② python -m mlx_lm.generate 弃用告警
控制台改用 mlx_lm generate(或 python -m mlx_lm generate 中间加空格),告警即消失。
③ mlx_lm lora 报 unrecognized arguments: --num-epochs 5 --lora-parameters {...}
0.31.3 已移除这两个旧参数:
--num-epochs→ 改用--iters(按"轮数 × 样本数 / batch"换算,本例 5 × 86/4 ≈ 110);--lora-parameters '{...}'→ 改成 YAML 配置文件-c lora_config.yaml。
④ mlx_lm evaluate 报 the following arguments are required: --tasks
0.31.3 的 evaluate 已变为 lm-eval-harness 驱动,只认 --tasks,不支持 --data 与 --adapter-path,
无法评自定义 jsonl 或 LoRA。自定义领域评测改用程序化 eval.py;通用能力体检才用 mlx_lm evaluate --tasks ...。
⑤ 本地 MLX 模型与标准 HF 模型的区分
只有 mlx-community/* 这类仓库是 MLX 格式(config.json 含 quantization 块 + model.safetensors)。
若 hf download 的是普通 HF 模型(如 Qwen/Qwen2.5-3B-Instruct),需先转格式:
mlx_lm convert --hf-path ./models/Qwen2.5-3B-Instruct -q --local-dir ./models/Qwen2.5-3B-Instruct-4bit。
本文基于 mlx-lm 0.31.3、Apple Silicon(macOS)环境实测整理。