Skip to main content

08 - 评测与接回推理框架

专题的最后一篇,两件事:给模型打个分,然后把权重交给自制推理框架跑起来。

第二件事是整个专题的收口。训练和推理两条线在这里接上,说明这个模型不只是训练脚本里的一堆张量,而是一个真能被独立加载、独立跑的东西。

前置:06 或 07 篇产出的 checkpoint。

零、开始之前:为什么要评测

0.1 loss 低不代表模型好

03 篇一直盯着 loss,它确实是训练是否正常的核心指标。但 loss 有个根本局限:它衡量的是模型对训练分布的拟合程度,不是模型有多好用。

一个只在中文网页上训的模型,在中文网页上 loss 会很低,但它可能完全不会做数学题。loss 不会告诉你这件事。

评测就是拿一些外部的、有标准答案的任务去问模型,看它答得怎么样。

0.2 评测要回答三个问题

问题用什么测
训练有没有真的学到东西困惑度,跟随机初始化和早期 checkpoint 比
有没有学到具体知识选择题评测(MMLU、C-Eval)
后训练有没有起作用对比 base / SFT / DPO 三个模型的输出

0.3 先把预期放对:0.5B 能到什么水平

这一节决定了如何解读评测结果。

0.5B 的模型在知识类评测上,成绩大概率接近随机猜。 MMLU 和 C-Eval 都是四选一,随机基线 25%,0.5B 训 10B token 通常也就在 25% 到 30% 之间。

这不是训错了,是规模决定的。这类评测考的是世界知识,而知识的容量跟参数量强相关。0.5B 装不下那么多东西。

那还测什么。测这几样:

  • 困惑度在下降,说明预训练有效
  • HellaSwag 这类常识补全比随机好,说明学到了语言规律(它考的是「哪个续写更合理」,不是硬知识)
  • base / SFT / DPO 的输出差异肉眼可见,说明后训练链路是通的

最实在的验收是最后那条:同一个问题问三个模型,base 在续写、SFT 在回答、DPO 答得更整齐。这个对比能证明整条链路是通的,比一个 27% 的 MMLU 分数有意义得多。

先把预期放对:这一节决定了怎么解读后面的分数四选一的随机基线 25%MMLU25% 到 30% 英文 57 学科知识 · 231,400 行 · MITC-Eval25% 到 30% 中文 52 学科 · 13,948 行 · CC-BY-NC-SA,非商用HellaSwag有机会到 30% 以上 常识续写,考语言规律不是硬知识 —— 建议先跑这个GSM8K基本是 0 生成式小学数学题,0.5B 做不了这类任务025%50%75%100%0.5B 在知识类评测上接近随机,这不是训错了,是规模决定的 —— 这类评测考的是世界知识,而知识容量跟参数量强相关,0.5B 装不下那么多东西。最实在的验收是另一件事:同一个问题问 base / SFT / DPO 三个模型,base 在续写、SFT 在回答、DPO 答得更整齐。这个对比能证明整条链路是通的,比一个 27% 的 MMLU 分数有意义得多。
把预期画在随机基线旁边,是为了避免一种常见的误判:看到 27% 就以为训练失败了。真正该看的是有没有稳定地站在 25% 右边,以及 HellaSwag 能不能拉开距离 —— 那才是「学到了语言规律」的证据。

一、评测分两大类

1.1 判别式:选择题

给题干和几个选项,让模型挑一个。MMLU、C-Eval、HellaSwag 都是这类。

好处是判分完全客观,不需要另一个模型或人来评价。这一篇主要讲这类。

1.2 生成式:让模型自由回答

比如 GSM8K 数学题,模型要写出解题过程和答案。判分要么用规则抽取最终答案比对,要么请一个更强的模型当裁判。

0.5B 做不了这类任务,这一篇不展开。

二、选择题怎么算分

2.1 不要让模型生成字母

最直觉的做法是把题目和选项拼好,让模型生成,看它输出的是不是 "B"。

这个做法对小模型完全不适用。 0.5B 的模型经常连一个合法的选项字母都吐不出来,它可能输出「答案是」「我认为」,或直接续写题干。

这样一来,「不会答」和「格式不对」就被混为一谈了,测出来的分数没有意义。

2.2 正确做法:比较四个选项的对数概率

