大模型 LoRA 微调全流程实战 - 车载问答全流程(跑通 + 参数详解 + 训练日志逐行解读 + 模型合并)
运行环境 :Windows 11 + Python 3.10 + PyTorch(CPU) + transformers 5.x + peft
基座模型 :Qwen1.5-1.8B-Chat | 数据规模 :30 条车载问答 | 训练耗时 :CPU 约 34 分钟
源码仓库(Gitee) :https://gitee.com/allensu0108/llm-fine-tuning
| 训练脚本、合并脚本、30 条数据与 README 全在里面,可直接克隆运行:
git clone https://gitee.com/allensu0108/llm-fine-tuning.git本文关键词:LoRA / PEFT / 大模型微调 / 训练日志解读 / 过拟合排查 / 模型合并 / Qwen1.5
摘要
很多人学 LoRA 卡在两个地方:参数表看不懂 ,和日志跑出来不会读。
本文用一个真实跑完的项目把这两件事讲透------从 30 条车载问答数据出发,微调 Qwen1.5-1.8B,全程 CPU 无显卡:
- 完整代码 :651 行脚本
vehicle_lora_finetune.py按 7 段逐段讲解------LoRA 注入、文本拼接与 labels 构造、Trainer 组装、版本兼容写法、推理验收;外加merge_lora_model.py把 adapter 合并成独立模型(含 24 MB → 6.84 GB 的真实体积实测),代码可直接复制运行 - 工程全貌 :真实训练目录逐行注释------三个
vehicle-chatbot-*目录各是干什么的、checkpoint为什么只剩 3 个、adapter 里到底存了什么、哪些目录不该传 Git - 原理与参数 :LoRA 到底改了模型哪里?
r、lora_alpha、target_modules、use_rslora等 12 个参数逐个讲清,配对照表和调参速查表 - 数字推导 :
6 条验证集、3 步/轮、15 总步、629 万可训练参数这些数字分别从哪个公式来的,能手算验证 - 两次对照实验:8 条 × 10 轮(过拟合实录,含曲线图)vs 30 条 × 5 轮(收敛健康)
- 日志手册:把训练日志拆成 6 类,逐行讲含义 + 一张"关键行 → 判断标准"的速查表
- 踩坑清单:628M 参数是怎么冒出来的、过拟合为什么会导致复读、模板不一致的致命后果
- 配套源码:全部脚本、数据与说明文档已开源在 Gitee → https://gitee.com/allensu0108/llm-fine-tuning (可 clone 直接跑)
目录
- 一、项目背景:我们要做什么
- [二、什么是 LoRA?原理通俗版](#二、什么是 LoRA?原理通俗版)
- [三、LoRA 配置参数详解(LoraConfig)](#三、LoRA 配置参数详解(LoraConfig))
- 四、训练超参详解(TrainingArguments)
- [五、训练数据与 chat template:为什么必须一致](#五、训练数据与 chat template:为什么必须一致)
- [六、实战代码逐段讲解:vehicle_lora_finetune.py 是怎么微调的](#六、实战代码逐段讲解:vehicle_lora_finetune.py 是怎么微调的)
- [七、模型合并:merge_lora_model.py 把 adapter 变成独立模型](#七、模型合并:merge_lora_model.py 把 adapter 变成独立模型)
- 八、核心数字是怎么算出来的
- [九、第一次翻车:8 条 × 10 轮 = 过拟合实录](#九、第一次翻车:8 条 × 10 轮 = 过拟合实录)
- [十、第二次成功:30 条 × 5 轮,曲线健康](#十、第二次成功:30 条 × 5 轮,曲线健康)
- 十一、训练日志逐行教学手册
- 十二、训练日志速查表(一页版)
- 十三、常见警告要不要管
- 十四、总结
一、项目背景:我们要做什么
1.1 需求
通用大模型什么都懂一点,但放到具体业务里常常"不够专业":问"胎压报警灯亮了还能开吗",它给的是泛泛的通用建议;问"360 影像车速快了会关闭吗",回答格式随意、容易扯远。
我们想让它学会三件事:车载场景的语气、简洁准确的结构、安全优先的措辞 。而全量微调 18 亿参数需要几十 GB 显存,所以选择 LoRA。
1.2 一条主线跑完全流程
vehicle_qa_local.json(30 条原始问答)
│ 统一成 instruction / input / output 三字段
▼
vehicle_qa_formatted.json
│ 按固定模板拼文本 → 分词 → 生成 labels
▼
Dataset(input_ids / attention_mask / labels)
│ Trainer 训练:前向 → 算 loss → 反向 → 只更新 LoRA 的 A、B
▼
vehicle-chatbot-final/(adapter 24 MB + tokenizer)
│ ① 训练后自测:PeftModel → merge_and_unload()(只在内存里合,第六章 6.6)
│ ② 交付合并:merge_lora_model.py 落盘(第七章)
▼
vehicle-chatbot-merged/(独立完整模型 ≈ 6.84 GB)→ 生成回答
1.3 硬件与成果一览
| 项目 | 配置 / 结果 |
|---|---|
| 设备 | 普通桌面 CPU,CUDA 可用:False |
| 数据 | 30 条车载问答(训练 24 / 验证 6) |
| 可训练参数 | 6,291,456(0.3413%) ← 纯 LoRA |
| 训练 | 30 条 × 5 轮 = 15 步 ,耗时 2016 秒(33 分 36 秒) |
| 显存/内存 | 1.8B fp32 权重常驻约 7.4 GB,建议内存 ≥ 16 GB |
| 产物 | vehicle-chatbot-final/(adapter,24 MB );vehicle-chatbot-merged/(合并后独立模型,6.84 GB) |
有 NVIDIA 显卡的同学会快几十倍,代码里已做设备自适应,无需改代码。
1.4 真实项目目录结构(带注释)
先看全景图。每一行都标了它是干什么的、多大:
text
llm-fine-tuning/ ← 项目根目录
│
├─ vehicle_lora_finetune.py ← 【训练脚本】651 行,数据→训练→验证→推理全在这
├─ merge_lora_model.py ← 【合并脚本】adapter + 基座 → 独立模型(第七章)
├─ vehicle_qa_local.json ← 【原始数据】30 条车载问答(question / answer 两字段)
├─ vehicle_qa_formatted.json ← 【格式化数据】脚本自动转成 instruction/input/output
│
├─ models/ ← 基座模型存放处(自己下载,见 1.6 的 .gitignore)
│ └─ Qwen1.5-1.8B-Chat/
│ ├─ model.safetensors ← 权重本体 3503 MB(fp16 存储)
│ ├─ config.json ← 模型结构定义(层数、hidden_size=2048、24 层...)
│ ├─ generation_config.json ← 默认生成参数(temperature、top_p 等)
│ ├─ tokenizer.json ← 分词器 6.70 MB(词表 + 合并规则)
│ ├─ tokenizer_config.json ← 分词器的配置(特殊 token、chat_template)
│ ├─ vocab.json ← 词表 2.65 MB(token ↔ id 映射)
│ └─ merges.txt ← BPE 合并规则 1.59 MB
│
├─ vehicle-chatbot-lora/ ← 【过程产物】= 脚本里的 OUTPUT_CKPT(output_dir)
│ ├─ checkpoint-9/ ← 第 3 轮结束时的快照(第 9 步)
│ ├─ checkpoint-12/ ← 第 4 轮结束时的快照(第 12 步)
│ └─ checkpoint-15/ ← 最后一轮结束(第 15 步),内含 11 个文件:
│ ├─ adapter_model.safetensors ← 24.02 MB,LoRA 权重(和 final 里那份同源)
│ ├─ adapter_config.json ← r / alpha / target_modules,加载时按它重建结构
│ ├─ optimizer.pt ← 48.15 MB,优化器状态(只有续训才需要)
│ ├─ scheduler.pt ← 学习率调度器状态
│ ├─ rng_state.pth ← 随机数种子状态(保证续训可复现)
│ ├─ trainer_state.json ← 每步 loss / 学习率的原始数据(复盘曲线就看它)
│ ├─ training_args.bin ← 本次训练的全部超参(二进制存盘)
│ ├─ tokenizer.json ← 10.89 MB,随 checkpoint 一起存的分词器
│ ├─ tokenizer_config.json / chat_template.jinja / README.md
│
├─ vehicle-chatbot-final/ ← 【交付产物】训练结束时 save_model() 落盘的 adapter
│ ├─ adapter_model.safetensors ← 24.02 MB ← 全部"知识增量"就这 24 MB
│ ├─ adapter_config.json ← 【配方】r=16 / lora_alpha=32 / use_rslora=true
│ │ target_modules=[q,k,v,o_proj];没它 adapter 无法还原
│ ├─ tokenizer.json ← 10.89 MB
│ ├─ tokenizer_config.json / chat_template.jinja
│ ├─ training_args.bin ← 记录训练配置,方便日后追溯
│ └─ README.md ← Trainer 自动生成(含训练框架版本信息)
│
├─ vehicle-chatbot-merged/ ← 【部署产物】merge_lora_model.py 的输出
│ ├─ model.safetensors ← 7006.98 MB ≈ 6.84 GB,完整 fp32 权重
│ ├─ config.json ← 标准模型结构(architectures=Qwen2ForCausalLM,
│ │ dtype=float32),没有任何 LoRA / peft 字段
│ ├─ generation_config.json ← 推理默认参数,可直接生成
│ ├─ tokenizer.json ← 10.89 MB
│ └─ tokenizer_config.json / chat_template.jinja
│ (没有 adapter_config.json,也不再需要安装 peft)
│
└─ venv/ ← Python 虚拟环境(37467 个文件,永远不要传 Git)
1.5 三个"输出目录"到底有什么区别
很多人第一次跑完会懵:怎么冒出三个 vehicle-chatbot-* 目录?它们的职责完全不同:
| 目录 | 谁生成的 | 体积 | 作用 | 能否单独运行 |
|---|---|---|---|---|
vehicle-chatbot-lora/ |
Trainer 自动存(output_dir) |
每个约 83 MB × 3 ≈ 250 MB | 训练过程快照:断点续训、复盘每轮 loss | 不能,必须配基座模型 |
vehicle-chatbot-final/ |
训练结束 save_model() |
24 MB | 交付出去的 LoRA adapter,可重新挂载/再合并 | 不能,必须配基座 + peft |
vehicle-chatbot-merged/ |
merge_lora_model.py |
6.84 GB | 部署发布用,一个目录即完整模型 | 能,标准 transformers 模型 |
几个能顺便看懂的细节:
- 为什么只有
checkpoint-9/12/15,没有 3 和 6? 因为save_strategy="epoch"(每轮结束存一次,一轮 = 3 步),save_total_limit=3(只留最近 3 个),早期快照被自动清掉了。这也解释了磁盘占用为什么不会无限增长。 - checkpoint 每个约 83 MB 是怎么来的?
adapter_model.safetensors24.02 MB +optimizer.pt48.15 MB +tokenizer.json10.89 MB ≈ 83 MB。其中 48.15 MB 的优化器状态正是 Adam 为 629 万可训练参数存的两份动量(6,291,456 × 4 字节 × 2 ≈ 48 MB),不续训的话这些文件可以直接删。 vehicle-chatbot-final/里为什么也要放分词器? 因为分词器决定了文本怎么切成 token,换一个分词器,同一句话的 id 就变了,模型立刻"看不懂话"。所以 adapter 必须和它训练时的分词器绑定发布。- 合并后的目录为什么没有
adapter_config.json? 合并已经把增量算进W了,LoRA 结构被卸载,剩下的就是一个普普通通的 Qwen1.5 ------ 这也是它体积从 24 MB 涨回 6.84 GB 的原因。对比两个config.json就能看出区别:合并后的architectures写的是Qwen2ForCausalLM、dtype是float32,里面完全没有 LoRA 的影子。 adapter_config.json还藏着一条容易踩的坑 :里面的base_model_name_or_path记的是./models/Qwen1.5-1.8B-Chat。所以换了基座目录名、或者把 adapter 拷到别的机器上加载时,它会按这个字段去找底模 ------ 找不到就报路径错。迁移时要么保持目录一致,要么显式指定--base_model/ 把该字段改成新路径。- 训练产物里唯一"必需"的只有两个文件 :
adapter_model.safetensors(权重)和adapter_config.json(配方)。其余的分词器是"强烈建议保留",optimizer.pt、scheduler.pt、rng_state.pth、training_args.bin则属于"续训/追溯"才用得上的附属品。
1.6 如果要把项目传到 Git,怎么处理这些目录
真正需要版本管理的其实只有 2 个 .py + 2 个 .json ,其余都可以重新生成或下载。建议的 .gitignore:
gitignore
# 基座模型:3.4 GB,让克隆者自己去 HuggingFace 下(第四章命令)
models/
# 虚拟环境:3.7 万个文件,装了就有
venv/
.venv/
# 训练过程快照:约 250 MB,且会随训练反复变化
vehicle-chatbot-lora/
# 部署产物:6.84 GB,能从 adapter 一条命令合并回来
vehicle-chatbot-merged/
# Python 缓存
__pycache__/
*.py[cod]
克隆者拿到的仓库只有几百 KB,按「下载基座 → 跑训练 → 跑合并」三步就能复现出全部产物。
下面是整条微调链路的总览图,先建立全局印象,再逐章深入:
#mermaid-svg-ANMGOoTX2wri7Ear{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ANMGOoTX2wri7Ear .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ANMGOoTX2wri7Ear .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ANMGOoTX2wri7Ear .error-icon{fill:#552222;}#mermaid-svg-ANMGOoTX2wri7Ear .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ANMGOoTX2wri7Ear .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ANMGOoTX2wri7Ear .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ANMGOoTX2wri7Ear .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ANMGOoTX2wri7Ear .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ANMGOoTX2wri7Ear .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ANMGOoTX2wri7Ear .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ANMGOoTX2wri7Ear .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ANMGOoTX2wri7Ear .marker.cross{stroke:#333333;}#mermaid-svg-ANMGOoTX2wri7Ear svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ANMGOoTX2wri7Ear p{margin:0;}#mermaid-svg-ANMGOoTX2wri7Ear .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ANMGOoTX2wri7Ear .cluster-label text{fill:#333;}#mermaid-svg-ANMGOoTX2wri7Ear .cluster-label span{color:#333;}#mermaid-svg-ANMGOoTX2wri7Ear .cluster-label span p{background-color:transparent;}#mermaid-svg-ANMGOoTX2wri7Ear .label text,#mermaid-svg-ANMGOoTX2wri7Ear span{fill:#333;color:#333;}#mermaid-svg-ANMGOoTX2wri7Ear .node rect,#mermaid-svg-ANMGOoTX2wri7Ear .node circle,#mermaid-svg-ANMGOoTX2wri7Ear .node ellipse,#mermaid-svg-ANMGOoTX2wri7Ear .node polygon,#mermaid-svg-ANMGOoTX2wri7Ear .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ANMGOoTX2wri7Ear .rough-node .label text,#mermaid-svg-ANMGOoTX2wri7Ear .node .label text,#mermaid-svg-ANMGOoTX2wri7Ear .image-shape .label,#mermaid-svg-ANMGOoTX2wri7Ear .icon-shape .label{text-anchor:middle;}#mermaid-svg-ANMGOoTX2wri7Ear .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ANMGOoTX2wri7Ear .rough-node .label,#mermaid-svg-ANMGOoTX2wri7Ear .node .label,#mermaid-svg-ANMGOoTX2wri7Ear .image-shape .label,#mermaid-svg-ANMGOoTX2wri7Ear .icon-shape .label{text-align:center;}#mermaid-svg-ANMGOoTX2wri7Ear .node.clickable{cursor:pointer;}#mermaid-svg-ANMGOoTX2wri7Ear .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ANMGOoTX2wri7Ear .arrowheadPath{fill:#333333;}#mermaid-svg-ANMGOoTX2wri7Ear .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ANMGOoTX2wri7Ear .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ANMGOoTX2wri7Ear .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ANMGOoTX2wri7Ear .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ANMGOoTX2wri7Ear .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ANMGOoTX2wri7Ear .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ANMGOoTX2wri7Ear .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ANMGOoTX2wri7Ear .cluster text{fill:#333;}#mermaid-svg-ANMGOoTX2wri7Ear .cluster span{color:#333;}#mermaid-svg-ANMGOoTX2wri7Ear div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ANMGOoTX2wri7Ear .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ANMGOoTX2wri7Ear rect.text{fill:none;stroke-width:0;}#mermaid-svg-ANMGOoTX2wri7Ear .icon-shape,#mermaid-svg-ANMGOoTX2wri7Ear .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ANMGOoTX2wri7Ear .icon-shape p,#mermaid-svg-ANMGOoTX2wri7Ear .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ANMGOoTX2wri7Ear .icon-shape .label rect,#mermaid-svg-ANMGOoTX2wri7Ear .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ANMGOoTX2wri7Ear .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ANMGOoTX2wri7Ear .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ANMGOoTX2wri7Ear :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 原始数据
vehicle_qa_local.json
格式化
instruction/input/output
分词 + labels
Trainer 训练
只更新 LoRA A/B
adapter 产物
24 MB
merge_lora_model.py
合并回主权重
独立完整模型
6.84 GB
部署推理
二、什么是 LoRA?原理通俗版
2.1 全量微调的痛点
普通微调会更新模型的全部权重。以 7B 模型为例,光把权重从 float32 存下来就要 28GB 显存,训练时还需要梯度、优化器状态,实际占用可达 100GB+。普通人的电脑根本跑不动。
2.2 LoRA 的核心思想:不训练"原矩阵",训练"两个小矩阵"
设想模型里某个线性层权重是 W(比如 768 × 768)。全量微调要更新整个 W。
LoRA 的做法是:冻结 W 不动 ,在旁边挂上两个小矩阵 A 和 B,只训练它们:
原始层: y = W @ x
加了 LoRA: y = W @ x + (alpha / r) * B @ A @ x
└── 这就是"增量" ΔW = B @ A
A的形状是r × 输入维度,B的形状是输出维度 × rr就是秩(rank),通常只有 8 / 16 / 32,比原矩阵维度小得多- 训练完,效果约等于在原权重上加了一个低秩修正:
W_new ≈ W + ΔW
本项目实测 :trainable params: 6,291,456,占总参数 1,843,120,128 的 0.3413%。
这个 629 万还能手算验证:
6,291,456 = 24 层 × 4 个注意力矩阵 × [16 × (2048 + 2048)]
↑层数 ↑q/k/v/o ↑r ↑输入维 ↑输出维
能自己算出来,才说明真的理解了 LoRA 的参数规模。
LoRA 的旁路结构可以用下面这张图直观理解:
渲染错误: Mermaid 渲染失败: Setting W as parent of W would create a cycle
2.3 为什么"低秩"可行?(直觉版)
研究认为:模型微调时,真正需要的方向变化其实集中在一个很低的维度空间里(比 768 维小得多)。既然有效信息只有 16 维,就没必要更新全部 768 维 ------ 用 16 维去"代理"就够了。这就是 r=16 也能有不错效果的原因。
2.4 为什么 LoRA 省显存、省时间
| 对比项 | 全量微调 | LoRA |
|---|---|---|
| 可训练参数 | 100% | 通常 < 1%(1.8B 模型约 629 万参数) |
| 需要保存的梯度 | 全部权重 | 只有 A、B 小矩阵 |
| 优化器状态(Adam 两倍参数) | 巨大 | 很小 |
| 产出物大小 | 几个 GB 的新模型 | 几十 MB 的 adapter |
| 能否一卡多任务 | 一个任务一个完整模型 | 一个基座 + N 个 adapter 随意切换 |
这就是为什么教程里老强调:用 LoRA 单张消费级显卡就能微调大模型。
三、LoRA 配置参数详解(LoraConfig)
3.1 LoraConfig 参数对照表
下表最后一列是本项目 vehicle_lora_finetune.py 的真实取值,可以直接对照自己的代码看。
| 参数 | 默认值 | 作用 / 说明 | 本项目取值 |
|---|---|---|---|
r |
8 | 秩。低秩矩阵宽度,决定可学习容量。经验:简单任务 8~16,难任务 32~64。r 翻倍 ≈ 新增 LoRA 参数翻倍 | ✔ 16 |
lora_alpha |
8 | 缩放系数 。实际缩放 = alpha / r。惯例 alpha = 2r,本项目 32/16 = 2,非常标准 |
✔ 32 |
target_modules |
无(必须给) | 往哪些模块插 LoRA ,需与模型实际结构匹配。Qwen/Llama 共 7 个线性层:注意力 q/k/v/o_proj + FFN gate/up/down_proj |
✔ 注意力 4 个(见 3.3) |
lora_dropout |
0.0 | LoRA 层 dropout,防过拟合。数据少就开 0.05~0.1 | ✔ 0.05 |
bias |
"none" |
是否训练 bias:none(不训,最省) / all(全训) / lora_only |
✔ "none" |
task_type |
None |
任务类型:CAUSAL_LM(对话/续写)、SEQ_2_SEQ_LM(翻译/摘要)、TOKEN_CLS 等 |
✔ CAUSAL_LM |
fan_in_fan_out |
False |
权重存成 (out, in) 顺序的老模型(如 GPT-2 的 Conv1D)才需要 True。Qwen 不需要 |
缺省(正确) |
modules_to_save |
None |
除 LoRA 层外,还想全量微调 哪些模块(如 ["embed_tokens","lm_head"],多用于分类头)。⚠️ 小数据下慎开,见第九章 |
缺省 None |
init_lora_weights |
True |
A 用高斯初始化、B 全零初始化(保证训练开始时 ΔW=0,不破坏原模型) | 缺省(正确) |
layers_to_transform / layers_pattern |
None |
只想在部分层(而非全部层)插 LoRA 时用,新手不需要 | 缺省(正确) |
use_rslora |
False |
开 RS-LoRA :缩放改 alpha / sqrt(r),高秩、长训练时更稳(PEFT ≥ 0.9 可用) |
✔ True |
inference_mode |
False |
是否直接进入推理模式,框架自动处理,不用手填 | 缺省(正确) |
3.2 可直接运行的配置代码
python
lora_config = LoraConfig(
r=16, # 秩:旁路矩阵宽度
lora_alpha=32, # 缩放:alpha / r = 2
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"], # 注意力 4 个投影
lora_dropout=0.05, # 防过拟合
bias="none", # 不训练 bias(最省)
task_type="CAUSAL_LM", # 自回归文本生成
use_rslora=True, # 秩稳定缩放,长训练更稳(PEFT >= 0.9)
modules_to_save=None, # 纯对话 SFT 保持 None
)
peft_model = get_peft_model(model, lora_config)
peft_model.print_trainable_parameters() # 打印"可训练参数 / 总参数(占比)"
3.3 target_modules 怎么选:4 个还是 7 个?
| 类别 | 模块 | 作用 | 建议 |
|---|---|---|---|
| 注意力投影 | q_proj / k_proj / v_proj / o_proj |
决定"关注哪些信息、怎么聚合" | 必选,性价比最高 |
| 前馈网络(FFN) | gate_proj / up_proj / down_proj |
决定"知识怎么存储与加工" | 知识密集任务建议加上 |
- 只训注意力 → 模型**"会听话、格式对"**(本项目 30 条数据走的就是这条路)
- 加上 FFN → 知识吸收能力更强,但参数量约增加 1.75 倍(4 个变 7 个),小数据下过拟合风险也随之上升
- 省显存技巧 :只想压缩到极致,可以只写
["q_proj", "v_proj"]
3.4 调参经验速查(什么时候动哪个)
| 现象 | 优先调整 |
|---|---|
| 训练 loss 一直不降 | 先查数据/模板,再调大 r,学习率提到 2e-4 |
| 过拟合(训练 loss 低、表现差) | 调大 lora_dropout 到 0.1,减少 epochs,加大数据量 |
| 学不会新知识/风格 | 调大 r(16→32→64),或给 target_modules 加 FFN |
| 显存不够 | 调小 r、减小 max_length、减小 batch_size、确认 modules_to_save=None |
| 效果"学到了但飘" | alpha/r 保持 2 左右,别乱改比例 |
四、训练超参详解(TrainingArguments)
新手最容易忽略:真正决定"能不能训出来"的,不只是 LoRA 那几个参数,还有训练超参。
4.1 参数对照表
| 参数 | 默认 | 作用 / 建议 | 本项目取值 |
|---|---|---|---|
output_dir |
必填 | 保存 checkpoint / 日志的目录 | ✔ ./vehicle-chatbot-lora |
num_train_epochs |
3 | 完整训练几轮。数据大 1~2,数据小 3~5 | ✔ 5 |
per_device_train_batch_size |
8 | 单设备每步样本数,小省显存、大跑得快 | ✔ 1 |
gradient_accumulation_steps |
1 | 攒 N 步再更新一次权重,等效放大 batch = batch × N。小显存必备 | ✔ 8 |
learning_rate |
5e-5 | LoRA 常用 1e-4 ~ 2e-4 | ✔ 1e-4 |
lr_scheduler_type |
linear | 学习率衰减方式,数据多/轮数多建议 cosine |
✔ cosine |
warmup_ratio / warmup_steps |
0 | 前 N 步用小学习率热身,防一开始震荡 | ✔ warmup_steps ≈ 总步数 × 3% |
weight_decay |
0.0 | 权重衰减(L2 正则),轻微防过拟合 | ✔ 0.01 |
max_grad_norm |
1.0 | 梯度裁剪,防梯度爆炸 | ✔ 1.0 |
logging_steps |
500 | 每隔多少步打印 loss,新手设 1 方便盯 | ✔ 1 |
save_strategy |
steps | steps / epoch / no,多久存一次 checkpoint |
✔ epoch |
save_total_limit |
无 | 最多保留几个 checkpoint,防爆硬盘 | ✔ 3 |
eval_strategy |
no | 多久在验证集评一次(旧版叫 evaluation_strategy) |
✔ epoch |
load_best_model_at_end |
False |
结束时自动装回验证 loss 最低的权重 | ✔ True |
metric_for_best_model / greater_is_better |
--- | 以什么指标选"最优" | ✔ loss / False |
fp16 |
False |
半精度训练,省一半显存。只对 NVIDIA GPU 有效 | ✔ False(CPU) |
bf16 |
False |
新版 GPU(A100/4090) 支持,更稳的省显存方式 | 有 GPU 可开 |
gradient_checkpointing |
False |
用"重算前向"换显存,再省约 30%,代价是略慢 | 大模型时开 |
optim |
adamw_torch | 优化器,新手保持默认 | ✔ adamw_torch |
report_to |
all | 上报 wandb/tensorboard,新手设 "none" 少踩坑 |
✔ "none" |
seed |
42 | 随机种子,保证可复现 | 可不改 |
4.2 显存不足时的经典组合
把这段理解透,比背参数强:
python
train_args = TrainingArguments(
output_dir="./vehicle-chatbot-lora",
num_train_epochs=5,
per_device_train_batch_size=1, # 先压 batch
gradient_accumulation_steps=8, # 等效 batch=8,用速度换稳定
learning_rate=1e-4,
lr_scheduler_type="cosine",
weight_decay=0.01,
logging_steps=1,
save_strategy="epoch",
save_total_limit=3,
fp16=False, # 有 NVIDIA GPU 时改 True
optim="adamw_torch",
report_to="none",
)
# 有验证集时,打开"每轮模拟考 + 自动选最优"
if has_eval:
train_args.eval_strategy = "epoch"
train_args.load_best_model_at_end = True
train_args.metric_for_best_model = "loss"
train_args.greater_is_better = False
为什么要
load_best_model_at_end? 它让 Trainer 每轮存 checkpoint,训练结束后自动回滚到"验证集表现最好的那一轮",而不是硬用最后一轮。这样即使 epochs 设多了,也不会拿到过拟合最严重的那份权重。
4.3 版本兼容提醒(transformers 5.x 踩坑)
| 旧写法 | 新写法 | 说明 |
|---|---|---|
torch_dtype= |
dtype= |
只是改名,模型加载时提示 deprecated |
evaluation_strategy= |
eval_strategy= |
≥ 4.46 改名,写旧的会直接报错 |
warmup_ratio= |
warmup_steps= |
5.x 移除,需自己算:int(总步数 × 0.03) |
tokenizer=(Trainer) |
processing_class= |
5.x 改名 |
五、训练数据与 chat template:为什么必须一致
5.1 数据文件长什么样
vehicle_qa_local.json 是一个 JSON 数组 ,每个元素含 question / answer;脚本会统一整理成 instruction / input / output 三字段:
json
[
{
"instruction": "你是车载智能助手。回答简洁准确。不提供危险驾驶建议。遇到故障请提示安全停车并联系服务站。",
"input": "胎压报警灯亮了还能继续开吗?",
"output": "不能继续高速行驶。请先安全靠边,检查轮胎是否明显亏气或扎钉;若胎压过低,请更换备胎或联系救援,切勿长距离行驶。"
},
{
"instruction": "你是车载智能助手。回答简洁准确。不提供危险驾驶建议。遇到故障请提示安全停车并联系服务站。",
"input": "怎么关闭自动启停?",
"output": "按下中控上标有 A OFF 的按键即可关闭;下次车辆启动后自动启停会默认重新开启。"
}
]
提示:JSONL(每行一个对象)格式也能被
load_dataset("json", ...)正常读取。
为什么要加固定的 instruction? 它相当于告诉模型"以后遇到这类问题,都按这个身份和风格来答",能显著提升指令跟随能力。
5.2 训练和推理必须用同一套模板(最容易犯的致命错误)
apply_chat_template 会把消息渲染成模型官方定义的特殊 token 文本。如果训练时用的格式和推理时用的格式不一样,模型会学到一种"方言",而推理时却用另一种"方言"提问,效果自然很差。
常见表现:训练 loss 很低,但生成胡言乱语 / 不停复读。
铁律:训练时的拼接格式和推理时的拼接格式必须一字不差。
本项目用的是显式拼接(比起 chat template 更直观、更不容易踩坑):
python
# 训练时:拼上"答案"
text = f"{inst}\n\n用户问题:{inp}\n\n助手回答:{out}"
# 推理时:同样格式,但答案留空,让模型自己续写
prompt = f"{inst}\n\n用户问题:{query}\n\n助手回答:"
排查技巧:把训练数据的实际拼接结果打印出来,和推理时的 prompt 贴在一起逐字符对比。
5.3 如果用的是 chat template,注意这一个开关
- 训练阶段 :
add_generation_prompt=False(末尾不加<|im_start|>assistant,因为数据里已经包含 assistant 的回答) - 推理阶段 :
add_generation_prompt=True(模型要"接着 assistant 的位置自己写")
这是唯一允许(也必须)不一致的地方,其余内容必须完全一致。
5.4 labels 的一个细节:padding 位置要填 -100
python
tokenized["labels"] = [
[t if m else -100 for t, m in zip(ids, mask)] # 真词留原值,pad 位置填 -100
for ids, mask in zip(tokenized["input_ids"], tokenized["attention_mask"])
]
-100 是 HuggingFace 约定的"忽略标记",这些位置不参与 loss 计算------比直接复制 input_ids 更严谨。
六、实战代码逐段讲解:vehicle_lora_finetune.py 是怎么微调的
完整脚本 651 行,中文注释写得很细,共 7 个部分。本节按"从上到下读一遍"的顺序,只贴真正决定微调效果 的代码段,并讲清每段的意图与坑。
阅读顺序:全局配置 → 数据准备 → 模型 + LoRA → 拼接与分词 → 训练 → 推理 → 入口
6.1 第一部分:全局配置区(所有"旋钮"都在这)
python
MODEL_NAME = "Qwen/Qwen1.5-1.8B-Chat" # 也可填本地目录,如 "./models/Qwen1.5-1.8B-Chat"
OUTPUT_CKPT = "./vehicle-chatbot-lora" # 中间 checkpoint(每轮存一次)
OUTPUT_FINAL = "./vehicle-chatbot-final" # 最终产物(推理时加载这里)
LORA_R = 16
LORA_ALPHA = 32
LORA_DROPOUT = 0.05
TARGET_MODULES = ["q_proj", "k_proj", "v_proj", "o_proj"] # 注意力 4 个投影矩阵
EXTRA_MODULES = ["lm_head", "embed_tokens"] # ⚠️ 变量还留着,但已不再使用(见下方说明)
EPOCHS = 5
BATCH_SIZE = 1
GRAD_ACC = 8
LEARNING_RATE = 1e-4
MAX_LENGTH = 512
REPORT_TO = "none"
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
USE_FP16 = DEVICE.type == "cuda" # fp16 只有 GPU 支持,CPU 强制 fp32
TORCH_DTYPE = torch.float16 if USE_FP16 else torch.float32
_HF_VER = tuple(int(x) for x in _HF_VERSION.split(".")[:2]) # "5.1.0" -> (5, 1)
三个设计值得直接抄走:
- 配置全部提到文件顶部:调参只改这一块,不用在 600 行里到处找
- 设备/精度自适应 :
USE_FP16由设备决定,同一份代码 CPU / GPU 都能跑,不会因为在 CPU 上写死fp16=True而报错 - 版本号提前解析成元组 :
_HF_VER后面用来兼容 transformers 4.x / 5.x 的参数改名,是本脚本最关键的"自保代码"
⚠️
EXTRA_MODULES是最值钱的"活教材" :脚本第 89 行仍然定义着["lm_head", "embed_tokens"],但第 272 行的LoraConfig里已经把它注释掉了:
pythonmodules_to_save=None, # 原来是 modules_to_save=EXTRA_MODULES这一行就是"628M 参数事故"的修复点。 全量微调词嵌入 + 输出头后,trainable 参数从 629 万暴涨到 6.28 亿(25%),小数据下极易过拟合复读。变量留着、开关关掉,这是整篇教程最该记住的一个改动。
6.2 第二部分:数据准备 ------ 两种输入格式都能吃
python
def prepare_data(data_size=5000, use_demo=False, data_path=None):
if use_demo: # ① 内置 3 条演示数据,离线跑通
raw_items = [{"question": d["question"], "answer": d["answer"]} for d in DEMO_DATA]
elif data_path: # ② 本地 JSON(推荐)
with open(data_path, "r", encoding="utf-8") as f:
raw_items = json.load(f)
else: # ③ 两个都没给 -> 直接停下并提示正确用法
raise SystemExit("[数据] 未指定数据来源!请用 --data_path 或 --demo")
raw_items = raw_items[:data_size] # 统一截断:--data_size 10 就只取前 10 条
formatted = []
for it in raw_items:
if "instruction" in it: # 写法②:已是三字段,直接用(防止重复套角色提示)
formatted.append({"instruction": it["instruction"],
"input": it.get("input", ""),
"output": it["output"]})
else: # 写法①:原始问答,自动补上固定角色提示
formatted.append({
"instruction": "你是车载智能助手。回答简洁准确。不提供危险驾驶建议。遇到故障请提示安全停车并联系服务站。",
"input": it["question"],
"output": it["answer"],
})
with open("vehicle_qa_formatted.json", "w", encoding="utf-8") as f:
json.dump(formatted, f, ensure_ascii=False, indent=2) # 落盘,方便事后复查
return formatted
这段代码一次解决三个新手常见问题:
| 问题 | 处理方式 |
|---|---|
| 数据格式五花八门 | 自动识别 question/answer 与 instruction/input/output 两种写法,统一成三字段 |
| 训练前不知道数据长啥样 | 整理结果落盘 成 vehicle_qa_formatted.json,可以直接打开检查 |
| 固定角色提示每行都要写 | 代码自动补 上固定 instruction(固定角色能明显提升指令跟随能力) |
6.3 第三部分:模型加载 + LoRA 注入(核心中的核心)
python
def setup_model_and_lora():
model = AutoModelForCausalLM.from_pretrained(
MODEL_NAME,
torch_dtype=TORCH_DTYPE, # GPU 用 fp16 / CPU 用 fp32
device_map="auto" if USE_FP16 else None, # 只有 GPU 才用得上 accelerate 分层加载
trust_remote_code=True, # Qwen 系模型需要
)
tokenizer = AutoTokenizer.from_pretrained(
MODEL_NAME, trust_remote_code=True,
padding_side="right", # 因果 LM 必须右填充
)
if tokenizer.pad_token is None: # 有的模型没定义 pad_token
tokenizer.pad_token = tokenizer.eos_token # 拿 eos 顶替
接着才是 LoRA 本体:
python
lora_config = LoraConfig(
r=LORA_R, # 16
lora_alpha=LORA_ALPHA, # 32 -> 缩放 alpha/r = 2
lora_dropout=LORA_DROPOUT, # 0.05
target_modules=TARGET_MODULES, # 注意力 q/k/v/o 四个投影
bias="none",
use_rslora=True, # 缩放改为 alpha/√r,高秩与长训练更稳
modules_to_save=None, # ← 纯 LoRA 的关键(见 6.1 的警告)
task_type=TaskType.CAUSAL_LM, # 自回归对话/续写
)
peft_model = get_peft_model(model, lora_config)
peft_model.print_trainable_parameters() # 6,291,456 || 1,843,120,128 || 0.3413
return peft_model, tokenizer
四个容易被忽略但很致命的细节:
| 细节 | 为什么重要 |
|---|---|
padding_side="right" |
因果 LM 只能用右填充。左填充会让 pad 参与预测目标,训练直接崩 |
pad_token = eos_token |
分词器没有 pad_token 时,不做这一步 padding 会直接报错 |
device_map 仅 GPU 使用 |
CPU 上传这个参数依赖 accelerate,可能直接报错;脚本用三元表达式规避 |
print_trainable_parameters() |
必须打印 :0.3% = 纯 LoRA ✅,25% = 全量层没关 ⚠️ |
6.4 第四部分:文本拼接 + 分词 + labels(全文最关键的十行)
python
def preprocess_function(examples):
texts = []
for inst, inp, out in zip(examples["instruction"], examples["input"], examples["output"]):
texts.append(f"{inst}\n\n用户问题:{inp}\n\n助手回答:{out}") # ← 训练格式在这里定死
tokenized = tokenizer(texts, truncation=True, padding="max_length", max_length=MAX_LENGTH)
tokenized["labels"] = [
[t if m else -100 for t, m in zip(ids, mask)] # 真词留原值,pad 位置改 -100
for ids, mask in zip(tokenized["input_ids"], tokenized["attention_mask"])
]
return tokenized
dataset = Dataset.from_dict({ ...三列... })
return dataset.map(preprocess_function, batched=True) # 一批批处理,比逐条快得多
这一段有三个考点:
① 训练模板必须与推理一字不差
训练:你是车载智能助手...\n\n用户问题:胎压报警灯亮了还能继续开吗?\n\n助手回答:<标准答案>
推理:你是车载智能助手...\n\n用户问题:胎压报警灯亮了还能继续开吗?\n\n助手回答:<留空,让模型自己续写>
格式不一致 = 训练 loss 很低但输出复读乱码,因为推理时喂进去的是"没见过的前缀"。
② -100 是"忽略位",不是随便填的数
- 自回归训练的目标就是"看前面的词预测下一个词",所以
labels基本等于input_ids本身 - 但 padding 出来的空位不是真实文字,不能让模型学"预测 pad"
attention_mask=0的位置统一填-100,HuggingFace 算 loss 时自动跳过
③ batched=True 的 examples 是"按列打包"的字典 :{"instruction": [...整列...], "input": [...], "output": [...]},所以里面要用 zip 按行对齐再拼。
6.5 第五部分:训练主流程(8 步串起来)
python
def train_model(data_size=5000, use_demo=False, data_path=None):
data = prepare_data(data_size, use_demo, data_path) # ① 数据
model, tokenizer = setup_model_and_lora() # ② 模型 + LoRA
tokenized = build_tokenized_dataset(data, tokenizer) # ③ 分词 + labels
n_eval = max(0, min(100, len(tokenized) // 5)) # ④ 留 1/5 当验证集(推导见第八章)
if n_eval >= 1 and len(tokenized) - n_eval >= 1:
train_ds = tokenized.select(range(len(tokenized) - n_eval)) # 前面 -> 训练
eval_ds = tokenized.select(range(len(tokenized) - n_eval, len(tokenized))) # 最后 -> 验证
has_eval = True
else:
train_ds, eval_ds, has_eval = tokenized, None, False # 数据太少切不出来,不敢切
接着是训练超参,注意 ⑤-2 那段版本兼容:
python
args_kw = dict(
output_dir=OUTPUT_CKPT, num_train_epochs=EPOCHS,
per_device_train_batch_size=BATCH_SIZE, gradient_accumulation_steps=GRAD_ACC,
learning_rate=LEARNING_RATE, lr_scheduler_type="cosine",
weight_decay=0.01, max_grad_norm=1.0,
logging_steps=1, save_strategy="epoch", save_total_limit=3,
fp16=USE_FP16, optim="adamw_torch", report_to=REPORT_TO,
)
# ⑤-2 warmup:4.x 叫 warmup_ratio,5.x 改名 warmup_steps,两者不能同时传
_steps_per_epoch = (len(train_ds) + BATCH_SIZE * GRAD_ACC - 1) // (BATCH_SIZE * GRAD_ACC)
if _HF_VER >= (5, 0):
args_kw["warmup_steps"] = max(1, round(0.03 * _steps_per_epoch * EPOCHS))
else:
args_kw["warmup_ratio"] = 0.03
if has_eval:
args_kw["eval_strategy" if _HF_VER >= (4, 46) else "evaluation_strategy"] = "epoch"
args_kw["load_best_model_at_end"] = True
args_kw["metric_for_best_model"] = "loss"
args_kw["greater_is_better"] = False
最后组装 Trainer 并开训:
python
training_args = TrainingArguments(**args_kw)
data_collator = DataCollatorForSeq2Seq(tokenizer=tokenizer, model=model, padding=True)
trainer = Trainer(
model=model, args=training_args,
train_dataset=train_ds, eval_dataset=eval_ds,
data_collator=data_collator,
**({"processing_class": tokenizer} if _HF_VER >= (5, 0) else {"tokenizer": tokenizer}),
)
trainer.train() # ⑧ 取 batch → 前向 → 算 loss → 反向 → 更新 → 打日志
trainer.save_model(OUTPUT_FINAL) # ⑨ 保存 adapter
tokenizer.save_pretrained(OUTPUT_FINAL)
为什么用 args_kw 字典 + TrainingArguments(**args_kw)? 因为有些参数要"看情况才加":有没有验证集、transformers 是 4.x 还是 5.x。先用字典攒够,最后一次性展开传参,比写两套 if/else 复制配置干净得多。
load_best_model_at_end=True 的完整机制(值得单独理解):
第 1 轮训完 → 在 6 条验证集上算 eval_loss → 存 checkpoint-1
第 2 轮训完 → 再算一次 → 存 checkpoint-2
...
第 5 轮训完 → 算完 → 自动找出 eval_loss 最小的那份权重 → 装回模型 → 才 save_model
所以 ./vehicle-chatbot-final 里存的不是最后一轮,而是最好的一轮。
6.6 第六部分:推理测试 ------ 怎么验收微调结果
python
def test_model():
if not os.path.isdir(OUTPUT_FINAL): # 没训过就别硬跑,先友好提示
print(f"[推理] 找不到模型目录 {OUTPUT_FINAL},请先运行训练。")
return
base_model = AutoModelForCausalLM.from_pretrained(
MODEL_NAME, torch_dtype=TORCH_DTYPE,
device_map="auto" if USE_FP16 else None, trust_remote_code=True)
model = PeftModel.from_pretrained(base_model, OUTPUT_FINAL) # 挂上训练好的 adapter
model = model.merge_and_unload() # W ← W + (alpha/r)·B·A,然后卸掉 LoRA 结构
model.eval() # 关闭 dropout 等训练专属行为
tokenizer = AutoTokenizer.from_pretrained(OUTPUT_FINAL, trust_remote_code=True)
for query in ["胎压报警灯亮了还能继续开吗?",
"360全景影像车速快了会关闭吗?",
"倒车影像不显示了怎么办?"]: # 故意换问法,考泛化
# ① prompt 与训练格式一字不差,只把"答案"留空
prompt = f"你是车载智能助手。回答简洁准确。不提供危险驾驶建议。遇到故障请提示安全停车并联系服务站。\n\n用户问题:{query}\n\n助手回答:"
inputs = tokenizer(prompt, return_tensors="pt")
inputs = {k: v.to(model.device) for k, v in inputs.items()} # 张量搬到模型所在设备
with torch.no_grad(): # 推理不需要反向传播
outputs = model.generate(
**inputs, max_new_tokens=200, temperature=0.7, do_sample=True, top_p=0.9,
# repetition_penalty=1.2, # 防复读(软惩罚),过拟合后 1.1 压不住
# no_repeat_ngram_size=6, # 禁止重复 6-gram(硬刹车),中文句级复读立竿见影
pad_token_id=tokenizer.eos_token_id,
)
full = tokenizer.decode(outputs[0], skip_special_tokens=True)
answer = full[len(prompt):] # 掐掉输入部分,只留模型新生成的内容
print(f"用户问题:{query}\n助手回答:{answer}\n" + "-" * 50)
| 关键点 | 说明 |
|---|---|
merge_and_unload() |
把 W + (alpha/r)·B·A 真正算进主权重并卸掉 LoRA 结构。合并后就是普通模型,推理更快、更好部署(这里只合到内存里用来测试;要落盘成独立模型用于交付,见第七章) |
| 训练与推理基座必须相同 | adapter 只是"增量",必须挂在训练时的同一个 MODEL_NAME 上,挂错基座 = 乱码 |
full[len(prompt):] |
decode 结果是 prompt + 生成内容,从 len(prompt) 处切掉输入才是答案 |
| 3 个问题是训练集之外的 | 只有用没见过的真实问题回答得好,才能算微调成功 |
6.7 第七部分:入口与常用命令
python
def main():
global EPOCHS, BATCH_SIZE, GRAD_ACC, MODEL_NAME # 要改全局变量必须先声明
args = parse_args()
if args.model_name:
MODEL_NAME = args.model_name # 命令行覆盖模型来源(本地目录 / HF id)
print(f"CUDA 可用:{torch.cuda.is_available()},当前设备:{DEVICE}")
if args.demo: # 演示模式自动缩水,保证几分钟跑完
EPOCHS = min(args.epochs, 1)
BATCH_SIZE, GRAD_ACC = 1, 1
if args.skip_train:
test_model() # 只推理
else:
train_model(data_size=args.data_size, use_demo=args.demo, data_path=args.data_path)
if not args.train_only:
test_model() # 训完自动验收效果
| 场景 | 命令 |
|---|---|
| 先跑通(离线、3 条数据、几分钟) | python vehicle_lora_finetune.py --demo |
| 正式训练(30 条 × 5 轮) | python vehicle_lora_finetune.py --data_path vehicle_qa_local.json --epochs 5 --data_size 30 |
| 只训练,不测试 | 追加 --train_only |
| 只测试(模型已训好) | 追加 --skip_train |
| 换基座模型 | 追加 --model_name ./models/Qwen1.5-1.8B-Chat |
if __name__ == "__main__": main()是 Python 标准写法:只有直接运行本文件时才训练 ,被别人import时不会触发(否则一 import 就开训,非常危险)。
6.8 这份代码里"最值钱"的 4 处工程细节
| # | 细节 | 价值 |
|---|---|---|
| 1 | _HF_VER 版本判断 + 三元表达式选参数名 |
一份代码同时兼容 transformers 4.46 与 5.x,不被改名坑死 |
| 2 | args_kw 字典按需拼装 |
有/无验证集都能跑,配置不重复 |
| 3 | modules_to_save=None |
trainable 从 628M 压到 629 万,这是效果变好的根因 |
| 4 | 用 -100 做 labels 忽略位 |
比"直接复制 input_ids"更严谨,pad 不参与 loss |
完整源码见 Gitee 仓库 gitee.com/allensu0108/llm-fine-tuning 中的
vehicle_lora_finetune.py(651 行,逐行中文注释)。
vehicle_lora_finetune.py 的 7 段代码在训练流程中的位置如下:
#mermaid-svg-2aY9qRZxRxTPHf1E{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-2aY9qRZxRxTPHf1E .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-2aY9qRZxRxTPHf1E .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-2aY9qRZxRxTPHf1E .error-icon{fill:#552222;}#mermaid-svg-2aY9qRZxRxTPHf1E .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-2aY9qRZxRxTPHf1E .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-2aY9qRZxRxTPHf1E .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-2aY9qRZxRxTPHf1E .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-2aY9qRZxRxTPHf1E .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-2aY9qRZxRxTPHf1E .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-2aY9qRZxRxTPHf1E .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-2aY9qRZxRxTPHf1E .marker{fill:#333333;stroke:#333333;}#mermaid-svg-2aY9qRZxRxTPHf1E .marker.cross{stroke:#333333;}#mermaid-svg-2aY9qRZxRxTPHf1E svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-2aY9qRZxRxTPHf1E p{margin:0;}#mermaid-svg-2aY9qRZxRxTPHf1E .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-2aY9qRZxRxTPHf1E .cluster-label text{fill:#333;}#mermaid-svg-2aY9qRZxRxTPHf1E .cluster-label span{color:#333;}#mermaid-svg-2aY9qRZxRxTPHf1E .cluster-label span p{background-color:transparent;}#mermaid-svg-2aY9qRZxRxTPHf1E .label text,#mermaid-svg-2aY9qRZxRxTPHf1E span{fill:#333;color:#333;}#mermaid-svg-2aY9qRZxRxTPHf1E .node rect,#mermaid-svg-2aY9qRZxRxTPHf1E .node circle,#mermaid-svg-2aY9qRZxRxTPHf1E .node ellipse,#mermaid-svg-2aY9qRZxRxTPHf1E .node polygon,#mermaid-svg-2aY9qRZxRxTPHf1E .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-2aY9qRZxRxTPHf1E .rough-node .label text,#mermaid-svg-2aY9qRZxRxTPHf1E .node .label text,#mermaid-svg-2aY9qRZxRxTPHf1E .image-shape .label,#mermaid-svg-2aY9qRZxRxTPHf1E .icon-shape .label{text-anchor:middle;}#mermaid-svg-2aY9qRZxRxTPHf1E .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-2aY9qRZxRxTPHf1E .rough-node .label,#mermaid-svg-2aY9qRZxRxTPHf1E .node .label,#mermaid-svg-2aY9qRZxRxTPHf1E .image-shape .label,#mermaid-svg-2aY9qRZxRxTPHf1E .icon-shape .label{text-align:center;}#mermaid-svg-2aY9qRZxRxTPHf1E .node.clickable{cursor:pointer;}#mermaid-svg-2aY9qRZxRxTPHf1E .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-2aY9qRZxRxTPHf1E .arrowheadPath{fill:#333333;}#mermaid-svg-2aY9qRZxRxTPHf1E .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-2aY9qRZxRxTPHf1E .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-2aY9qRZxRxTPHf1E .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-2aY9qRZxRxTPHf1E .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-2aY9qRZxRxTPHf1E .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-2aY9qRZxRxTPHf1E .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-2aY9qRZxRxTPHf1E .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-2aY9qRZxRxTPHf1E .cluster text{fill:#333;}#mermaid-svg-2aY9qRZxRxTPHf1E .cluster span{color:#333;}#mermaid-svg-2aY9qRZxRxTPHf1E div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-2aY9qRZxRxTPHf1E .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-2aY9qRZxRxTPHf1E rect.text{fill:none;stroke-width:0;}#mermaid-svg-2aY9qRZxRxTPHf1E .icon-shape,#mermaid-svg-2aY9qRZxRxTPHf1E .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-2aY9qRZxRxTPHf1E .icon-shape p,#mermaid-svg-2aY9qRZxRxTPHf1E .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-2aY9qRZxRxTPHf1E .icon-shape .label rect,#mermaid-svg-2aY9qRZxRxTPHf1E .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-2aY9qRZxRxTPHf1E .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-2aY9qRZxRxTPHf1E .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-2aY9qRZxRxTPHf1E :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} ① 全局配置区
所有旋钮
② 数据准备
两种输入格式
③ 模型加载 + LoRA 注入
④ 文本拼接 + 分词 + labels
⑤ 训练主流程
8 步串起来
⑥ 推理测试
验收微调结果
⑦ 入口与常用命令
七、模型合并:merge_lora_model.py 把 adapter 变成独立模型
训练脚本的
test_model()里其实已经调用过一次merge_and_unload(),但那只是"在内存里合一下、测完就丢"。真正要交付或部署 ,必须把合并结果落盘成一个独立模型 ------这就是merge_lora_model.py(150 行)干的事。
7.1 先看一组反直觉的数字:24 MB vs 7 GB
真实产物对照(就是本项目磁盘上的实测大小):
| 目录 | 关键文件 | 大小 | 说明 |
|---|---|---|---|
vehicle-chatbot-final/(训练产物) |
adapter_model.safetensors |
24.02 MB | 只存 LoRA 增量(A/B 矩阵) |
vehicle-chatbot-final/ |
adapter_config.json |
< 1 KB | r / alpha / target_modules 等"怎么合的说明书" |
vehicle-chatbot-merged/(合并产物) |
model.safetensors |
7006.98 MB ≈ 6.84 GB | 完整权重(fp32,1.84B 参数) |
为什么 adapter 只有 24 MB? 一个算式就够:
6,291,456 个参数 × 4 字节(fp32) = 24.0 MB ← 正好等于磁盘上的 24.02 MB
这也反向验证了日志里那句 trainable params: 6,291,456 (0.34%) 没说谎:整轮训练真正被改动的只有 629 万个参数,其余 18 亿个参数一个字节都没变。 这 24 MB 就是 15 步训练的全部"知识增量"------够不够用,取决于数据质量和训练配置,跟文件大小无关。
7.2 两种"合并"别搞混
| 场景 | 代码 | 结果存哪 |
|---|---|---|
| 训练后测效果(第六章 6.6) | test_model() 里的 merge_and_unload() |
只在内存里,进程一结束就没了 |
| 部署 / 交付(本章) | merge_lora_model.py 完整流程 |
落盘成 vehicle-chatbot-merged/ 独立目录 |
一句话:
test_model()的合并是"自己看一眼",本章的合并是"打包发货"。
7.3 核心原理:一行代码背后的数学
python
model = PeftModel.from_pretrained(base_model, args.adapter) # 挂上 A/B 增量
model = model.merge_and_unload(progressbar=True) # 把增量算进主权重
它逐层执行的是:
W_new = W + (alpha/√r) · B·A # 本项目 alpha=32, r=16, use_rslora=True → 缩放系数 = 32/√16 = 8
三点必须说清楚:
alpha/r是最常见的写法;但本项目训练时开了use_rslora=True(见adapter_config.json),缩放实际是alpha/√r = 8。好在merge_and_unload()会自动读adapter_config.json里的use_rslora字段并正确缩放,不需要你手算 ------脚本注释里简写成(alpha/r)·A·B只是便于理解B·A相乘后得到与原始权重同形状的增量矩阵,所以能无缝加回去unload之后模型不再是PeftModel,而是普通Qwen2ForCausalLM:少一层包装,推理路径更短
adapter_config.json 里有两个字段值得单独记住:
| 字段 | 本项目取值 | 含义 |
|---|---|---|
base_model_name_or_path |
./models/Qwen1.5-1.8B-Chat |
adapter 必须挂同源基座的凭据,换基座 = 权重对不齐 |
modules_to_save |
null |
确认是纯 LoRA("628M 事故"修复后的结果) |
7.4 脚本逐段讲解(150 行,7 个步骤)
① 路径参数 + 开局安全检查
python
BASE_MODEL = "./models/Qwen1.5-1.8B-Chat" # 基础模型(与训练时同一来源)
ADAPTER_DIR = "./vehicle-chatbot-final" # 训练产出:LoRA adapter 目录
OUTPUT_DIR = "./vehicle-chatbot-merged" # 合并结果目录
for name, path in [("基础模型", args.base_model), ("adapter", args.adapter)]:
if not os.path.isdir(path):
raise SystemExit(f"[合并] 找不到{name}目录:{path}\n"
f" 请先检查路径,或运行训练脚本生成 adapter。")
os.makedirs(args.output_dir, exist_ok=True)
开局就检查两个输入目录是否存在,报错信息直接告诉你怎么修------比跑到一半崩在
from_pretrained上友好得多。三个路径都能用--base_model / --adapter / --output_dir覆盖。
② 设备与精度:CPU 只能 fp32
python
use_cuda = torch.cuda.is_available()
dtype = torch.float16 if use_cuda else torch.float32
if not use_cuda:
print("[合并] 提示:CPU 合并需 ~8GB 权重常驻 + 保存时临时拷贝,请保证内存充足。")
这就是为什么合并后 config.json 里写着 "dtype": "float32"------本次是纯 CPU 合并,所以体积是 fp16 的两倍(约 7 GB 而不是 3.5 GB)。
③ 加载基础模型(版本兼容的老配方)
python
load_kw = dict(
device_map="auto" if use_cuda else None, # 有 GPU 才用得上
trust_remote_code=True,
)
if _HF_VER >= (5, 0):
load_kw["dtype"] = dtype # transformers 5.x
else:
load_kw["torch_dtype"] = dtype # 4.x
base_model = AutoModelForCausalLM.from_pretrained(args.base_model, **load_kw)
和训练脚本里 _HF_VER 的用法完全一致:先用字典攒参数,再按版本决定塞哪个名字。
④ 挂载 + 合并(本章主角)
python
print(f"[合并] 挂载 adapter:{args.adapter}")
model = PeftModel.from_pretrained(base_model, args.adapter)
print("[合并] 正在把 LoRA 增量合并回主权重(CPU 上需要几分钟)...")
model = model.merge_and_unload(progressbar=True) # 逐层执行 W ← W + 缩放·B·A
model.eval() # 推理模式:关掉 dropout
progressbar=True 会打印逐层进度条,24 层一层层走,CPU 上几分钟属正常。
⑤ 保存完整模型 + 分词器
python
model.save_pretrained(args.output_dir, safe_serialization=True) # 全部权重 + config.json
tokenizer = AutoTokenizer.from_pretrained(args.adapter, trust_remote_code=True) # 从 adapter 目录取
tokenizer.save_pretrained(args.output_dir) # 与模型放同一目录
两个细节值得注意:
safe_serialization=True走 safetensors 格式,比老的.bin(pickle)加载更快也更安全- 分词器从 adapter 目录读 :训练时用的就是同一套
tokenizer_config.json+chat_template.jinja,这样能保证"合并后模型的分词器 = 训练时的分词器"
⑥ 顺手复制生成配置
python
src_gen = os.path.join(args.base_model, "generation_config.json")
if os.path.isfile(src_gen):
shutil.copy2(src_gen, os.path.join(args.output_dir, "generation_config.json"))
这样新目录的 temperature / top_p / eos_token_id 等生成默认值与基座完全一致,推理时不用另配。
⑦ 列出产物 + 告诉你怎么用
python
for name in sorted(os.listdir(args.output_dir)):
full = os.path.join(args.output_dir, name)
if os.path.isfile(full):
print(f" {name} ({os.path.getsize(full) / 1024 / 1024:.1f} MB)")
跑完直接把"怎么加载这个目录"的示例代码贴在终端里,省得回头翻文档。
7.5 运行与实测输出
powershell
# 在 vehicle_lora 目录下,先激活 venv
python merge_lora_model.py
# 全部用默认路径时无需传参;也可手动指定三处路径
python merge_lora_model.py `
--base_model ./models/Qwen1.5-1.8B-Chat `
--adapter ./vehicle-chatbot-final `
--output_dir ./vehicle-chatbot-merged
实测打印(关键部分):
[合并] CUDA 可用:False,设备=cpu,精度=torch.float32
[合并] 提示:CPU 合并需 ~8GB 权重常驻 + 保存时临时拷贝,请保证内存充足。
[合并] 加载基础模型:./models/Qwen1.5-1.8B-Chat
[合并] 挂载 adapter:./vehicle-chatbot-final
[合并] 正在把 LoRA 增量合并回主权重(CPU 上需要几分钟)...
[合并] 保存完整模型到:./vehicle-chatbot-merged
[合并] 分词器已保存到:./vehicle-chatbot-merged
[合并] 已复制 generation_config.json
[合并] 完成!产物目录内容:
chat_template.jinja (0.0 MB)
config.json (0.0 MB)
generation_config.json (0.0 MB)
model.safetensors (7007.0 MB)
tokenizer.json (10.9 MB)
tokenizer_config.json (0.0 MB)
产物清单(实测):
| 文件 | 大小 | 作用 |
|---|---|---|
model.safetensors |
7007.0 MB | 完整权重(已含 LoRA 增量,fp32) |
config.json |
< 1 KB | 模型结构,其中 "dtype": "float32" |
generation_config.json |
< 1 KB | 生成默认参数(从基座目录复制) |
tokenizer.json |
10.9 MB | 词表 |
tokenizer_config.json / chat_template.jinja |
< 1 KB | 分词器配置 + chat 模板 |
7.6 合并前后对比:为什么要多这一步
| 维度 | 只用 adapter(-final/) |
合并后(-merged/) |
|---|---|---|
| 体积 | 24 MB | 6.84 GB |
| 依赖 | 必须装 peft;加载 = 基座 + 挂 adapter + 现场 merge |
只要 transformers,from_pretrained 一行 |
| 交付 | 要交付两份(基座 + adapter),路径不能错 | 交付一个目录,直接拷走 |
| 基座风险 | 基座换成别的版本 → 输出直接崩 | 增量已固化进权重,不存在挂错 |
| 推理速度 | 略慢(多一层 LoRA 计算路径) | 与原生模型一致 |
| 后续可改性 | 可继续训练、可换新 adapter | 是普通模型,不能再 merge_and_unload,但可以重新对它做一轮新的 LoRA 训练 |
三条铁律:
- adapter 必须与基座同源 :
adapter_config.json里的base_model_name_or_path就是凭据,硬换基座会让输出变乱码 - CPU 合并前先看内存 :fp32 下 1.84B 参数 ≈ 7.4 GB 常驻,保存时还要临时复制一份,物理内存建议 ≥ 16 GB(本次就是纯 CPU 跑完的)
- 合并是"只读 + 复制"操作 :不会回改
-final/里的 adapter,也不会重新训练;失败直接重跑,无副作用
7.7 合并之后能干什么
合并后就是一个"会答车载问题"的普通模型,一行就能加载:
python
from transformers import AutoModelForCausalLM, AutoTokenizer
model = AutoModelForCausalLM.from_pretrained(r"./vehicle-chatbot-merged", trust_remote_code=True)
tokenizer = AutoTokenizer.from_pretrained(r"./vehicle-chatbot-merged", trust_remote_code=True)
prompt = "你是车载智能助手。回答简洁准确。不提供危险驾驶建议。遇到故障请提示安全停车并联系服务站。\n\n用户问题:胎压报警灯亮了还能继续开吗?\n\n助手回答:"
inputs = {k: v.to(model.device) for k, v in tokenizer(prompt, return_tensors="pt").items()}
out = model.generate(**inputs, max_new_tokens=200, do_sample=True, temperature=0.7, top_p=0.9,
pad_token_id=tokenizer.eos_token_id)
print(tokenizer.decode(out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True))
后续常见的三条路:
| 方向 | 做法 | 目的 |
|---|---|---|
| 减小体积 | 在 GPU 上重新合并(天然 fp16,约 3.5 GB) | 省磁盘、加载更快 |
| 极致压缩 | 转 GGUF + 4bit 量化(llama.cpp / ollama) | 消费级笔记本也能跑 |
| 服务化 | 用 vLLM / TGI 加载该目录 | 多并发高吞吐 API |
到这一步整条链路就走完了:准备数据 → 训练 LoRA → 看日志诊断 → 推理验证 → 合并导出 → 部署 。
下一步真正值得投入的是数据,而不是继续拧超参。
模型合并的完整流程如下:
#mermaid-svg-gVSVcph00tOGWJpB{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-gVSVcph00tOGWJpB .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-gVSVcph00tOGWJpB .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-gVSVcph00tOGWJpB .error-icon{fill:#552222;}#mermaid-svg-gVSVcph00tOGWJpB .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-gVSVcph00tOGWJpB .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-gVSVcph00tOGWJpB .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-gVSVcph00tOGWJpB .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-gVSVcph00tOGWJpB .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-gVSVcph00tOGWJpB .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-gVSVcph00tOGWJpB .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-gVSVcph00tOGWJpB .marker{fill:#333333;stroke:#333333;}#mermaid-svg-gVSVcph00tOGWJpB .marker.cross{stroke:#333333;}#mermaid-svg-gVSVcph00tOGWJpB svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-gVSVcph00tOGWJpB p{margin:0;}#mermaid-svg-gVSVcph00tOGWJpB .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-gVSVcph00tOGWJpB .cluster-label text{fill:#333;}#mermaid-svg-gVSVcph00tOGWJpB .cluster-label span{color:#333;}#mermaid-svg-gVSVcph00tOGWJpB .cluster-label span p{background-color:transparent;}#mermaid-svg-gVSVcph00tOGWJpB .label text,#mermaid-svg-gVSVcph00tOGWJpB span{fill:#333;color:#333;}#mermaid-svg-gVSVcph00tOGWJpB .node rect,#mermaid-svg-gVSVcph00tOGWJpB .node circle,#mermaid-svg-gVSVcph00tOGWJpB .node ellipse,#mermaid-svg-gVSVcph00tOGWJpB .node polygon,#mermaid-svg-gVSVcph00tOGWJpB .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-gVSVcph00tOGWJpB .rough-node .label text,#mermaid-svg-gVSVcph00tOGWJpB .node .label text,#mermaid-svg-gVSVcph00tOGWJpB .image-shape .label,#mermaid-svg-gVSVcph00tOGWJpB .icon-shape .label{text-anchor:middle;}#mermaid-svg-gVSVcph00tOGWJpB .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-gVSVcph00tOGWJpB .rough-node .label,#mermaid-svg-gVSVcph00tOGWJpB .node .label,#mermaid-svg-gVSVcph00tOGWJpB .image-shape .label,#mermaid-svg-gVSVcph00tOGWJpB .icon-shape .label{text-align:center;}#mermaid-svg-gVSVcph00tOGWJpB .node.clickable{cursor:pointer;}#mermaid-svg-gVSVcph00tOGWJpB .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-gVSVcph00tOGWJpB .arrowheadPath{fill:#333333;}#mermaid-svg-gVSVcph00tOGWJpB .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-gVSVcph00tOGWJpB .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-gVSVcph00tOGWJpB .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-gVSVcph00tOGWJpB .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-gVSVcph00tOGWJpB .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-gVSVcph00tOGWJpB .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-gVSVcph00tOGWJpB .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-gVSVcph00tOGWJpB .cluster text{fill:#333;}#mermaid-svg-gVSVcph00tOGWJpB .cluster span{color:#333;}#mermaid-svg-gVSVcph00tOGWJpB div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-gVSVcph00tOGWJpB .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-gVSVcph00tOGWJpB rect.text{fill:none;stroke-width:0;}#mermaid-svg-gVSVcph00tOGWJpB .icon-shape,#mermaid-svg-gVSVcph00tOGWJpB .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-gVSVcph00tOGWJpB .icon-shape p,#mermaid-svg-gVSVcph00tOGWJpB .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-gVSVcph00tOGWJpB .icon-shape .label rect,#mermaid-svg-gVSVcph00tOGWJpB .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-gVSVcph00tOGWJpB .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-gVSVcph00tOGWJpB .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-gVSVcph00tOGWJpB :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 基座模型
Qwen1.5-1.8B-Chat
merge_lora_model.py
LoRA adapter
vehicle-chatbot-final
合并:W_new = W + ΔW
独立完整模型
vehicle-chatbot-merged
from_pretrained 一行加载
八、核心数字是怎么算出来的
跑完一次训练,日志里会出现几个关键数字。它们不是随口来的,全部来自一组参数 + 三个公式 。以本项目 30 条 × 5 轮 为例,逐个拆解。
8.1 "6 条验证" ← 切分公式
python
n_eval = max(0, min(100, len(tokenized) // 5)) # min 封顶、max 保底(防御式写法)
if n_eval >= 1 and len(tokenized) - n_eval >= 1:
train_ds = tokenized.select(range(len(tokenized) - n_eval)) # 前面大部分 → 训练
eval_ds = tokenized.select(range(len(tokenized) - n_eval, len(tokenized))) # 最后 n_eval 条 → 验证
代入 30:
30 // 5 = 6 (// 是整除,向下取整)
min(100, 6) = 6 → n_eval = 6
训练集 = 30 - 6 = 24 条,验证集 = 最后 6 条
这是标准的留出法:验证集固定留总数据的 1/5,和训练轮数、batch 都无关。
为什么验证集必须是模型"没见过"的题? 因为只在做过的题上打分没有意义------就像 8 条数据训 10 轮那次,模型把题背下来了,考满分(train loss 0.12)但实际全是复读。
8.2 "3 步/轮" ← BATCH_SIZE 和 GRAD_ACC
python
BATCH_SIZE = 1 # 每步喂给设备几条样本(显存不够就调小)
GRAD_ACC = 8 # 梯度累积步数:攒 8 步的梯度再更新一次参数
先分清两个概念:
| 概念 | 含义 |
|---|---|
| micro-step(微观步) | 模型实际吃一个 batch(1 条)做一次前向+反向。24 条 → 每轮前向 24 次 |
| update step(权重更新步,进度条上的"步") | 攒够梯度真正更新一次权重。攒 8 个 micro-step 才更新 1 次 |
所以每轮更新步数 = 24 ÷ 8 = 3。脚本里正好有一处一模一样的计算(算 warmup 用的):
python
_steps_per_epoch = (len(train_ds) + BATCH_SIZE * GRAD_ACC - 1) // (BATCH_SIZE * GRAD_ACC) # 向上取整
# (24 + 8 - 1) // 8 = 31 // 8 = 3 (加 -1 是为了把除法换成"向上取整")
8.3 "15 总步" ← EPOCHS
python
EPOCHS = 5 # 把全部训练数据完整过几遍(1 遍 = 1 个 epoch)
总更新步 = 每轮更新步 × 轮数 = 3 × 5 = 15 ← 进度条上的 0/15
命令行传
--epochs 5会覆盖脚本里的全局EPOCHS常量。
8.4 为什么"6 条验证"比"1 条验证"可信
日志里 eval_loss 的计算方式是:
eval_loss = 所有验证样本 loss 之和 ÷ 样本数 (交叉熵的平均)
--data_size 8时:8 // 5 = 1,eval_loss 是1 条样本的 loss。这条题恰好偏难或偏易,数字就忽高忽低------你看到 epoch 6~10 之间 1.72~1.79 的来回抖动,很多是这条样本的噪音,不是真实变化--data_size 30时:6 条样本求平均,个体波动被平均掉一部分,更能反映模型在"没见过的同类问题"上的真实水平,不同 epoch 之间的差异也更可信
8.5 一页总表
| 你看到的数 | 出处 | 公式 |
|---|---|---|
| 30 条数据 | --data_size 30 + 本地文件正好 30 条 |
--- |
| 6 条验证 | n_eval = min(100, len // 5) |
30 // 5 = 6 |
| 24 条训练 | 切分逻辑 | 30 - 6 = 24 |
| 3 步/轮 | BATCH_SIZE=1, GRAD_ACC=8 |
24 ÷ (1×8) = 3 |
| 15 总步 | --epochs 5 |
3 × 5 = 15 |
| eval_loss | Trainer 内部 | 验证集样本交叉熵的平均 |
| trainable 6,291,456 | LoRA 旁路参数 | 24 层 × 4 矩阵 × 16 × 4096 |
九、第一次翻车:8 条 × 10 轮 = 过拟合实录

9.1 先看数据
用 8 条数据、--epochs 10 跑的结果(也就是开启了 modules_to_save 的那一版):
| epoch | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|
| eval_loss | 4.02 | 2.51 | 1.94 | 1.80 | 1.76 | 1.72 | 1.73 | 1.76 | 1.78 | 1.79 |
| train_loss | 4.16 | 4.16 | 2.08 | 1.19 | 0.75 | 0.48 | 0.29 | 0.19 | 0.15 | 0.12 |
每个数字都在说话:
- 训练 loss 一路降到 0.12------7 条样本被背得滚瓜烂熟
- 验证 loss 在 epoch 6 触底(1.721) ,之后 4 轮不降反升------标准的过拟合拐点:模型开始死记训练题,验证卷越答越差
- 配置了
load_best_model_at_end,所以最后装回的是 epoch 6 的权重
9.2 那为什么"最佳"的输出还是复读、胡说?
因为这轮的核心问题不是"哪轮最好",而是 8 条数据训 10 轮这件事本身就是错的,epoch 6 的"最佳"只是过拟合区里相对不那么糟的一个:
| # | 原因 | 说明 |
|---|---|---|
| 1 | 数据太少 | 7 条样本被反复背 10 遍 → 输出分布被这几条训练题强烈绑架,失去泛化能力 |
| 2 | EXTRA_MODULES 全量微调了词嵌入 + 输出头 |
628M 可训练参数里绝大部分是它。层越靠近输出,被小数据扰动越大,最伤基础能力,复读退化基本是它 + 过拟合叠加造成的 |
| 3 | 验证集只有 1 条 | epoch 6~10 的 eval_loss 只差 0.06,在一条句子上纯属噪声,别把"1.721 vs 1.787"当真实差异 |
| 4 | 复读的机制 | 温度 0.7 + repetition_penalty=1.1 压不住小模型的循环复读;另外 prompt 里固定带了"不提供危险驾驶建议...",训练样本每条都有这句,模型学到后陷入"安全套话复读环" |
9.3 反过来看:怎么判断"好没好"
只看一个指标就够------日志里的 eval_loss 走势(不是 train loss):
一路下降 → 还在变好,多训几轮值得
train 还在降、eval 反弹 → 过拟合了,拐点前那一轮最好
想复盘每轮的对比,可以去 OUTPUT_CKPT 里的 checkpoint-xxx/trainer_state.json,里面记了 best_metric 和 best_model_checkpoint。
9.4 必须知道的局限
- eval_loss 有噪声 :8 条数据只切出 1 条验证(
8 // 5 = 1),数字只能看趋势,不能当精确测量 - loss 衡量不了"对不对" :它只反映"模型复现数据集回答格式/内容"的程度,衡量不了回答是否安全、有无胡说。真正好不好,要拿训练集之外的真实问题 去问它,人工判断------脚本里的
test_model()干的就是这件事 - 想让判断更可信:加大数据量(能留出更多验证条),或单独准备一批测试问题固定评测
9.5 正式方案:三件事必须一起改
别再用 8 条 × 10 轮了,那是用 21 分钟 CPU 时间证明"模型会背题"。要训出能看的模型:
| 改动 | 做法 | 效果 |
|---|---|---|
| 数据上全量 | --data_size 30(去掉限制) |
每轮见 24 条,覆盖更多问法 |
| 轮数降回来 | --epochs 5 以内 |
停在过拟合拐点之前 |
| 关掉全量层 | modules_to_save=None |
只训 LoRA(几十 MB),速度更快、不破坏基础能力 |
另外推理侧可以加两个防复读参数(治标但立竿见影):
python
outputs = model.generate(
**inputs,
max_new_tokens=200,
temperature=0.7,
do_sample=True,
top_p=0.9,
repetition_penalty=1.2, # 【软惩罚】重复过的词降权;小模型复读时 1.1 压不住,提到 1.2
no_repeat_ngram_size=6, # 【硬约束】禁止同一条回答出现重复的 6-gram,专治整句循环
pad_token_id=tokenizer.eos_token_id,
)
这两个是治标,真正的病根在数据量和轮数------治好了数据,复读会自然减少。
十、第二次成功:30 条 × 5 轮,曲线健康

同样一份代码,只改"数据量 + 轮数 + 关掉全量层":
eval_loss e1 3.186 → e2 2.351 → e3 2.043 → e4 1.958 → e5 1.943
三个关键信号:
- eval_loss 一路下降且末尾走平(1.958 → 1.943 只降 0.015)→ 5 轮刚好踩在收敛点附近,还没进入过拟合区,比 8 条训 10 轮(epoch 6 后反弹)健康得多
- train_loss 停在 ~1.59,不再像上轮那样死背到 0.12 → 模型在"学规律"而不是"背答案"
- 速度反而更快:纯 LoRA 只有 629 万参数,15 步 33 分钟,对比之前 628M 参数 10 步要 21 分钟------砍掉全量层的收益立竿见影
10.1 日志里的三个硬证据
| 信号 | 日志原文 | 说明 |
|---|---|---|
| 参数量 | trainable params: 6,291,456 (0.34%) |
从上次的 6.28 亿 → 629 万 ,说明关掉全量层生效了,现在是纯 LoRA(只剩注意力旁路);all params 也从 24.6 亿缩到 18.4 亿 |
| 步数 | 进度条 0/15 |
步数完全对得上:30 条 ÷ 5 = 留 6 条验证 → 24 条训练,24 ÷ (1×8) = 3 步/轮 × 5 轮 = 15 步 ✓ |
| 验证集 | 6 条 | 比之前的 1 条开始有统计意义了 |
10.2 训练配置速记(可复现命令)
powershell
python vehicle_lora_finetune.py `
--data_path vehicle_qa_local.json `
--model_name ./models/Qwen1.5-1.8B-Chat `
--epochs 5 `
--data_size 30
十一、训练日志逐行教学手册
适用对象:用
vehicle_lora_finetune.py在 CPU 上微调Qwen1.5-1.8B-Chat车载问答模型的同学。训练日志看着乱,其实只有 6 类。学会分类,就全会读了。
① 开头:程序自报家门
text
[模型] 已用命令行参数覆盖模型来源:./models/Qwen1.5-1.8B-Chat
- 用了
--model_name指定本地模型目录,脚本把默认的 HuggingFace 在线模型名换成本地路径 - 凡是以
[...]方括号开头、中文提示的行,都是脚本作者用print()写的"进展播报",不是报错
text
CUDA 可用:False,当前设备:cpu
- 电脑没有可用的 NVIDIA 显卡(
False),全部计算在 CPU 上进行 - 不用处理,它只是解释了"为什么这么慢"。有 GPU 时显示
True,快几十倍
② 数据行:清点训练"弹药"
text
[数据] 读取本地 JSON 文件:vehicle_qa_local.json
[数据] 本地文件共 30 条
[数据] 共 30 条,已写入 vehicle_qa_formatted.json
| 行 | 含义 |
|---|---|
| 第 1 行 | 找到数据文件 |
| 第 2 行 | 文件里一共 30 条问答 |
| 第 3 行 | --data_size 30 恰好全要,整理成标准三字段格式,备份到 vehicle_qa_formatted.json |
小知识:如果文件有 100 条、你只想取前 30 条,就传
--data_size 30。此时第 2 行仍显示 100,第 3 行显示 30。
③ 模型加载与 LoRA 配置
text
[模型] 使用本地已下载的模型目录:./models/Qwen1.5-1.8B-Chat
[模型] 设备=cpu,精度=torch.float32
[transformers] `torch_dtype` is deprecated! Use `dtype` instead!
Loading weights: 100%|...| 291/291 [00:01<00:00, ...]
设备=cpu,精度=fp32:CPU 不支持半精度,使用最稳的 32 位浮点torch_dtype is deprecated!:新版 transformers 提示旧参数改名,只是提醒,不影响运行Loading weights 291/291:把模型的 291 个权重分片全部读入内存,1~2 秒完成,正常
text
[模型] 注入 LoRA 适配器...
trainable params: 6,291,456 || all params: 1,843,120,128 || trainable%: 0.3413
- 大白话:18.4 亿参数里只有 629 万要训练(0.34%),其余全部冻结
- 为什么重要 :这是判断 LoRA 配得对不对的"第一眼指标"
0.3% ~ 1%→ 纯 LoRA,快且安全 ✅20%+→ 还全量训练了词嵌入/输出头等,慢、易过拟合 ⚠️(本脚本早期版本曾达 25%,见第九章)
text
Map: 100%|...| 30/30 [00:00<00:00, 723.17 examples/s]
- 30 条原始文本正在被转成模型能读的 token 数字,瞬间完成
Map是 HuggingFace 数据集术语,不是报错
④ 训练开始 + 两段忽略级警告
text
[训练] 开始...
[transformers] The tokenizer has new PAD/BOS/EOS tokens that differ from the model config ...
Updated tokens: {'bos_token_id': None, 'pad_token_id': 151643}
- 分词器补了
pad_token(让同一批次里长短不一的句子对齐补位)。忽略
text
0/15 [00:00<?, ?it/s] ... UserWarning: 'pin_memory' argument is set as true but no accelerator is found...
0/15:进度条。分母 15 = 每轮 3 次权重更新 × 5 轮(推导见第八章)pin_memory警告:该加速只有 GPU 用得上,CPU 上纯属白开。忽略
⑤ 训练中的 loss 行(最需要盯的地方)
每次权重更新会打印一行"体检报告":
text
{'loss': '4.213', 'grad_norm': '13.56', 'learning_rate': '0', 'epoch': '0.3333'}
{'loss': '4.258', 'grad_norm': '13.43', 'learning_rate': '0.0001', 'epoch': '0.6667'}
{'loss': '3.533', 'grad_norm': '9.064', 'learning_rate': '9.875e-05', 'epoch': '1'}
字段含义速查表
| 字段 | 含义 | 怎么读 |
|---|---|---|
loss |
模型预测错得多狠,越小越好 | 4.x → 1.x = 在进步 |
grad_norm |
这次更新的"步子"大小 | > 20 可能震荡;正常随训练变小 |
learning_rate |
当前学习率(余弦衰减) | 从 ~0.0001 降到接近 0,结尾学得细 |
epoch |
训练进度,0.3333 = 走完 1/3 轮 | 每轮 3 步 → 0.3333 / 0.6667 / 1.0 循环 |
为什么 epoch 是 0.3333 而不是 1? 因为每轮要更新 3 次权重,每行前进 1/3 轮。
规律 :epoch 走到 1.0、2.0、3.0... 的那一行下面,必然跟着一条 eval_loss------"这轮学完,考一次试"。
实际日志的 15 行 loss 走势(好消息:稳步下降)
轮1: 4.213 → 4.258 → 3.533
轮2: 2.905 → 2.606 → 2.367
轮3: 2.146 → 1.803 → 1.855
轮4: 1.659 → 1.558 → 1.733
轮5: 1.543 → 1.557 → 1.592
新手重点:轮 2 之后 loss 偶尔小幅上翘(如 1.803 → 1.855)属正常波动。
真正要警惕的是:一直不降 (没学到)或 降到底又开始狂涨(过拟合)。
⑥ 每轮末尾的 eval_loss(模拟考成绩,全日志最重要)
text
{'eval_loss': '3.186', 'eval_runtime': '34.81', 'eval_samples_per_second': '0.172', ...}
| 字段 | 含义 |
|---|---|
eval_loss |
用 6 条从未训练过的题考核,越低越好 |
eval_runtime |
考这 6 道题耗时(CPU 约 35 秒,正常) |
五轮"考试成绩"
e1 3.186 → e2 2.351 → e3 2.043 → e4 1.958 → e5 1.943
一路下降、末尾走平 → 5 轮不多不少(若末尾反弹 = 过拟合,应减轮数)。
⑦ 结束汇总与保存
text
{'train_runtime': '2016', 'train_samples_per_second': '0.06', 'train_steps_per_second': '0.007', 'train_loss': '2.355', 'epoch': '5'}
100%|...| 15/15 [33:36<00:00, 134.44s/it]
[训练] 完成!最终模型已保存到 ./vehicle-chatbot-final
| 字段 | 含义 |
|---|---|
train_runtime: 2016 |
总耗时 2016 秒 = 33 分 36 秒(CPU 正常水平) |
train_loss: 2.355 |
15 步的全程平均 loss(不是最后一步的值) |
100% 15/15 |
进度条走完 |
保存到 vehicle-chatbot-final |
开了 load_best_model_at_end,存的是 5 轮中 eval_loss 最低(第 5 轮 1.943)的权重 |
十二、训练日志速查表(一页版)
12.1 关键行 → 看什么 → 判断标准
| 日志位置 | 关键数字 | 健康 ✅ | 异常 ⚠️ |
|---|---|---|---|
开头 CUDA 可用 |
True / False |
无所谓(只决定速度) | --- |
本地文件共 N 条 |
N | ≥ 训练预期量 | N=0:数据没读进去 |
共 N 条,已写入... |
N = --data_size |
数量符合预期 | 数量不对:检查 data_size |
| `trainable params: A | B | ||
进度条 x/15 |
分母 | 分母 = 步/轮 × 轮数 | 分母=0/极小:数据或 batch 配错 |
训练行 loss |
数值 | 逐行/逐轮下降 | 纹丝不动 4.x:没在学 |
训练行 learning_rate |
~0 → 峰值 → 0 | 余弦衰减曲线 | 一直是 0:warmup 配置异常 |
训练行 grad_norm |
数值 | 随训练变小(1~15) | > 30:梯度爆炸,降 LR |
每轮末 eval_loss |
数值 | 逐轮下降后走平 | 反弹上升:过拟合,减轮数 |
汇总 train_runtime |
秒数 | CPU 1.8B 约 2 分钟/步 | 异常长/短:查硬件占用 |
末尾 保存到 ./xxx-final |
路径 | 有输出 | 无输出:保存出错 |
12.2 三条最重要的曲线(每次训练只盯这三个)
| 曲线 | 看什么 | 对应动作 |
|---|---|---|
loss 走势 |
是否下降 | 不降 → 调大 LR / 查数据 |
eval_loss 走势 |
是否降后走平 | 反弹 → 减 epochs |
| 推理回答质量 | 是否自然、无复读 | 复读 → 推理加防复读参数;治本要改数据 |
12.3 数值速算(当前配置 30 条 × 5 轮)
n_eval = 30 // 5 = 6 (验证集条数,取总数据 1/5)
训练条数 = 30 - 6 = 24
步/轮 = 24 ÷ (1 × 8) = 3 (BATCH_SIZE=1,GRAD_ACC=8)
总步数 = 3 × 5 = 15 (进度条分母)
eval_loss = 6 条验证样本交叉熵的平均
12.4 判断口诀
loss 降、eval 降 → 正常,继续
loss 降、eval 反弹 → 过拟合,减 epochs
loss 不降 → 没学会,查 LR 和数据
eval 低、回答却烂 → 数据太少 / 测试题在训练集内,先人工加题
回答复读、绕圈 → 推理加 no_repeat_ngram_size,治本要增数据
十三、常见警告要不要管
| 警告 | 要不要处理 |
|---|---|
torch_dtype is deprecated! Use dtype instead! |
忽略(新版改名提示) |
pin_memory ... no accelerator |
忽略(仅 GPU 生效) |
tokenizer has new PAD/BOS/EOS tokens |
忽略(自动补齐 pad_token) |
evaluation_strategy / warmup_ratio 参数报错 |
要处理 (改名了,换 eval_strategy / warmup_steps) |
一般 UserWarning |
先忽略,看是否影响 loss / eval |
十四、总结
14.1 三个最有价值的结论
- 参数量看
trainable%一眼定生死 :0.3%~1%是纯 LoRA,健康;20%+说明还全量训练了词嵌入/输出头,小数据下必翻车 - 数据量是天花板,超参是地板 :30 条 × 5 轮 ≈ 健康;8 条 × 10 轮 = 过拟合现场。数据质量 > 一切调参技巧
- 只看
eval_loss,别只看train_loss:训练 loss 降到 0.12 不代表模型变好,那可能只是"背下来了"。过拟合拐点是能肉眼看见的
14.2 新手盯这 4 个地方就够
| # | 看什么 | 健康标准 |
|---|---|---|
| 1 | trainable% |
0.3% 左右 → 配置健康 |
| 2 | loss 是否逐轮下降 |
是 → 在学东西 |
| 3 | eval_loss 走势 |
降→平 = 轮数刚好;降→反弹 = 过拟合;一直 4.x = LR 或数据有问题 |
| 4 | 结尾的推理回答 | 真正的期末考:复读 = 过拟合/参数问题;像人话 = 流程调对了 |
14.3 进阶路线
| 阶段 | 学什么 | 解决什么问题 |
|---|---|---|
| L1(本文) | LoRA 参数 + 训练闭环 + 日志诊断 | 跑通、看懂、能排错 |
| L2 | 数据工程:清洗、去重、多样性、质量排序 | 提升效果上限的唯一途径 |
| L3 | QLoRA(4bit 量化 + LoRA,bitsandbytes) |
单卡微调 7B / 13B |
| L4 | trl 的 SFTTrainer |
自动处理 chat template 与 loss mask,少踩坑 |
| L5 | DPO / ORPO 对齐训练 | 让模型"有偏好、懂拒绝" |
| L6 | vLLM / DeepSpeed 部署与规模化 | 上线与多卡扩容 |
14.4 建议的实验顺序(一次只改一个变量)
--demo(3 条内置数据)→ 确认环境与代码没问题- 30 条 × 3 轮 → 看 eval_loss 是否下降
- 30 条 × 5 轮 → 对比提升幅度,找到收敛点
target_modules加上gate_proj / up_proj / down_proj→ 对比知识类问题的准确度- 数据扩到 100+ 条 → 感受"数据量才是天花板"
每一步只改一个变量,并记录 eval_loss------这才叫实验,否则只是碰运气。
写在最后:本文所有数字(loss 曲线、参数量、耗时、日志原文)都来自真实运行记录,没有一个是编的。如果你也在 CPU 上折腾微调,欢迎在评论区交流踩坑经验。
源码仓库(Gitee):https://gitee.com/allensu0108/llm-fine-tuning
bash
git clone https://gitee.com/allensu0108/llm-fine-tuning.git
仓库内含:训练脚本 vehicle_lora_finetune.py(651 行逐行中文注释)、合并脚本 merge_lora_model.py、30 条车载问答数据、完整 README(环境安装 → 训练 → 合并 → 推理使用)。克隆下来按 README 三步即可复现本文全部结果,觉得有用的话给个 Star 支持一下 ⭐
最后用一张图总结整篇文章的完整知识链路:
#mermaid-svg-HHTZZJT05nUD67y1{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-HHTZZJT05nUD67y1 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-HHTZZJT05nUD67y1 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-HHTZZJT05nUD67y1 .error-icon{fill:#552222;}#mermaid-svg-HHTZZJT05nUD67y1 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-HHTZZJT05nUD67y1 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-HHTZZJT05nUD67y1 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-HHTZZJT05nUD67y1 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-HHTZZJT05nUD67y1 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-HHTZZJT05nUD67y1 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-HHTZZJT05nUD67y1 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-HHTZZJT05nUD67y1 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-HHTZZJT05nUD67y1 .marker.cross{stroke:#333333;}#mermaid-svg-HHTZZJT05nUD67y1 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-HHTZZJT05nUD67y1 p{margin:0;}#mermaid-svg-HHTZZJT05nUD67y1 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-HHTZZJT05nUD67y1 .cluster-label text{fill:#333;}#mermaid-svg-HHTZZJT05nUD67y1 .cluster-label span{color:#333;}#mermaid-svg-HHTZZJT05nUD67y1 .cluster-label span p{background-color:transparent;}#mermaid-svg-HHTZZJT05nUD67y1 .label text,#mermaid-svg-HHTZZJT05nUD67y1 span{fill:#333;color:#333;}#mermaid-svg-HHTZZJT05nUD67y1 .node rect,#mermaid-svg-HHTZZJT05nUD67y1 .node circle,#mermaid-svg-HHTZZJT05nUD67y1 .node ellipse,#mermaid-svg-HHTZZJT05nUD67y1 .node polygon,#mermaid-svg-HHTZZJT05nUD67y1 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-HHTZZJT05nUD67y1 .rough-node .label text,#mermaid-svg-HHTZZJT05nUD67y1 .node .label text,#mermaid-svg-HHTZZJT05nUD67y1 .image-shape .label,#mermaid-svg-HHTZZJT05nUD67y1 .icon-shape .label{text-anchor:middle;}#mermaid-svg-HHTZZJT05nUD67y1 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-HHTZZJT05nUD67y1 .rough-node .label,#mermaid-svg-HHTZZJT05nUD67y1 .node .label,#mermaid-svg-HHTZZJT05nUD67y1 .image-shape .label,#mermaid-svg-HHTZZJT05nUD67y1 .icon-shape .label{text-align:center;}#mermaid-svg-HHTZZJT05nUD67y1 .node.clickable{cursor:pointer;}#mermaid-svg-HHTZZJT05nUD67y1 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-HHTZZJT05nUD67y1 .arrowheadPath{fill:#333333;}#mermaid-svg-HHTZZJT05nUD67y1 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-HHTZZJT05nUD67y1 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-HHTZZJT05nUD67y1 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-HHTZZJT05nUD67y1 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-HHTZZJT05nUD67y1 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-HHTZZJT05nUD67y1 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-HHTZZJT05nUD67y1 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-HHTZZJT05nUD67y1 .cluster text{fill:#333;}#mermaid-svg-HHTZZJT05nUD67y1 .cluster span{color:#333;}#mermaid-svg-HHTZZJT05nUD67y1 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-HHTZZJT05nUD67y1 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-HHTZZJT05nUD67y1 rect.text{fill:none;stroke-width:0;}#mermaid-svg-HHTZZJT05nUD67y1 .icon-shape,#mermaid-svg-HHTZZJT05nUD67y1 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-HHTZZJT05nUD67y1 .icon-shape p,#mermaid-svg-HHTZZJT05nUD67y1 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-HHTZZJT05nUD67y1 .icon-shape .label rect,#mermaid-svg-HHTZZJT05nUD67y1 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-HHTZZJT05nUD67y1 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-HHTZZJT05nUD67y1 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-HHTZZJT05nUD67y1 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
数据准备
30 条车载问答
LoRA 配置
r=16, alpha=32
训练超参
5 轮 × 3 步
训练 + 日志诊断
盯 eval_loss
是否过拟合?
减轮数 / 加数据
关掉全量层
推理验收
模型合并
adapter → 独立模型
部署上线