temperature=0 从来不等于可复现——真正让你两次跑出不同结果的,不是采样,是你控制不了的 batch size。
几乎每个用 LLM 做过 eval 的人都撞过这堵墙:同一个 prompt、temperature=0、连 seed 都固定了,跑两次结果还是不一样;A/B 两版 prompt 差 2 分,你不知道那是真提升还是噪声。多数人把它归成「模型玄学」,然后要么假装没看见,要么疯狂加 seed。2025 年 Thinking Machines Lab 那篇被反复转的博客把真凶挖了出来:漂移的根源不是采样随机,而是浮点非结合律 + 服务端动态 batching——你的请求落在多大的 batch 里,你根本控制不了,而 batch size 会改变 matmul/attention 的归约顺序,逐 token 拨动 logits。这一期不解释「什么是 temperature」(那是 101),只讲四件工程上要命的事:漂移到底从哪进来、seed/system_fingerprint 能给你什么保证、怎么让 eval 不再 flaky、以及确定性要用吞吐换的成本账。
先破一个直觉:temperature=0 是贪心解码(每步取 argmax logit),理论上完全确定。那漂移从哪来?浮点加法不满足结合律:(a+b)+c ≠ a+(b+c) 在有限精度下会差最后几个 bit。而 matmul、RMSNorm、attention 这些算子的 GPU kernel,会按当前 batch 大小选不同的归约(reduction)策略与分块顺序——这就是 Thinking Machines 说的缺乏 batch-invariance:同一个样本,单独跑和塞在 32 条请求的 batch 里跑,归约顺序不同,算出的 logits 就差那么一丁点。多数 token 上这点误差无所谓,但只要碰到两个 logit 几乎并列的临界 token,argmax 就翻面,选了不同的词——之后自回归把这个分叉无限放大,两条轨迹彻底发散。关键在于:生产推理服务的 batch size 随实时负载浮动,你作为调用方完全无法控制,所以「同一请求、不同时刻」就是会漂。
先自己复现一次,把「玄学」变成可观测——固定输入跑 N 次,定位第一个分歧 token的位置:
# 诊断:同一请求跑 N 次,看输出在第几个 token 开始分叉
outs = [call(prompt, temperature=0, seed=0) for _ in range(20)]
uniq = set(outs)
print(f"{len(uniq)} 种不同输出 / 20 次") # >1 就是非确定
# 找首个分歧位:把每次输出按 token 对齐,定位第一个不一致的 index
# 通常分歧点越靠后、越集中在 near-tie 的临界 token —— 印证 batch 归约漂移
把这段当你接任何新 provider/新自托管栈的第一个体检项:先量清楚它有多不确定,再决定要不要为确定性付钱。
seed=0 + temperature=0 就复现了——单机低负载测确实稳,一上生产高并发就漂;② 把非确定当模型 bug 提工单——这是浮点+batching 的系统性质,不是缺陷;③ 只测一次就下结论。闭源 API 提供的 seed 参数常被误读成「确定性开关」。官方口径其实很克制:设了相同的 seed、相同的其余参数,输出「mostly deterministic」;而且它绑定一个 system_fingerprint——代表后端权重 + 基础设施配置的指纹。只要供应商换了推理配置(一年会变几次),fingerprint 一变,seed 的复现保证立刻作废。更细的坑:官方明说即便 seed 与 fingerprint 都对得上,仍可能观察到差异;且 max_tokens 越大、输出越长,复现性越差(第 1 点的分叉放大,token 越多越容易踩到临界点)。所以 seed 的正确定位是:降低方差的工具,不是消除方差的保证。
resp = client.chat.completions.create(
model="...", messages=msgs,
temperature=0,
seed=42, # 固定 seed
max_tokens=256, # 越短越稳:别开到 4k 去赌复现
)
fp = resp.system_fingerprint # 记录它!
log(fp) # fp 变了 = 你的复现基线作废,重跑基准
# 正确用法:seed 固定 + 监控 fingerprint 变更 + 输出尽量短
# 错误期待:拿它做 bit-level 回归断言 / 跨 fingerprint 比对
把第 1、2 点接到 eval 上:既然同一请求会漂,那你的 pass/fail 判定在贴着阈值的边界样本上就会随机翻面。跑一次得 82 分,再跑得 85 分,你若用单次跑去判「新 prompt 更好」,很可能只是在读噪声。两条工程出路,二选一别混用:(A) 要真复现——自托管,上 batch-invariant kernel(vLLM 有 demo),把系统性漂移按住,评估可 bit 对齐;(B) 接受非确定——每个样本重复采样 n≥5,把单点分数变成分布 + 置信区间,A/B 判定看区间是否重叠,而不是看两个单点谁大。绝大多数用闭源 API 的团队只能走 (B),那就别再假装单次跑是真理。
# eval 稳健化:重复采样 → 报均值±std,而不是单点
scores = [grade(run(case)) for _ in range(5)] # n≥5
mean, std = stat(scores)
report(case, mean, std)
# A/B 判定:看置信区间是否重叠,别用 82 vs 85 这种单点差下结论
# 额外:把 near-tie(重复跑里 pass/fail 翻面)的样本单独标注,它们是噪声高发区
确定性有真实价格。batch-invariant kernel 放弃了「按 batch 自适应挑最快归约策略」这条优化路,Thinking Machines 的实测里确定性模式明显更慢(未充分优化时吞吐近乎腰斩),之后才靠工程把差距收窄。所以别无脑全局开确定性,按场景决策:debug / 复现 bug / 合规审计 / 回归基线——要,可复现是刚需;on-policy RL 训练——要,采样端(inference)和训练端数值若不一致,on-policy 会悄悄退化成 off-policy,梯度有偏;创意生成 / 头脑风暴 / 多样候选——不要,你本就想要多样性,强上 temp=0 求「稳定」只会让输出变呆、多次调用给你同一个平庸答案。
要 · debug / 复现线上 bug — 必须能重放同一轨迹,否则无从定位。
要 · 合规审计 / 回归断言 — 结论要可重现;用 (A) 自托管或至少固定 seed+短输出。
要 · on-policy RL — sampler 与 trainer 数值一致,否则训练目标偏移。
看情况 · prefix caching 命中 — 输入前缀 bit 稳定才吃得到缓存红利;漂了就 miss。
不要 · 创意 / 多候选 / 集成投票 — 多样性就是价值,temp>0 是特性不是 bug。
temperature=0 求「一致」,产出千篇一律且僵化;② 为「可复现」在生产全局关 batching,吞吐腰斩、成本翻倍还没人真的需要;③ 做 RL 却让推理与训练走两套数值路径,train/inference mismatch 让曲线诡异发散。temperature>0 的采样本来就把 token 当概率分布抽——你早就接受了 run-to-run 不同,心理上不会误以为「应该一致」;而且两个 near-tie token 在采样下本来概率就接近,选哪个都在分布内,不像贪心那样制造「本该确定却变了」的错觉。所以真正的教训不是「关掉随机就确定」,而是:贪心的确定性是纸面上的,它对底层浮点噪声异常敏感——要真复现,必须去修 kernel 的 batch-invariance,而不是把 temperature 拧到 0 了事。