qwen3-8b微调实战

前言

在完成微调前备知识的学习后,正式开始使用unsloth对Qwen3-8B-unsloth-bnb-4bit模型的lora微调实战

模型加载

1
2
3
4
5
6
7
8
9
10
11
12
13
from unsloth import FastLanguageModel
import torch

max_seq_length = 8192
dtype = None
load_in_4bit = True

model, tokenizer = FastLanguageModel.from_pretrained(
model_name = "/workspace/qwen3-8b",
max_seq_length = max_seq_length,
dtype = dtype,
load_in_4bit = load_in_4bit,
)

FastLanguageModelUnsloth 框架的核心入口类,即“把 Hugging Face 的 transformers 模型‘加速’成支持 QLoRA 微调、显存占用减半、速度提升 2-5 倍的封装器。”

max_seq_length = 8192作用:告诉框架 “后续所有输入序列的最大长度”内部一次性为位置编码、注意力掩码、KV-Cache 等开辟的张量尺寸,因此显存随它 平方级增长

dtype = None作用:让 Unsloth 自动选择最合适的浮点精度

load_in_4bit = True作用:把模型权重量化成 4-bit,显存降到 1/4,QLoRA 微调必备。

查看模型与分词器信息

模型信息

运行

1
model

通过阅读模型信息我们可以了解到:

1
(embed_tokens): Embedding(151936, 4096, padding_idx=151654)

模型有 15 万个 token 的字典,每个字/词被翻译成 4096 维向量,第 151 654 号 token 被官方指定为填充符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
(layers): ModuleList(
(0-2): 3 x Qwen3DecoderLayer(
(self_attn): Qwen3Attention(
(q_proj): Linear4bit(in_features=4096, out_features=4096, bias=False)
(k_proj): Linear4bit(in_features=4096, out_features=1024, bias=False)
(v_proj): Linear4bit(in_features=4096, out_features=1024, bias=False)
(o_proj): Linear4bit(in_features=4096, out_features=4096, bias=False)
(q_norm): Qwen3RMSNorm((128,), eps=1e-06)
(k_norm): Qwen3RMSNorm((128,), eps=1e-06)
(rotary_emb): LlamaRotaryEmbedding()
)
(mlp): Qwen3MLP(
(gate_proj): Linear(in_features=4096, out_features=12288, bias=False)
(up_proj): Linear(in_features=4096, out_features=12288, bias=False)
(down_proj): Linear(in_features=12288, out_features=4096, bias=False)
(act_fn): SiLU()
)
(input_layernorm): Qwen3RMSNorm((4096,), eps=1e-06)
(post_attention_layernorm): Qwen3RMSNorm((4096,), eps=1e-06)
)

共有36层Qwen3DecoderLayer,每层包含Qwen3AttentionQwen3MLP一个 SwiGLU 前馈网络),Qwen3RMSNorm(两个归一化层,对 4096 维的隐藏向量做“均方根归一化”,防止梯度爆炸、稳定训练。)

image-20250812153659843

大模型-qwen3 模型结构解读-66 - jack-chen666 - 博客园

LoRA可以插到哪里呢?

凡是打印里每层 Decoder 中出现的 Linear4bit(q/k/v/o + gate/up/down)就是 LoRA 可插、且默认会被插入的位置。

分词器信息

运行

1
tokenizer

查看tokenizer信息

