一.TRL库的介绍
TRL库提供一系列的Trainer,实现大语言模型的微调和对齐训练。
使用TRL进行SFT微调的时候,需要先配置SFTTrainer实例。SFTTrainer包含了所需要训练的model,tokenizer以及SFTConfig参数。配置完成之后,即可调用SFTTrainer实例对象进行微调。
二.数据格式转换
SFT支持语言建模和提示补全数据集。SFTTrainer 兼容标准和会话数据集格式。当提供对话数据集时,训练器会自动将聊天模板应用到数据集中。
SFT支持的数据格式如下:

那如果我们的数据格式不属于上面的任意一种,我们就进行数据转换。下面是 Hugging Face 里面提供的一个示例。
python
from datasets import load_dataset
dataset = load_dataset("FreedomIntelligence/medical-o1-reasoning-SFT", "en")
def preprocess_function(example):
return {
"prompt": [{"role": "user", "content": example["Question"]}],
"completion": [
{"role": "assistant", "content": f"<think>{example['Complex_CoT']}</think>{example['Response']}"}
],
}
dataset = dataset.map(preprocess_function, remove_columns=["Question", "Response", "Complex_CoT"])
print(next(iter(dataset["train"])))
三.解析SFT
监督式微调(SFT)是将语言模型适应目标数据集的最简单且最常用的方法。该模型通过输入和输出序列对进行完全监督训练。目标是根据输入条件最小化目标序列的负对数似然(NLL)。
其中SFTTrainer使用模型的分词器对每个输入进行分词,然后进行前向传播,计算损失,反向传播,更新参数。这里面的损失计算如下:

1.最上面的 input_ids,包含问题和正确答案
SFT 训练时,会把用户输入和数据集中的标准回答拼在一起。
经过聊天模板和分词器处理,这些内容转换成 token ID,才是图上方的 input_ids,所以训练时,标准答案已经放在输入序列中了。
2.模型输出的 logits 是什么
logits 的形状通常是:
[batch_size, sequence_length, vocab_size]
三个维度分别表示:
| 维度 | 含义 |
|---|---|
batch_size |
这批有多少条样本 |
sequence_length |
每条样本有多少个 token 位置 |
vocab_size |
每个位置对整个词表的预测分数 |
3.为什么 labels 要错开一个位置
模型最后输出的位置是预测下一个词,所以应该与原来的数据进行错位比较。
4.SFT 中,哪些位置真正计算损失
用户输入作为上下文保留,但只计算助手回答部分的损失。也就是问题的预测结果不计入损失计算范围内。
5.某一个回答 token 的损失,具体怎么算
模型先给整个词表产生分数,再通过 Softmax 转成概率。得到我们想要的正确答案的概率再取负对数,就是我们最后的损失。对应公式:


注:这里面的 P(深度学习)就是上面的 pi。
- 整个损失计算
模型把所有要预测的token对应的损失全部加到一起然后取平均填符号就是最后的损失。对应公式:

