[控场AI]
· 7 分钟阅读· 3,564 字

vLLM temperature=0 竟非确定:200条评测23条翻转

vLLM temperature=0 竟非确定:200条评测23条翻转

temperature=0 不等于确定性:批处理改变浮点归约顺序,一个 token 翻转就能毁掉整条回答。

文章从一个真实故障切入:作者跑 200 条评测 prompt 的回归测试,发现 23 条回答与上次不同,而模型权重、代码和 temperature=0 都没变。排查后确认元凶是 vLLM 的连续批处理——批次形状随流量变化,导致矩阵乘法 split-K、注意力归约、RMSNorm 等 kernel 采用不同的浮点加法顺序。浮点加法不满足结合律,bf16 下 7 位尾数放大了舍入误差;当 top-1 与 top-2 logprob 几乎打平时,argmax 翻转,自回归生成又把单个 token 翻转扩散为整条回答分歧。seed 在 greedy decoding 下无效,因为非确定性发生在前向传播而非采样阶段。作者给出五条可操作实践:语义判分替代精确字符串匹配、需要比特级一致时串行化请求、使用批不变 kernel、监控 logprob 差距标记脆弱用例、每用例多次运行看通过率。前缀缓存、分块 prefill、张量并行和硬件差异是额外来源。

一次没有改动 prompt 的失败

作者在周二跑回归测试时发现,200 条评测 prompt 中,有 23 条回答与上一次运行不同。服务器没有重部署,模型权重没变,temperature=0 也没改。起初他以为有人动了手脚,花了一小时排查,最后发现凶手是 vLLM 的批处理机制。

这个现象并不孤例。thinking Machines Lab 在 2025 年的一篇分析中把问题命名为“批不变性缺失”(lack of batch invariance)。简单说,greedy decoding 本身是确定的,但它所依赖的 logits 计算不是。一旦你的请求和其他人的请求共享 GPU,批次形状就会随流量变化,进而改变浮点运算的求和顺序。

vLLM temperature 0 非确定性来源

为什么批大小会改变结果

vLLM 使用连续批处理(continuous batching),每个前向传播都会把当前所有在途序列打包在一起。矩阵乘法可能对小批次采用 split-K 拆分归约,注意力解码可能根据查询数量切分 KV 序列,RMSNorm 的归约策略也随批大小变化。每种策略在数学上都正确,但它们对同一组数字的加法顺序不同。

而浮点加法不满足结合律——(0.1+0.2)+0.3 和 0.1+(0.2+0.3) 在 Python 里就能得到不同结果。模型推理用 bf16,尾数只有 7 位,舍入误差被放大。一个 logit 是 32 层里成千上万次乘加的结果,批次一变,最后几位有效数字就漂移了。

连续批处理(continuous batching) 是 vLLM 等推理引擎的核心调度技术:它不再等待一批请求全部完成后才统一返回,而是每生成一个 token 就检查队列,把新到达的请求立即插入当前批次,把已完成的序列移出。这样 GPU 几乎不会空转,吞吐量大幅提升。但对确定性而言,这意味着同一请求在不同时间运行,所加入的批次组成截然不同——批次里其他序列的长度、数量、生成阶段都在变。连续批处理追求的是最大化利用率,而非固定计算图,因此它天然与“同一输入、同一计算路径”的目标冲突。这也是为什么即使 temperature=0、模型权重和代码都不变,回答仍可能逐字不同。

split-K 归约 是一种矩阵乘法优化:当矩阵乘法的输出维度较小、K 维(归约维)很大时,单个线程块的计算量不足,GPU 利用率低。split-K 把 K 维切成若干段,由多个线程块并行计算部分和,最后再把这些部分和相加。数学上结果等价,但部分和相加的顺序取决于切分数和调度方式,而切分数又由批大小和矩阵形状决定。类似地,注意力解码会根据查询数量切分 KV 序列做并行归约,RMSNorm 也会根据元素数量选择不同的向量化归约策略。所有这些 kernel 都正确,但浮点加法不满足结合律,归约顺序一变,最后几位有效数字就不同。

一个 token 翻转如何毁掉整条回答

大多数时候,logits 的微小差异无关紧要。只有当 top-1 和 top-2 候选几乎打平时,argmax 才会翻转。作者回溯了 23 条翻转回答,发现每一处首次分歧的位置,top-1 与 top-2 的 logprob 差距都极小。