1
2
3
4
5
6
7
8
Qwen2TokenizerFast(name_or_path='/workspace/qwen3-8b', vocab_size=151643, model_max_length=40960, is_fast=True, padding_side='left', truncation_side='right', special_tokens={'eos_token': '<|im_end|>', 'pad_token': '<|vision_pad|>', 'additional_special_tokens': ['<|im_start|>', '<|im_end|>', '<|object_ref_start|>', '<|object_ref_end|>', '<|box_start|>', '<|box_end|>', '<|quad_start|>', '<|quad_end|>', '<|vision_start|>', '<|vision_end|>', '<|vision_pad|>', '<|image_pad|>', '<|video_pad|>']}, clean_up_tokenization_spaces=False, added_tokens_decoder={
151643: AddedToken("<|endoftext|>", rstrip=False, lstrip=False, single_word=False, normalized=False, special=True),
151644: AddedToken("<|im_start|>", rstrip=False, lstrip=False, single_word=False, normalized=False, special=True),
151645: AddedToken("<|im_end|>", rstrip=False, lstrip=False, single_word=False, normalized=False, special=True),
151646: AddedToken("<|object_ref_start|>", rstrip=False, lstrip=False, single_word=False, normalized=False, special=True),
截取部分
}
)

vocab_size=151643:模型真正能理解和生成的子词/符号有这 151643 种,其余位置是预留空白。

model_max_length=40960:理论最大输入长度 40k token(实际受显存限制)

is_fast=True:表示 tokenizer 使用的是 Hugging Face 的「Rust 高速实现」(即 tokenizers 库)

special_tokens:打印的 special_tokens 字典 & added_tokens_decoder 已经把 151643-151668 全部列出,共 26 个

模拟一次模型处理流程

将对话内容通过tokenizer进行处理

1
2
3
4
5
6
7
8
9
10
messages = [
{"role" : "user", "content" : "你好,好久不见!"}
]

text = tokenizer.apply_chat_template(
messages,
tokenize = False,
add_generation_prompt = True,
enable_thinking = False, # 设置不思考
)

apply_chat_template 是把「人类对话格式的 Python 列表」一键翻译成 模型能直接理解的带特殊标记的文本字符串(或 token id 序列) 的“官方模板引擎”。

转化后的格式为:

1
'<|im_start|>user\n你好,好久不见!<|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n'

然后将转化后的字符串转成 GPU 上的 PyTorch token 张量,准备直接送进模型推理或训练。

1
inputs = tokenizer(text, return_tensors="pt").to("cuda")

以上代码共做了三步:

  1. tokenizer(text) 把前面 apply_chat_template 得到的字符串按词表切成 token id 列表
  2. return_tensors=“pt” 把列表包成 PyTorch 张量(shape = [1, seq_len])。
  3. .to(“cuda”) 把张量搬到 GPU 显存

输出如下

1
2
3
{'input_ids': tensor([[151644,    872,    198, 108386,   3837, 111920, 101571,   6313, 151645,
198, 151644, 77091, 198, 151667, 271, 151668, 271]],
device='cuda:0'), 'attention_mask': tensor([[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]], device='cuda:0')}
形状 每个数字的含义
input_ids [1, 17] 17 个 token 的 ID 列表,已放到 GPU
attention_mask [1, 17] 17 个 1,表示“这些位置都是有效 token,无填充”
1
2
3
4
5
6
outputs = model.generate(
input_ids=inputs.input_ids,
attention_mask=inputs.attention_mask,
max_new_tokens=max_seq_length,
use_cache=True,#启用 KV-Cache,避免重复计算,显存换时间
)

让模型在 GPU 上 根据已有 token 继续生成文本,直到达到 max_new_tokens 或遇到终止符。

outputs格式和inputs类似,使用nput_ids表示后续字符

1
response = tokenizer.batch_decode(outputs)

把模型输出的 token id 序列outputs)一次性还原成 人类可读的字符串

输出如下

1
'<|im_start|>user\n你好,好久不见!<|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n你好!好久不见!最近过得怎么样?有什么新鲜事想和我分享吗?😊<|im_end|>'

这里展示的是没有思考过程的,最简单对话流程,若设置思考模式,完整代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
text = tokenizer.apply_chat_template(
messages,
tools = tools,#同样,可以设置function calling
tokenize = False,
add_generation_prompt = True,
enable_thinking = True, # 设置思考
)

inputs = tokenizer(text, return_tensors="pt").to("cuda")

outputs = model.generate(
input_ids=inputs.input_ids,
attention_mask=inputs.attention_mask,
max_new_tokens=max_seq_length,
use_cache=True,
)