把每个选项分别拼到题干后面,算模型给这个选项的对数概率,谁高选谁

prompt = "中国的首都是哪里?\nA. 上海\nB. 北京\nC. 广州\nD. 深圳\n答案:"

分别算:
logP(" A" | prompt)
logP(" B" | prompt)
logP(" C" | prompt)
logP(" D" | prompt)
做法一:让模型自由生成(小模型不可用)题干 + 四个选项 + 「答案:」「我认为」「这道��题…」吐不出合法字母,「不会答」与「格式错」混为一谈做法二:比较四个选项的对数概率(标准做法)同一个 prompt+ 「 A」+ 「 B」+ 「 C」+ 「 D」各自算 logP,条形越长概率越高−3.9−1.2 最高,选它−4.6−5.1模型必定给出一个选择,不存在格式问题。选项若是完整句子而非字母,还需除以 token 数做长度归一化,否则对数概率求和会让短选项系统性占优(2.3 节)。
把四个选项分别拼到同一个 prompt 后面,比谁的对数概率高。这样评测的是模型的知识而非它的格式遵循能力,对 0.5B 这种连合法选项字母都未必吐得出来的模型是唯一可行的做法。lm-evaluation-harness 等标准框架都采用这个方案。

这样模型一定会给出一个选择,不存在格式问题。这是 lm-evaluation-harness 等标准评测框架的做法。

@torch.no_grad()
def option_logprob(model, tok, prompt, option, device, normalize=True):
p_ids = tok.encode(prompt).ids
o_ids = tok.encode(option).ids
ids = torch.tensor([p_ids + o_ids], device=device)

logits, _ = model(ids)
logprobs = F.log_softmax(logits[0].float(), dim=-1)

total = 0.0
for k, tid in enumerate(o_ids):
pos = len(p_ids) + k - 1 # 第 i 个 token 由第 i-1 个位置预测
total += logprobs[pos, tid].item()

return total / len(o_ids) if normalize else total

pos = len(p_ids) + k - 1 仍是那个错位关系:第 i 个 token 由第 i-1 个位置的输出预测。01 篇 0.3 节、06 篇 2.5 节、07 篇 3.1 节都是同一件事。

2.3 长度归一化的坑

如果选项不是 A/B/C/D 而是完整句子(HellaSwag 就是这样),会遇到一个问题。

对数概率是每个 token 的对数概率求和,而每个 token 的对数概率都是负数。所以选项越长,总和越小。不做处理的话,模型会系统性地偏向最短的选项。

解决办法是除以 token 数,得到平均每 token 的对数概率:

return total / len(o_ids) if normalize else total

要注意这不是唯一做法,标准评测框架里通常会同时报归一化和不归一化两个分数(accacc_norm)。跟别人的分数对比时要确认用的是同一种,否则数字没有可比性。

对 A/B/C/D 这种等长选项,归不归一化没差别。

每个 token 的对数概率都是负数,所以选项越长,求和越小条越长 = 对数概率之和越负。三个选项里 C 其实是最好的(平均每 token −1.40)选项 A · 3 token总和 −4.5 ← 最接近 0,不归一化就会选它选项 B · 6 token总和 −9.0选项 C · 12 token总和 −16.8 ← 真正最好的那个,却排在最后模型会系统性地偏向最短的选项 —— 它比的不是「哪个更合理」,而是「哪个更短」除以 token 数之后:平均每 token 的对数概率选项 A−1.50选项 B−1.50选项 C−1.40 ← 长度被抵消掉,C 正确胜出这不是唯一做法:标准评测框架通常同时报归一化和不归一化两个分数(acc 与 acc_norm)跟别人的分数对比时必须确认用的是同一种,否则数字没有可比性。A/B/C/D 等长选项则无差别。
这个坑的隐蔽之处在于它不会让评测崩掉,只会让分数系统性地偏。HellaSwag 这类选项是完整句子的评测最容易中招 —— 如果模型的正确率恰好接近随机,先别怀疑模型,回来看看是不是没做归一化。

2.4 few-shot 提示

很多评测报告写的是「5-shot」,意思是在题目前面先放 5 个带答案的示例:

def build_prompt(question, choices, few_shot=None):
parts = []
for q, ch, ans in (few_shot or []):
block = q + "\n" + "\n".join(f"{k}. {v}" for k, v in ch.items())
parts.append(block + f"\n答案:{ans}\n")
block = question + "\n" + "\n".join(f"{k}. {v}" for k, v in choices.items())
parts.append(block + "\n答案:")
return "\n".join(parts)

few-shot 的作用是告诉模型「这是个选择题,该输出选项字母」。对 base 模型帮助很大,对 SFT 之后的模型帮助小一些,因为它已经知道该回答问题了。

比较不同模型的分数时,shot 数必须一致。 0-shot 和 5-shot 的分数差好几个点是常事。

三、能跑的评测集

2026-08-19 用 HuggingFace API 实测:

数据集行数许可考什么0.5B 的预期
cais/mmlu231,400MIT英文 57 学科知识,四选一接近 25% 随机
ceval/ceval-exam13,948CC-BY-NC-SA-4.0中文 52 学科,四选一接近 25% 随机
Rowan/hellaswag59,950常识续写,四选一可能到 30% 以上
openai/gsm8k17,584MIT小学数学应用题,生成式基本是 0

ceval/ceval-exam 是 CC-BY-NC-SA,非商用。

建议先跑 HellaSwag。 它考的是语言规律不是硬知识,0.5B 有机会明显超过随机基线,能真正验证预训练有效。MMLU 和 C-Eval 跑一下做记录就行,别指望好看。

四、困惑度

4.1 就是 loss 取指数

困惑度(perplexity,PPL)的定义:

PPL=exp(loss)\text{PPL} = \exp(\text{loss})

直觉解释:模型在预测下一个 token 时,相当于在多少个候选之间犹豫。PPL 等于 10,表示模型的不确定性相当于在 10 个候选里均匀猜。

对照表:

lossPPL含义
10.3732,000随机初始化,正好等于词表大小
6.00403
4.0055
3.0020小模型常见落点
2.007

最上面那行值得注意:随机初始化时 PPL 正好等于词表大小 32000。因为模型在 32000 个 token 里均匀猜,不确定性就是 32000。

这跟 02 篇 8.3 节那个 ln(32000) = 10.3735 是同一件事的两种说法,指数和对数互为逆运算。

PPL = exp(loss) · 直觉:模型在多少个候选之间犹豫loss10.37356.004.003.002.00训练推进的方向PPL32,00040355207随机初始化正好等于词表大小小模型常见落点最左边那格值得记:随机初始化时模型在 32000 个 token 里均匀猜�,不确定性就是 32000 —— 这和 02 篇 8.3 节那个 ln 32000 = 10.3735 是同一件事的两种说法。PPL 只能跟自己比。换语料 PPL 就变;换 tokenizer 更是完全没有可比性 —— 词表小的模型每个 token 携带信息少,PPL 天然更低,但这不代表它更好。正确用法是纵向比自己的不同 checkpoint。
这把尺子最大的用处是给 loss 一个可以说出口的含义:loss 从 4.0 降到 3.0 听不出多少收益,说成「候选从 55 个收窄到 20 个」就具体了。但也正因为它依赖词表大小,跨模型比 PPL 基本是无效对比。

4.2 PPL 只能跟自己比

PPL 受两个东西影响很大:测试语料tokenizer

换个语料,PPL 就变。换个 tokenizer,切分粒度变了,PPL 更是完全没有可比性——词表小的模型每个 token 携带信息少,PPL 天然更低,但这不代表它更好。

所以看到「我的模型 PPL 比 GPT-2 低」这种说法,基本没有意义,除非两者用同一个 tokenizer 在同一份语料上测。

PPL 的正确用法是纵向对比自己:训练早期 vs 后期、不同超参、不同 checkpoint。这时候语料和 tokenizer 都固定,比较才成立。

五、接回自制推理框架

5.1 这一步为什么是收口

到这里为止,模型一直活在训练脚本里。要证明它是个独立的东西,就得让另一套代码把它加载起来并正常工作。

这也是最容易出问题的一步。权重加载成功、不报任何错、但输出是胡言乱语,是这一步的典型状态。

5.2 三个必须对齐的约定

模型不只是权重,还有一堆隐含约定。训练端和推理端有任何一处对不上,输出就废。

