跳到主要内容

零温度并非确定性:复现那些你无法重新运行的事故

· 阅读需 11 分钟
Tian Pan
Software Engineer

模型给了客户一个错误的、代价高昂的回答。你打开复盘报告(post-mortem),将完全相同的提示词(prompt)粘贴回完全相同的端点,并将 temperature=0,结果你得到了一个不同的答案。不是更坏,也不是更好——只是不同。你本该找出根本原因(root-cause)的 bug 无法按需复现,而那个每个人都告诉你保证能复现的开关刚才却当面撒了谎。

就在这一刻,大多数团队发现“将 temperature 设置为 0”只是民间传说,而非工程控制手段。零 temperature 改变了模型的“采样”(sampling)方式——它强制进行贪婪解码(greedy decoding),始终选择概率最高的 token。它并不能保证两次计算得出的概率本身是完全一致的。而在生产环境的服务栈中,它们通常并不一致。

本能反应是归咎于采样器的随机性,于是人们加倍下注:设置 top_k=1top_p=1、固定随机种子(seed)。输出依然在漂移。原因在于,这种非确定性从未存在于采样器中。它存在于产生 logits 的浮点运算中,而且——更微妙的是——存在于你的请求到达 GPU 时所在的“批次”(batch)中。在你理解这种区别之前,你的故障复盘将一直以耸耸肩无奈告终。

贪婪解码只是拿走了一个色子,而非全部

语言模型会输出下一个 token 的概率分布。Temperature 在采样前对该分布进行缩放:高 temperature 使其平坦(更多令人惊讶的 token 变得可行),低 temperature 使其尖锐,而零 temperature 则将采样坍缩为 argmax —— 每次都选择那个可能性最大的 token。到目前为止,看起来是确定性的。

问题在于当两个候选 token 几乎持平时会发生什么。假设 token A 的得分为 0.5001,而 token B 的得分为 0.4999。如果 logits 计算结果相同,贪婪解码将确定地选择 A。但 logits 是经过数十个 Transformer 层、数十亿次浮点乘加运算(multiply-accumulate)的结果,而浮点加法不满足结合律。一旦涉及到舍入(rounding),(a + b) + c 并不总是等于 a + (b + c)。重新排列加法顺序,小数点后第十位就会产生摆动。

小数点后第十位的摆动是看不见的 —— 直到它越过了 0.5001 和 0.4999 之间的边界。然后 argmax 就会从 A 翻转到 B。现在你在第 103 个位置得到了一个不同的 token,而该 token 会影响之后的所有 token,两个“相同”的贪婪运行便明显地分化成了不同的段落。贪婪解码消除了在给定分数的情况下选择 哪个 token 的随机性。但它对分数本身的随机性无能为力。

这就是为什么那些仔细设置 top_k=1 并固定种子的人会感到惊讶。他们关上了采样器的前门,而 logits 却从后门漏了出来。

真正的元凶是批次不变性,而非并发

这是连经验丰富的工程师也会栽跟头的地方,因为显而易见的解释是错误的。显而易见的解释是:“GPU 并发运行成千上万个线程,线程调度是非确定性的,所以每次求和的顺序都不同。”这很直观 —— 但基本是错的。

在 GPU 上用相同的输入运行两次单矩阵乘法,尽管存在并发,你每次都会得到 位级一致(bitwise identical)的结果。现代 GPU 内核(kernels)的编写方式是无论线程如何调度,都按固定顺序进行累加。仅仅并发并不等同于非确定性。如果真是这样,单个矩阵乘法(matmul)就已经不稳定了,但事实并非如此。

来自 Thinking Machines Lab 的 2025 年分析指出了真正的原因,这更有趣:前向传播是运行间确定性的(run-to-run deterministic),但不是批次不变的(batch-invariant)。批次不变性意味着为 你的 请求计算出的数字不取决于内核运行时批次中还有 其他什么 内容。生产环境的服务器不具备这一特性。为了高效利用硬件,运行时环境会将并发请求动态地打包成批次,而批次大小决定了触发哪种内核策略 —— 归约(reductions)如何拆分、任务如何在核心之间分块(tiled)。

不同的批次大小意味着不同的归约顺序,意味着不同的舍入,也意味着偶尔会出现 token 翻转。面对这个现实吧:服务器上的其他用户改变了你的输出。 当流量较小时,你的请求以 2 个为一组的批次运行,走一条数值路径。在高峰负荷时,它以 200 个为一组的批次运行,走另一条路径。相同的提示词、相同的 temperature、相同的种子、相同的模型权重 —— 答案却不同,而改变的变量是别人的流量。

这就是为什么当你凌晨 2 点在一个安静的端点上重现该 bug 时,它就消失了。你并不是没能复现条件;负载本身就是条件之一,而你无法按需召唤它。