response = tokenizer.batch_decode(outputs)

当然,除了使用上述底层API进行对话外,Unsloth还提供了更加便捷的流式输出模型对话信息的函数,基本对话效果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
messages = [
{"role" : "user", "content" : "你好,好久不见!"}
]

text = tokenizer.apply_chat_template(
messages,
tokenize = False,
add_generation_prompt = True,
enable_thinking = False,
)

_ = model.generate(
**tokenizer(text, return_tensors = "pt").to("cuda"),
max_new_tokens = 256, # Increase for longer outputs!
temperature = 0.7, top_p = 0.8, top_k = 20, # For non thinking
streamer = TextStreamer(tokenizer, skip_prompt = True),#实时流式输出:每解码一个 token 就立刻打印到终端
)

准备数据集

下载数据集

选取的两个数据集

  1. 我们使用 Open Math Reasoning 数据集,该数据集曾被用于赢得 AIMO(AI 数学奥林匹克 - 第二届进步奖)挑战!我们从中抽取了 10% 可验证的推理轨迹,这些轨迹是基于 DeepSeek R1 模型生成的,并且准确率超过 95%。数据集地址:https://huggingface.co/datasets/unsloth/OpenMathReasoning-mini
  2. 我们还利用了 Maxime Labonne 的 FineTome-100k 数据集,该数据集风格类似 ShareGPT。但我们需要将其转换为 HuggingFace 通用的多轮对话格式。数据集地址:https://huggingface.co/datasets/mlabonne/FineTome-100k

在实际微调过程中,大多都会使用huggingface的datasets库进行数据集下载和管理,实际下载流程如下:

1
!pip install --upgrade datasets huggingface_hub

datasets 是 Hugging Face 提供的一个高效数据处理库,专为机器学习和大语言模型(LLM)训练而设计。它支持加载、处理、转换和保存各种格式的数据(如 JSON、CSV、Parquet 等),并能与 transformers 模型无缝集成。通过 datasets,开发者可以快速完成数据清洗、切分、tokenization 等常见任务,大大提升训练效率,特别适合用于指令微调、对话生成、Function Calling 等任务的数据预处理。

然后分别下载并导入这两个库:

1
reasoning_dataset = load_dataset("unsloth/OpenMathReasoning-mini", split = "cot")

cot全称为Chain-of-Thought,思维链,是「一步一步把思考过程写出来」的解题方式,而不是直接给出最终答案。

只下 cot 是因为任务只需要“带推理过程”的那部分数据,其他子集对当前微调目标无用,避免冗余下载。

1
non_reasoning_dataset = load_dataset("mlabonne/FineTome-100k", split = "train")

查看数据集

然后输入数据集名称,即可查看数据集基本信息:

1
reasoning_dataset
1
2
3
4
Dataset({
features: ['expected_answer', 'problem_type', 'problem_source', 'generation_model', 'pass_rate_72b_tir', 'problem', 'generated_solution', 'inference_mode'],
num_rows: 19252
})

一共 19 252 条 CoT(思维链)数学题,每条包含 8 个字段,可直接用来训练/评估模型的逐步推理能力。

generated_solution:模型自己写的 逐步推理 + 最终答案(就是你想要的 CoT)

expected_answer:标准答案(通常是一个简洁数字或表达式)

generation_model:生成这条 CoT 的“教师模型”名字,比如 qwen2-72b

加上索引则可以直接查看对应数据集信息:

1
reasoning_dataset[0]
1
2
3
4
5
6
7
8
{'expected_answer': '14',
'problem_type': 'has_answer_extracted',
'problem_source': 'aops_c4_high_school_math',
'generation_model': 'DeepSeek-R1',
'pass_rate_72b_tir': '0.96875',
'problem': 'Given $\\sqrt{x^2+165}-\\sqrt{x^2-52}=7$ and $x$ is positive, find all possible values of $x$.',
'generated_solution': "<think>\nOkay, let's see. I need to solve the equation √(x² + 165) - √(x² - 52) = 7, a截取部分",
'inference_mode': 'cot'}

