一.TRL库的介绍
TRL库提供一系列的Trainer,实现大语言模型的微调和对齐训练。
使用TRL进行SFT微调的时候,需要先配置SFTTrainer实例。SFTTrainer包含了所需要训练的model,tokenizer以及SFTConfig参数。配置完成之后,即可调用SFTTrainer实例对象进行微调。
二.数据格式转换
SFT支持语言建模和提示补全数据集。SFTTrainer 兼容标准和会话数据集格式。当提供对话数据集时,训练器会自动将聊天模板应用到数据集中。
SFT支持的数据格式如下:
那如果我们的数据格式不属于上面的任意一种,我们就进行数据转换。下面是 Hugging Face 里面提供的一个示例。
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。
6. 整个损失计算
模型把所有要预测的token对应的损失全部加到一起然后取平均填符号就是最后的损失。对应公式:
7. 上述过程对应的pytorch代码:
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)
这里我们直接拿我们最后微调的实例来进行参数的解析。
# 构造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加载模型和优化器
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加载数据集
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"), )注意在加载之前我们需要先下载模型和数据集,具体下载方法如下:
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然后下载数据集:
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 - <<'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]) PY5.3 创建SFTTrainer
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 模型训练
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))六. 完整代码
#%% 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 显存约束下取得了较为稳定的收敛效果,模型在关键词抽取任务上的表现明显优于基础模型。后续可以根据实际验证集表现,进一步调整超参数以获得更优效果。
八. 验证模型
验证并测试微调前后的结果:
"""对同一批关键词提取问题,分别按基础模型和微调模型适用的输入格式比较输出。""" 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 文件,便于后续分析和复现。
总体而言,验证结果证实了前文训练配置的有效性,微调后的模型在关键词抽取任务上表现稳定,达到了预期的微调目标。