前段时间面试一个做 LLM 训练的候选人,我在白板上写了一个看着很基础的问题:训练框架里算出来的 token 概率,和线上采样引擎生成时算出来的概率,为什么对不上?现场沉默了三秒,然后候选人开始了常见的“精度论”——是不是 float16 和 float32 的差异?是不是 softmax 的数值精度不够?这个回答不能说错,但只摸到了冰山一角。
这个题目其实是我从真实线上事故里抽出来的。当时我们用 GRPOTrainerWrapper 炼了一版模型,部署到自研采样引擎后,生成效果和训练时的验证指标对不上,排查了整整两天。这篇文章就是那两天排查过程的完整复盘,也顺带把面试候选人的回答分成三个层次讲清楚。如果你正在做训练-推理一体化,或者被线上生成效果波动折磨,这篇应该能帮你省不少时间。
1. 面试现场重现:一个看似简单的概率问题
1.1 三类候选人的典型回答
这个问题我面过不少人,基本能直接筛掉一大半。回答大概分三类。
第一类:直接甩锅浮点精度。“float16 和 float32 的精度差异嘛,概率差一点点很正常。”但差多少算正常?说不出来。训练用 bf16,推理用 fp16,词表里几万个 logits,误差累积确实存在,但它通常只影响小数点后第三四位的扰动,不会让“概率对不上”成为线上事故的元凶。浮点精度是个放大器,不是根因。
第二类:归因于 temperature。“推理时温度不等于 1,概率分布本来就变了。”这个方向对了,但只对了十分之一。temperature 确实改变分布,但如果你把训练时的生成配置和推理时的采样配置设成一样,概率依然可能对不上。我自己实测过:temperature 全部设成 1.0,top_p 设成 1,penalty 全关,同一个 checkpoint 在两端算出来的 token 概率还是对不上。
第三类,也是我期望听到的回答:先反问到底“哪个概率对不上”。训练框架里你看到的是什么?是 loss 曲线上的平均 NLL(负对数似然),还是某个 token 的 softmax 输出?采样引擎里你看到的又是什么?是每一步的 logprob 累加,还是采样出来的整句概率?这两个数字根本不在一个定义域里。能问到这一层的候选人,至少说明他真正调试过训练和推理两端。
1.2 我真实想问的三个层次
我真正想听到的拆解,是把“概率对不上”拆成三个层次。
第一层是数值层。logits 的计算路径不同——flash attention 的在线 softmax、torch.compile 的算子融合、bf16 到 fp16 的转换——概率到底差多少?这是工程精度问题,要拿数据说话,不要凭感觉。
第二层是算法层。温度缩放、top_p 截断、repetition penalty,这些采样后处理改变了概率分布的定义,训练框架里没有对应操作,那它俩当然不是一个概率。这一层是大多数人能想到的,但也是大多数人只停留在这里的一层。
第三层是定义层。训练时模型学习的是“语料中的条件分布”,推理时采样的是“自生成序列上的联合分布”。一个是 teacher forcing 下的条件概率,一个是自回归路径上的条件概率乘积,这两者本来就不该完全相等。能把这一层讲明白的人,才是真正碰过训练和推理两端的人。
这三层,也是后面整篇文章的骨架。
2. 概率对不上的第一层:训练与推理的 logits 处理逻辑完全不同
2.1 logits 到概率,两边走的不是同一条路
先看训练 SFT 时的链路:model(input_ids) → logits → softmax(temperature=1.0) → cross entropy loss。你看到的 loss 其实是所有 token 的平均负对数似然,模型只关心“下一个 token 的真实分布”,温度被约定为 1,没有任何额外后处理。
再看推理采样时的链路:model(input_ids) → logits → [repetition penalty → temperature 缩放 → top_k/top_p 截断 → softmax → 再归一化] → 采样一个 token,记录 logprob,然后滚动拼接,重复上面的过程。
这两条链路里,logits 本身可能还一样,但从 logits 到“概率”的那段变换,中间隔着一整条后处理流水线。你拿训练时原生的 softmax 概率,和采样引擎经过一堆后处理之后算出来的概率去对齐,从一开始就没有可比性。
这也是我在面试里反复强调的一点:当候选人说“我对比了两边的概率”,我第一个问题一定是“你怎么拿到这个概率的”。如果他说“直接用了采样引擎返回的 logprob”,那基本可以判断他没做过端到端的概率对齐。正确做法是先绕过所有采样后处理,直接对比两端的 logits。
2.2 温度缩放的数学影响与 padding 陷阱
temperature 不是“乘个系数”就完了。logits 除以 T 之后,softmax 的分布形状会变。当 T 小于 1,高概率 token 更突出,低概率 token 被进一步压制;T 大于 1,分布更平,低概率 token 的采样机会增加。温度等于 0.1 时,原本概率 0.6 的 token 可能变成 0.99,原本概率 0.001 的 token 可能变成 1e-9。如果你在对齐的是“概率乘积”,差几个数量级很正常。
padding 和 attention mask 的影响更隐蔽。训练时 batch 里有 padding,一些框架为了省事,对 padding 位置的 logits 没有屏蔽(没有用 -∞ 处理),模型被迫学习了“在 pad token 上也能输出分布”。线上推理是单条样本,没有 padding,模型在真实 token 上的 logits 分布反而和训练时学的不完全一样。
还有一个更常见的坑是 position encoding。训练时样本是 pad 到固定长度的,位置编码对齐到绝对位置;推理时如果是流式输入,position_ids 和训练时的对齐方式不一致,概率也会偏移。这个坑我在后面第 5 节会详细展开,因为我自己就栽在过这里。
flash attention 和 bf16 的数值近似也确实存在。flash attention 用 tiling 和在线 softmax,bf16 的 mantissa 精度只有大约 3 位十进制有效数字,logits 本身就在一个“近似区间”里。但再次强调,这一层只能解释小数点后几位的扰动,解释不了数量级的偏差。如果你发现两端的概率差了一个数量级,别在浮点精度上浪费时间。
2.3 “联合概率乘积”和“单点边缘平均”本来就不是一个指标
训练日志里那个数字,是 loss / NLL,是整个 batch 所有 token 的平均,不是“这条生成序列的概率”。采样引擎返回的,是这条路径的联合概率:P(t1) × P(t2|t1) × P(t3|t1,t2) × ... 的 logprob 之和。两者之间隔着“所有路径的平均”与“一条特定路径”的差异。
即使模型训练得完美,给定同样的上下文,采样出来的一条路径的联合概率,和训练集上所有 token 的平均 NLL,也不是同一个值。前者是单次随机抽样的结果,后者是整个数据集的期望值。拿彩票中奖号码去对彩票的期望收益,对不上的原因不是彩票系统出 bug,而是你看错了指标。
这里我想用一道经典的面试概率题来解释:一根长度为 1 的线,随机折两下成三段,三段能组成三角形的概率是多少?答案是 1/4。推导也不难:设两个折点 X、Y 独立均匀分布在 [0,1] 上,三段长度分别为 min(X,Y)、|X-Y|、1-max(X,Y)。能组成三角形的充要条件是每一段都小于 1/2。在 (X,Y) 的单位正方形里,满足条件的区域是两个边长为 1/2 的三角形区域,总面积就是 1/4。
这道题的精髓在于:每段长度的边缘分布都是均匀的,但三段同时满足“三角形不等式”这个联合约束时,概率从“看似 1”被压到了 1/4。这就是集合与概率统计的联系:独立边缘分布没问题,一旦讨论联合事件,就必须重新划定样本空间。LLM 里的 token 概率也一样——单个 token 的分布边缘看起来没问题,但一整条路径的联合分布受整体结构约束(语法、逻辑、长度限制),所以“边缘概率的乘积”并不等于“路径的真实联合概率”。这个“乘积”本身就是概率论里最容易产生误解的地方。
3. 概率对不上的第二层:GRPO 训练框架里的“概率”是相对量
3.1 SFT 学绝对似然,GRPO 学的是 group 内的相对优势
SFT 是 teacher forcing 下的最大似然估计,学的是 P(next token | prefix) 这个绝对分布。语料里出现频率高的 token,模型就把它的概率抬高;出现频率低的,就压低。它拟合的是一个客观的、不随环境变化的概率分布。
GRPO 不一样。它是对同一个 prompt 采样 G 个回复构成一个 group,计算每条回复的 reward 相对 group 均值的 advantage,再通过策略梯度把高 reward 回复的概率往上抬、低 reward 的往下压。关键在于 advantage 做了中心化和归一化:adv = (r_i - mean(r_group)) / std(r_group)。
这意味着模型收到的训练信号是“在这组样本里,你比平均水平好多少”,不是“你的绝对概率应该变成多少”。同一个 token,在某个 group 里可能被当成“该侧重的高概率”,在另一个 group 里可能被当成“该压低的低概率”。模型最终学到的分布,是被 group 结构调制过的一个相对分布,不是单纯的条件概率。
所以,训练框架里 GRPO 训练中记录的“概率”,天然带着 group 的烙印。你拿这个概率去和采样引擎在整条词表上算出来的原生分布做对比,等于拿“组内排名”去对比“全年级排名”,数字当然不一样。
3.2 old_logprobs 与 ref model logprob:冻结与漂移的矛盾
GRPO 训练框架通常要保存两类概率快照。
第一类是 old_logprobs:训练开始时 policy model 对当前样本的 logprob,用于 importance ratio 计算。这是策略梯度里的标准做法——用旧策略采样的数据来估计新策略的梯度。但这个 logprob 只是“训练开始时”的快照,模型每更新一步,新的 policy distribution 都在漂移。推理时采样引擎用的是“当前部署权重”的实时 logprob,和训练日志里记录的冻结快照,本质上是两个不同版本的模型输出的概率。
第二类是 ref logprob:参考模型(通常是 SFT 版本或上一版)的 logprob,用于 KL 散度惩罚。这个更坑。很多实现里 KL 惩罚项算的是 ref logprob - policy logprob,但 ref 模型在推理时如果没关闭采样后处理——比如也用了 temperature 0.8——那 ref 那侧的“概率”也被改写过了。训练框架里算的是原生分布下的 KL,线上采样算的是带 temperature 的分布,两边 KL 值对不上,就会表现为“训练时 KL 很小,线上却明显偏”。
我踩过的另一个细节是:GRPO 里对同一个 prompt 采样的 G 个回复,长度可能完全不同。padding 之后的 attention mask 会把短回复的 padding 部分屏蔽掉,但有些实现没有把被屏蔽位置的 logprob 强制设为 0,导致 old_logprobs 里混入了 padding 位置的错误概率。推理端没有 padding,这个误差就凭空消失了——两边对不上,纯粹是记录方式不一致。
3.3 一道折线段三角形概率题,正好解释联合约束如何改变概率
回到那道折线段概率题。两根折点均匀独立分布时,每一段长度单独看都是 [0,1] 上的均匀分布,边缘概率没有任何问题。但三段要同时满足“每段都小于 1/2”这个联合约束时,概率就只剩 1/4。
是什么改变了概率?是约束改变了样本空间。原本你在整个单位正方形里均匀撒点,一旦限定“三段能组成三角形”,可行区域缩成了两个小三角形,概率密度要重新归一化。
GRPO 和采样引擎的错位也是这个味道。训练时,模型在一个 group 的约束集合里被优化——高 reward 和低 reward 的回复在同一个样本空间里被比较,概率被“相对化”了;推理时,采样引擎面对的是整个词表的原生分布,没有任何 group 约束。你拿“约束空间里的概率”和“全空间里的概率”对比,就像拿“折成三段后恰好能组成三角形的概率”和“每一段都短于 1/2 的边缘概率”对比,维度都不一样。
有候选人问过我:那训练框架里算出来的 loss 里的概率,和采样引擎里 logprob 相加的概率,哪个是“对的”?我的回答是:在各自的定义域里都是对的。你之所以觉得对不上,是把两个定义域硬焊在一起了。
4. 概率对不上的第三层:采样引擎概率乘积里的隐藏暗礁
4.1 每一步条件概率的乘积,前提是“自生成历史”
采样引擎记录的概率是序列级的 logprob,即 log P(t1) + log P(t2|t1) + log P(t3|t1,t2) + ...。这个乘积有一个隐式前提:每一步的 context 都由“模型自己之前生成的内容”组成,也就是所谓的自生成历史。
训练时是 teacher forcing,context 永远是真实语料中的前文。这两个场景下的输入分布完全不同。即使模型能力完全一致,自回归路径上某个错误 token 一旦被采样出来,后续所有条件概率都建立在“错误的上下文”上,概率乘积会出现断崖式下跌。训练框架里不会有这种断崖,因为 loss 里每一步的输入都是“正确的”。
这在长文本生成里尤其明显。训练时上下文长度是固定截断的,推理时一旦超过训练窗口,模型进入“训练时从未见过的远端依赖区域”,条件概率本身就开始发飘。每一步都稍微飘一点,几十步之后,概率乘积能差出好几个数量级。你拿训练时短上下文下统计出的概率去校验线上长上下文生成的联合概率,对不上是数学上的必然。
4.2 top_p 截断与重新归一化:概率常数说变就变
top_p 的机制是把词表里概率累积到指定阈值(比如 0.9)的 token 挑出来,构成候选集,然后对候选集内的概率做重新归一化。
原来的 softmax 是在整个词表 V 上归一化的,top_p 截断后只在一个小子集 S 上归一化。一个原本概率为 0.2 的 token,如果它落在 S 里,重新归一化后可能变成 0.4。也就是说,同一个 token 在采样引擎里返回的 prob,根本不是“模型原生概率”,而是“在截断后的候选集里的条件概率”。
类比一下:你问一个班里“小明成绩在全班排多少”,和问“小明成绩在实验班里排多少”,得到的百分比当然不一样。训练框架的 softmax 是全班排名,采样引擎的 top_p 是实验班排名。你用实验班的百分比去质疑全班排名对不上,那是问题问错了对象。
这里还要注意 top_k 和 top_p 同时开启时的顺序。常见实现是先 top_k 挑出 k 个候选 token,再做 top_p 截断,最后统一重新归一化。中间任何一处概率常数变化,都会让最终 logprob 和训练时的原生 softmax prob 差一大截。所以在做概率对齐测试时,top_k 和 top_p 必须全部显式关闭,只留最原始的 softmax。
4.3 repetition penalty 与 eos 处理:训练框架里根本不存在的操作
repetition penalty 是推理侧特有的。它的实现是:对已经生成过的 token 的 logits 直接减去一个惩罚值(或按频率缩放),然后重新 softmax。这一步发生在 temperature 缩放之前还是之后,不同引擎实现不同,结果也不同。训练框架(SFT/GRPO)的程序路径里没有 repetition penalty 这个概念,如果你线上开着 penalty,拿训练时的原生 logprob 来做对比,必然对不上。
反过来说,这也在提醒你:如果要评估“模型本身”的能力,线上评测时一定要把 penalty 关掉。你看到的效果提升,到底是模型真的变强了,还是 repetition penalty 恰好掩盖了模型的重复倾向?这两件事必须分开。
eos 处理也是重灾区。训练时,eos token 被当作正常的 label 参与 loss 计算,除非显式 ignore;推理采样时,eos 是“停止条件”,生成到 eos 就截断,后面的 token 不再采样。如果你统计序列概率时把 eos 之后的内容也算进去,或者采样引擎遇到 eos 还不停止,概率乘积就会被 eos token 本身的概率污染。
我还见过一种实现:模型生成了 pad token 而不是 eos,采样引擎把它当成停止符,但训练时 pad 位置的 loss 是被 ignore 掉的。这个概率在训练框架里根本不存在,线上却作为“序列终结点”出现,对得上才怪。所以排查概率对齐问题之前,先把 eos_token_id 和 pad_token_id 的配置从头到尾检查一遍,别在这种低级问题上浪费时间。
更极端的还有 mirostat、DRY sampler 这类高级后处理。它们看着像“采样策略”,实际是在直接改写 logits 分布。这类后处理在做离线评测时最好全部关闭,否则你做的所有数值对比都会被污染。
4.4 一个对我冲击很大的实测现象
我自己做过的实测:同一个 checkpoint,同一段 prompt,greedy decode(temperature 约为 0、top_p=1、penalty=0)下,采样引擎返回的 top-1 token 和训练框架前向计算的 top-1 完全一致。但只要把 temperature 调到 0.8 再看,top-1 的选择就开始漂移,top-10 的 logits 排名也出现差异。这个差异不是 bug,而是 temperature 改变了 softmax 的秩——原来排第 3 的 token 在低温下可能挤进前 2,原来排第 1 的 token 可能被挤出前 3。
所以在排查“概率对不上”时,第一件事永远是先跑 greedy,把所有采样后处理全部关掉,把变量锁死。如果 greedy 都对不上,再去查权重转换、dtype、position_ids 这些底层问题;如果 greedy 一致但带温度就偏,那只是采样参数的数学后果,不是故障。
5. 工程排查实录:从 SFTTrainerWrapper 到自定义采样器的完整链路
5.1 一次真实线上事故复盘:生成效果变差,怀疑点在概率
场景还原一下。我们内部用 GRPOTrainerWrapper 跑了一版偏好对齐模型,训练日志显示验证集 loss 正常、KL 散度也符合预期,部署到自研采样引擎之后,生成内容的重复率明显偏高,某些 prompt 下模型表现甚至不如 SFT 基座。
组里第一反应是“线上采样参数是不是和训练时分叉了”。我们把训练时的 generation config 和线上推理 config 逐一对比,确实发现训练框架里自带的 sample 配置和线上自研采样引擎的默认参数不一致——top_k 默认值不同,repetition penalty 一个开了另一个没开。但把参数调成一致之后,问题只缓解了一半。某个长上下文 prompt 下,生成概率依然和训练框架前向计算的结果有明显的数量级偏差。
这时候就引出一个关键认知:参数一致只是必要条件,概率链路里还有隐藏分歧。接下来就是完整的排查链路。
5.2 可复现的五步排查法
第一步,greedy 解码对齐。把线上采样引擎的所有后处理全部关掉:temperature 设为接近 0、top_k=0、top_p=1.0、penalty=0、no_repeat_ngram=0。用固定 50 条 prompt 做 greedy 生成,比较采样引擎选出的每一步 token 是否和训练框架下 model.generate 的结果一致。这一步能过滤掉 80% 的“配置差异”。如果 greedy 结果都不一致,直接进入第二步。
第二步,logits 级 diff。加载同一个 checkpoint,用同一个 tokenizer 处理同一段 prompt,分别跑训练框架的前向和采样引擎的前向,取最后一个有效 token 的 logits 向量,对比 top-10 的 logits 值。注意三个细节:vocab size 必须一致,tokenizer 文件必须一致;两边都单条样本跑,不要混 batch;dtype 一致,尽量都 load 成 bf16。如果 top-10 logits 误差超过 0.01,就要怀疑权重转换、layer fusion、attention 实现差异。
第三步,logprob 级对比。在完全关闭采样后处理的条件下,让采样引擎输出每一步的 logprob,和训练框架前向计算的每一步 softmax logprob 做逐 token 对比。这一步最容易发现问题。我自己踩过的一个坑是:训练框架里默认把 label 中 pad 部分设成 -100,采样引擎在计算 logprob 时对输入序列的每一个 token 都算一遍 softmax,包括 prompt 部分和 pad 部分。两边统计范围不一样,概率总和当然不同。
第四步,检查采样配置的隐藏参数。很多采样引擎有默认的 top_k=40、repetition_penalty=1.0 这类“看不见的默认值”,或者对 eos token 有特殊 bias。必须逐一显式设置成“无后处理”状态,不要依赖默认值。
第五步,检查训练日志的“概率”到底是什么。如果训练框架打印的是“mean probability = exp(-loss)”,那它在数学上是所有 token 的平均几何概率,不是采样路径的联合概率。这个值天然比采样引擎里一条路径的联合概率大,因为平均掉了很多低概率 token。拿它做对比,本身就是错的。
5.3 我最后定位到的原因与修复
那次事故的真凶是 position_ids。
训练框架在 packing(多段拼接)时,会按 segment 重新生成 position_ids,也就是每段样本内部的相对位置从 0 开始。线上流式推理时,模型按绝对位置生成 position_ids——从 0 一直排到当前长度。结果就是:模型在训练时学到的是“每一段内部从 0 开始的相对位置”,线上推理时却给了它从 0 到很长的绝对位置,长上下文下 logits 分布偏移,概率自然对不上。
这个原因藏得很深,因为短 prompt 下绝对位置和相对位置几乎没区别,只有长上下文才会暴露。我们的训练框架日志里也没有直接记录 position_ids 的生成逻辑,最后是靠 logits 级 diff——把长 prompt 的 logits 一对比,发现位置靠后的 token 偏差越来越大,才反向定位到 position encoding。
修复分两步。推理端把 position_ids 改成与训练一致的 per-segment 相对位置;同时在离线评测里加了一条“长 prompt + 流式输入”的 golden test,让这种偏移在回归测试阶段就能暴露,而不是等上线之后由用户反馈来发现。
6. 训练推理一致性:哪些该对齐、哪些不该对齐
6.1 必须对齐的部分与没必要对齐的部分
先说必须对齐的部分。
tokenizer 和 vocab size 必须一致。如果两端 vocab 不一致,logits 的 index 都偏移了,概率对上是巧合。权重和 dtype 必须匹配——bf16 导出的模型别用 fp32 去对,也别在推理端用 fp16 直接 load bf16 的 pt 文件。基础采样参数要么显式一致,要么显式关闭。position_ids 和 attention_mask 的处理逻辑必须一致,这个我在上一节已经吃过亏。
再说没必要对齐、也永远对不上的部分。
训练日志里的平均 loss 对应的“平均概率”,和单条生成序列的联合概率,这俩不是一个东西,不该拿去比。训练时 teacher forcing 下的条件概率,和推理时自生成历史下的条件概率乘积,这俩也不该拿去比。GRPO group 内被 relative advantage 调制过的概率,和线上全词表原生分布概率,更不该拿去比。
把这些定义搞清楚之后,很多“对不上”根本不是事故,而是拿错了参照系。我见过团队为了一个不可能对齐的指标折腾了一个星期,最后发现是训练日志里打出来的那个“prob”压根不是采样引擎里那个“prob”。先审题,再动手,能省下大量时间。
6.2 把概率对齐做成 CI 回归测试
工程上我建议做三层自动化,把概率对齐从“出事再查”变成“日常守门”。
第一层是 logit-level golden test。选 50 条固定 prompt,比较 top-10 logits 差异,阈值设为绝对误差小于 0.01。任何一次升级——换 flash attention 版本、换推理框架、调 dtype——都要跑。这一层能快速暴露底层权重转换或算子融合的问题。
第二层是 generation-level regression。greedy 生成固定长度,比如 128 token,要求每一步的 token id 完全一致。这层比 logit-level 更严格,因为它要求每一步的 argmax 都相同。只要出现一个 token 不一致,就会被标记。
第三层是 distribution-level KL 监控。在线上抽样记录生成序列的 logprob 分布,和训练时验证集上的 logprob 分布做 KL 散度。如果 KL 持续放大,说明分布漂移了。这不一定是 bug,但需要人工介入看方向。
另外提醒一句:如果你的评估流程里用了 LLM as judge 来判断生成质量,请务必同时记录生成时的 logprob。因为 judge 本身也是 LLM,它的判断同样受采样概率影响,而且 judge 的偏好可能直接来自它自己的训练分布偏移。数值 logprob 是一条更硬、更可复现的指标,适合做回归门槛;judge 只适合做方向性判断,不适合做精确对齐。
6.3 面试官视角:这个问题最终想考察什么
这个题目我最想听见的不是某个具体 bug,而是三个层次的思考。
第一,候选人能不能区分“数值误差”和“定义错位”。知道浮点精度只能解释小数点后几位的差异,解释不了数量级的偏差。
第二,候选人有没有真正跑过训练框架和推理引擎。知道两者从 logits 到概率之间隔着一整套后处理流水线,知道 top_p 会重新归一化,知道 repetition penalty 会改写 logits。
第三,候选人是否理解现代偏好对齐训练里的概率已经被 group 结构相对化了,和推理采样里的词表级原生分布根本不是一个东西。能把这三点讲清楚的候选人,通常是真正拆过训练和推理两端代码的人,而不是只读过论文摘要的人。
这类问题这几年我在面试里问了很多次,也在线上排障里踩了很多次。我最大的体会是:先把“概率”的定义锁死,再谈对得上对不上。很多看似神秘的线上概率漂移,最后查下来都是参照系错了。另一个实际操作心得是,任何时候做概率对齐测试,都先从 greedy 开始——把采样后处理全部关掉,让 logits 本身说话。这一个习惯,帮我避开了至少一半的无效排查。