能够看出这是一个基于DeepSeek R1回答的数学数据集,其中problem是问题,generated_solution是数学推导过程(即思考过程),而expected_answer则是最终的答案。该数据集总共接近2万条数据

而对话数据集如下:

1
non_reasoning_dataset
1
2
3
4
Dataset({
features: ['conversations', 'source', 'score'],
num_rows: 100000
})
1
non_reasoning_dataset[0]
1
2
3
4
5
6
{'conversations': [{'from': 'human',
'value': 'Explain what boolean operators are, what they do, and provide examples of how they can be used in programming. Additionally, describe the concept of operator precedence and prov截取'},
{'from': 'gpt',
'value': 'Boolean operators are logical operators used in programming to manipulate boolean values. The截取'}],
'source': 'infini-instruct-top-500k',
'score': 5.212620735168457}

其中每一条数据都是一个对话,包含一组或者多组ChatGPT的聊天信息,其中from代表是用户消息还是大模型回复消息,而value则是对应的文本。该对话数据集总共包含10万条数据

能够看出dataset是一种类似json的数据格式,每条数据都以字段格式进行存储,在实际微调过程中,我们需要先将数据集的目标字段进行提取和拼接,然后加载到Qwen3模型的提示词模板中,并最终带入Unsloth进行微调。

数据集清洗

对话数据集的清洗

接下来尝试对上述两个格式各异的数据集进行数据清洗,主要是围绕数据集进行数据格式的调整,便于后续带入Qwen3提示词模板。对于dataset格式的数据对象来说,可以先创建满足格式调整的函数,然后使用map方法对数据集格式进行调整。

1
2
3
4
5
6
7
8
9
10
def generate_conversation(examples):
problems = examples["problem"]
solutions = examples["generated_solution"]
conversations = []
for problem, solution in zip(problems, solutions):
conversations.append([
{"role" : "user", "content" : problem},
{"role" : "assistant", "content" : solution},
])
return { "conversations": conversations, }

这里先创建generate_conversation函数,用于对reasoning_dataset中的每一条数据进行格式调整,即通过新创建一个新的特征conversations,来以对话形式保存历史问答数据:

1
2
3
4
reasoning_data = reasoning_dataset.map(
generate_conversation, # 处理函数
batched=True # 批量处理,加快速度
)

map:对数据集中的每一批样本调用 generate_conversation

batched=True:一次传入一批(几百到几千条)样本,避免逐行慢速 Python 循环

接下来将其带入Qwen3的提示词模板中进行转化:

1
2
3
4
reasoning_conversations = tokenizer.apply_chat_template(
reasoning_data["conversations"],
tokenize = False,
)

之后即可带入这些数据进行微调。能看出每条数据的格式都和Unsloth底层对话API创建的数据格式类似,之后我们或许可以借助Unsloth底层对话API来创建微调数据集。

推理数据集的推理

然后继续处理non_reasoning_conversations数据集,由于该数据集采用了sharegpt对话格式,因此可以直接借助Unsloth的standardize_sharegpt库进行数据集的格式转化,转化效果如下所示:

1
from unsloth.chat_templates import standardize_sharegpt

standardize_sharegpt的作用

把“ShareGPT 格式”的对话数据一键转成 Unsloth / Hugging Face 通用的 role/content 列表,后续就能直接用 apply_chat_template 生成训练文本。

1️⃣ ShareGPT 原始长什么样?

1
2
{"from": "human", "value": "1+1=?"}
{"from": "gpt", "value": "2"}

2️⃣ 转换后长什么样?

1
2
{"role": "user",      "content": "1+1=?"}
{"role": "assistant", "content": "2"}
1
dataset = standardize_sharegpt(non_reasoning_dataset)

接下来即可直接带入Qwen3对话模板中进行格式调整:

1
2
3
4
non_reasoning_conversations = tokenizer.apply_chat_template(
dataset["conversations"],
tokenize = False,
)

数据集采样

自此即完成了每个数据集的格式调整工作,不过这两个数据集并不均衡,能看得出非推理类数据集的长度更长。我们假设希望模型保留一定的推理能力,但又特别希望它作为一个聊天模型来使用。

因此,我们需要定义一个 仅聊天数据的比例目标是从两个数据集中构建一个混合训练集。这里我们可以设定一个 25% 推理数据、75% 聊天数据的比例:也就是说,从推理数据集中抽取 25%(或者说,抽取占比为 100% - 聊天数据占比 的部分),最后将这两个数据集合并起来即可。

1
2
3
4
5
6
7
8
9
10
chat_percentage = 0.75

import pandas as pd
#先把非推理对话列表转成 Pandas Series,方便后续抽样
non_reasoning_subset = pd.Series(non_reasoning_conversations)

non_reasoning_subset = non_reasoning_subset.sample(#sample(...)为无放回随机抽样
int(len(reasoning_conversations) * (1.0 - chat_percentage)),#计算 需要抽多少条非推理样本
random_state = 2407,
)

这里我们需要先将上述list格式的数据转化为pd.Series数据,然后进行采样,并最终将其转化为dataset类型对象。(此外也可以先转化为dataset对象类型,然后再进行采样)

1
2
3
4
5
6
7
8
9
10
data = pd.concat([
pd.Series(reasoning_conversations),
pd.Series(non_reasoning_subset)
])
data.name = "text"

from datasets import Dataset

combined_dataset = Dataset.from_pandas(pd.DataFrame(data))
combined_dataset = combined_dataset.shuffle(seed = 3407)#用固定种子随机打乱顺序

pd.concat([…]):纵向拼接 → 一条长 Series,顺序:先推理,后非推理

Dataset.from_pandas(…):把 Pandas Series 转成 Hugging Face Dataset

把“推理对话”和“抽样后的非推理对话”合并成一个 随机打乱 Dataset 对象,后面可直接拿去训练。

查看数据集

1
combined_dataset[0]
1
2
{'text': "<|im_start|>user\nCalculate the pH during a titration when 9.54 mL of a 0.15 M HCl solution has reacted with 22.88 mL of a 0.14 M NaOH solution?<|im_end|>\n<|im_st截取",
'__index_level_0__': 49038}

其中text字段就是后续带入微调的字段。

数据集保存

1
combined_dataset.save_to_disk("/workspace/cleaned_qwen3_dataset")

后续使用时即可使用如下代码进行读取:

1
2
from datasets import load_from_disk
combined_dataset = load_from_disk("cleaned_qwen3_dataset")

Qwen3推理能力高效微调流程

准备完数据之后,即可开始进行微调。这里我们先进行少量数据微调测试,程序能够基本跑通后,我们再进行大规模数据集微调。

进行LoRA参数注入

1
2
3
4
5
6
7
8
9
10
11
12
13
model = FastLanguageModel.get_peft_model(
model,
r = 32, # 秩(LoRA 低秩矩阵的列数)。越大可学习参数越多,显存也越高。常用 8/16/32/64/128
target_modules = ["q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"], # 在哪些线性层插入 LoRA 适配器(Attention + MLP)
lora_alpha = 32, # 缩放因子。经验值 = rank 或 2×rank,控制更新强度
lora_dropout = 0, # LoRA 本身的 dropout 比例;0 省显存且速度最快
bias = "none", # 是否训练原 Linear 的偏置。设为 "none" 不训练,进一步节省显存
use_gradient_checkpointing = "unsloth", # 梯度检查点:True 省显存,"unsloth" 再省 30 %,超长上下文必开
random_state = 3407, # 随机种子,保证 LoRA 初始化可复现
use_rslora = False, # 默认 False,True 则启用 Rank-Stabilized LoRA(训练更稳,但显存稍高)
loftq_config = None, # LoftQ 量化初始化,None 表示不用;若配置可进一步压缩初始权重
)

