大模型 LoRA 微调全流程实战 - 车载问答全流程(跑通 + 参数详解 + 训练日志逐行解读 + 模型合并)

大模型 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 到底改了模型哪里?rlora_alphatarget_modulesuse_rslora 等 12 个参数逐个讲清,配对照表和调参速查表
  • 数字推导6 条验证集3 步/轮15 总步629 万可训练参数 这些数字分别从哪个公式来的,能手算验证
  • 两次对照实验:8 条 × 10 轮(过拟合实录,含曲线图)vs 30 条 × 5 轮(收敛健康)
  • 日志手册:把训练日志拆成 6 类,逐行讲含义 + 一张"关键行 → 判断标准"的速查表
  • 踩坑清单:628M 参数是怎么冒出来的、过拟合为什么会导致复读、模板不一致的致命后果
  • 配套源码:全部脚本、数据与说明文档已开源在 Gitee → https://gitee.com/allensu0108/llm-fine-tuning (可 clone 直接跑)

目录


一、项目背景:我们要做什么

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.safetensors 24.02 MB + optimizer.pt 48.15 MB + tokenizer.json 10.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 写的是 Qwen2ForCausalLMdtypefloat32,里面完全没有 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.ptscheduler.ptrng_state.pthtraining_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 不动 ,在旁边挂上两个小矩阵 AB,只训练它们:

复制代码
原始层:    y = W @ x
加了 LoRA: y = W @ x + (alpha / r) * B @ A @ x
                                     └── 这就是"增量" ΔW = B @ A
  • A 的形状是 r × 输入维度B 的形状是 输出维度 × r
  • r 就是秩(rank),通常只有 8 / 16 / 32,比原矩阵维度小得多
  • 训练完,效果约等于在原权重上加了一个低秩修正:W_new ≈ W + ΔW

本项目实测trainable params: 6,291,456,占总参数 1,843,120,1280.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)

三个设计值得直接抄走:

  1. 配置全部提到文件顶部:调参只改这一块,不用在 600 行里到处找
  2. 设备/精度自适应USE_FP16 由设备决定,同一份代码 CPU / GPU 都能跑,不会因为在 CPU 上写死 fp16=True 而报错
  3. 版本号提前解析成元组_HF_VER 后面用来兼容 transformers 4.x / 5.x 的参数改名,是本脚本最关键的"自保代码"

⚠️ EXTRA_MODULES 是最值钱的"活教材" :脚本第 89 行仍然定义着 ["lm_head", "embed_tokens"],但第 272 行的 LoraConfig 里已经把它注释掉了:

python 复制代码
modules_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/answerinstruction/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=Trueexamples 是"按列打包"的字典{"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

三点必须说清楚:

  1. alpha/r 是最常见的写法;但本项目训练时开了 use_rslora=True(见 adapter_config.json),缩放实际是 alpha/√r = 8好在 merge_and_unload() 会自动读 adapter_config.json 里的 use_rslora 字段并正确缩放,不需要你手算 ------脚本注释里简写成 (alpha/r)·A·B 只是便于理解
  2. B·A 相乘后得到与原始权重同形状的增量矩阵,所以能无缝加回去
  3. 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 只要 transformersfrom_pretrained 一行
交付 要交付两份(基座 + adapter),路径不能错 交付一个目录,直接拷走
基座风险 基座换成别的版本 → 输出直接崩 增量已固化进权重,不存在挂错
推理速度 略慢(多一层 LoRA 计算路径) 与原生模型一致
后续可改性 可继续训练、可换新 adapter 是普通模型,不能再 merge_and_unload,但可以重新对它做一轮新的 LoRA 训练

三条铁律

  1. adapter 必须与基座同源adapter_config.json 里的 base_model_name_or_path 就是凭据,硬换基座会让输出变乱码
  2. CPU 合并前先看内存 :fp32 下 1.84B 参数 ≈ 7.4 GB 常驻,保存时还要临时复制一份,物理内存建议 ≥ 16 GB(本次就是纯 CPU 跑完的)
  3. 合并是"只读 + 复制"操作 :不会回改 -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_metricbest_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

三个关键信号

  1. eval_loss 一路下降且末尾走平(1.958 → 1.943 只降 0.015)→ 5 轮刚好踩在收敛点附近,还没进入过拟合区,比 8 条训 10 轮(epoch 6 后反弹)健康得多
  2. train_loss 停在 ~1.59,不再像上轮那样死背到 0.12 → 模型在"学规律"而不是"背答案"
  3. 速度反而更快:纯 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 三个最有价值的结论

  1. 参数量看 trainable% 一眼定生死0.3%~1% 是纯 LoRA,健康;20%+ 说明还全量训练了词嵌入/输出头,小数据下必翻车
  2. 数据量是天花板,超参是地板 :30 条 × 5 轮 ≈ 健康;8 条 × 10 轮 = 过拟合现场。数据质量 > 一切调参技巧
  3. 只看 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 trlSFTTrainer 自动处理 chat template 与 loss mask,少踩坑
L5 DPO / ORPO 对齐训练 让模型"有偏好、懂拒绝"
L6 vLLM / DeepSpeed 部署与规模化 上线与多卡扩容

14.4 建议的实验顺序(一次只改一个变量)

  1. --demo(3 条内置数据)→ 确认环境与代码没问题
  2. 30 条 × 3 轮 → 看 eval_loss 是否下降
  3. 30 条 × 5 轮 → 对比提升幅度,找到收敛点
  4. target_modules 加上 gate_proj / up_proj / down_proj → 对比知识类问题的准确度
  5. 数据扩到 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 → 独立模型
部署上线

相关推荐
卷无止境1 小时前
大模型如何调用工具,一次讲清背后的技术门道
后端·python
吴佳浩 Alben1 小时前
走向 Memory OS:企业私有化 Agent 设计与实现
人工智能·深度学习·神经网络·语言模型·架构·自动化·ai编程
维核科技3 小时前
本地优先的 AI:不联网也能跑的模型和智能体
人工智能
七夜zippoe3 小时前
Function Calling 深度解析:从参数定义到错误处理的完整实践
人工智能·ai·agent·function·calling
郝学胜-神的一滴4 小时前
Effective Python 条款 10 :海象运算符_=
开发语言·python·程序人生·开源
wuyk5554 小时前
Python零基础入门第十四章:异常处理(try-except)
开发语言·python
2601_962078194 小时前
Appium+Python+pytest自动化测试框架详解
自动化测试·python·appium·pytest·移动应用
卷无止境7 小时前
智能体开发环境ADE浅析,编程工具的下一次范式跃迁
后端·python
m0_734571769 小时前
深入理解人工智能 chatGPT的软件架构
人工智能