RTX 4060 跑通 Qwen2.5-0.5B LoRA SFT/DPO微调)
- 前言:
-
- 前置基本知识:
-
- [1. 什么是 Qwen2.5-0.5B-Instruct](#1. 什么是 Qwen2.5-0.5B-Instruct)
- [2. 什么是 LoRA](#2. 什么是 LoRA)
- [3. 什么是 SFT](#3. 什么是 SFT)
- [4. 什么是 DPO](#4. 什么是 DPO)
- [5. 训练中遇到的坑:](#5. 训练中遇到的坑:)
- [6. 我们现在跑通了什么](#6. 我们现在跑通了什么)
- [7. 为什么现在还不能说项目完成(后续进一步目标。)](#7. 为什么现在还不能说项目完成(后续进一步目标。))
- 正文:
-
- **1.项目在干什么?**
- [**2. 项目地图**:](#2. 项目地图:)
-
- [1. 项目文件夹:](#1. 项目文件夹:)
- [2. 入口是什么?](#2. 入口是什么?)
- [3. 核心模块是什么?](#3. 核心模块是什么?)
- [4. 出口是什么?](#4. 出口是什么?)
- 项目四条线:
-
- [**线 1:数据线**](#线 1:数据线)
- [**线 2:SFT-LoRA 训练线**](#线 2:SFT-LoRA 训练线)
- [**线 3:DPO 偏好优化线**](#线 3:DPO 偏好优化线)
- [**线 4:评测线**](#线 4:评测线)
- [**3. 项目核心文件**:](#3. 项目核心文件:)
-
-
- [3.1 sft核心文件](#3.1 sft核心文件)
- [3.2 dpo核心文件](#3.2 dpo核心文件)
- [3.3 评估的核心文件:](#3.3 评估的核心文件:)
-
- 核心代码讲解
-
- [文件1: **scripts\train_sft_lora.py**](#文件1: scripts\train_sft_lora.py)
- 文件2:scripts\infer.py
- 文件3:data\processed\custom_sft_train.jsonl
- 文件4:scripts\train_dpo.py
- 文件5:dpo_larger_v6_train.jsonl
前言:
这份报告是写给"完全不懂大模型后训练,但想把项目讲清楚"的自己看的。
我在做什么?
我做了一个很小但完整的大模型微调项目:
模型:
text
Qwen/Qwen2.5-0.5B-Instruct
硬件:
text
本地 RTX 4060 Laptop GPU
目标:
text
先跑通 LoRA SFT,再用公开数据训练,再做自采集数据清洗和训练,之后再做 DPO。
不是为了训练出很强的大模型,而是为了能真正理解:
- 模型怎么加载。
- 数据怎么变成训练格式。
- LoRA 微调到底训练了哪些参数。
- adapter 怎么保存和加载。
- SFT 前后怎么对比。
- 遇到环境问题时怎么排查。
- 公开数据和自采集数据分别解决什么问题。
- 爬取来的文本如何清洗、筛选并变成 instruction-answer 数据。
让我们面试的时候可以讲清楚。
前置基本知识:
1. 什么是 Qwen2.5-0.5B-Instruct
Qwen 是通义千问系列开源模型。
0.5B 表示模型大约 5 亿参数。
Instruct 表示它已经做过指令微调,可以直接按照用户问题回答。
我们选它是因为:
- 模型小,本地 4060 能跑。
- 下载和训练都比较快。
- 适合学习完整流程。
- 后续可以迁移到更大的 Qwen 模型。
2. 什么是 LoRA
LoRA 是一种参数高效微调方法。
普通全量微调会更新模型大部分参数,成本很高。
LoRA 的做法是:
text
冻结原模型参数
只在部分线性层上插入小的低秩矩阵
训练这些小矩阵
所以它的优点是:
- 显存需求低。
- 训练速度快。
- adapter 文件小。
- 适合个人 GPU 学习。
我们 smoke test 中看到:
text
trainable params: 4,399,104
all params: 498,431,872
trainable%: 0.8826
这说明我们只训练了不到 1% 的参数。
这里很容易混淆一点:
text
LoRA 不是和 SFT 并列的训练目标。
LoRA 是"怎么训练":只训练少量 adapter 参数。
SFT 是"用什么目标训练":用 instruction-answer 数据做有监督学习。
所以我们现在做的不是"只做 SFT,没做 LoRA",而是:
text
用 LoRA 方法做 SFT 微调,也就是 LoRA SFT。
3. 什么是 SFT
SFT 是 supervised fine-tuning,有监督微调。
它的数据大概长这样:
json
{
"messages": [
{"role": "system", "content": "你是一个中文助手。"},
{"role": "user", "content": "请解释什么是LoRA微调。"},
{"role": "assistant", "content": "LoRA是一种参数高效微调方法......"}
]
}
SFT 的目的不是让模型凭空变聪明,而是让模型学习一种目标回答方式。
比如我们希望模型能正确解释:
- LoRA
- SFT
- DPO
- adapter
- loss
- batch size
- gradient accumulation
现在 base model 会把 LoRA 解释成无线通信 LoRa 或其他错误概念,这就是我们后续训练要改善的点。
4. 什么是 DPO
DPO 是 direct preference optimization,直接偏好优化。
它不是普通问答数据,而是成对偏好数据:
json
{
"prompt": "为什么第一次学习微调适合用小模型?",
"chosen": "小模型下载快、显存压力小、迭代快,适合理解完整流程。",
"rejected": "因为小模型便宜,随便跑就行。"
}
DPO 让模型更偏向 chosen,远离 rejected。
但一开始不要急着做 DPO。
原因:
text
先把 SFT 跑稳,再做 DPO。
如果 SFT 数据和训练都没做好,DPO 只会放大混乱。
5. 训练中遇到的坑:
最麻烦的问题是 Windows 一直弹窗:
text
python.exe - 应用程序错误
内存不能为 read
这不是普通 Python 报错。
普通 Python 报错会有 traceback,能看到哪一行错了。
这个弹窗说明:
text
Python 进程直接崩溃了。
这种问题通常来自底层原生库,比如:
- PyTorch
- tokenizers
- safetensors
- accelerate
- 版本不兼容的二进制包
我们最后发现,原来的包太新:
text
transformers 5.8.1
peft 0.19.1
trl 1.4.0
huggingface-hub 1.14.0
在 Windows 原生训练路径里不稳定。
解决方式是降到更保守的稳定版本:
text
transformers 4.46.3
peft 0.13.2
accelerate 1.1.1
datasets 3.1.0
trl 0.12.2
同时恢复 CUDA 版 PyTorch:
text
torch 2.5.1+cu124
6. 我们现在跑通了什么
我们已经跑通了完整 smoke test:
text
环境检查
-> base model 推理
-> demo 数据准备
-> LoRA SFT 训练
-> adapter 保存
-> adapter 加载推理
-> base vs SFT 对比
这说明工程链路是通的。
但注意:
text
模型效果现在还不好。
这很正常,因为 demo 只用了 4 条训练样本。
这个阶段的意义是:
text
证明代码、环境、GPU、训练、保存、加载都能工作。
7. 为什么现在还不能说项目完成(后续进一步目标。)
因为我们是跟着大模型把流程打通了。但如果我们想自己亲手训练一个专属于自己的大模型 的话,我们最好有自己完成以下这部分数据操作闭环的能力:
第一个闭环是公开数据集:
- 准备 500-2000 条干净 SFT 数据。
- 跑一次真实 LoRA SFT。
- 固定 prompt 对比 base 和 public-SFT。
- 记录 loss、训练时间、显存占用。
第二个闭环是自采集数据:
- 爬取或收集自己喜欢的中文技术学习内容。
- 清洗网页噪声、导航栏、广告、重复文本和无关内容。
- 把清洗后的内容改写成 instruction-answer 样本。
- 第一版曾生成 144 条 train 和 16 条 eval,但 Stage 3B 初跑后发现它仍然有幻觉。
- 后来我们减少泛化的项目总结样本,加入更直接的 targeted QA,先修订成 119 条 train 和 13 条 eval。
- Stage 2B.2 又补了 8 条 badcase 样本,数据变成 126 条 train 和 14 条 eval。
- Stage 2B.3 继续补 loss-vs-behavior 和 replay 样本,当前数据变成 142 条 train 和 15 条 eval,另有 28 条 focused patch。
- 再训练 custom adapter,并对比 base、public-SFT、custom-SFT 三种输出。
之后再对badcase进行改进分析,并在 SFT 稳定后进入 DPO。
正文:
1.项目在干什么?
text
问题:
这个项目的目标是什么?
输入是什么?
输出是什么?
数据长什么样?
模型/算法在中间做了什么?
最后生成什么 artifact?
我们这个Qwen的微调项目是:
text
输入:SFT 数据、DPO preference 数据、固定评测 prompt
中间:Qwen2.5 + LoRA SFT + DPO
输出:adapter、评测报告、最终 checkpoint 决策
目标:跑通本地后训练闭环,并证明不能只看 loss/preference accuracy
好的,我们一句话理解这个项目:这个项目是在本地 RTX 4060 Laptop GPU 上,基于 Qwen/Qwen2.5-0.5B-Instruct,跑通一条大模型后训练学习链路:
text
base 模型推理
-> public instruction SFT
-> 自采集技术数据 custom-SFT
-> 固定 prompt 行为评测
-> DPO preference optimization
-> 再评测、再复盘
2. 项目地图:
text
核心入口:训练、推理、评测从哪个脚本开始
核心数据:真正被模型吃进去的数据是哪几个
核心模块:数据准备、模型训练、评测分别在哪
核心出口:模型、报告、日志、checkpoint 输出到哪里
一般深度学习项目通常都能拆成四条线:
text
数据线:raw -> processed -> dataloader
模型线:base model -> adapter/model head -> checkpoint
训练线:config -> trainer -> loss -> save
评测线:prompt/test set -> inference -> score/report
那我们的这个微调项目:
1. 项目文件夹:
| 文件夹 | 作用 | 第一次读项目时怎么看 |
|---|---|---|
configs/ |
SFT/DPO 的配置文件 | 看训练超参数和数据路径 |
data/raw/ |
原始数据和中间清洗结果 | 只看有哪些数据来源 |
data/processed/ |
训练用 JSONL | 看 SFT 和 DPO 数据格式 |
data/samples/ |
固定评测 prompt | 看模型要通过哪些测试题 |
scripts/ |
核心代码 | 重点读这个目录 |
outputs/ |
训练出的 adapter/checkpoint | 只看最终推荐的几个 |
reports/ |
实验报告和最终总结 | 写帖子/面试时重点读 |
project_learning_notebooks_zh/ |
中文学习笔记 | 用来辅助读代码 |
.hf_cache/ |
Hugging Face 缓存 | 第一次不用看 |
2. 入口是什么?
text
训练入口:scripts/train_sft_lora.py
DPO 入口:scripts/train_dpo.py
推理入口:scripts/infer.py
对比入口:scripts/compare_four_outputs.py
评分入口:scripts/score_fixed_prompt_outputs.py
3. 核心模块是什么?
text
数据准备:scripts/prepare_custom_technical_data.py
SFT-LoRA:scripts/train_sft_lora.py
DPO:scripts/train_dpo.py
评测:scripts/compare_four_outputs.py + scripts/score_fixed_prompt_outputs.py
4. 出口是什么?
text
SFT adapter:outputs/sft_lora_qwen05b_custom_v3_from_v1_patch
DPO artifact:outputs/dpo_lora_qwen05b_naive_v6
评测报告:reports/stage5_structured_behavior_score_report.md
最终总结:reports/final_project_summary_zh.md
项目四条线:
线 1:数据线
text
raw source
-> cleaned chunks
-> instruction seed
-> SFT train/eval JSONL
-> DPO chosen/rejected JSONL
核心文件:
text
scripts/prepare_custom_technical_data.py
data/processed/custom_sft_train.jsonl
data/processed/dpo_stage5h_prompt7_train.jsonl
线 2:SFT-LoRA 训练线
text
Qwen2.5 base model
-> chat messages
-> assistant loss
-> LoRA adapter
-> saved checkpoint
核心文件:
text
scripts/train_sft_lora.py
configs/sft_qwen05b.yaml
线 3:DPO 偏好优化线
text
SFT adapter
-> prompt/chosen/rejected
-> policy model
-> frozen reference model
-> DPO adapter
核心文件:
text
scripts/train_dpo.py
configs/dpo_qwen05b_v7_stage5h.yaml
configs/dpo_qwen05b_v8_stage5m_from_v6.yaml
线 4:评测线
text
fixed prompts
-> base/SFT/DPO 输出对比
-> keyword rubric scoring
-> fixed gate / expanded gate
-> 接受或拒绝 checkpoint
核心文件:
text
scripts/compare_four_outputs.py
scripts/score_fixed_prompt_outputs.py
scripts/score_expanded_behavior_outputs.py
reports/stage5_structured_behavior_score_report.md
3. 项目核心文件:
问题:
text
一个最小输入
一个核心入口脚本
一个核心模型/训练模块
一个最小输出
一个评测结果
也就是先跑通或读通一条最小闭环:
text
一条数据
-> 一个入口脚本
-> 一个模型处理过程
-> 一个输出结果
-> 一个评测判断
上面四条线已经给了核心文件了,不过仍然是给的太多了,只看上面的那三条:
第一个问题:入口是什么?
text
训练入口:scripts/train_sft_lora.py
DPO 入口:scripts/train_dpo.py
推理入口:scripts/infer.py
对比入口:scripts/compare_four_outputs.py
评分入口:scripts/score_fixed_prompt_outputs.py
第二个问题:核心模块是什么?
text
数据准备:scripts/prepare_custom_technical_data.py
SFT-LoRA:scripts/train_sft_lora.py
DPO:scripts/train_dpo.py
评测:scripts/compare_four_outputs.py + scripts/score_fixed_prompt_outputs.py
第三个问题:出口是什么?
text
SFT adapter:outputs/sft_lora_qwen05b_custom_v3_from_v1_patch
DPO artifact:outputs/dpo_lora_qwen05b_naive_v6
评测报告:reports/stage5_structured_behavior_score_report.md
最终总结:reports/final_project_summary_zh.md
面试包:reports/stage6_final_interview_package.md
即可。如果这些要看的代码还是多,那就只看这些:
3.1 sft核心文件
| 类型 | 真实路径 | 看它干嘛 |
|---|---|---|
| 训练主入口 | scripts/train_sft_lora.py |
SFT + LoRA 的全部核心逻辑都在这里 |
| SFT 样本 | data/processed/custom_sft_train.jsonl |
看一条真实训练数据长什么样 |
| 推理入口 | scripts/infer.py |
训练后用它加载 adapter 验证回答 |
3.2 dpo核心文件
然后再看一下dpo的数据样本和参数即可。
| 类型 | 路径 | 今天看它干嘛 |
|---|---|---|
| DPO 数据 | data/processed/dpo_larger_v6_train.jsonl |
看 prompt/chosen/rejected 长什么样 |
| v6 配置 | configs/dpo_qwen05b_v6_naive.yaml |
看一次成功跑通的 larger DPO 设置 |
| v7 配置 | configs/dpo_qwen05b_v7_stage5h.yaml |
看 prompt 7 修复数据怎么接入 |
| DPO 主脚本 | scripts/train_dpo.py |
看 policy/reference/DPOTrainer 主链路 |
| 修复复盘 | reports/stage5j_to_5p_prompt7_repair_report.md |
看为什么不要继续盲目加训练 |
3.3 评估的核心文件:
| 类型 | 路径 | 今天看它干嘛 |
|---|---|---|
| 固定题库 | data/samples/custom_technical_prompts.jsonl |
8 道核心行为考试题 |
| 多模型对比 | scripts/compare_four_outputs.py |
让 base / SFT / DPO 回答同一批题 |
| 固定打分 | scripts/score_fixed_prompt_outputs.py |
用规则判断回答是否踩中关键点 |
| 扩展打分 | scripts/score_expanded_behavior_outputs.py |
用 metadata 支持更多 held-out prompt |
| 评估报告 | reports/stage5_structured_behavior_score_report.md |
最终验收证据 |
先别急着上 LLM-as-judge。这个项目的 80% 评估理解,都在"固定 prompt + 可复现规则 + 人工解释"这条线上。
这三个表格里的不是都看,只看你觉得最重要的,甚至每个表格看一个最重要的py+推理的json,就足够了。
核心代码讲解
文件1: scripts\train_sft_lora.py
python
"""Train a LoRA SFT adapter for Qwen2.5-0.5B-Instruct.
This first version intentionally uses Transformers Trainer + PEFT instead of a
high-level TRL trainer. It keeps the training path explicit and easier to debug
on Windows.
"""
'''
以上注释的意思:
# 为 Qwen2.5-0.5B-Instruct 训练一个 LoRA SFT adapter。
# LoRA: 只训练一小部分新增参数,不改动完整大模型参数。
# SFT: SFT 是 Supervised Fine-Tuning,监督微调。
也就是你准备一些这样的数据:
{
"messages": [
{"role": "system", "content": "你是一个中文助手"},
{"role": "user", "content": "什么是 LoRA?"},
{"role": "assistant", "content": "LoRA 是一种参数高效微调方法......"}
]
}
训练模型学会:
看到 system + user 后,生成 assistant 的回答
'''
'''
整体文件流程:
它读取 jsonl 训练数据,把聊天格式转成 token,然后用 Transformers 的 Trainer 训练 LoRA adapter,最后保存到 output_dir。
main()
↓
parse_args() 读取命令行参数
↓
load_tokenizer() 加载 tokenizer
↓
ChatSFTDataset(...) 加载训练集和验证集
↓
load_model() 加载 Qwen 基础模型并挂 LoRA
↓
TrainingArguments(...) 设置训练参数
↓
Trainer(...) 创建 Hugging Face Trainer
↓
trainer.train() 开始训练
↓
trainer.save_model(...) 保存 LoRA adapter
↓
tokenizer.save_pretrained() 保存 tokenizer
'''
'''
导入Python 标准库:
- argparse:读取命令行参数。
- json:读取 .jsonl 数据。
- dataclass:定义简单数据结构。
- Path:处理文件路径。
- Any:类型注解,不过这个文件里其实没用到它。
'''
from __future__ import annotations
import argparse
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
'''
深度学习相关库:
- torch:PyTorch。
- Dataset:自定义训练数据集。
- AutoModelForCausalLM:加载因果语言模型,也就是 Qwen 这种自回归生成模型。
- AutoTokenizer:加载 tokenizer。
- Trainer:Hugging Face 官方训练器。
- TrainingArguments:训练参数配置。
'''
import torch
import transformers
from torch.utils.data import Dataset
from transformers import AutoModelForCausalLM, AutoTokenizer, Trainer, TrainingArguments
'''
PEET: (原来我们学的peet在这里)
这几个就是 LoRA 微调的核心:
- LoraConfig:LoRA 配置。
- TaskType.CAUSAL_LM:任务类型是因果语言建模。
- get_peft_model:把普通模型包装成带 LoRA 的模型。
'''
from peft import LoraConfig, TaskType, get_peft_model
'''
下面这个非常重要:
在 Hugging Face / PyTorch 的语言模型训练里,labels 里如果某个位置是 -100,那么这个位置不会计算 loss。(也就是说,labels 里是 -100 的 token,不参与训练损失)
为什么需要这个?
因为我们做 SFT 时,不希望模型学习预测用户问题,而是只希望模型学习预测 assistant 的回答。
比如一条数据:
system: 你是一个助手
user: 什么是 LoRA?
assistant: LoRA 是一种参数高效微调方法。
训练时模型输入是完整文本:
system + user + assistant
但是 loss 只算 assistant 部分:
system + user 部分:labels = -100,不算 loss
assistant 部分:labels = 对应 token id,计算 loss
所以这个文件训练的是:
给定 prompt,让模型学会生成 assistant 的答案。
如果不用-100会怎样?
假设不 mask prompt,直接:labels = full_ids
那么模型会被训练去预测完整对话:
- 预测 system 内容
- 预测 user 内容
- 预测 assistant 内容
这有几个问题:
第一,浪费训练能力。
我们不需要模型学会生成用户问题。
第二,训练目标不干净。
聊天助手的目标是根据用户输入回答,而不是复现整段聊天记录。
第三,多轮数据里更明显。
如果有很多轮 user/assistant,不 mask user 的话,模型也会学习生成 user 的下一句话,这不是我们想要的 assistant 行为。
所以 SFT 通常会把非 assistant 部分 mask 掉。
总结:
-100 不是把 user 从输入里删掉,而是告诉训练器:"user 是题目,不是答案;题目可以看,但不要因为题目预测错了而扣分。只扣 assistant 答案部分的分。"
所以这个文件训练出来的模型目标是:
看到 system + user 后,生成 assistant。
'''
IGNORE_INDEX = -100
'''
ChatExample:一条训练样本的数据结构
- input_ids:模型真正看到的 token id。
- attention_mask:告诉模型哪些位置是真实 token,哪些是 padding。
- 1 = 真实内容
- 0 = padding
- labels:训练目标。
对语言模型来说,labels 通常和 input_ids 很像,但这个文件里会把 prompt 部分改成 -100,让模型只学习 assistant 的回答。
'''
@dataclass
class ChatExample:
input_ids: list[int]
attention_mask: list[int]
labels: list[int]
'''
__init__():读取 jsonl 文件
初始化时需要三个参数:
- path:训练文件路径。
- tokenizer:模型 tokenizer。
- max_length:最大 token 长度。
例如默认训练文件是:
- data/processed/sft_train.jsonl
代码就会逐行读取
'''
# 把这段代码当成一个"数据加工厂"来看。
'''
它的任务只有一个:把 jsonl 文件里的聊天数据,一条一条变成模型训练需要的 ChatExample。
也就是把:
{"messages": [...]}
加工成:
ChatExample(
input_ids=[...],
attention_mask=[...],
labels=[...],
)
概括这个类的作用:
1. 从 jsonl 文件里读取训练数据;
2. 每一行数据都是一条聊天记录;
3. 把聊天记录转换成模型可以训练的格式;
4. 最后让 Trainer 可以像访问列表一样访问这些样本。
可以把它理解成:
- ChatSFTDataset = 一个装满训练样本的列表
- 只不过这个"列表"不是手写的,而是从文件里读出来并加工好的。
'''
class ChatSFTDataset(Dataset[ChatExample]):
def __init__(self, path: str, tokenizer: AutoTokenizer, max_length: int) -> None:
"""
__init__ 是创建数据集对象时自动执行的函数。
比如你写:
train_dataset = ChatSFTDataset(
"data/processed/sft_train.jsonl",
tokenizer,
512
)
Python 就会自动执行这里的 __init__。
参数解释:
path:
训练数据文件路径,比如:
data/processed/sft_train.jsonl
tokenizer:
Qwen 的 tokenizer。
它负责把聊天文本变成 token id。
max_length:
每条训练样本最多保留多少个 token。
太长的样本会被截断。
"""
self.examples: list[ChatExample] = []
self.tokenizer = tokenizer # 保存 tokenizer,后面 encode() 函数里会用它。
self.max_length = max_length # 保存最大长度,后面如果 token 太长,要用它截断。
# self.examples 用来保存所有加工好的训练样本。
#
# 一开始它是空列表:
#
# []
#
# 后面文件每读取一行数据,就 encode 一次,
# 然后 append 到这个列表里。
#
# 例如文件可能长这样:
#
# 第 1 行:
# {"messages":[{"role":"system","content":"你是一个助手"},{"role":"user","content":"你好"},{"role":"assistant","content":"你好!"}]}
#
# 第 2 行:
# {"messages":[{"role":"system","content":"你是一个助手"},{"role":"user","content":"什么是 LoRA?"},{"role":"assistant","content":"LoRA 是一种参数高效微调方法。"}]}
#
# 最后self.examples大概长这样:
#
# self.examples = [
# ChatExample(...),
# ChatExample(...),
# ChatExample(...),
# ]
# 也就是说,最终文件里的每一行,都会变成一个 ChatExample。
with Path(path).open("r", encoding="utf-8") as f:
for line_no, line in enumerate(f, start=1):
line = line.strip()
if not line:
continue
row = json.loads(line)
try:
# 这一行:会把 messages 编码成训练样本。
self.examples.append(self.encode(row["messages"]))
except Exception as exc:
# 如果某一行编码失败,会报出具体文件和行号:
# 这个设计很好,方便 debug 数据问题。
raise ValueError(f"Failed to encode {path}:{line_no}: {exc}") from exc
# 如果文件里没有任何样本,直接报错。
if not self.examples:
raise ValueError(f"No examples loaded from {path}")
# encode():把一条聊天数据 messages,转成模型训练需要的 ChatExample。
"""
encode() 的作用:
把一条聊天数据 messages
转成模型训练需要的 ChatExample。
输入是人类好理解的 messages:
[
{"role": "system", "content": "你是一个助手"},
{"role": "user", "content": "什么是 LoRA?"},
{"role": "assistant", "content": "LoRA 是一种参数高效微调方法。"}
]
输出是模型训练用的:
ChatExample(
input_ids=[...],
attention_mask=[...],
labels=[...]
)
"""
def encode(self, messages: list[dict[str, str]]) -> ChatExample:
'''
我们的原始数据长这样:
messages = [
{"role": "system", "content": "你是一个助手"},
{"role": "user", "content": "什么是 LoRA?"},
{"role": "assistant", "content": "LoRA 是一种参数高效微调方法。"}
]
人能看懂这个结构,但是模型看不懂,模型真正能看懂的是类似这种连续文本:
<|im_start|>system
你是一个助手
<|im_end|>
<|im_start|>user
什么是 LoRA?
<|im_end|>
<|im_start|>assistant
LoRA 是一种参数高效微调方法。
<|im_end|>
这就是 chat template 做的事。
'''
# messages[:-1] 表示:
#
# 取 messages 里除了最后一条以外的所有消息。
#
# 原始 messages:
#
# [
# system,
# user,
# assistant
# ]
#
# prompt_messages:
#
# [
# system,
# user
# ]
#
# 为什么要去掉最后一条?
#
# 因为最后一条 assistant 是标准答案。
# prompt_messages 只保留"题目部分"。
#
prompt_messages = messages[:-1]
assistant_message = messages[-1]
# 检查最后一条消息是不是 assistant。
#
# 因为这个训练脚本的逻辑是:
#
# 前面的 system/user 是题目
# 最后的 assistant 是答案
#
# 如果最后一条不是 assistant,
# 那代码就不知道到底哪一部分是要训练的答案。
if assistant_message.get("role") != "assistant":
raise ValueError("Last message must be assistant.")
'''
注意这里用了:
add_generation_prompt=True
意思是给最后加上 assistant 开始回答的标记。
可以理解为:
模型,轮到你回答了
这里的意思是:
假设你的训练数据是:
messages = [
{"role": "system", "content": "你是一个助手"},
{"role": "user", "content": "什么是 LoRA?"},
{"role": "assistant", "content": "LoRA 是一种参数高效微调方法。"}
]
人能看得懂这个结构,但是模型看不懂,模型真正能看懂的是类似这种连续文本:
<|im_start|>system
你是一个助手
<|im_end|>
<|im_start|>user
什么是 LoRA?
<|im_end|>
<|im_start|>assistant
LoRA 是一种参数高效微调方法。
<|im_end|>
这就是 chat template 做的事。
那什么叫"assistant 开始回答的标记"?
其实就是在文本最后加上这一段
<|im_start|>assistant
也就是说,原来只有:
system: 你是一个助手
user: 什么是 LoRA?
加上 assistant 开头后,变成:
system: 你是一个助手
user: 什么是 LoRA?
assistant:
它只是一个格式标记,告诉模型:
接下来要生成的是 assistant 角色的内容。
就像你写作文时:
问题:什么是 LoRA?
回答:
看到"回答:"两个字,你就知道后面该写答案了。
'''
# prompt_id中的prompt_messages = messages[:-1]意思是:拿掉最后一条 assistant 答案。
'''
原来的 messages 是:
[
system,
user,
assistant
]
messages[:-1] 之后变成:
[
system,
user
]
也就是:只保留题目,不保留标准答案。
所以prompt_ids的意思是:"把 system + user 转成模型格式,并且在最后补一个 assistant 开头。
所以 prompt_ids 对应的内容大概是:
<|im_start|>system
你是一个助手
<|im_end|>
<|im_start|>user
什么是 LoRA?
<|im_end|>
<|im_start|>assistant
所以 prompt_ids 可以理解成:
题目部分 + "assistant 要开始回答了"
而full_ids传进去的是完整的 messages,包括最后的 assistant 答案。
<|im_start|>system
你是一个助手
<|im_end|>
<|im_start|>user
什么是 LoRA?
<|im_end|>
<|im_start|>assistant
LoRA 是一种参数高效微调方法。
<|im_end|>
所以full_ids意思是:
full_ids = 问题部分 + 答案部分
prompt_ids:告诉程序:题目到哪里结束,答案从哪里开始。
full_ids:这张纸的作用是:真正拿去训练模型
而分成prompt_ids和full_ids也不是为了生成两个输入给模型。
- 真实目的分别是:
- prompt_ids:用来算"答案从第几个 token 开始"
- full_ids:用来作为完整训练文本
'''
prompt_ids = self.tokenizer.apply_chat_template(
prompt_messages,
add_generation_prompt=True,
return_tensors=None,
)
full_ids = self.tokenizer.apply_chat_template(
messages,
add_generation_prompt=False,
return_tensors=None,
)
# 截断太长的样本:如果一条训练样本太长,就只保留前 max_length 个 token。(大模型训练不能无限长。文本越长,占用显存越多)
if len(full_ids) > self.max_length:
full_ids = full_ids[: self.max_length]
# prompt_len 的意思是:题目部分占了多少个 token。(看里面的关键词prompt_ids和full_ids)
prompt_len = min(len(prompt_ids), len(full_ids))
# 这句是构造训练labels的核心,假设IGNORE_INDEX = -100,同时用labels乘prompt_len
'''
类似于:
本来full_ids = [101, 102, 103, 104, 201, 202, 203]
其中101, 102, 103, 104 是题目部分,201, 202, 203 是答案部分;
那[IGNORE_INDEX] * prompt_len拼起来就是labels = [-100, -100, -100, -100, 201, 202, 203]
意思是:
- 前面题目部分:不算 loss
- 后面答案部分:算 loss
所以模型训练目标是:
- 看到题目后,学会生成答案。
而不是: 学会复述题目。
'''
labels = [IGNORE_INDEX] * prompt_len + full_ids[prompt_len:]
# 保证 labels 和 input_ids 一样长
labels = labels[: len(full_ids)]
# attention_mask 是告诉模型:
# - 哪些 token 是真实内容?
# - 哪些 token 是 padding?
# 在这一刻,还没有 padding。所以 full_ids 里的所有 token 都是真实内容。因此全部填 1。
attention_mask = [1] * len(full_ids)
# 返回一条训练样本。把整理好的 input_ids、attention_mask、labels 打包成一条训练样本。
return ChatExample(input_ids=full_ids, attention_mask=attention_mask, labels=labels)
# len和getitem这两个函数是 PyTorch Dataset 的标准接口。
'''
可以把 ChatSFTDataset 理解成一个列表:
self.examples = [
ChatExample(...), # 第 0 条
ChatExample(...), # 第 1 条
ChatExample(...), # 第 2 条
]
'''
def __len__(self) -> int:
return len(self.examples)
def __getitem__(self, index: int) -> ChatExample:
return self.examples[index]
# DataCollatorForChatSFT的作用是:把多条长度不同的 ChatExample 拼成一个 batch。
# 为什么需要 DataCollator?因为模型训练通常不是一条一条训练,而是一批一批训练。
'''
比如一个 batch 里面有 3 条样本:
- 样本1:长度 5
- 样本2:长度 8
- 样本3:长度 6
但是 PyTorch tensor 要求同一个 batch 里的数据长度一样。所以要补齐成:
样本1:长度 8
样本2:长度 8
样本3:长度 8
这个"补齐"的动作叫:padding
- DataCollatorForChatSFT 就是专门做 padding 的。
'''
class DataCollatorForChatSFT:
def __init__(self, tokenizer: AutoTokenizer) -> None:
self.tokenizer = tokenizer
# 这里需要保存 tokenizer。为什么需要 tokenizer?因为 padding 时要知道:pad token id 是多少?
# 让这个对象可以像函数一样被调用。
def __call__(self, features: list[ChatExample]) -> dict[str, torch.Tensor]:
max_len = max(len(x.input_ids) for x in features) # 找到当前 batch 里最长的长度
# pad_id 是 padding 用的 token id。比如有些 tokenizer 里:pad_id = 151643。这个具体数字不重要。它的作用是:用这个 token 来补齐 input_ids。
pad_id = self.tokenizer.pad_token_id
input_ids = []
attention_mask = []
labels = []
# 遍历 batch 里的每条样本
# pad_len 是当前样本需要补多少个 token。
# padding attention_mask。原来真实 token 的 attention_mask 是 1。padding 出来的位置应该是 0。
# padding labels。labels 的 padding 部分要补 -100。因为 padding 位置不是答案,也不是正常文本。所以这些位置不能计算 loss。
for feature in features:
pad_len = max_len - len(feature.input_ids)
input_ids.append(feature.input_ids + [pad_id] * pad_len)
attention_mask.append(feature.attention_mask + [0] * pad_len)
labels.append(feature.labels + [IGNORE_INDEX] * pad_len)
# 返回tensor。
# 前面input_ids、attention_mask、labels 还是 Python list。模型不能直接训练 Python list。所以这里转成 PyTorch tensor。最后返回一个字典:
return {
"input_ids": torch.tensor(input_ids, dtype=torch.long),
"attention_mask": torch.tensor(attention_mask, dtype=torch.long),
"labels": torch.tensor(labels, dtype=torch.long),
}
# 这个函数负责读取命令行参数。
# 这个函数负责读取命令行参数。
#
# 每个参数的意思和用处:
#
# --model_name
# 基础模型名称。
# 默认是 Qwen/Qwen2.5-0.5B-Instruct。
# 训练时会先加载这个基础模型,然后在它上面挂 LoRA。
#
# --init_adapter_path
# 可选参数,用来指定已有 LoRA adapter 的路径。
# 如果传了这个参数,就不是从零训练 LoRA,而是在已有 adapter 基础上继续训练。
# 如果不传,就会新建一个 LoRA adapter。
#
# --train_file
# 训练集文件路径。
# 默认是 data/processed/sft_train.jsonl。
# 这个文件里的样本会用于真正更新 LoRA 参数。
#
# --eval_file
# 验证集文件路径。
# 默认是 data/processed/sft_eval.jsonl。
# 这个文件里的样本不会直接更新参数,只用于训练过程中评估模型效果。
#
# --output_dir
# 输出目录。
# 训练得到的 LoRA adapter、checkpoint 和 tokenizer 会保存到这里。
# 后面推理时,这个目录可以作为 --adapter_path 使用。
#
# --max_length
# 每条训练样本最大 token 长度。
# 如果一条样本超过这个长度,会被截断。
# 默认是 512。
#
# --batch_size
# 每张 GPU 每一步放入多少条样本。
# 默认是 1,比较省显存。
# 实际有效 batch size 还要结合 --grad_accum 一起看。
#
# --grad_accum
# 梯度累积步数。
# 默认是 8。
# 例如 batch_size=1 且 grad_accum=8 时,
# 相当于累积 8 个小 batch 后再更新一次参数。
# 单卡情况下,有效 batch size 约等于 batch_size * grad_accum。
#
# --epochs
# 训练轮数。
# 1.0 表示完整看一遍训练集。
# 3.0 表示完整看三遍训练集。
#
# --lr
# 学习率。
# 默认是 2e-4,也就是 0.0002。
# LoRA 微调时这个学习率是比较常见的起点。
#
# --lora_r
# LoRA 的秩 r。
# r 越大,可训练参数越多,表达能力越强,但也更占显存。
# 默认是 8。
#
# --lora_alpha
# LoRA 的缩放参数。
# 通常和 lora_r 配合使用。
# 默认是 16。
#
# --lora_dropout
# LoRA 层的 dropout 概率。
# 用于降低过拟合风险。
# 默认是 0.05。
#
# --logging_steps
# 每隔多少 step 打印一次训练日志。
# 默认是 10。
#
# --eval_steps
# 每隔多少 step 在验证集上评估一次。
# 默认是 100。
#
# --save_steps
# 每隔多少 step 保存一次 checkpoint。
# 默认是 100。
#
# --report_to
# 指定训练日志上报到哪里。
# 默认是 tensorboard。
# 如果传 none,就不向日志工具上报。
#
# --local_files_only
# 是否只从本地缓存加载模型和 tokenizer。
# 加上这个参数后,不会联网下载。
# 这是一个开关参数,不需要额外传值。
#
# --gradient_checkpointing
# 是否开启 gradient checkpointing。
# 开启后可以减少显存占用,但训练速度会变慢。
# 这是一个开关参数,不需要额外传值。
#
# 返回值:
# parser.parse_args() 会返回 args。
# 后面可以通过 args.model_name、args.lr、args.epochs 等方式使用这些参数。
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Train Qwen LoRA SFT locally.")
parser.add_argument("--model_name", default="Qwen/Qwen2.5-0.5B-Instruct")
parser.add_argument(
"--init_adapter_path",
default=None,
help="Optional LoRA adapter to continue training from.",
)
parser.add_argument("--train_file", default="data/processed/sft_train.jsonl")
parser.add_argument("--eval_file", default="data/processed/sft_eval.jsonl")
parser.add_argument("--output_dir", default="outputs/sft_lora_qwen05b")
parser.add_argument("--max_length", type=int, default=512)
parser.add_argument("--batch_size", type=int, default=1)
parser.add_argument("--grad_accum", type=int, default=8)
parser.add_argument("--epochs", type=float, default=1.0)
parser.add_argument("--lr", type=float, default=2e-4)
parser.add_argument("--lora_r", type=int, default=8)
parser.add_argument("--lora_alpha", type=int, default=16)
parser.add_argument("--lora_dropout", type=float, default=0.05)
parser.add_argument("--logging_steps", type=int, default=10)
parser.add_argument("--eval_steps", type=int, default=100)
parser.add_argument("--save_steps", type=int, default=100)
parser.add_argument("--report_to", default="tensorboard")
parser.add_argument("--local_files_only", action="store_true")
parser.add_argument("--gradient_checkpointing", action="store_true")
return parser.parse_args()
def load_tokenizer(model_name: str, local_files_only: bool) -> AutoTokenizer:
# 从 Hugging Face 或本地缓存加载 tokenizer。
#
# model_name:
# 模型名称,比如 Qwen/Qwen2.5-0.5B-Instruct。
#
# local_files_only:
# 如果是 True,就只从本地缓存加载,不联网。
tokenizer = AutoTokenizer.from_pretrained(model_name, local_files_only=local_files_only)
# 有些大语言模型没有单独的 pad_token。
#
# 但是训练 batch 时需要 padding,
# 所以必须有 pad_token_id。
#
# 如果 tokenizer 没有 pad_token,
# 就用 eos_token 作为 pad_token。
#
# eos_token 是"文本结束符"。
# 用它临时代替 padding token 是常见做法。
if tokenizer.pad_token_id is None:
tokenizer.pad_token = tokenizer.eos_token
return tokenizer
# 这个函数负责加载基础模型,并给它挂上 LoRA。
#
# 它支持两种情况:
#
# 1. 如果传了 args.init_adapter_path:
# 加载已有 LoRA adapter,并继续训练。
#
# 2. 如果没有传 args.init_adapter_path:
# 新建一个 LoRA adapter,从零开始训练。
def load_model(args: argparse.Namespace) -> torch.nn.Module:
# 选择模型加载时使用的数据类型。
#
# 如果有 CUDA,并且 GPU 支持 bf16,就用 bf16。
# 否则先设为 fp16。
#
# 注意:如果没有 GPU,后面会改成 fp32。
dtype = torch.bfloat16 if torch.cuda.is_available() and torch.cuda.is_bf16_supported() else torch.float16
# 兼容不同版本的 transformers。
#
# transformers v4 通常使用参数名:
# torch_dtype
#
# transformers v5 可能使用参数名:
# dtype
dtype_arg = "dtype" if int(transformers.__version__.split(".", maxsplit=1)[0]) >= 5 else "torch_dtype"
# 加载基础语言模型。
#
# AutoModelForCausalLM 表示加载"因果语言模型",
# 也就是 Qwen、LLaMA 这类自回归生成模型。
model = AutoModelForCausalLM.from_pretrained(
args.model_name,
**{dtype_arg: dtype if torch.cuda.is_available() else torch.float32},
device_map="auto" if torch.cuda.is_available() else None,
local_files_only=args.local_files_only,
)
# 训练时一般关闭 use_cache。
#
# use_cache=True 主要用于推理加速,
# 会缓存 past_key_values。
#
# 训练时不需要它,而且它可能和 gradient checkpointing 冲突,
# 所以这里关掉。
model.config.use_cache = False
# 如果用户提供了已有 LoRA adapter 路径,
# 就加载这个 adapter,并继续训练。
if args.init_adapter_path:
# PeftModel 用来把 LoRA adapter 加载到基础模型上。
from peft import PeftModel
model = PeftModel.from_pretrained(
model,
args.init_adapter_path, # # 已有 LoRA adapter 的路径。
is_trainable=True, # is_trainable=True 表示加载进来的 adapter 还要继续训练。
)
# 如果没有提供已有 adapter,
# 就创建一个新的 LoRA adapter。
else:
# 定义 LoRA 配置。
lora_config = LoraConfig(
task_type=TaskType.CAUSAL_LM, # 任务类型是因果语言模型。Qwen 这种自回归生成模型属于 CAUSAL_LM。
r=args.lora_r, # LoRA 的秩 r。秩越大,可训练参数越多,表达能力越强,但更占显存。
lora_alpha=args.lora_alpha, # LoRA 的缩放参数。
lora_dropout=args.lora_dropout, # LoRA dropout,用于减少过拟合
target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
)
# 上面的q,k,v是指定在哪些模块上加 LoRA。
#
# q_proj, k_proj, v_proj, o_proj 属于 attention 部分。
#
# gate_proj, up_proj, down_proj 属于 MLP / FFN 部分。
#
# 这样设置表示:
# 不仅在 attention 上加 LoRA,
# 也在前馈网络上加 LoRA。
model = get_peft_model(model, lora_config)
# 如果开启 gradient checkpointing,
# 并且当前模型支持 enable_input_require_grads,
# 就调用它。
#
# 这通常用于 LoRA + gradient checkpointing 的组合,
# 目的是保证梯度可以正常传到可训练的 LoRA 参数。
if args.gradient_checkpointing and hasattr(model, "enable_input_require_grads"):
model.enable_input_require_grads()
return model
# main() 是整个训练脚本的主流程。
#
# 它负责把前面所有模块串起来:
#
# 读取参数
# 加载 tokenizer
# 加载训练集和验证集
# 加载模型并挂 LoRA
# 设置训练参数
# 创建 Trainer
# 开始训练
# 保存 adapter 和 tokenizer
def main() -> None:
# 读取命令行参数。
args = parse_args()
# 加载 tokenizer。
tokenizer = load_tokenizer(args.model_name, args.local_files_only)
# 加载训练集。
#
# ChatSFTDataset 会读取 jsonl 文件,
# 把每一行 messages 转成 ChatExample。
train_dataset = ChatSFTDataset(args.train_file, tokenizer, args.max_length)
# 加载验证集。
#
# 验证集用来评估训练过程中的模型效果,
# 不直接参与参数更新。
eval_dataset = ChatSFTDataset(args.eval_file, tokenizer, args.max_length)
# 加载基础模型,并挂上 LoRA。
model = load_model(args)
# 打印可训练参数数量。
#
# 这行很有用,可以确认现在是不是只训练 LoRA 参数。
#
# 正常情况下,可训练参数占比应该比较小,
# 而不是 100%。
model.print_trainable_parameters()
# 配置 Hugging Face Trainer 的训练参数。
training_args = TrainingArguments(
output_dir=args.output_dir, # 输出目录。checkpoint 和最终模型会保存到这里
per_device_train_batch_size=args.batch_size, # 每张 GPU 每一步训练多少条样本。
per_device_eval_batch_size=1, # 验证时每张 GPU 每一步多少条样本。这里固定为 1,比较省显存。
gradient_accumulation_steps=args.grad_accum, # 梯度累积步数。比如 batch_size=1,grad_accum=8,就相当于累积 8 个小 batch 后再更新一次参数
num_train_epochs=args.epochs, # 训练轮数。
learning_rate=args.lr, # 学习率。
logging_steps=args.logging_steps, # 每隔多少 step 打印一次日志。
eval_strategy="steps", # 按 step 做验证。也就是每隔 eval_steps 步验证一次。
eval_steps=args.eval_steps, # 每隔多少 step 进行一次验证。
save_steps=args.save_steps, # 每隔多少 step 保存一次 checkpoint。
save_total_limit=2, # 最多保留 2 个 checkpoint。
bf16=torch.cuda.is_available() and torch.cuda.is_bf16_supported(), # 如果 GPU 支持 bf16,就用 bf16 训练。
fp16=torch.cuda.is_available() and not torch.cuda.is_bf16_supported(), # 如果有 GPU 但不支持 bf16,就用 fp16 训练。
report_to=[] if args.report_to.lower() == "none" else [args.report_to],# 日志上报位置。如果 args.report_to 是 "none",就不上报。否则默认上报到 tensorboard。
remove_unused_columns=False,
gradient_checkpointing=args.gradient_checkpointing, # 是否开启 gradient checkpointing。开启后更省显存,但训练速度会慢一点。
)
# 创建 Trainer。
#
# Trainer 会负责完整训练流程:
#
# 从 train_dataset 取数据
# 用 data_collator 拼 batch
# 把 batch 喂给 model
# 计算 loss
# 反向传播
# 更新 LoRA 参数
# 定期验证
# 定期保存 checkpoint
trainer = Trainer(
model=model, # 带 LoRA 的模型。
args=training_args, # 训练参数。
train_dataset=train_dataset, # 训练集。
eval_dataset=eval_dataset, # 验证集。
data_collator=DataCollatorForChatSFT(tokenizer), # 数据整理器。它负责把多条长度不同的 ChatExample,padding 成同样长度,并转成 torch.Tensor。
)
trainer.train() # 开始训练。
# 保存训练好的 LoRA adapter。
#
# 因为 model 是 PEFT/LoRA 模型,
# 这里通常保存的是 adapter_config.json
# 和 adapter_model.safetensors 等 LoRA 文件。
trainer.save_model(args.output_dir)
# 保存 tokenizer。
#
# 这样推理时可以直接从 output_dir 加载 tokenizer 配置。
tokenizer.save_pretrained(args.output_dir)
# 打印保存位置。
print("Saved LoRA adapter to:", args.output_dir)
if __name__ == "__main__":
main()
文件2:scripts\infer.py
python
"""Run local inference with Qwen2.5-0.5B-Instruct and optional LoRA adapter."""
# 运行本地推理,默认模型是 Qwen2.5-0.5B-Instruct,也可以额外加载 LoRA 微调权重。
# 为了让类型注解更灵活的头文件
# 比如函数返回值里可以写:tuple[AutoTokenizer, torch.nn.Module, torch.device]
from __future__ import annotations
'''
- argparse:解析命令行参数。
- torch:PyTorch,负责模型运行。
- transformers:Hugging Face Transformers,用来加载模型和 tokenizer。
- AutoModelForCausalLM:加载因果语言模型,也就是生成式大语言模型。
- AutoTokenizer:加载对应 tokenizer。
'''
import argparse
from typing import Any
import torch
import transformers
from transformers import AutoModelForCausalLM, AutoTokenizer
# 默认使用的是 Hugging Face 上的:Qwen/Qwen2.5-0.5B-Instruct
DEFAULT_MODEL = "Qwen/Qwen2.5-0.5B-Instruct"
# 默认prompt:你是一个清晰、严谨的助手。除非用户要求其他语言,否则用中文回答。
DEFAULT_SYSTEM_PROMPT = (
"You are a clear and rigorous assistant. Answer in Chinese unless the user "
"asks for another language."
)
# parse_args():读取命令行参数 (这个函数负责解析用户在命令行里传入的参数。)
# 比如可以这么运行:python infer.py --prompt "请解释一下 LoRA 是什么"
# --model_name 指定模型名称,默认是 Qwen2.5-0.5B-Instruct
# --adapter_path 指定 LoRA adapter 路径。
# --max_new_tokens 最多可以生成多少个新Token
# --temperature 温度系数。越高,回答越随机;越低,回答越稳定。
# --top_p 从累计概率最高的前 p 部分词里采样。
# --max_new_tokens。 系统提示词,可以自己覆盖默认值。
# --dtype 模型推理的数据类型。
# fp16:半精度,省显存,GPU 常用。
# bf16:bfloat16,部分新 GPU 支持,数值更稳定。
# fp32:单精度,CPU 常用,显存/内存占用更大。
# auto:自动选择。
# --local_files_only 只从本地缓存加载模型,不联网下载。
# --print_full_text 默认只打印模型新生成的回答。 如果加上这个参数,会把完整输入加输出都打印出来。
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run Qwen local inference.")
parser.add_argument("--model_name", default=DEFAULT_MODEL)
parser.add_argument("--adapter_path", default=None)
parser.add_argument("--prompt", required=True)
parser.add_argument("--system_prompt", default=DEFAULT_SYSTEM_PROMPT)
parser.add_argument("--max_new_tokens", type=int, default=256)
parser.add_argument("--temperature", type=float, default=0.7)
parser.add_argument("--top_p", type=float, default=0.9)
parser.add_argument("--dtype", choices=["auto", "fp16", "bf16", "fp32"], default="auto")
parser.add_argument("--local_files_only", action="store_true")
parser.add_argument("--print_full_text", action="store_true")
return parser.parse_args()
# 选择模型精度
# 这个函数根据命令行参数决定用什么数据类型加载模型。
def select_dtype(name: str) -> torch.dtype:
if name == "fp16":
return torch.float16
if name == "bf16":
return torch.bfloat16
if name == "fp32":
return torch.float32
if torch.cuda.is_available():
return torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
return torch.float32
# load_model():加载 tokenizer、模型和 LoRA
def load_model(args: argparse.Namespace) -> tuple[AutoTokenizer, torch.nn.Module, torch.device]:
# 选择dtype和设备(如果有 CUDA,就用 GPU;否则用 CPU。)
dtype = select_dtype(args.dtype)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# 从 Hugging Face 或本地缓存加载 tokenizer。
tokenizer = AutoTokenizer.from_pretrained(
args.model_name,
local_files_only=args.local_files_only,
)
# 下面这一段很重要:
# 有些生成模型没有单独的 pad_token。
# 为了避免生成时报错,就把 padding token 设置成 eos token。
if tokenizer.pad_token_id is None:
tokenizer.pad_token = tokenizer.eos_token
# 兼容 Transformers v4 和 v5
# transformers v4通常使用 torch_dtype
# Transformers v5 以后可能改成 dtype
dtype_arg = "dtype" if int(transformers.__version__.split(".", maxsplit=1)[0]) >= 5 else "torch_dtype"
# 构造模型加载参数:
model_kwargs: dict[str, Any] = {
dtype_arg: dtype,
"local_files_only": args.local_files_only,
}
# 如果有 GPU:让 Transformers 自动把模型放到合适设备上。
if torch.cuda.is_available():
model_kwargs["device_map"] = "auto"
# 加载模型model = AutoModelForCausalLM.from_pretrained(args.model_name, **model_kwargs)
# 真正加载大语言模型
model = AutoModelForCausalLM.from_pretrained(args.model_name, **model_kwargs)
# 可选加载 LoRA adapter
# 如果用户传了:--adapter_path ./my_lora 那么就会在基础模型上加载 LoRA 微调权重。
# 不过这要求安装peft
if args.adapter_path:
from peft import PeftModel
model = PeftModel.from_pretrained(model, args.adapter_path)
# 如果没有GPU,就把模型放到CPU上
if not torch.cuda.is_available():
model.to(device)
# 设置为推理模式,关闭训练行为,比如 dropout。
model.eval()
return tokenizer, model, device
# 构造模型输入
# 这个函数负责把用户 prompt 转成模型能理解的 tensor。
def build_model_inputs(
tokenizer: AutoTokenizer,
args: argparse.Namespace,
device: torch.device,
) -> tuple[dict[str, torch.Tensor], int]:
# 这里构造了聊天格式:
# system: 你是一个清晰严谨的助手......
# user: 用户的问题
messages = [
{"role": "system", "content": args.system_prompt},
{"role": "user", "content": args.prompt},
]
# apply_chat_template() 会根据 Qwen 的聊天模板,把 messages 转成模型训练时熟悉的格式。
# 比如内部可能会变成类似:
'''
<|im_start|>system
...
<|im_end|>
<|im_start|>user
...
<|im_end|>
<|im_start|>assistant
其中:
add_generation_prompt=True
表示在最后加上 assistant 开始回答的标记,让模型知道该继续生成回答了。
'''
try:
encoded = tokenizer.apply_chat_template(
messages,
tokenize=True,
add_generation_prompt=True,
return_tensors="pt",
return_dict=True,
)
except TypeError:
encoded = tokenizer.apply_chat_template(
messages,
tokenize=True,
add_generation_prompt=True,
return_tensors="pt",
)
# 兼容不同返回类型
'''
# 后面这段是在处理 encoded 的不同可能返回形式:
可能出现三种情况:
1. 返回的是一个 torch.Tensor
2. 返回的是类似字典的 BatchEncoding
3. 返回的是普通 token id 列表
最后统一成:
model_inputs = {
"input_ids": ...
}
'''
if isinstance(encoded, torch.Tensor):
model_inputs = {"input_ids": encoded.to(device)}
elif hasattr(encoded, "to"):
moved = encoded.to(device)
model_inputs = {key: value for key, value in moved.items() if isinstance(value, torch.Tensor)}
else:
model_inputs = {"input_ids": torch.tensor([encoded], dtype=torch.long, device=device)}
'''
如果没有 input_ids,就报错:
if "input_ids" not in model_inputs:
raise TypeError("Chat template did not produce input_ids.")
'''
if "input_ids" not in model_inputs:
raise TypeError("Chat template did not produce input_ids.")
# 最后记录 prompt 长度:
# prompt_len = model_inputs["input_ids"].shape[-1]
# 这个长度后面用来区分:
# 哪些 token 是输入 prompt
# 哪些 token 是模型新生成的回答
prompt_len = model_inputs["input_ids"].shape[-1]
return model_inputs, prompt_len
# generate():真正生成回答
# 这个函数负责完整推理流程。
def generate(args: argparse.Namespace) -> str:
# 第一步:加载模型
tokenizer, model, device = load_model(args)
# 第二步:构造输入
model_inputs, prompt_len = build_model_inputs(tokenizer, args, device)
# 判断是否采样。
# 如果 temperature > 0,就启用采样。
# 如果 temperature = 0,就不采样,生成更确定。
do_sample = args.temperature > 0
# 构造生成参数:
generation_kwargs: dict[str, Any] = {
**model_inputs,
"max_new_tokens": args.max_new_tokens,
"do_sample": do_sample,
"pad_token_id": tokenizer.pad_token_id,
"eos_token_id": tokenizer.eos_token_id,
}
# 如果启用采样:
if do_sample:
generation_kwargs["temperature"] = args.temperature
generation_kwargs["top_p"] = args.top_p
# 然后真正调用生成:
# torch.inference_mode() 会关闭梯度计算,比 no_grad() 更适合纯推理。
with torch.inference_mode():
output_ids = model.generate(**generation_kwargs)
# 输出处理:
# 如果用户加了:--print_full_text
if args.print_full_text:
# 就输出完整文本:
return tokenizer.decode(output_ids[0], skip_special_tokens=True).strip()
# 否则只输出新生成的部分:
new_tokens = output_ids[0, prompt_len:]
return tokenizer.decode(new_tokens, skip_special_tokens=True).strip()
# main() 和程序入口。
'''
程序入口做两件事:
1. 解析命令行参数。
2. 调用 generate() 并打印结果。
'''
def main() -> None:
args = parse_args()
print(generate(args))
if __name__ == "__main__":
main()
文件3:data\processed\custom_sft_train.jsonl
json
{"messages": [{"role": "system", "content": "你是一个清晰、严谨、适合初学者的大模型微调中文助教。回答时优先解释 LoRA、SFT、DPO、数据清洗、显存和实验复盘。"}, {"role": "user", "content": "为什么不能只看 loss 判断一次 SFT 是否成功?"}, {"role": "assistant", "content": "不能只看 loss,因为 loss 是平均训练目标上的拟合信号,不等于目标 prompt 的实际回答已经正确。本项目里 public-SFT 能正常训练,但 Stage 4A 仍然误解 LoRA/SFT/DPO;Stage 2B.2 的 v2 甚至 loss 更低却出现行为回归。所以 SFT 是否成功要同时看 train loss、eval loss、固定 prompt 对比、badcase review 和是否引入旧能力回归。"}]}
{"messages": [{"role": "system", "content": "你是一个清晰、严谨、适合初学者的大模型微调中文助教。回答时优先解释 LoRA、SFT、DPO、数据清洗、显存和实验复盘。"}, {"role": "user", "content": "面试时怎样解释:为什么我没有只汇报 SFT loss?"}, {"role": "assistant", "content": "我会说 loss 是必要但不充分的训练指标。我的目标是让模型正确解释 LoRA/SFT/DPO,所以我还做了固定 prompt 对比。public-SFT 和 v2 patch 都证明了这一点:训练能跑、loss 能降,不代表目标概念已经修正。因此我把 badcase 变成数据补丁,并用三方对比验证行为是否真的改善。"}]}
{"messages": [{"role": "system", "content": "你是一个清晰、严谨、适合初学者的大模型微调中文助教。回答时优先解释 LoRA、SFT、DPO、数据清洗、显存和实验复盘。"}, {"role": "user", "content": "请解释自采集技术数据从采集、清洗、去重、筛选到 instruction-answer 转换的流程。"}, {"role": "assistant", "content": "先采集项目相关来源并保留 source_id、标题、路径或 URL。再清洗网页噪声、导航栏、广告、重复空白和离题内容,然后去重和筛选高质量技术文本。最后把有用内容改写成 instruction-answer 样本,并转换成 Qwen chat JSONL。"}]}
{"messages": [{"role": "system", "content": "你是一个清晰、严谨、适合初学者的大模型微调中文助教。回答时优先解释 LoRA、SFT、DPO、数据清洗、显存和实验复盘。"}, {"role": "user", "content": "把VRAM解释给刚开始学大模型微调的人听。"}, {"role": "assistant", "content": "可以先记住一句话:显存决定了模型、激活、优化器状态和 batch 能否放在 GPU 上。 结合这个项目看,本项目 public LoRA SFT 约占 5.5GB/8GB,adapter 推理约占 1.2GB/8GB。 需要避免的坑是:推理显存低不代表 DPO 一定够,因为 DPO 通常比 SFT 更吃显存。"}]}
{"messages": [{"role": "system", "content": "你是一个清晰、严谨、适合初学者的大模型微调中文助教。回答时优先解释 LoRA、SFT、DPO、数据清洗、显存和实验复盘。"}, {"role": "user", "content": "如果面试官问badcase和这个项目有什么关系,应该怎么回答?"}, {"role": "assistant", "content": "可以这样回答:badcase 是模型失败样例,用来指导下一轮数据和训练设计。 LoRA/SFT/DPO 概念误解就是 Stage 2B 的核心 badcase。 我们还专门记录了相关误区:badcase 不是丢脸的结果,而是下一阶段数据建设的方向。"}]}
{"messages": [{"role": "system", "content": "你是一个清晰、严谨、适合初学者的大模型微调中文助教。回答时优先解释 LoRA、SFT、DPO、数据清洗、显存和实验复盘。"}, {"role": "user", "content": "DPO 和 SFT 的区别是什么?为什么通常先做 SFT 再做 DPO?"}, {"role": "assistant", "content": "SFT 使用 instruction-answer 数据教模型模仿目标回答,是打基础。DPO 使用 chosen/rejected 偏好对,让模型更偏向人类更喜欢的回答。通常先做 SFT,因为如果模型基础回答能力还不稳定,DPO 会放大混乱而不是稳定改进。"}]}
{"messages": [{"role": "system", "content": "你是一个清晰、严谨、适合初学者的大模型微调中文助教。回答时优先解释 LoRA、SFT、DPO、数据清洗、显存和实验复盘。"}, {"role": "user", "content": "请把下面的项目记录整理成适合面试讲述的三点总结。\n\n补充材料:\n- CUDA is available.\n- The 4060 is visible to PyTorch.\n- Qwen model downloads automatically from Hugging Face.\n- Base inference produces a readable answer.\nStatus: completed. Base inference runs locally from .hf_cache.\n### Stage 2A: Public SFT Dataset Baseline\nGoal:"}, {"role": "assistant", "content": "可以从三点讲:第一,这段记录来自 PROJECT_RUNBOOK.md,说明项目不是只跑 demo,而是在持续记录过程。第二,核心内容是:- CUDA is available. The 4060 is visible to PyTorch. Qwen model downloads automatically from Hugging Face. Base inference produces a readable answer. Status: completed. Base inference runs locally from .hf_cache. Stage 2... 第三,面试中要强调实验结论、问题定位和下一步改进,而不是只报一个训练命令。"}]}
{"messages": [{"role": "system", "content": "你是一个清晰、严谨、适合初学者的大模型微调中文助教。回答时优先解释 LoRA、SFT、DPO、数据清洗、显存和实验复盘。"}, {"role": "user", "content": "public-SFT、Stage 4A、Stage 2B 三者在项目闭环中分别是什么角色?"}, {"role": "assistant", "content": "public-SFT 是工程 baseline,用来证明训练、保存、加载 adapter 可行。Stage 4A 是行为评测,用固定 prompt 暴露 public-SFT 仍然答错的 badcase。Stage 2B 是数据改进阶段,把 badcase 转成自采集、清洗、去重和 instruction-answer 样本。"}]}
{"messages": [{"role": "system", "content": "你是一个清晰、严谨、适合初学者的大模型微调中文助教。回答时优先解释 LoRA、SFT、DPO、数据清洗、显存和实验复盘。"}, {"role": "user", "content": "如果面试官问LoRA和这个项目有什么关系,应该怎么回答?"}, {"role": "assistant", "content": "可以这样回答:LoRA 是一种参数高效微调方法。它冻结基础模型大部分参数,只训练插入到线性层旁边的低秩 adapter 矩阵。 本项目用 LoRA 在 8GB RTX 4060 上训练 Qwen2.5-0.5B-Instruct,避免全量微调带来的显存压力。 我们还专门记录了相关误区:不要把机器学习里的 LoRA 和无线通信里的 LoRa 混为一谈。"}]}
文件4:scripts\train_dpo.py
python
"""Run a Stage 5 DPO experiment.
The default config remains conservative, but later Stage 5 runs can opt into a
larger dataset and a separate frozen reference model.
"""
from __future__ import annotations
import argparse
import json
import time
from pathlib import Path
from typing import Any
import torch
import transformers
from datasets import Dataset
from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer
from trl import DPOConfig, DPOTrainer
DEFAULT_SYSTEM_PROMPT = (
"你是一个清晰、严谨、适合初学者的大模型微调中文助教。"
"回答时优先解释 LoRA、SFT、DPO、数据清洗、显存和实验复盘。"
)
def parse_scalar(value: str) -> Any:
text = value.strip()
lowered = text.lower()
if lowered in {"none", "null"}:
return "none"
if lowered in {"true", "false"}:
return lowered == "true"
try:
if any(ch in lowered for ch in [".", "e"]):
return float(text)
return int(text)
except ValueError:
return text.strip("\"'")
def read_simple_yaml(path: str | None) -> dict[str, Any]:
if not path:
return {}
config: dict[str, Any] = {}
for line_no, line in enumerate(Path(path).read_text(encoding="utf-8").splitlines(), start=1):
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if "#" in stripped:
stripped = stripped.split("#", maxsplit=1)[0].strip()
if not stripped:
continue
if ":" not in stripped:
raise ValueError(f"Unsupported config line {path}:{line_no}: {line}")
key, value = stripped.split(":", maxsplit=1)
config[key.strip()] = parse_scalar(value)
return config
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run Stage 5B tiny DPO.")
parser.add_argument("--config", default="configs/dpo_qwen05b.yaml")
parser.add_argument("--model_name", default=None)
parser.add_argument("--sft_adapter_path", default=None)
parser.add_argument("--dpo_file", default=None)
parser.add_argument("--eval_file", default=None)
parser.add_argument("--output_dir", default=None)
parser.add_argument("--max_length", type=int, default=None)
parser.add_argument("--max_prompt_length", type=int, default=None)
parser.add_argument("--per_device_train_batch_size", type=int, default=None)
parser.add_argument("--per_device_eval_batch_size", type=int, default=None)
parser.add_argument("--gradient_accumulation_steps", type=int, default=None)
parser.add_argument("--learning_rate", type=float, default=None)
parser.add_argument("--num_train_epochs", type=float, default=None)
parser.add_argument("--beta", type=float, default=None)
parser.add_argument("--logging_steps", type=int, default=None)
parser.add_argument("--eval_steps", type=int, default=None)
parser.add_argument("--save_steps", type=int, default=None)
parser.add_argument("--report_to", default=None)
parser.add_argument("--separate_ref_model", action="store_true", default=None)
parser.add_argument("--local_files_only", action="store_true")
parser.add_argument("--gradient_checkpointing", action="store_true")
parser.add_argument(
"--no_chat_template",
action="store_true",
help="Use raw prompt strings instead of Qwen chat-template prompts.",
)
parser.add_argument("--system_prompt", default=DEFAULT_SYSTEM_PROMPT)
parser.add_argument("--seed", type=int, default=42)
return parser.parse_args()
def merged_config(args: argparse.Namespace) -> dict[str, Any]:
config = read_simple_yaml(args.config)
defaults: dict[str, Any] = {
"model_name": "Qwen/Qwen2.5-0.5B-Instruct",
"sft_adapter_path": "outputs/sft_lora_qwen05b_custom_v3_from_v1_patch",
"dpo_file": "data/processed/dpo_tiny_train.jsonl",
"eval_file": "none",
"output_dir": "outputs/dpo_lora_qwen05b_tiny",
"max_length": 256,
"max_prompt_length": 128,
"per_device_train_batch_size": 1,
"per_device_eval_batch_size": 1,
"gradient_accumulation_steps": 8,
"learning_rate": 5e-5,
"num_train_epochs": 1,
"beta": 0.1,
"logging_steps": 10,
"eval_steps": 10,
"save_steps": 200,
"separate_ref_model": False,
"report_to": "none",
}
defaults.update(config)
for key in defaults:
value = getattr(args, key, None)
if value is not None:
defaults[key] = value
return defaults
def load_tokenizer(model_name: str, local_files_only: bool) -> AutoTokenizer:
tokenizer = AutoTokenizer.from_pretrained(model_name, local_files_only=local_files_only)
if tokenizer.pad_token_id is None:
tokenizer.pad_token = tokenizer.eos_token
return tokenizer
def load_policy_model(config: dict[str, Any], local_files_only: bool) -> torch.nn.Module:
dtype = torch.bfloat16 if torch.cuda.is_available() and torch.cuda.is_bf16_supported() else torch.float16
dtype_arg = "dtype" if int(transformers.__version__.split(".", maxsplit=1)[0]) >= 5 else "torch_dtype"
base_model = AutoModelForCausalLM.from_pretrained(
str(config["model_name"]),
**{dtype_arg: dtype if torch.cuda.is_available() else torch.float32},
device_map="auto" if torch.cuda.is_available() else None,
local_files_only=local_files_only,
)
base_model.config.use_cache = False
model = PeftModel.from_pretrained(
base_model,
str(config["sft_adapter_path"]),
is_trainable=True,
)
return model
def load_reference_model(config: dict[str, Any], local_files_only: bool) -> torch.nn.Module:
dtype = torch.bfloat16 if torch.cuda.is_available() and torch.cuda.is_bf16_supported() else torch.float16
dtype_arg = "dtype" if int(transformers.__version__.split(".", maxsplit=1)[0]) >= 5 else "torch_dtype"
base_model = AutoModelForCausalLM.from_pretrained(
str(config["model_name"]),
**{dtype_arg: dtype if torch.cuda.is_available() else torch.float32},
device_map="auto" if torch.cuda.is_available() else None,
local_files_only=local_files_only,
)
base_model.config.use_cache = False
model = PeftModel.from_pretrained(
base_model,
str(config["sft_adapter_path"]),
is_trainable=False,
)
model.eval()
for param in model.parameters():
param.requires_grad_(False)
return model
def read_dpo_rows(path: str, tokenizer: AutoTokenizer, system_prompt: str, use_chat_template: bool) -> list[dict[str, str]]:
rows: list[dict[str, str]] = []
with Path(path).open("r", encoding="utf-8") as f:
for line_no, line in enumerate(f, start=1):
line = line.strip()
if not line:
continue
row = json.loads(line)
try:
prompt = str(row["prompt"]).strip()
chosen = str(row["chosen"]).strip()
rejected = str(row["rejected"]).strip()
except KeyError as exc:
raise ValueError(f"Missing key in {path}:{line_no}: {exc}") from exc
if not prompt or not chosen or not rejected:
raise ValueError(f"Empty prompt/chosen/rejected in {path}:{line_no}")
if use_chat_template:
prompt = tokenizer.apply_chat_template(
[
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt},
],
add_generation_prompt=True,
tokenize=False,
)
rows.append({"prompt": prompt, "chosen": chosen, "rejected": rejected})
if not rows:
raise ValueError(f"No DPO rows loaded from {path}")
return rows
def gpu_snapshot() -> dict[str, Any]:
if not torch.cuda.is_available():
return {"cuda": False}
return {
"cuda": True,
"device": torch.cuda.get_device_name(0),
"allocated_gb": round(torch.cuda.memory_allocated(0) / 1024**3, 3),
"reserved_gb": round(torch.cuda.memory_reserved(0) / 1024**3, 3),
"max_allocated_gb": round(torch.cuda.max_memory_allocated(0) / 1024**3, 3),
"max_reserved_gb": round(torch.cuda.max_memory_reserved(0) / 1024**3, 3),
}
def main() -> None:
args = parse_args()
config = merged_config(args)
local_files_only = bool(args.local_files_only)
tokenizer = load_tokenizer(str(config["model_name"]), local_files_only=local_files_only)
rows = read_dpo_rows(
str(config["dpo_file"]),
tokenizer,
system_prompt=args.system_prompt,
use_chat_template=not args.no_chat_template,
)
eval_rows: list[dict[str, str]] | None = None
eval_file = str(config.get("eval_file", "none"))
if eval_file.lower() not in {"", "none", "null"}:
eval_rows = read_dpo_rows(
eval_file,
tokenizer,
system_prompt=args.system_prompt,
use_chat_template=not args.no_chat_template,
)
train_dataset = Dataset.from_list(rows)
eval_dataset = Dataset.from_list(eval_rows) if eval_rows else None
model = load_policy_model(config, local_files_only=local_files_only)
model.print_trainable_parameters()
ref_model = None
if bool(config.get("separate_ref_model", False)):
print("Loading separate frozen reference model...")
ref_model = load_reference_model(config, local_files_only=local_files_only)
print("Reference model loaded.")
if args.gradient_checkpointing and hasattr(model, "enable_input_require_grads"):
model.enable_input_require_grads()
report_to = [] if str(config["report_to"]).lower() == "none" else [str(config["report_to"])]
training_args = DPOConfig(
output_dir=str(config["output_dir"]),
per_device_train_batch_size=int(config["per_device_train_batch_size"]),
per_device_eval_batch_size=int(config["per_device_eval_batch_size"]),
gradient_accumulation_steps=int(config["gradient_accumulation_steps"]),
learning_rate=float(config["learning_rate"]),
num_train_epochs=float(config["num_train_epochs"]),
beta=float(config["beta"]),
max_length=int(config["max_length"]),
max_prompt_length=int(config["max_prompt_length"]),
logging_steps=int(config["logging_steps"]),
eval_steps=int(config["eval_steps"]),
save_steps=int(config["save_steps"]),
save_strategy="steps",
save_total_limit=1,
eval_strategy="steps" if eval_dataset is not None else "no",
bf16=torch.cuda.is_available() and torch.cuda.is_bf16_supported(),
fp16=torch.cuda.is_available() and not torch.cuda.is_bf16_supported(),
report_to=report_to,
remove_unused_columns=False,
gradient_checkpointing=bool(args.gradient_checkpointing),
seed=int(args.seed),
)
trainer = DPOTrainer(
model=model,
ref_model=ref_model,
args=training_args,
train_dataset=train_dataset,
eval_dataset=eval_dataset,
processing_class=tokenizer,
)
print("Stage 5 DPO config:")
print(json.dumps(config, ensure_ascii=False, indent=2))
print("Rows:", len(rows))
if eval_rows is not None:
print("Eval rows:", len(eval_rows))
print("GPU before train:", json.dumps(gpu_snapshot(), ensure_ascii=False))
start = time.perf_counter()
trainer.train()
elapsed = time.perf_counter() - start
trainer.save_model(str(config["output_dir"]))
tokenizer.save_pretrained(str(config["output_dir"]))
print("GPU after train:", json.dumps(gpu_snapshot(), ensure_ascii=False))
print(f"Runtime seconds: {elapsed:.1f}")
print("Saved DPO LoRA adapter to:", config["output_dir"])
if __name__ == "__main__":
main()
文件5:dpo_larger_v6_train.jsonl
json
{"prompt": "为什么不能只看 loss 判断一次 SFT 是否成功?", "chosen": "不能只看 loss,因为 loss 只是训练目标上的平均拟合信号,不等于目标 prompt 的实际回答已经正确。本项目里 public-SFT 能训练完成,v2 也有更低 loss 的迹象,但固定 prompt 仍会误解或回归,所以要同时看 train/eval loss、固定 prompt 对比、badcase review 和旧能力是否保住。", "rejected": "只要 loss 降低,就说明 SFT 已经成功。固定 prompt 的回答可以以后再看,训练指标已经足够证明模型变好了。", "source_type": "base:dpo_tiny_train.jsonl"}
{"prompt": "为什么 public-SFT adapter 没修正 LoRA/SFT/DPO 概念误解,反而说明 Stage 2B 有必要?", "chosen": "因为 public-SFT 只证明通用 instruction 数据能把 LoRA SFT 链路跑通,不代表它覆盖了本项目关心的 LoRA/SFT/DPO 技术概念。Stage 4A 发现概念仍错,所以 Stage 2B 要补自采集技术数据,定向覆盖这些 badcase。", "rejected": "因为 Stage 2B 是从零开始建模型,所以 public-SFT 没修好概念就说明要做三到六个月的自采集资料工作。", "source_type": "base:dpo_tiny_v2_train.jsonl"}
{"prompt": "为什么不能只看 loss 判断一次 SFT 是否成功?", "chosen": "不能只看 loss。loss 只是训练或验证数据上的平均拟合信号,是必要参考但不充分。一次 SFT 是否成功,还要看固定 prompt 行为是否更正确、目标 badcase 是否减少、旧能力是否回归。本项目里 public-SFT 能训练完成但仍误解 LoRA/SFT/DPO,所以必须用固定 prompt 对比和 badcase review 一起判断。", "rejected": "只能先说公开数据跑通,但 Stage 4A 发现 public-SFT 没修正 LoRA/SFT/DPO 概念误解,所以进入 Stage 2B。Stage 2B 的目标是纠正概念误解,而不是只用 loss 判断项目是否通过。第一版 public-SFT 需要再做三到六个月的自采集练,让模型更清楚明白它错在哪里。", "source_type": "base:dpo_tiny_v3_train.jsonl"}
{"prompt": "面试里如何解释 LoRA,不要把它说成无线通信?", "chosen": "LoRA 在本项目里指 Low-Rank Adaptation,是一种参数高效微调方法。它通常冻结基础模型大部分权重,只在部分线性层旁边训练低秩 adapter,所以不是无线通信里的 LoRa,也不是重新训练整套模型。", "rejected": "LoRA 主要是 Long Range 无线通信技术,本项目训练的是让模型学会更稳定的 adapter state。", "source_type": "curated_guardrail"}
{"prompt": "为什么不能只看 loss 判断一次 SFT 是否成功?", "chosen": "不能只看 loss。loss 是训练目标上的平均拟合信号,必要但不充分;一次 SFT 是否成功,还要看固定 prompt 行为、目标 badcase 是否修好、旧能力是否回归。这个项目里 public-SFT 能跑通但仍误解 LoRA/SFT/DPO,所以必须把 loss 曲线和固定 prompt 对比一起作为验收。", "rejected": "只能先说它失败,而不是只凭 loss 判断项目是否通过。所以 Stage 4A 需要看到 metrics 和工程闭环,确认问题已经解决。第一版 loss 能让团队看到项目成功了,但 Stage 2B/3A 仍然需要看到项目实际行为。", "source_type": "custom_sft_known_gap_vs_curated"}
{"prompt": "为什么不能只看 loss 判断一次 SFT 是否成功?", "chosen": "不能只看 loss。loss 是训练目标上的平均拟合信号,必要但不充分;一次 SFT 是否成功,还要看固定 prompt 行为、目标 badcase 是否修好、旧能力是否回归。这个项目里 public-SFT 能跑通但仍误解 LoRA/SFT/DPO,所以必须把 loss 曲线和固定 prompt 对比一起作为验收。", "rejected": "只能先说公开数据跑通,但 Stage 4A 发现 public-SFT 没修正 LoRA/SFT/DPO 概念误解,所以进入 Stage 2B。Stage 2B 的目标是纠正概念误解,而不是只用 loss 判断项目是否通过。第一版 public-SFT 需要再做三到六个月的资料补习。", "source_type": "failed_candidate_vs_curated"}
{"prompt": "为什么 public-SFT adapter 没修正 LoRA/SFT/DPO 概念误解,反而说明 Stage 2B 有必要?", "chosen": "因为公开数据跑通不等于目标领域行为已经解决。Stage 2B 用自采集技术数据定向改进 LoRA/SFT/DPO,而不是只说训练过模型。", "rejected": "因为公开数据跑通不等于目标领域行为已经解决。Stage 2B 做的是从零开始建模型,所以这时候遇到的错误不是笼统说的模型架构错,而是说:公开数据跑通但没修正概念误解,说明 Stage 4A 需要做三到六个月的自采集技术资料工作。", "source_type": "failed_candidate_vs_custom_sft"}
{"prompt": "自采集技术数据应该怎样清洗和转换?", "chosen": "先保留 source_id、标题、路径或 URL 等来源元数据,再清洗网页噪声、导航栏和广告,之后去重、筛选,最后改写为 instruction-answer 或 Qwen chat JSONL,并用固定 prompt 做回归检查。", "rejected": "来源元数据不重要,网页路径和标题会干扰模型训练,应该全部删除。", "source_type": "generated_data_pipeline"}
{"prompt": "为什么 DPO 比 SFT 更容易吃显存?", "chosen": "DPO 要比较 chosen/rejected,并且通常需要 reference policy 评分,所以比普通 SFT 更吃显存。8GB 下应先用 LoRA、batch_size=1、短 max_length/max_prompt_length、少量 pair 和少量 eval 做 smoke test。", "rejected": "只要有共享显存,DPO 行为问题就会自动解决。", "source_type": "generated_dpo_vram"}
{"prompt": "DPO 和 SFT 的训练信号有什么不同?", "chosen": "SFT 用 instruction-answer 样本教模型模仿标准答案;DPO 用 chosen/rejected 偏好对,让模型更偏向 chosen、远离 rejected。通常先 SFT 建立基础回答能力,再用 DPO 做偏好优化。", "rejected": "SFT 和 DPO 都只看 loss,谁 loss 低谁就成功。", "source_type": "generated_dpo_vs_sft"}
{"prompt": "面试里如何讲这个 LoRA SFT / DPO 项目?", "chosen": "我会说先用公开数据跑通可复现基线,证明训练、保存、加载和对比链路;Stage 4A 发现 public-SFT 没修正目标概念 badcase;于是 Stage 2B 自采集、清洗、去重并转换技术 instruction-answer 数据,再训练 custom-SFT 并做三方对比。", "rejected": "面试里重点讲 SQL 大数据和 Tokenizers,不需要讲 Stage 4A badcase。", "source_type": "generated_interview_narrative"}
未完待续