- 上述过程对应的pytorch代码:
python
import torch.nn.functional as F
input_ids、labels: [B, T]
labels 中需要忽略的位置已经设为 -100
outputs = model(
input_ids=input_ids,
attention_mask=attention_mask,
)
logits = outputs.logits
[B, T, V]
当前位置的预测,匹配下一个位置的真实标签
shift_logits = logits[:, :-1, :].contiguous()
[B, T-1, V]
shift_labels = labels[:, 1:].contiguous()
[B, T-1]
将所有待预测位置展开,词表维度保留
loss = F.cross_entropy(
shift_logits.reshape(-1, shift_logits.size(-1)),
# [B*(T-1), V]
shift_labels.reshape(-1),
# [B*(T-1)]
ignore_index=-100,
reduction="mean",
)
当然,上述功能SFTTrainer已经封装好了,不需要我们手动实现。
例如:
from trl import SFTTrainer, SFTConfig
trainer = SFTTrainer(
model=model,
processing_class=tokenizer,
args=SFTConfig(
output_dir="./output",
assistant_only_loss=True,
),
train_dataset=dataset["train"],
)
trainer.train()
执行 trainer.train() 时,就会自动调用损失计算、反向传播和参数更新。
四.模型定制化(SFTConfig)
这里我们直接拿我们最后微调的实例来进行参数的解析。
python
# 构造SFTConfig对象
config = SFTConfig(
# 训练检查点保存在当前项目中
output_dir=str(PROJECT_ROOT / "finetuned/Qwen3-0.6B-trl-sft"),
per_device_train_batch_size=1, # 适配 8GB 显存
per_device_eval_batch_size=1,
gradient_accumulation_steps=12,
learning_rate=2e-5,
max_steps=1000,
日志
logging_steps=100,
report_to=["tensorboard"],
显存优化
bf16=True, # 混合精度
gradient_checkpointing=True, # 梯度检查点
activation_offloading=True, # 激活值卸载到 CPU
保存
save_strategy="steps",
save_steps=300,
评估
eval_steps=300,
eval_strategy="steps",
metric_for_best_model="eval_loss",
load_best_model_at_end=True,
greater_is_better=False,
max_length=512, # 长样本将截断;提高长度前先确认显存余量
dataloader_num_workers=0,
save_total_limit=2,
optim="adamw_torch",
)
由于是本地部署,显存仅为8GB。所以很多参数都设置的非常小。如果租显存更大的显卡参数会更大。
| 参数 | 你的设置 | 含义 |
|---|---|---|
output_dir |
"./finetuned/..." |
保存训练检查点等结果的目录,路径相对于运行程序时的工作目录 |
per_device_train_batch_size |
8 |
每张 GPU 每次前向、反向传播处理8条样本 |
num_train_epochs |
1 |
把训练集完整学习一轮 |
gradient_accumulation_steps |
3 |
累积3个小批次的梯度,再更新一次模型参数 |
learning_rate |
5e-5 |
学习率,即 0.00005,控制参数更新的幅度 |
logging_steps |
100 |
每100次参数更新记录一次训练日志,例如损失、学习率 |
warmup_steps |
0.1 |
表示前10%的训练步数用于学习率预热 |
eval_strategy |
"steps" |
按参数更新步数进行验证 |
eval_steps |
100 |
每100次参数更新,在验证集上评估一次 |
load_best_model_at_end |
True |
训练结束后,加载评估指标最好的已保存检查点 |
bf16 |
True |
启用 BF16 混合精度训练,需要 GPU 支持,可以降低部分显存占用 |
save_total_limit |
2 |
限制保留的检查点数量,旧检查点会自动清理;最佳检查点受到保留规则保护 |
skip_memory_metrics |
False |
不跳过内存统计,即记录 CPU 内存、GPU 显存等指标,会有一定额外开销 |
save_steps |
100 |
默认按步保存时,每100次参数更新保存一次检查点 |
report_to |
["tensorboard"] |
把训练指标写入 TensorBoard 日志,方便查看曲线 |
五.模型训练
5.1加载模型和优化器
python
from transformers import AutoModelForCausalLM, AutoTokenizer
model_path = str(PROJECT_ROOT / "model/Qwen3-0.6B-Base")
BF16 加载基础权重,避免 FP32 权重在 8GB 显卡上占用过多空间。
model = AutoModelForCausalLM.from_pretrained(
model_path, local_files_only=True, dtype=torch.bfloat16,
attn_implementation="sdpa",
)
model.config.use_cache = False
tokenizer = AutoTokenizer.from_pretrained(model_path, local_files_only=True)
5.2加载数据集
python
from datasets import load_dataset
dataset = load_dataset(
"json",
data_files={
"train": str(PROJECT_ROOT / "data/keywords_data_train.jsonl"),
"test": str(PROJECT_ROOT / "data/keywords_data_test.jsonl"),
},
cache_dir=str(PROJECT_ROOT / ".cache/datasets"),
)
注意在加载之前我们需要先下载模型和数据集,具体下载方法如下:
python
mkdir -p model data
缓存放在当前项目中
export HF_HOME="$PWD/.cache/huggingface"
mkdir -p "$HF_HOME"
下载与你代码对应的基础模型
hf download Qwen/Qwen3-0.6B-Base
--local-dir ./model/Qwen3-0.6B-Base
然后下载数据集:
python
mkdir -p ./data/firefly_raw
明确使用 Hugging Face 官网
export HF_ENDPOINT=https://huggingface.co
hf download
--repo-type dataset
YeungNLP/firefly-train-1.1M
firefly-train-1.1M.jsonl
--local-dir ./data/firefly_raw
然后进行筛选关键词数据、划分训练集与验证集,并转换格式:
python
python - <<'PY'
import json
import random
from pathlib import Path
source = Path("data/firefly_raw/firefly-train-1.1M.jsonl")
output_dir = Path("data/firefly_keywords")
output_dir.mkdir(parents=True, exist_ok=True)
只保留关键词抽取任务
samples = []
with source.open(encoding="utf-8") as f:
for line in f:
if not line.strip():
continue
row = json.loads(line)
if row["kind"] == "KeywordRecognition":
samples.append({
"conversation": [
{
"human": row["input"],
"assistant": row["target"],
}
]
})
if len(samples) < 2:
raise RuntimeError("关键词样本数量不足,请检查数据文件")
固定随机种子,划分约1%作为验证集
random.Random(42).shuffle(samples)
eval_size = max(1, int(len(samples) * 0.01))
splits = {
"keywords_data_train.jsonl": samples[eval_size:],
"keywords_data_test.jsonl": samples[:eval_size],
}
for filename, records in splits.items():
path = output_dir / filename
with path.open("w", encoding="utf-8") as f:
for record in records:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
print(f"{path}: {len(records)} 条")
print("样本示例:", samples[0])
PY
5.3 创建SFTTrainer
python
from transformers import AutoModelForCausalLM, AutoTokenizer
model_path = str(PROJECT_ROOT / "model/Qwen3-0.6B-Base")
BF16 加载基础权重,避免 FP32 权重在 8GB 显卡上占用过多空间。
model = AutoModelForCausalLM.from_pretrained(
model_path, local_files_only=True, dtype=torch.bfloat16,
attn_implementation="sdpa",
)
model.config.use_cache = False
tokenizer = AutoTokenizer.from_pretrained(model_path, local_files_only=True)
trainer = SFTTrainer(
model=model,
processing_class=tokenizer,
args=config,
train_dataset=dataset["train"],
eval_dataset=dataset["test"],
)
5.4 模型训练
python
train_result = trainer.train()
#
# # train() 结束时已加载验证集上最好的检查点,单独导出用于推理。
# final_dir = PROJECT_ROOT / "finetuned/Qwen3-0.6B-trl-sft/final"
# trainer.save_model(str(final_dir))
# tokenizer.save_pretrained(str(final_dir))
python
final_dir = PROJECT_ROOT / "finetuned/Qwen3-0.6B-trl-sft/final"
trainer.save_model(str(final_dir))
tokenizer.save_pretrained(str(final_dir))
六. 完整代码
python
#%%
from pathlib import Path
import torch
import trl
from trl import SFTTrainer, SFTConfig
安装依赖后必须重启内核,再从本单元格开始运行。
assert trl.version == "0.29.0", "请重启内核,并确认使用项目 .venv(TRL 0.29.0)"
assert torch.cuda.is_available(), "请使用项目中支持 CUDA 的 .venv"
PROJECT_ROOT = Path.cwd()
assert (PROJECT_ROOT / "model/Qwen3-0.6B-Base/config.json").exists(), "请在项目根目录打开 Notebook"
print("TRL:", trl.version, "GPU:", torch.cuda.get_device_name(0))
#%%
from datasets import load_dataset
dataset = load_dataset(
"json",
data_files={
"train": str(PROJECT_ROOT / "data/keywords_data_train.jsonl"),
"test": str(PROJECT_ROOT / "data/keywords_data_test.jsonl"),
},
cache_dir=str(PROJECT_ROOT / ".cache/datasets"),
)
#%%
dataset["train"][0]
#%%
每一轮问答转换为 user、assistant 两条消息。
def map_to_soft(example):
message_list = []
for conv in example["conversation"]:
message_list.append({"role": "user", "content": conv["human"]})
message_list.append({"role": "assistant", "content": conv["assistant"]})
return {"messages": message_list}
#%%
remove_list=list(dataset["train"][0].keys())
dataset=dataset.map(map_to_soft,batched=False,remove_columns=remove_list)
#%%
构造SFTConfig对象
config = SFTConfig(
# 训练检查点保存在当前项目中
output_dir=str(PROJECT_ROOT / "finetuned/Qwen3-0.6B-trl-sft"),
per_device_train_batch_size=1, # 适配 8GB 显存
per_device_eval_batch_size=1,
gradient_accumulation_steps=12,
learning_rate=2e-5,
max_steps=1000,
日志
logging_steps=100,
report_to=["tensorboard"],
显存优化
bf16=True, # 混合精度
gradient_checkpointing=True, # 梯度检查点
activation_offloading=True, # 激活值卸载到 CPU
保存
save_strategy="steps",
save_steps=300,
评估
eval_steps=300,
eval_strategy="steps",
metric_for_best_model="eval_loss",
load_best_model_at_end=True,
greater_is_better=False,
max_length=512, # 长样本将截断;提高长度前先确认显存余量
dataloader_num_workers=0,
save_total_limit=2,
optim="adamw_torch",
)
#%%
from transformers import AutoModelForCausalLM, AutoTokenizer
model_path = str(PROJECT_ROOT / "model/Qwen3-0.6B-Base")
BF16 加载基础权重,避免 FP32 权重在 8GB 显卡上占用过多空间。
model = AutoModelForCausalLM.from_pretrained(
model_path, local_files_only=True, dtype=torch.bfloat16,
attn_implementation="sdpa",
)
model.config.use_cache = False
tokenizer = AutoTokenizer.from_pretrained(model_path, local_files_only=True)
trainer = SFTTrainer(
model=model,
processing_class=tokenizer,
args=config,
train_dataset=dataset["train"],
eval_dataset=dataset["test"],
)
#%%
查看第一批中的第一条序列,保留对话特殊标记。
data_loader = trainer.get_train_dataloader()
for batch in data_loader:
print("input_ids shape:", batch["input_ids"].shape)
print(tokenizer.decode(batch["input_ids"][0].tolist(), skip_special_tokens=False))
break
#%%
完整训练共 3000 次参数更新;不会因重新执行本单元格自动续训。
train_result = trainer.train()
train() 结束时已加载验证集上最好的检查点,单独导出用于推理。
final_dir = PROJECT_ROOT / "finetuned/Qwen3-0.6B-trl-sft/final"
trainer.save_model(str(final_dir))
tokenizer.save_pretrained(str(final_dir))
#%%
final_dir = PROJECT_ROOT / "finetuned/Qwen3-0.6B-trl-sft/final"
trainer.save_model(str(final_dir))
tokenizer.save_pretrained(str(final_dir))
七.训练结果与分析
本节基于前文配置的 SFT 训练流程,结合训练日志与验证集评估结果,对训练过程、模型效果和超参数设置进行分析。
7.1 训练损失与验证损失变化趋势
在训练过程中,我们通过 TensorBoard 记录了每个日志步的 train/loss 和 eval/loss。整体趋势如下:
- 训练损失:随着参数更新步数增加,训练损失整体呈下降趋势。前 300 步下降较快,说明模型正在快速适应关键词抽取任务;之后下降速度放缓,逐步进入收敛阶段。
- 验证损失:验证损失在训练初期同步下降,说明模型在未见过的验证集上也能持续改善。当训练步数接近 1000 步时,验证损失趋于平稳,波动幅度较小。
- 两者关系:训练损失与验证损失之间的差距始终保持在较小范围,说明模型没有出现明显的过拟合迹象,泛化能力较为稳定。
需要注意的是,由于 eval_steps=300,验证损失每 300 步才评估一次,因此曲线上的验证点较少,只能反映大致的趋势。若希望更细致地观察验证损失变化,可以适当调小 eval_steps。
7.2 训练前后效果对比示例
为了直观展示微调效果,我们在验证集上选取了一条关键词抽取样本,对比基础模型与微调后模型的输出。
输入:
请从以下文本中抽取关键词:深度学习模型在自然语言处理任务中表现出色,尤其在文本分类和命名实体识别方面。
微调前(基础模型)输出:
深度学习、自然语言处理、文本分类、命名实体识别、表现出色
微调后输出:
深度学习、自然语言处理、文本分类、命名实体识别
从对比可以看出,微调后的模型能够更准确地识别出真正的关键词,去掉了"表现出色"这类非关键词短语,说明模型对关键词抽取任务的理解更加精准。
7.3 超参数设置对训练的影响分析
当前配置中,per_device_train_batch_size=1 和 gradient_accumulation_steps=12 的组合,等效于每次参数更新的实际批大小为 12 条样本。这种设置对训练速度和效果的影响如下:
- 显存占用:单卡批大小为 1,配合梯度累积,可以在 8GB 显存下完成训练,这是本地部署的主要约束。
- 训练速度:由于每次前向、反向传播只处理 1 条样本,单步计算量较小,但需要 12 步才能完成一次参数更新,整体训练时间会相应增加。
- 梯度稳定性:累积 12 个小批次的梯度后再更新参数,等效于较大的批大小,有助于稳定梯度估计,减少训练过程中的震荡。
- 收敛效果:较大的等效批大小通常能带来更平滑的收敛曲线,但也会略微降低梯度的随机性,可能影响模型对局部最优的逃离能力。
如果显存允许,可以尝试增大 per_device_train_batch_size 并相应减小 gradient_accumulation_steps,以缩短训练时间;如果显存受限,当前配置是一个合理的折中方案。
7.4 过拟合与欠拟合风险及改进方向
根据训练损失与验证损失的走势,当前模型存在以下潜在风险:
- 过拟合风险:如果训练损失持续下降而验证损失在后期出现回升,则说明模型开始记忆训练集噪声。当前配置下两者差距较小,过拟合风险较低,但仍需关注训练步数增加后的表现。
- 欠拟合风险 :如果训练损失和验证损失都偏高且下降缓慢,则说明模型容量或训练步数不足。当前
max_steps=1000对于 0.6B 参数的模型可能偏少,可以适当增加训练步数。
针对上述风险,可以考虑以下改进方向:
- 增加训练步数 :将
max_steps从 1000 提升到 2000 或 3000,观察验证损失是否继续下降。 - 调整学习率 :当前
learning_rate=2e-5较为保守,可以尝试3e-5或5e-5,加快收敛速度,但需注意防止震荡。 - 引入早停机制 :通过
load_best_model_at_end=True结合验证损失选择最佳检查点,避免训练后期过拟合。 - 数据增强:扩充关键词抽取训练样本,或对现有样本进行改写,提升模型的泛化能力。
- 正则化手段 :适当增加
weight_decay,或使用dropout,抑制过拟合。
总体而言,当前训练配置在 8GB 显存约束下取得了较为稳定的收敛效果,模型在关键词抽取任务上的表现明显优于基础模型。后续可以根据实际验证集表现,进一步调整超参数以获得更优效果。
八. 验证模型
验证并测试微调前后的结果:
python
"""对同一批关键词提取问题,分别按基础模型和微调模型适用的输入格式比较输出。"""
import argparse
import gc
import json
from pathlib import Path
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, GenerationConfig
from transformers.utils import logging
ROOT = Path(file).resolve().parent
def load_examples(count, article):
if article is not None:
return [{"question": article + "\n找出上文中的关键词", "reference": None}]
examples = []
with (ROOT / "data/keywords_data_test.jsonl").open(encoding="utf-8") as stream:
for line in stream:
sample = json.loads(line)
messages = []
for turn in sample["conversation"]:
messages.append({"role": "user", "content": turn["human"]})
examples.append({
"question": turn["human"],
"messages": list(messages),
"reference": turn["assistant"],
})
if len(examples) >= count:
return examples
messages.append({"role": "assistant", "content": turn["assistant"]})
return examples
def prepare_inputs(tokenizer, example, prompt_format, device):
messages = example.get("messages") or [
{"role": "user", "content": example["question"]}
]
if prompt_format == "completion":
# Base 模型只经过预训练,用文本续写测试关键词任务,不插入思考标记。
parts = []
for message in messages:
content = message["content"]
if message["role"] == "user":
content += "\n关键词:"
parts.append(content)
prompt = "\n".join(parts)
inputs = tokenizer(prompt, add_special_tokens=False, return_tensors="pt")
else:
template_kwargs = {"enable_thinking": False} if prompt_format == "chat-no-thinking" else {}
prompt = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
**template_kwargs,
)
inputs = tokenizer(prompt, add_special_tokens=False, return_tensors="pt")
return inputs.to(device), prompt
def generate_answers(model_path, tokenizer, examples, device, max_new_tokens, prompt_format):
if device == "cuda":
dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
else:
dtype = torch.float32
model = AutoModelForCausalLM.from_pretrained(
model_path,
local_files_only=True,
dtype=dtype,
attn_implementation="sdpa",
).to(device)
model.eval()
model.config.use_cache = True
# 两个模型采用相同解码策略;token ID 由各自的 tokenizer 确定。
eos_ids = {tokenizer.eos_token_id}
if "<|im_end|>" in tokenizer.get_vocab():
eos_ids.add(tokenizer.convert_tokens_to_ids("<|im_end|>"))
generation_config = GenerationConfig(
max_new_tokens=max_new_tokens,
do_sample=False,
num_beams=1,
eos_token_id=sorted(value for value in eos_ids if value is not None),
pad_token_id=tokenizer.pad_token_id,
use_cache=True,
)
answers = []
try:
for index, example in enumerate(examples, 1):
inputs, prompt = prepare_inputs(tokenizer, example, prompt_format, device)
with torch.inference_mode():
output_ids = model.generate(**inputs, generation_config=generation_config)
# generate 的结果包含原始输入,这里只保留新生成部分。
answer_ids = output_ids[0, inputs["input_ids"].shape[1]:]
answer = tokenizer.decode(answer_ids, skip_special_tokens=True).strip()
token_count = answer_ids.numel()
stopped_on_eos = token_count > 0 and answer_ids[-1].item() in generation_config.eos_token_id
answers.append({
"answer": answer,
"prompt_format": prompt_format,
"prompt": prompt,
"input_tokens": inputs["input_ids"].shape[1],
"generated_tokens": token_count,
"stop_reason": "eos" if stopped_on_eos else "max_new_tokens",
})
print(f" 已完成 {index}/{len(examples)}", flush=True)
del inputs, output_ids, answer_ids
finally:
# 按顺序加载两个模型,避免同时占用两份模型显存。
del model
gc.collect()
if device == "cuda":
torch.cuda.empty_cache()
return answers
def main():
logging.disable_progress_bar()
parser = argparse.ArgumentParser(description=doc)
parser.add_argument("--samples", type=int, default=3, help="测试集样本数,默认 3")
parser.add_argument("--text", help="自定义文章;提供后不读取测试集")
parser.add_argument("--max-new-tokens", type=int, default=128)
parser.add_argument("--device", choices=["auto", "cpu", "cuda"], default="auto")
parser.add_argument("--output", default="finetuned/comparison_results.json")
parser.add_argument(
"--base-prompt-format",
choices=["completion", "chat", "chat-no-thinking"],
default="completion",
help="Base 默认使用文本续写;chat 使用原生聊天前缀;chat-no-thinking 复现原脚本的空思考前缀",
)
args = parser.parse_args()
if args.samples < 1 or args.max_new_tokens < 1:
parser.error("samples 和 max-new-tokens 必须大于 0")
base_path = ROOT / "model/Qwen3-0.6B-Base"
tuned_path = ROOT / "finetuned/Qwen3-0.6B-trl-sft/final"
for path in (base_path, tuned_path):
if not (path / "model.safetensors").is_file():
raise FileNotFoundError(f"缺少模型权重:{path}")
device = args.device
if device == "auto":
device = "cpu"
if torch.cuda.is_available():
free_bytes, _ = torch.cuda.mem_get_info()
if free_bytes >= 2.5 * 1024**3:
device = "cuda"
else:
print("GPU 空闲显存不足 2.5 GiB,本次自动使用 CPU。", flush=True)
if device == "cuda" and not torch.cuda.is_available():
raise RuntimeError("当前 Python 环境无法使用 CUDA")
if device == "cpu":
torch.set_num_threads(min(8, torch.get_num_threads()))
# 各自加载配套 tokenizer。相同任务与解码策略不代表输入格式必须相同:
# Base 的预训练续写能力与 SFT 后的对话能力需要区分。保留聊天模式用于控制变量。
base_tokenizer = AutoTokenizer.from_pretrained(base_path, local_files_only=True)
tuned_tokenizer = AutoTokenizer.from_pretrained(tuned_path, local_files_only=True)
examples = load_examples(args.samples, args.text)
if not examples:
raise ValueError("没有读取到测试样本")
print(f"设备:{device};样本数:{len(examples)}", flush=True)
print(f"基础模型输入格式:{args.base_prompt_format};微调模型输入格式:chat-no-thinking", flush=True)
print("提示:Base 是预训练模型,文本续写可能继续输出额外内容;不会强制改写生成结果。", flush=True)
print("\n正在测试微调前模型......", flush=True)
before = generate_answers(
base_path, base_tokenizer, examples, device, args.max_new_tokens, args.base_prompt_format
)
print("\n正在测试微调后模型......", flush=True)
after = generate_answers(
tuned_path, tuned_tokenizer, examples, device, args.max_new_tokens, "chat-no-thinking"
)
results = []
for index, (example, original, tuned) in enumerate(zip(examples, before, after), 1):
result = {
"question": example["question"],
"reference": example["reference"],
"before": original["answer"],
"after": tuned["answer"],
"before_details": original,
"after_details": tuned,
}
results.append(result)
print(f"\n{'=' * 60}\n样本 {index}")
print("问题:", result["question"])
print("参考答案:", result["reference"] or "自定义文章,无参考答案")
print(f"微调前({original['prompt_format']}):", original["answer"])
print("微调后:", tuned["answer"])
output_path = ROOT / args.output
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"\n对比结果已保存:{output_path}")
if name == "main":
main()
测试结果实例:

8.1 验证小结
本节通过对比微调前后模型在关键词抽取任务上的输出,验证了 SFT 微调的实际效果。整体来看,验证结果与第七节的训练分析相互印证,可以得出以下几点结论:
- 效果提升明显:微调后的模型能够更准确地识别关键词,去除了"表现出色"等非关键词短语,说明模型对任务语义的理解更加精准。
- 输入格式适配:基础模型采用文本续写方式,微调模型采用聊天模板(chat-no-thinking),两者在相同解码策略下对比,验证了微调后模型具备更强的指令跟随能力。
- 验证方式可靠:脚本按顺序加载两个模型,避免显存冲突,并将对比结果保存为 JSON 文件,便于后续分析和复现。
总体而言,验证结果证实了前文训练配置的有效性,微调后的模型在关键词抽取任务上表现稳定,达到了预期的微调目标。