一是 RoPE 的配对方式。 02 篇 3.7 节专门讲过:rotate_half(前后半配对,HuggingFace 约定)和交错配对(相邻两维,原论文约定)都能训,但互不兼容。我们用的是 rotate_half,推理端必须一样。

二是 tokenizer。 必须是 01 篇训出来的同一个 tokenizer.json。换一个词表,token id 的含义全变了。

三是对话模板。 06 篇的 ChatML 格式,推理时拼 prompt 要用同一套,<|im_start|><|im_end|> 也要被识别成单个 token。

这三条里,RoPE 那条最隐蔽,因为另外两条错了通常会立刻输出乱码,而 RoPE 错了模型还能吐出通顺的词,只是内容毫无逻辑,容易被误判成「模型就是这么菜」。

模型不只是权重,还有一堆隐含约定 —— 任何一处对不上,输出就废① RoPE 的配对方式rotate_half(前后半配对,HF 约定)还是交错配对(相邻两维,原论文)我们训练端用 rotate_half,推理端必须一样 · 02 篇 3.7② tokenizer必须是 01 篇训出来的同一个 tokenizer.json换一个词表,token id 的含义全变了③ 对话模板06 篇的 ChatML 格式,推理时拼 prompt 要用同一套im_start / im_end 也要被识别成单个 token①最隐蔽:②③错了通常立刻输出乱码,而 RoPE 错了模型还能吐出通顺的词,只是内容毫无逻辑 —— 很容易被误判成「模型就是这么菜」推理端还多一个训练时不存在的东西:KV cache。它是加速手段,不该改变输出验证方法同一段 prompt 一次性前向同一段 prompt 逐 token 生成相同位置的 logits 应当一致对不上最常见的原因是 RoPE 位置索引错了:用 cache 时每步只送一个新 token,它的位置应该是 cache_len 而不是 0。典型表现是前几个 token 正常,越往后越乱。
三个约定加一个 cache,构成了「权重加载成功、不报任何错、但输出是胡言乱语」这一状态的全部可能来源。逐层比对能定位到层,这四条则告诉你定位到层之后该怀疑什么 —— 数值容差上,bf16 下逐层最大绝对差在 1e-2 量级正常,跳到 1e-1 以上就不是误差而是实现不一致。

5.3 逐层比对:怎么定位到底哪错了

输出不对但不知道哪一步出的问题,靠猜很慢。正确做法是逐层比对:同一份输入,同一份权重,看两边每一层的输出从哪里开始分叉。

code/compare_impl.py 用 forward hook 抓训练端每一层的输出:

def capture_activations(model, input_ids):
acts = {}
handles = []

def mk(name):
def hook(_module, _inp, out):
acts[name] = (out[0] if isinstance(out, tuple) else out).detach().float()
return hook

handles.append(model.embed.register_forward_hook(mk("embed")))
for i, blk in enumerate(model.blocks):
handles.append(blk.attn.register_forward_hook(mk(f"block{i}.attn")))
handles.append(blk.mlp.register_forward_hook(mk(f"block{i}.mlp")))
handles.append(blk.register_forward_hook(mk(f"block{i}.out")))
...

然后逐个比最大绝对差,标出第一处超阈值的层。第一处分叉的位置直接指向病因

同一份权重 + 同一份输入训练端 model.py自制推理框架embed差 2.1e-7 一致block0.attn差 8.4e-4 一致block0.mlp差 9.1e-4 一致block1.attn差 3.7e-1 超阈值← 第一处分叉block1.mlp之后全错,无需再看…logits分叉点即病因:embed 就偏 → tokenizer 不同或权重共享没处理;attn 先偏 → RoPE 约定或 repeat_kv;mlp 先偏 → SwiGLU 的 gate 与 up 接反;只有 logits 偏 → lm_head 权重共享没接上。
误差会沿着网络向后传播放大,所以只有第一处超阈值的层有诊断价值,它之后的层必然全错。bf16 下逐层最大绝对差在 1e-2 量级属于正常的浮点误差,突然跳到 1e-1 以上就是实现不一致。
第一处对不上大概率是什么
embed 就偏tokenizer 不是同一个,或权重共享没处理
block*.attn 先偏RoPE 约定,或 GQA 的 repeat_kv
block*.mlp 先偏SwiGLU 的 gateup 接反了
只有 logitslm_head 的权重共享没接上

