基于TRL库的SFT微调实现

一.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。

  1. 整个损失计算

模型把所有要预测的token对应的损失全部加到一起然后取平均填符号就是最后的损失。对应公式:

  1. 上述过程对应的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) &gt;= 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 "&lt;|im_end|&gt;" in tokenizer.get_vocab():
    eos_ids.add(tokenizer.convert_tokens_to_ids("&lt;|im_end|&gt;"))
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 &gt; 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 &gt;= 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 文件,便于后续分析和复现。

总体而言,验证结果证实了前文训练配置的有效性,微调后的模型在关键词抽取任务上表现稳定,达到了预期的微调目标。

相关推荐
陈嘿萌3 小时前
CVPR 2026|CHAL:面向运动红外小目标检测的因果引导分层异常感知学习
图像处理·人工智能·深度学习·计算机视觉·学术研究与理论基础
无障碍研究院3 小时前
2026TADC|圆桌对话:多学科无障碍研究
人工智能·无障碍
leizhen198408093 小时前
Vibe Coding:门槛降了,但判断力成了新的门槛
人工智能·安全·ios
科创致远3 小时前
2026轻MES行业数字化转型痛点与ROI报告 ——面向中小制造企业的轻量化生产管理与价值评估
大数据·人工智能·制造
u1301303 小时前
AI 日报(2026年10月17日)
人工智能
Teable任意门互动3 小时前
项目管理数字化落地案例|任意门互动科技(Teable 官方合作伙伴)以多维表格搭建项目管理平台
人工智能·开源·软件工程·飞书·开源软件·数据库开发
这张生成的图像能检测吗3 小时前
(论文速读)CurveNet:沿点云学习几何曲线,让局部特征看见长距离形状
人工智能·计算机视觉·点云·3d技术
AI模型调用笔记3 小时前
OpenAI API 故障切换:超时容灾三大坑
人工智能·语言模型
TMT星球3 小时前
2026阅文创作大会:首批AI精品动画亮相,推出OPC一站式平台与IP委员会模式
人工智能·网络协议·tcp/ip