常见的翻转点包括 “However” vs “But”、列表用 “1.” 还是项目符号、“is” vs “can be”。这些选择风格上微不足道,但自回归生成会把第一个翻转的 token 作为后续所有 token 的上下文。第 40 步把 “However” 换成 “But”,第 41 步之后的每一步都基于不同的上下文计算,其中新的近似平局又会以不同方式解决。作者有 3 条回答最终得出了不同结论,根源只是第四位有效数字的舍入误差。

seed 参数救不了你

seed 控制的是采样用的随机数生成器。在 temperature=0 下没有采样,只有 argmax,seed 无事可做。非确定性发生在前向传播中,seed 根本触及不到。托管 API 同理——提供商把你的请求和别人的批在一起,文档里通常只说 temperature=0 给出“大致”确定的结果,从不保证字节级一致。

bf16(bfloat16) 是一种 16 位浮点格式,包含 1 位符号、8 位指数和 7 位尾数。与 fp16 相比,bf16 牺牲了尾数精度来换取与 fp32 相同的指数范围,因此不易溢出,非常适合深度学习训练和推理。但 7 位尾数意味着有效十进制精度大约只有 2 到 3 位,每次乘加都会引入相对误差约 2^-8 ≈ 0.4%。一个 logit 要经过 32 层、每层数千次乘加,误差会累积。当两个候选 logit 的真实差距小于这个累积误差时,argmax 结果就由舍入噪声决定,而非语义。这正是批大小变化能翻转 token 的物理根源。

让 LLM 评测可复现的五个实践

作者在踩坑后整理了一套可操作的做法,核心思路是:先明确你需要哪一级确定性,然后只为那一级付出成本。

1. 停止断言精确字符串相等。 大多数评测关心的是“答案是否正确”,而不是“答案是否逐字相同”。用检查器、解析器或评分标准来判分,这一步就能让测试套件稳定下来。

2. 需要比特级一致时,串行化请求。 设置 --max-num-seqs 1 后,作者两次运行 200 条全部一致。代价是吞吐量暴跌,所以只用在专用评测服务器上,不要用在生产环境。

3. 寻找批不变模式。 较新版本的 vLLM 和 SGLang 加入了实验性的确定性或批不变选项,使用固定归约顺序的 kernel,与批大小无关。速度更慢,且不同版本 flag 不同,使用前务必查对应版本文档。

4. 记录 logprob 差距。 请求 logprobs=True 和 top_logprobs=2,如果某个用例在前 50 个 token 内出现 top-1/top-2 差距小于约 0.05 的位置,它大概率会 flake。标记它,或者改写 prompt 让模型不再走钢丝。

5. 每个用例跑多次。 一个用例 3 次里过 2 次,这本身就是关于 prompt 脆弱性的真实信息。用一次侥幸的 greedy 运行把它藏起来,是更糟的选择。

作者的日常检查清单

  • 评测服务器与生产分离,比较 prompt 版本时加 --max-num-seqs 1
  • 判分用语义或程序化方式,绝不对全文用 ==
  • 每次运行存储前 100 个 token 的 top_logprobs=2
  • 前 50 个 token 内出现近似平局的用例打上 fragile 标签单独汇报
  • prompt 变更看 3 次运行的通过率,而不是单次结果

其他非确定性来源

批大小是稳态下最常咬人的因素,因为流量每秒都在变。但同一主题下还有其他来源:前缀缓存复用会让 KV 值在不同于全新 prefill 的批次中计算;长 prompt 的分块 prefill 会与其他工作一起调度,再次改变形状;张量并行下的 all-reduce 有自己的归约顺序;不同 GPU 型号或 CUDA 版本可能直接选择不同的 kernel。

这些因素共同指向一个事实:同一个输入走了不同的计算路径。对做 LLM 评测和 prompt 迭代的团队来说,接受这一点并围绕它设计流程,比追求不存在的完美确定性更实际。作者最后说,他那个 prompt 改动其实没问题,只是花了一天才能够证明。

前缀缓存(prefix caching) 是推理引擎为共享相同 prompt 前缀的请求复用 KV cache 的优化。当请求 A 的前缀命中缓存时,它的 KV 值是在之前某个批次中计算并存储的,而请求 B 的相同前缀可能在新批次中重新计算。即使数学上等价,缓存命中的 KV 值与全新 prefill 的 KV 值在浮点层面可能不同,因为计算时的批次形状、kernel 选择和归约顺序不同。这个差异会沿着后续所有 token 传播。类似地,分块 prefill 把长 prompt 切成若干块分批计算,块的大小和调度取决于当时其他请求的负载,因此同一个长 prompt 在不同流量下会走不同的计算路径。

#vLLM#temperature 0#确定性推理#LLM 评测#连续批处理#浮点精度#greedy decoding#batch invariance
分享:

相关推荐