破坏不变性的内核是那些跨变长维度进行归约的内核:

  • RMSNorm 使用数据并行归约,但小批次会强制执行拆分归约策略,从而改变求和顺序。
  • 矩阵乘法(Matrix multiplication)根据批次维度选择分块和“split-K”策略;小批次路径的归约方式与大批次路径不同。
  • Attention 是最严重的魁首,因为归约同时沿特征维度和序列维度运行,且 KV 缓存的分块选择取决于序列在之前的请求中是如何拆分的。

确定性的实际代价

这是可以修复的,这才是真正有用的消息。修复方法是编写 批次不变内核 (batch-invariant kernels):选择一种归约策略和一种分块配置,并无论批次大小如何都坚持使用它,这样你的请求所对应的数值计算,无论你是在与另一个请求还是与一千个请求共享 GPU 时,都是完全相同的。Thinking Machines 的工作端到端地证明了这一点。在使用原生内核时,Qwen3-235B 在温度为零的情况下,针对 1,000 个完全相同的请求生成了 80 个不同的完成结果,第一次分歧出现在第 103 个 token 左右。而使用批次不变内核,所有 1,000 个结果都是位一致的。

代价是性能,在你采用它之前,你应该了解大致的数量级。强制使用固定的 matmul 配置与经过调优的 cuBLAS 相比,损失约 20%。未经优化的确定性 vLLM 运行速度比非确定性基准慢约 2.1 倍——55 秒对 26 秒——而更好的确定性注意力内核将这一差距缩小到约 1.6 倍(42 秒)。在稳定性比速度更重要的情况下,选择 FP32 而非低精度会带来更高的代价。因此,确定性并非免费的,它不是你在生产环境中为每个工作负载随手开启的开关。它是一种深思熟虑的权衡,即当你认为可复现性值得牺牲一部分吞吐量时。

有一个地方这种权衡是绝对值得的:在策略强化学习 (on-policy reinforcement learning)。如果你的采样路径和训练路径计算 logits 的数值哪怕只有微小的差异,你学习的策略就不是你正在更新的策略,它们之间的 KL 散度会发生漂移,训练可能会悄无声息地变得不稳定。位一致的推理使两条路径合二为一,散度归零。如果你正在进行 RLHF 或 RLVR,批次不变推理就从“锦上添花”变成了“核心支撑”。

为无法重新运行的复盘而设计

大多数团队无法控制他们的内核——他们调用的是托管 API,而这些 API 并不提供批次不变性。在这种情况下,现实的目标不是位一致的可复现性,而是你可以实际进行的 复盘 (post-mortem)。这意味着在事故发生时捕获足够的状态来推导发生了什么,因为你将无法重新召唤完全相同的运行过程。

从版本控制开始,因为托管模型是一个移动的目标。调用 固定版本的端点 (version-pinned endpoints),永远不要使用浮动别名。像 "latest" 这样的别名可能会在你不知情的情况下被重新指向——新的权重、调整过的系统提示词、不同的安全过滤器——即使你的代码没有变更,你的模型行为也会发生变化。至少固定版本能让模型保持不动。然后快照记录影响输出的所有其他版本:提示词模板、检索索引、工具模式 (tool schema)、护栏/审核配置以及评估数据集。针对“在 14:32 分,系统究竟是什么样子的?”这个溯源问题,应该有一个存储好的答案,而不是一个考古项目。

对生产流量样本捕获 对数概率 (logprobs),而不仅仅是最终文本。Token 级的对数概率是你的分布指纹。如果供应商悄悄地重新指向了别名、量化了模型或更换了硬件,文本看起来可能没问题,但概率分布会发生可衡量的偏移——而记录下来的 logprobs 往往是证明你所依赖的基础设施发生了变化的唯一证据。它们将“这周模型感觉不太一样”变成了直观的图表。

然后构建一个 回放测试框架 (replay harness),记录你 可以 固定的东西,并允许你覆盖那些你 无法 固定的东西。除了推理数值之外,经典的“罪魁祸首”与分布式系统一直难以回放的原语相同:挂钟时间 (wall-clock time)、随机种子、外部工具响应以及检索结果。一个基于“现在”进行推理的智能体在回放时的行为会与事故发生时不同,除非你为其提供一个 冻结时钟 (frozen clock)。记录每一次工具调用及其响应,以便回放能够重建确切的执行路径,而不是产生新的、不同的副作用。固定种子,尽管它们不能完全救你,但消除你 可以 控制的变量可以缩小寻找无法控制变量的范围。

观念的转变才是关键。停止将 LLM 调用视为可以随意重新评估的纯函数 f(prompt) → output。应将其视为一个 分布式事件,其结果取决于模型版本、服务器负载、浮点归约顺序、时钟以及当时的工具响应。其中一些你可以固定,另一些你只能记录。“将温度设为零”只控制了其中之一——也是对可复现性最不重要的一个——这就是为什么每一个寄希望于此的人都会感到失望。可复现性不是你传递的一个参数。它是你在事故发生前构建的一种架构,这样当那个无法重新运行的答案出现在你的队列中时,你才有东西可以回放。

References:Let's stay in touch and Follow me for more thoughts and updates