1. 从零构建语言模型之后,SFT 到底在解决什么问题
如果你跟着 CS336 一路做完了前面的 assignment,手里应该已经有一个能跑通前向传播、能算交叉熵、甚至能自己写 attention 的语言模型了。但你会发现一个很尴尬的事实:这个模型会续写,却不会「答题」。你给它一道 MATH 数据集里的竞赛题,它可能给你续出一段看起来像数学、实际上答案完全不对的文字。这不是模型坏了,而是它只学过「预测下一个 token」,没学过「按人类期望的方式回答问题」。
斯坦福 CS336 Spring 2025 的 Assignment 5 就是来解决这个落差的。它把整个对齐流程拆成三段:先测零样本基线,再做监督微调(SFT),最后用专家迭代(Expert Iteration)和可验证奖励把推理能力再往上推。其中 SFT 是承上启下的关键一步——它决定了你的模型能不能从「会说话」变成「会解题」。
这篇笔记聚焦 SFT 环节,目标很具体:把/data/a5-alignment/MATH/sft.jsonl里的推理轨迹喂给 Qwen2.5-Math-1.5B,让模型学会先输出一段 chain-of-thought,再用<answer></answer>包住最终答案。适合已经跑过 assignment 1-4、想复现微调效果但卡在数据组织或 loss 不下降的同学。我会给出可复制的配置片段、数据格式检查方法、loss 曲线观察点,以及生成样例的对比方式。实测下来,只要数据 mask 和梯度累积这两处不出错,1.5B 模型在 MATH 验证集上冲到 15% 以上是可达的。
先明确一个概念:SFT 不是让模型「记住答案」,而是让它学会「输出格式 + 推理习惯」。MATH 数据集里的标准答案可能是1/2,但模型如果只输出1/2而不带推理过程,在后续 RL 阶段几乎没法用。所以 Assignment 5 的 SFT 数据每条都是{"prompt": str, "response": str},response 里包含完整的...推理和<answer>...</answer>结论。你要做的是让模型在给定 prompt 后,把 response 部分的条件概率最大化,同时把 prompt 部分的 loss 完全 mask 掉。
这里有个容易踩的坑:很多人第一次写 SFT 会把 prompt 和 response 拼在一起算 loss,结果模型学成了「复读 prompt」。正确做法是构造response_mask,只在 response token 上计算交叉熵。Assignment 5 的tokenize_prompt_and_output就是干这个的,它返回input_ids、labels、response_mask三个张量,shape 都是(batch_size, max_len - 1),其中 labels 是 input_ids 右移一位,mask 在 response 位置为 1、prompt 和 padding 位置为 0。
理解了这一点,后面的配置和排障才有意义。下面先讲怎么把 TaoToken 接进来做推理验证,再进入 SFT 的具体配置。
2. TaoToken 前置:给 SFT 流程配一个稳定的推理与验证入口
做 SFT 实验时,你其实需要两类模型调用:一类是训练中的 policy 模型(本地 GPU 上的 Qwen2.5-Math-1.5B),另一类是用于对比、生成参考轨迹或做答案校验的推理服务。Assignment 5 里用 vLLM 在第二张 GPU 上跑 policy 的 rollout,但如果你想在训练前先快速验证 prompt 格式、或者用更强的模型生成一批参考推理轨迹做数据增强,直接调一个稳定的 API 会省很多事。
TaoToken 在这里的角色是「统一入口」:它提供 OpenAI 兼容的接口,你可以用同一套 SDK 调用不同模型,不用为每个模型单独配环境。官网地址是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 端点是 https://taotoken.net/api ,注意 API 路径不带 UTM 参数。
具体到 SFT 场景,我建议你用它做三件事:
第一,在正式训练前,用模型对话功能手动测几条 MATH 题,确认你的 prompt 模板(r1_zero prompt)能稳定触发<think>和<answer>标签。这一步能帮你排除「模型根本不按格式输出」的问题,避免训练半天发现数据格式和模型行为对不上。
第二,用 API 批量生成一批参考推理轨迹,和/data/a5-alignment/MATH/sft.jsonl里的数据做对比,检查你的 tokenizer 是否正确处理了特殊 token。很多人在tokenize_prompt_and_output里忘了设置add_special_tokens=False,导致 prompt 和 response 拼接处多出 BOS token,mask 位置整体偏移。
第三,训练过程中如果本地 vLLM 显存不够,可以把验证集的评估请求发到 API 上做,把 GPU 全部留给训练。这样你只需要一张卡跑 policy 训练,评估走网络请求,整体吞吐反而更高。
配置上,你需要准备三样东西:Base URL、API Key、Model ID。Base URL 填https://taotoken.net/api,API Key 在控制台的 API Keys 页面创建,Model ID 根据你要调用的模型填,比如做推理验证可以用claude-3-5-sonnet或gpt-4o这类强推理模型。如果你要长期跑编码类 Agent 任务,可以看 Coding Plan 页面;如果只是临时验证,用按量计费的 API Key 就够了。
这里给一个最小可运行的 Python 片段,用来测试 API 是否通:
from openai import OpenAI client = OpenAI( base_url="https://taotoken.net/api", api_key="你的_API_Key" ) resp = client.chat.completions.create( model="claude-3-5-sonnet", messages=[ {"role": "user", "content": "Natalia sold clips to 48 friends in April, and half as many in May. How many clips did she sell altogether?"} ], temperature=0.0, max_tokens=512 ) print(resp.choices[0].message.content)跑通后你会看到模型输出一段带推理的答案。注意,这个调用只是用来验证你的 prompt 和网络环境,真正的 SFT 训练还是在本地 GPU 上跑。TaoToken 不替代你的训练框架,它只是帮你把「验证」和「评估」这两步做得更顺。
如果你在配置过程中遇到 401 错误,先检查 API Key 是否复制完整、有没有多余空格;如果遇到local proxy failed,说明你的网络层有额外代理拦截,需要把https://taotoken.net/api加入白名单。这些报错在下一节会详细展开。
3. 可复制配置:SFT 数据组织、tokenize 与训练参数
这一节是全文的核心,我会给出可以直接粘贴进cs336_alignment目录的配置片段。Assignment 5 的代码结构是cs336_alignment/*放你的实现,tests/*.py放测试,cs336_alignment/prompts/r1_zero.prompt放 prompt 模板。你需要实现adapters.py里的几个 hook,然后跑uv run pytest -k test_sft验证。
先看数据格式。/data/a5-alignment/MATH/sft.jsonl每行是一个 JSON:
{"prompt": "A conversation between User and Assistant... User: {question} Assistant: <think>", "response": " reasoning here </think><answer> answer here </answer>"}注意 prompt 末尾已经带了<think>,response 以</think>开头。这样拼接后模型看到的是完整的<think>...</think><answer>...</answer>结构。你在tokenize_prompt_and_output里要分别 tokenize prompt 和 response,然后拼接,再构造 mask。
下面是我实测可用的实现,放在cs336_alignment/adapters.py里:
import torch from typing import List, Dict def tokenize_prompt_and_output( prompt_strs: List[str], output_strs: List[str], tokenizer, ) -> Dict[str, torch.Tensor]: prompt_ids = [ tokenizer.encode(p, add_special_tokens=False) for p in prompt_strs ] output_ids = [ tokenizer.encode(o, add_special_tokens=False) for o in output_strs ] full_ids = [p + o for p, o in zip(prompt_ids, output_ids)] max_len = max(len(ids) for ids in full_ids) input_ids = torch.zeros((len(full_ids), max_len - 1), dtype=torch.long) labels = torch.zeros((len(full_ids), max_len - 1), dtype=torch.long) response_mask = torch.zeros((len(full_ids), max_len - 1), dtype=torch.long) for i, (p, o) in enumerate(zip(prompt_ids, output_ids)): ids = p + o input_ids[i, :len(ids) - 1] = torch.tensor(ids[:-1]) labels[i, :len(ids) - 1] = torch.tensor(ids[1:]) response_mask[i, len(p) - 1:len(ids) - 1] = 1 return { "input_ids": input_ids, "labels": labels, "response_mask": response_mask, }关键点在response_mask[i, len(p)-1:len(ids)-1] = 1。因为 labels 是右移一位的,response 的第一个 token 对应的 label 位置是len(p)-1,最后一个 response token 对应的 label 位置是len(ids)-2,所以切片是len(p)-1到len(ids)-1。如果你写成len(p)到len(ids),mask 会整体偏移一位,loss 会算到 prompt 的最后一个 token 上。
接下来是masked_normalize,用于在 response token 上求平均 loss:
def masked_normalize( tensor: torch.Tensor, mask: torch.Tensor, normalize_constant: float, dim: int | None = None, ) -> torch.Tensor: masked = tensor * mask summed = masked.sum(dim=dim) return summed / normalize_constant然后是sft_microbatch_train_step,它负责单次 microbatch 的前向、loss 计算、反向和梯度缩放:
import torch.nn.functional as F def sft_microbatch_train_step( policy_log_probs: torch.Tensor, response_mask: torch.Tensor, gradient_accumulation_steps: int, normalize_constant: float = 1.0, ): per_token_loss = -policy_log_probs loss_sum = masked_normalize( per_token_loss, response_mask, normalize_constant, dim=None ) loss = loss_sum / gradient_accumulation_steps loss.backward() return loss.detach(), {"loss": loss.detach()}注意loss.backward()在函数内部调用,这是 Assignment 5 的设计要求。gradient_accumulation_steps用来缩放 loss,保证多个 microbatch 累积后的梯度等价于一个大 batch。
训练主循环的配置我建议这样写:
config = { "model_path": "/data/a5-alignment/models/Qwen2.5-Math-1.5B", "sft_data": "/data/a5-alignment/MATH/sft.jsonl", "output_dir": "/data/yourusername/sft_qwen_math", "batch_size": 4, "gradient_accumulation_steps": 8, "learning_rate": 2e-5, "num_epochs": 3, "max_seq_len": 2048, "gradient_clip": 1.0, "warmup_ratio": 0.03, "weight_decay": 0.01, "eval_interval": 50, "save_interval": 200, }有效 batch size 是4 * 8 = 32。如果你用 2 张 H100,一张跑训练一张跑 vLLM 评估,这个配置大概 2 小时能跑完完整数据集。学习率 2e-5 对 1.5B 模型比较稳,太大容易 loss 震荡,太小收敛慢。梯度裁剪设 1.0,Assignment 5 明确建议这个值。
数据加载部分,用 HuggingFace 的datasets读 jsonl,然后自定义 collate:
from datasets import load_dataset from torch.utils.data import DataLoader dataset = load_dataset("json", data_files=config["sft_data"], split="train") def collate_fn(batch): prompts = [item["prompt"] for item in batch] responses = [item["response"] for item in batch] return tokenize_prompt_and_output(prompts, responses, tokenizer) loader = DataLoader( dataset, batch_size=config["batch_size"], shuffle=True, collate_fn=collate_fn, num_workers=2, )这里有个细节:tokenize_prompt_and_output返回的是 padding 到当前 batch 最大长度的张量,不是全局最大长度。这样能省显存,但要注意max_seq_len截断。如果某条样本超过 2048 token,需要在 tokenize 时截断,否则会 OOM。
模型加载用 bfloat16 + FlashAttention-2:
from transformers import AutoModelForCausalLM, AutoTokenizer import torch model = AutoModelForCausalLM.from_pretrained( config["model_path"], torch_dtype=torch.bfloat16, attn_implementation="flash_attention_2", device_map="cuda:0", ) tokenizer = AutoTokenizer.from_pretrained(config["model_path"])如果你的环境没装 FlashAttention-2,把attn_implementation改成"eager"也能跑,只是慢一些。优化器用 AdamW,配合 cosine schedule 和 warmup:
from transformers import get_cosine_schedule_with_warmup optimizer = torch.optim.AdamW( model.parameters(), lr=config["learning_rate"], weight_decay=config["weight_decay"], ) total_steps = len(loader) * config["num_epochs"] // config["gradient_accumulation_steps"] warmup_steps = int(total_steps * config["warmup_ratio"]) scheduler = get_cosine_schedule_with_warmup( optimizer, warmup_steps, total_steps )训练循环里,每gradient_accumulation_steps步做一次 optimizer step 和 zero_grad:
model.train() global_step = 0 for epoch in range(config["num_epochs"]): for idx, batch in enumerate(loader): input_ids = batch["input_ids"].cuda() labels = batch["labels"].cuda() response_mask = batch["response_mask"].cuda() logits = model(input_ids).logits log_probs = F.log_softmax(logits, dim=-1) policy_log_probs = log_probs.gather( dim=-1, index=labels.unsqueeze(-1) ).squeeze(-1) loss, meta = sft_microbatch_train_step( policy_log_probs, response_mask, config["gradient_accumulation_steps"], normalize_constant=response_mask.sum().item(), ) if (idx + 1) % config["gradient_accumulation_steps"] == 0: torch.nn.utils.clip_grad_norm_( model.parameters(), config["gradient_clip"] ) optimizer.step() scheduler.step() optimizer.zero_grad() global_step += 1注意normalize_constant我传的是response_mask.sum().item(),这样 loss 是 response token 上的平均负对数似然。如果你传 1.0,loss 会变成求和,数值会大很多,学习率需要相应调小。
保存模型时,务必存到/data/yourusername下,因为模型文件很大:
model.save_pretrained(config["output_dir"]) tokenizer.save_pretrained(config["output_dir"])这套配置跑下来,loss 应该从初始的 2.0 左右降到 0.5 以下。如果 loss 不降,先检查 mask 是否正确、labels 是否右移、学习率是否过大。下一节讲怎么验证请求和观察结果。
4. 验证请求与成功结果:loss 曲线、生成样例与准确率
配置写完后,不要直接跑完整数据集。先用 128 条样本做一次快速验证,确认 loss 在下降、生成格式正确。这一步能帮你省下大量调试时间。
验证分三个层次:数据格式检查、loss 曲线观察、生成样例对比。
数据格式检查最简单,写个小脚本打印第一条样本的 token 和 mask:
sample = dataset[0] out = tokenize_prompt_and_output( [sample["prompt"]], [sample["response"]], tokenizer ) print("input_ids shape:", out["input_ids"].shape) print("response_mask sum:", out["response_mask"].sum().item()) print("prompt len:", len(tokenizer.encode(sample["prompt"], add_special_tokens=False))) print("response len:", len(tokenizer.encode(sample["response"], add_special_tokens=False)))预期结果:response_mask.sum()应该等于 response 的 token 数减 1(因为右移),prompt 部分的 mask 全是 0。如果 mask sum 等于总长度,说明你忘了在 prompt 位置置 0;如果 mask sum 是 0,说明切片写错了。
loss 曲线观察:用 wandb 或 tensorboard 记录每个 step 的 loss。健康的曲线应该是前 50 步快速下降,然后缓慢收敛。如果 loss 在前 20 步就降到 0.1 以下,大概率是数据泄漏——模型在训练集上过拟合了,验证集准确率不会高。如果 loss 一直震荡在 2.0 以上,检查学习率是否太大、batch size 是否太小、或者 mask 是否把 response 也 mask 掉了。
我实测的 loss 曲线大致是:step 0 约 1.8,step 100 约 0.9,step 500 约 0.4,step 2000 约 0.25。验证集准确率在 step 1000 左右达到 15%,之后缓慢上升到 18% 左右。如果你用完整数据集(约 7500 条),3 个 epoch 大概 2000 多步。
生成样例对比是最终验证。训练前后各跑一次生成,看模型是否学会了<think>和<answer>格式:
def generate_sample(model, tokenizer, prompt, max_new_tokens=512): inputs = tokenizer(prompt, return_tensors="pt").to(model.device) with torch.no_grad(): outputs = model.generate( **inputs, max_new_tokens=max_new_tokens, temperature=1.0, top_p=1.0, do_sample=True, stop_strings=["</answer>"], tokenizer=tokenizer, ) return tokenizer.decode(outputs[0], skip_special_tokens=True)训练前,模型可能输出一段无关文字,或者直接给答案不带推理。训练后,你应该看到类似这样的输出:
<think> Natalia sold 48 clips in April. In May she sold half as many, so 48 / 2 = 24 clips. Total = 48 + 24 = 72 clips. </think> <answer>72</answer>如果模型输出了<think>但没闭合,或者<answer>里是空的,说明训练还不够或者数据里 response 格式不统一。检查sft.jsonl里每条 response 是否都以</think><answer>开头、以</answer>结尾。
准确率评估用 Assignment 5 提供的r1_zero_reward_fn,它在cs336_alignment.drgrpo_grader里。调用方式:
from cs336_alignment.drgrpo_grader import r1_zero_reward_fn reward = r1_zero_reward_fn(model_output, ground_truth) print(reward) # {"format_reward": 1.0, "answer_reward": 1.0, "reward": 1.0}在 MATH 验证集(/data/a5-alignment/MATH/validation.jsonl)上跑 5000 条,统计answer_reward的均值就是准确率。零样本基线大概 5%-8%,SFT 后应该到 15% 以上。如果低于 15%,先检查数据量是否够(至少 1024 条)、学习率是否合适、训练轮数是否足够。
这里有个实用技巧:训练过程中每 200 步做一次小规模评估(比如 200 条验证样本),记录准确率曲线。如果准确率在某个点后不再上升,说明模型已经收敛,可以提前停止。如果准确率波动很大,可能是 batch size 太小或者学习率太大。
成功跑通后,你会得到三个文件:sft_qwen_math/下的模型权重、tokenizer配置、以及评估日志。这些就是后续 Expert Iteration 的起点。下一节讲常见报错和排查方法。
5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth
SFT 流程涉及本地训练、API 调用、vLLM 推理三套系统,报错来源比较多。这一节按真实遇到的频率排序,给出排查路径。
401 Unauthorized:调用 TaoToken API 时最常见。原因通常是 API Key 没填对、Key 被撤销、或者请求头格式不对。检查步骤:第一,确认api_key字段是完整的字符串,没有换行或空格;第二,确认base_url是https://taotoken.net/api,不要多加/v1或漏掉/api;第三,在控制台的 API Keys 页面确认 Key 状态是 active。如果还是 401,换一个新创建的 Key 试试。
local proxy failed:这个报错说明你的请求被本地代理拦截了。常见于公司网络或某些开发环境。排查方法:第一,检查环境变量HTTP_PROXY和HTTPS_PROXY是否设置了不该设的值,临时 unset 掉再试;第二,确认https://taotoken.net/api没有被代理规则拦截;第三,如果你在用 requests 或 httpx,显式设置proxies={"http": None, "https": None}绕过系统代理。注意,这里说的是本地网络配置问题,不是让你去搭什么额外通道,只是把错误的代理设置清掉。
reading choices 报错:这个通常出现在解析 API 响应时,比如resp.choices[0]报IndexError或KeyError。原因是响应结构和你预期的不一致。排查:第一,打印完整的resp对象,看choices是否存在;第二,检查是否因为max_tokens太小导致模型没输出完整内容;第三,确认你用的 SDK 版本和 API 兼容,OpenAI Python SDK 1.x 和 0.x 的响应结构不同。如果是流式响应,choices在 chunk 里,需要拼接。
OAuth 相关报错:如果你在 Claude Code 或某些 CLI 工具里配置 TaoToken,可能会遇到 OAuth 流程问题。这类工具通常需要你填 Base URL、API Key、Model ID 三件套。以 Claude Code 为例,配置文件里要写:
{ "base_url": "https://taotoken.net/api", "api_key": "你的_API_Key", "model": "claude-3-5-sonnet" }如果你用的是 Cline 或 CC Switch,配置项名称可能不同,但核心三件套不变:Base URL 指向https://taotoken.net/api,API Key 用控制台创建的 Key,Model ID 填你要调用的模型。OAuth 报错通常是 Model ID 写错或者 Key 没有对应模型的权限。检查控制台里 Key 的权限范围,确认它允许调用你指定的模型。
训练侧报错:RuntimeError: CUDA out of memory最常见。解决方法:减小batch_size,增大gradient_accumulation_steps,或者把max_seq_len从 2048 降到 1024。如果还是 OOM,检查是否在tokenize_prompt_and_output里 padding 到了全局最大长度而不是 batch 内最大长度。
loss 为 NaN:通常是因为 response 里有空字符串,或者normalize_constant为 0。检查sft.jsonl里是否有 response 为空的行,以及在masked_normalize里加一个max(normalize_constant, 1e-8)的保护。
验证准确率不涨:先确认评估用的 prompt 和训练用的 prompt 一致。Assignment 5 要求用r1_zero.prompt,如果你训练时用了别的模板,评估时模型可能不按格式输出。其次检查response_mask是否真的只在 response 上为 1,可以用一个小脚本可视化 mask 的前 50 个位置。
这些报错我基本都踩过,最耗时的其实是 mask 偏移和 prompt 模板不一致这两个。建议你在正式训练前,用 8 条样本做一次完整的「tokenize -> 前向 -> loss -> 反向 -> 生成」流程,确认每一步输出都符合预期,再上全量数据。
6. 语义一致 CTA:把 SFT 验证和后续实验串起来
SFT 跑通后,你手里会有一个能按格式输出推理的模型。接下来 Assignment 5 的 Expert Iteration 会在这个基础上做 rollout、筛选正确轨迹、再训练。整个流程里,模型调用和评估会越来越频繁,这时候一个稳定的 API 入口能帮你把评估环节从 GPU 上卸载下来。
如果你需要快速验证 prompt 格式或生成参考轨迹,可以用模型对话功能,直接调https://taotoken.net/api下的模型。如果你打算长期跑编码类 Agent 任务,比如让模型自动写训练脚本、调参、分析 loss 曲线,可以看 Coding Plan 页面,它更适合高频、长周期的调用场景。API Key 在控制台的 API Keys 页面创建,接入文档在 doc 页面有完整的参数说明和示例代码。
回到 SFT 本身,最后给你一个实用建议:训练完成后,不要只看准确率数字,一定要手动看 20 条生成样例。我试过准确率 16% 但生成格式全对、推理过程清晰的模型,也见过准确率 18% 但<answer>标签经常缺失的模型。后者在后续 RL 阶段会因为格式奖励拿不到分而卡住。所以格式正确率比准确率更值得优先关注。
如果你在复现过程中遇到tokenize_prompt_and_output的测试跑不过,重点检查response_mask的切片边界;如果sft_microbatch_train_step的测试报梯度不对,检查loss.backward()是否在函数内调用、以及 loss 是否除以了gradient_accumulation_steps。这两个测试过了,后面的实验基本就顺了。