用法上有个细节:脚本默认先自己跟自己比,结果应该全是 0。这一步是在验证比对逻辑本身没写错——如果自比对都不是 0,那说明抓 activation 的代码有问题,后面比出来的差异都不可信。

python compare_impl.py --ckpt out_sft/sft_epoch2.pt

确认自比对全 0 之后,把 other 换成自己推理框架的输出再比。

5.4 数值容差取多少

两边实现不可能逐位相同,因为算子的执行顺序、是否用了融合 kernel 都会造成浮点误差。

经验阈值:bf16 下逐层最大绝对差在 1e-2 量级是正常的,1e-3 以内很好。如果某一层突然跳到 1e-1 甚至 1 以上,那就不是数值误差,是实现不一致。

相对差比绝对差更可靠,因为深层的激活值本身数量级就大。compare_impl.py 两个都打。

5.5 KV cache 的正确性怎么验

推理框架会引入训练时不存在的东西:KV cache。它是加速手段,不该改变输出

验证方法很简单:同一段 prompt,一次性前向(不用 cache)和逐 token 生成(用 cache),两者在相同位置的 logits 应该一致。

不一致的常见原因是 RoPE 的位置索引错了——用 cache 时每步只送一个新 token,但它的位置应该是 cache_len,而不是 0。这个 bug 的表现很典型:生成的前几个 token 正常,越往后越乱

六、采样策略

模型输出的是 32000 个 token 的概率分布,怎么从里面选一个,有几种做法。

策略做法什么时候用
greedy永远选概率最高的评测、需要复现
temperature分布除以 T 再采样,T 越大越随机对话,一般 0.7 到 1.0
top-k只在概率最高的 k 个里采样配合 temperature
top-p累积概率到 p 的最小集合里采样现在更常用,一般 0.9

评测一律用 greedy。 因为评测要可复现,采样会引入随机性,同一个模型跑两次分数不一样,没法比较。

对话场景用 temperature 加 top-p。0.5B 的模型建议 temperature 别调太高,它本来就容易跑偏,高温度会让它更加胡说。

七、验收

这是专题最后一篇,验收清单也是整条链路的总检查。

  • 困惑度比随机初始化(32,000)低了好几个数量级
  • 训练后期的 PPL 明显低于早期 checkpoint
  • HellaSwag 准确率超过 25% 的随机基线
  • MMLU / C-Eval 跑通并记录(接近随机是正常的)
  • compare_impl.py 自比对全 0
  • 推理框架和训练端逐层比对,最大差在 1e-2 以内
  • 用 cache 和不用 cache 的输出一致
  • 推理框架里,同一个问题 base / SFT / DPO 三个模型的输出差异肉眼可见
  • greedy 解码两次跑出完全相同的结果

倒数第二条是整个专题真正的验收。 拿「帮我写一封请假邮件」去问三个模型:base 应该在续写(可能接出「请假邮件怎么写才礼貌」这种),SFT 应该给一封邮件,DPO 给的那封应该更完整。

三者差异明显,说明数据、模型、训练、后训练这一整条链路都是通的。这比任何一个评测分数都更能说明问题。

八、常见问题

现象大概率是什么原因
推理框架输出通顺但毫无逻辑RoPE 约定不一致,见 5.2 节
推理框架直接输出乱码tokenizer 不是同一个
生成前几个 token 正常,越往后越乱KV cache 的 RoPE 位置索引错了,见 5.5 节
逐层比对自比对就不是 0hook 抓的是引用不是拷贝,检查有没有 .detach()
某一层差异突然到 1 以上不是数值误差,是实现不一致,按 5.3 节的表查
评测准确率正好等于随机基线可能所有选项算出来的分一样,检查 pos 的错位
HellaSwag 分数低于随机长度归一化的方向搞反了
分数和别人报的对不上shot 数不同,或者归一化方式不同,见 2.3 和 2.4
同一模型两次评测分数不同用了采样,评测要 greedy

专题到这里就完整了

从 01 篇的 train.bin,到 02 篇的 model.py,到 05 篇训出来的权重,再到这一篇被自制推理框架加载起来说话。整条链路走完,你手上多的不只是一个模型文件,是一套「模型是怎么来的」的完整认知。

至于这个 0.5B 模型本身好不好用——它不好用。但这从来不是这个专题的目标。