这一步“LoRA 参数注入”就是:在不改动原模型权重的前提下,给指定层插入少量 可训练低秩矩阵 (LoRA 适配器),从而只更新 < 1 % 的参数,完成高效微调。

不是“在原有层之外再增加一层”,而是把 LoRA 的“小矩阵”插到 原有线性层内部

  • 原层结构(冻结): x → Linear4bit(W) → y
  • 注入后结构(冻结 + 可训练): x → [Linear4bit(W) + LoRA(A·B)] → y

AB 两个低秩矩阵被 注册为同一层的新参数不新建网络层,参数在 前向时相加反向只更新 A 和 B

设置微调参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from trl import SFTTrainer, SFTConfig

trainer = SFTTrainer(
model=model, # 已插入 LoRA 的 4-bit 模型
tokenizer=tokenizer, # 对应 tokenizer(含 chat 模板)
train_dataset=combined_dataset, # 训练集:聊天+推理对话
eval_dataset=None, # 如需验证,把验证集放进来即可

args=SFTConfig(
dataset_text_field="text", # 训练集中每条样本的字段名(对话列表)
per_device_train_batch_size=2, # 每张显卡上的 batch_size(显存决定)
gradient_accumulation_steps=4, # 4 次累积 → 全局有效 batch = 2×4 = 8
warmup_steps=5, # 前 5 步线性预热学习率
max_steps=30, # 训练 30 步(调试阶段);正式可用 num_train_epochs
learning_rate=2e-4, # LoRA 常用 2e-4;长训降到 2e-5
logging_steps=1, # 每 1 步打印一次日志
optim="adamw_8bit", # 8-bit AdamW,省显存
weight_decay=0.01, # L2 正则
lr_scheduler_type="linear", # 线性衰减到 0
seed=3407, # 固定随机种子
report_to="swanlab", # 把指标推送到 swanlab
),
)

TRL (Transformers Reinforcement Learning,用强化学习训练Transformers模型) 是一个领先的Python库,旨在通过监督微调(SFT)、近端策略优化(PPO)和直接偏好优化(DPO)等先进技术,对基础模型进行训练后优化。TRL 建立在 🤗 Transformers 生态系统之上,支持多种模型架构和模态,并且能够在各种硬件配置上进行扩展。

其中SFTTrainer:一个专门为指令微调设计的训练器,封装了 Hugging Face 的 Trainer,而SFTConfig:配置训练参数的专用类,功能类似 TrainingArguments。而SFTConfig核心参数解释如下:

