读完这篇文章,你将用监督微调(SFT)把一个 1.5B 规模的数学模型在 GSM8K 上的零样本推理正确率从 1.56% → 62.9%,同时把输出格式遵循率从 18.9% → 100%。我们将完整走通数据集下载、Prompt 架构、训练配置和评估方法,所有代码均来自本仓库 alignment 文件夹,保证可复现与透明。
本文将深入剖析 llm-from-scratch 仓库中 {INLINECODE_0} 模块,展示 SFT 的完整流程。
大语言推理模型常见的两个痛点:一是“答不对”,二是“答不规范”。前者意味着推理链条断裂或迁移失效,后者则让后续评估与系统对接步步惊心。
在这篇 How‑to 指南里,我们用 GSM8K 的标准数据与明确的评估规则,让 Qwen/Qwen2.5-Math-1.5B 通过 SFT 后,能力迁移到不同的数据集上, 学会“思考→作答”的结构化输出:不仅更常答对,也更懂规范。结果来自实测, 正确率由 1.56% 提升到 62.9%,格式遵循由 18.9% 提升到 100%.
一个好比喻:把模型想象成一个聪明但散漫的学生。零样本时,它能“蒙”对少数题;SFT 就像班主任的“规范化带教”,教它先写草稿(
我们在 alignment/drgrpo_grader.py 中实现了严格的格式检查与宽容但可靠的数学等价判断(符号化、数值化与 LaTeX 解析的组合),是本文准确率与格式遵循的核心评估逻辑。
本文的流水线架构如下:从 GSM8K JSONL 到 Prompt 构造,再到 SFT 训练与 vLLM 推理评估,最后汇总指标(准确率/格式遵循)。
flowchart LR A["GSM8K JSONL 数据集"] --> B["R1PromptTemplate 构造 Prompt"] B --> C["SFT 训练 (next-token LM)"] C --> D["保存 Checkpoint"] D --> E["vLLM 推理评估"] E --> F["r1_zero_reward_fn 计算格式与正确性"] F --> G["输出指标: 准确率 & 格式遵循"]
关键路径对应的源码均在 alignment 目录下:dataset.py(数据加载)、r1 prompt.py(模板)、sft.py(训练)、evaluate.py(评估)、drgrpograder.py(格式与答案打分)。
cd dataset wget https://raw.githubusercontent.com/openai/grade-school-math/master/grade_school_math/data/train.jsonl wget https://raw.githubusercontent.com/openai/grade-school-math/master/grade_school_math/data/test.jsonl
建议将文件移动/复制为仓库约定路径:{INLINE CODE0} 与 {INLINE CODE1}(alignment/args.py 默认如此)。
uv run -m alignment.evaluate
uv run -m alignment.sft
首先用 R1 模板将问题转为 Prompt,将“推理过程 + 最终答案”打包为监督信号。alignment/dataset.py 与 r1_prompt.py 如下:
# alignment/dataset.py
from torch.utils.data import Dataset
import json
from .r1_prompt import R1PromptTemplate
class Gsm8kDataset(Dataset):
def __init__(self, data_path: str, promt_template_path: str):
template = R1PromptTemplate(promt_template_path)
self.data = []
self.label = []
self.ground_truth = []
with open(data_path, "r") as f:
lines = f.readlines()
for line in lines:
qa = json.loads(line)
question = qa["question"]
answer_think = qa["answer"]
think, answer = answer_think.split("####")
think, answer = think.strip(), answer.strip()
self.data.append(template.gen_prompt(question))
self.label.append(template.gen_response(think, answer))
self.ground_truth.append(answer)
def __len__(self):
return len(self.data)
def __getitem__(self, idx):
return self.data[idx], self.label[idx], self.ground_truth[idx]
# alignment/r1_prompt.py(关键:强制 <think>/<answer> 标签)
class R1PromptTemplate:
def __init__(self, template_path: os.PathLike):
with open(template_path, "r") as f:
self.template = f.read().strip()
def gen_prompt(self, question: str) -> str:
return self.template.replace(r"{question}", question)
def gen_response(self, think: str, answer: str) -> str:
return think + "</think>" + " <answer>" + answer + " </answer>"
模板文件 alignment/prompts/r1_zero.prompt:
A conversation between User and Assistant... <think> reasoning process here </think> <answer> answer here </answer>.
User: {question}
Assistant: <think>
这保证了训练时模型学习“先写 {INLINE CODE0} 再写 {INLINE CODE1}”,评估时也能稳定抽取并验证答案。
在 alignment/sft.py,我们将 prompt + completion 拼接,对 prompt 区间的 label 置为 -100,从而让交叉熵只在“回应(think + answer)”上回传梯度:
# alignment/sft.py(节选)
model.train()
for epoch in range(args.epochs):
for i, batch in enumerate(train_data_loader):
prompts, completions, _ = batch
full_texts = [p + c + tokenizer.eos_token for p, c in zip(prompts, completions)]
inputs = tokenizer(
full_texts,
return_tensors="pt",
padding=True,
truncation=True,
max_length=args.max_seq_len,
).to(model.device)
# 计算每个样本的 prompt token 长度
prompt_tokens = tokenizer(list(prompts), add_special_tokens=False)
prompt_lengths = [len(ids) for ids in prompt_tokens.input_ids]
labels = inputs.input_ids.clone()
for idx in range(len(prompts)):
prompt_len = prompt_lengths[idx]
labels[idx, :prompt_len] = -100 # 对 prompt 部分不计 loss
# Mask padding tokens
labels[labels == tokenizer.pad_token_id] = -100
outputs = model(
input_ids=inputs.input_ids,
attention_mask=inputs.attention_mask,
labels=labels,
)
loss = outputs.loss
loss = loss / args.gradient_accumulation_steps
loss.backward()
if (i + 1) % args.gradient_accumulation_steps == 0:
print(f"Epoch {epoch}, Iteration {i}, Loss: {loss.item() * args.gradient_accumulation_steps}")
optimizer.step()
optimizer.zero_grad()
# 评估:将策略权重加载到 vLLM 并在测试集上跑评估
load_policy_into_vllm_instance(model, eval_model)
evaluate_math(
eval_model,
args.prompt_template_path,
args.sft_test_data,
args.batch_size,
log_sample=True,
)
model.save_pretrained(args.checkpoint_path)
这段逻辑实现了标准的 SFT:用“回应”作为监督目标,配合累积梯度与分卡策略,保证 1.5B 规模模型在可控显存与吞吐下完成训练。
评估由 alignment/evaluate.py 驱动,调用 r1 zeroreward_fn 计算每条样本的格式与答案得分:
# alignment/evaluate.py(核心)
from collections.abc import Callable
from vllm import LLM, SamplingParams
from .drgrpo_grader import r1_zero_reward_fn
def evaluate_vllm(vllm_model: LLM,
reward_fn: Callable[[str, str], dict[str, float]],
prompts: list[str],
ground_truths: list[str],
eval_sampling_params: SamplingParams,
log_sample: bool) -> dict:
outputs = vllm_model.generate(prompts, eval_sampling_params)
generated_texts = [output.outputs[0].text for output in outputs]
rewards = [reward_fn(generated_text, ground_truth)
for generated_text, ground_truth in zip(generated_texts, ground_truths)]
avg_format_rewards = sum([r["format_reward"] for r in rewards]) / len(prompts)
avg_answer_rewards = sum([r["answer_reward"] for r in rewards]) / len(prompts)
avg_all_rewards = sum([r["reward"] for r in rewards]) / len(prompts)
print(f"avg_format_rewards: {avg_format_rewards}")
print(f"avg_answer_rewards: {avg_answer_rewards}")
print(f"avg_all_rewards: {avg_all_rewards}")
return {
"avg_format_rewards": avg_format_rewards,
"avg_answer_rewards": avg_answer_rewards,
"avg_all_rewards": avg_all_rewards,
}
奖励函数严格要求格式标签,并对答案进行多重等价校验:
# alignment/drgrpo_grader.py(节选)
def r1_zero_reward_fn(response, ground_truth, fast=True):
# 格式严格:必须包含 </think> <answer> ... </answer>
if "</think> <answer>" in response and "</answer>" in response:
model_answer = response.split("<answer>")[-1].replace("</answer>", "")
# 允许 \boxed{...} 的答案形式并抽取
if "\\boxed" in model_answer:
model_answer = extract_answer(model_answer)
if model_answer is None:
return {"format_reward": 1.0, "answer_reward": 0.0, "reward": 0.0}
# 字符串、数值、LaTeX、SymPy 等价综合判断
is_correct = grade(model_answer, str(ground_truth), fast)
if is_correct:
return {"format_reward": 1.0, "answer_reward": 1.0, "reward": 1.0}
else:
return {"format_reward": 1.0, "answer_reward": 0.0, "reward": 0.0}
else:
# 不符合格式:直接判 0 分(同时 answer_reward 也为 0)
return {"format_reward": 0.0, "answer_reward": 0.0, "reward": 0.0}
因此:
数据读取与 Ground Truth 解析(alignment/evaluate.py):
# alignment/evaluate.py(节选)
def get_gsm8k_test_data(test_data_path: os.PathLike) -> list[dict]:
data = []
with open(test_data_path, "r") as f:
for line in f.readlines():
obj = json.loads(line.strip())
ts = obj["answer"].split("####")
if len(ts) != 2:
print(f"invalid answer: {obj['answer']}")
continue
data.append({"question": obj["question"],
"think": ts[0].strip(),
"answer": ts[1].strip()})
return data
在同一评估口径下,我们在 GSM8K 上得到如下对比(报告值):
| 指标 | 微调前(零样本) | SFT 微调后 |
|---|---|---|
| 推理准确率 | 1.56% | 62.9% |
| 格式遵循率 | 18.9% | 100% |
说明:
通过对 Qwen/Qwen2.5-Math-1.5B 在 GSM8K 上的监督微调,我们实现了两条主线的同步提升:
这背后的关键是“结构化输出模板 + 只对回应部分计损失 + 严格但宽容的评估规则”。如果你也在做数学推理或其他需要“思考‑答案二段式”输出的任务,强烈建议复用本文的架构与代码。
行动号召:现在就下载 GSM8K,跑一遍 {INLINE CODE0} 与 {INLINE CODE1},观察你本地的改进幅度吧! 可以参考 llm-from-scratch 仓库中 {INLINECODE_2} 模块,对照进行学习。
开放问题:在你的场景里,是否遇到过“答案正确但格式导致系统解析失败”的案例?你是如何设计模板与评估逻辑来避免它的?欢迎在评论区分享你的经验与挑战。
来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/31475801/viewspace-3101712/ ,如需转载,请注明出处,否则将追究法律责任。