参数名 含义
dataset_text_field="text" 数据集中用于训练的字段名称,如 textprompt
per_device_train_batch_size=2 每张 GPU 上的 batch size 是 2
gradient_accumulation_steps=4 梯度累计 4 次后才进行一次反向传播(等效于总 batch size = 2 × 4 = 8)
warmup_steps=5 前 5 步进行 warmup(缓慢提升学习率)
max_steps=30 最多训练 30 步(适合调试或快速实验)
learning_rate=2e-4 初始学习率(短训练可用较高值)
logging_steps=1 每训练 1 步就打印一次日志
optim="adamw_8bit" 使用 8-bit AdamW 优化器(节省内存,Unsloth 支持)
weight_decay=0.01 权重衰减,用于防止过拟合
lr_scheduler_type="linear" 线性学习率调度器(从高到低线性下降)
seed=3407 固定随机种子,确保结果可复现
report_to="none" 不使用 WandB 或 TensorBoard 等日志平台(可改为 "wandb"
  1. per_device_train_batch_size=2 每次前向只用了 2 条样本 → 显存占用小,单卡就能跑。

    batch_size 决定「每一步真正喂给模型的样本数量」,越大训练越稳,但对显存要求越高。

  2. gradient_accumulation_steps=4 把这 2 条样本算出的梯度先攒起来,攒够 4 次再一次性做反向传播 → 等效于一次性看了 2 × 4 = 8 条样本,但显存仍按 2 条算。

此时基本训练过程为: 1. 从 combined_dataset 中取出一批样本(2 条) 2. 重复上面过程 4 次(gradient_accumulation_steps=4) 3. 将累计的梯度用于更新模型一次参数(等效于一次大 batch 更新) 4. 重复上述过程,直到 max_steps=30 停止

设置训练可视化swanlab

🤗HuggingFace Trl | SwanLab官方文档

只需要在你的训练代码中,找到HF的Config部分(比如SFTConfigGRPOConfig等),添加report_to="swanlab"参数,即可完成集成。

1
2
3
4
5
6
7
8
from trl import SFTConfig, SFTTrainer

args = SFTConfig(
...,
report_to="swanlab"
)

trainer = Trainer(..., args=args)

默认下,项目名会使用你运行代码的目录名

如果你想自定义项目名,可以设置SWANLAB_PROJECT环境变量:

1
2
import os
os.environ["SWANLAB_PROJECT"]="qwen2-sft"

微调执行流程

一切准备就绪后,接下来即可开始进行微调。由于本次微调总共只运行30个step,整个过程并不会很长,实际执行过程如下:

1
trainer_stats = trainer.train()

保存模型

1. 保存 LoRA Adapter

1
2
3
4
# 保存 LoRA adapter(仅几十 MB)
save_path = "./lora-adapter"
model.save_pretrained(save_path) # LoRA 权重
tokenizer.save_pretrained(save_path) # 词表

以后加载:

1
2
3
4
5
6
7
8
from unsloth import FastLanguageModel
model, tokenizer = FastLanguageModel.from_pretrained(
model_name = "base-model-name-or-path",
max_seq_length = 2048,
load_in_4bit = True,
)
model = FastLanguageModel.get_peft_model(model, ...) # 同训练时参数
model.load_adapter(save_path) # 把 LoRA 权重挂回去

2.合并 LoRA → 完整模型

如果你想把 LoRA 权重合并到基座 得到一个独立的大模型(方便推理、上传 Hub):

1
2
3
4
# 合并权重
merged_model = model.merge_and_unload() # 返回普通 transformers 模型
merged_model.save_pretrained("./merged-model")
tokenizer.save_pretrained("./merged-model")

合并后就是完整的大模型(GB 级),可直接用 AutoModelForCausalLM.from_pretrained("./merged-model") 加载,不依赖 Unsloth。

微调结果

可视化结果

image-20250813111238359

图表 | Fine-tune-Qwen-8B/rat-2

指标名称 含义 单位/范围提示 常见关注点
train/loss 训练损失(Training Loss) 标量,越小越好 是否持续下降、是否震荡、是否过拟合
train/grad_norm 梯度范数(Gradient Norm) 标量,通常 0.01–1.0 为合理区间 是否爆炸(>10)或消失(<1e-4)
train/learning_rate 学习率(Learning Rate) 标量,如 1e-4、5e-4 等 是否过大导致震荡、过小导致收敛慢
train/epoch 已训练的轮次(Epoch) 标量,1.0 表示完整遍历一次训练集 当前已训练多少轮、是否还需继续训练
train/global_step 全局步数(Global Step) 整数,每个 batch +1 与 epoch 对应,计算已见样本量

对话测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
messages = [
{"role" : "user", "content" : "解决(x + 2)^2 = 0."}
]
text = tokenizer.apply_chat_template(
messages,
tokenize = False,
add_generation_prompt = True, # Must add for generation
enable_thinking = True, # Disable thinking
)

from transformers import TextStreamer
_ = model.generate(
**tokenizer(text, return_tensors = "pt").to("cuda"),
max_new_tokens = 20488, # Increase for longer outputs!
temperature = 0.6, top_p = 0.95, top_k = 20, # For thinking
streamer = TextStreamer(tokenizer, skip_prompt = True),
)