输出差了一点点?用 logprobs 分清「噪声」还是「bug」

一个真实的排查故事:KV Cache 端到端验证中,同一请求在两条执行路径上稳定输出 artificial(小写)与 Artificial(大写)。是采样噪声,还是数据 bug?这篇文章给出一个定量判别工具,并演示它如何在一整天内把根因收敛到一行代码。

一个让工程师失眠的问题

LLM 推理服务上线前的常见一幕:端到端测试输出与基线「几乎一致」——只有一两个 token 的差异。Artificial 变成了 artificial,开头多了个空格,某个近义词被替换。更折磨人的是:同一个差异有时出现有时不出现,同一套代码昨天复现今天消失。

这种「几乎一致」的状态是排查的泥潭。它不够错——错到一眼能看出乱码,直接进定位流程;它又不够对——对到可以直接放行。工程师卡在中间:这是硬件浮点噪声(可以容忍),还是数据在缓存链路的某个环节丢了 bit(必须修复)?两条路线的修复动作完全不同,而肉眼、逐字 diff、甚至回归测试都无法可靠地区分它们。

这篇文章介绍一个定量工具——logprobs 判别法——它把「看起来一样」变成「可度量」。方法来自一次真实的排查(Falcon-H1-7B 的 KV Cache 端到端验证),全文的数据都是那次实战的实测值。


问题:肉眼分不清的两种差异

LLM 推理的输出差异只有两个来源。

浮点非确定性:bf16 存储的舍入误差、kernel 执行顺序的微扰、并行归约的合并方式——这些因素让同一输入在两次计算中产生微小扰动(同代码路径的 run-to-run 抖动通常在 1e-3 nats 量级;不同实现之间的差异可能更大,见「边界与取舍」)。它天然存在,不构成 bug。

数据位级差异:KV Cache 或 mamba state 在缓存链路(写入缓存 → 驱逐 → 读回 → 续算)中丢了数据、写错了位置、或恢复了错误的内容。它是 bug,且往往藏在「输出基本正确」的表象下——因为一个 bit 的差异通常只会让个别低确信度 token 的 argmax 翻转。

两种来源的表现几乎相同:都表现为个别 token 的翻转。而传统的排查手段在这里全部失灵。逐字 diff 只能告诉你「有差异」,不能告诉你差异需要多大的力;回归测试同样只输出 pass/fail;逐字节对比最强,但它要改代码、挂诊断、跑真机轮次——在还不知道值不值得投入之前就用了最重的武器。

需要的是一把「分流器」:先定量判断这差异是噪声还是 bug,再决定投入多少。


工具:logprobs——模型不确定性的定量读数

OpenAI 兼容 API 有一个常被忽略的参数:logprobs=N。它让服务在每个生成位置返回 top-N 候选 token 的对数概率(logprob),而不只是 top-1 的文本。logprobs=2 即每个位置返回 top-2:

位置 6:top-1 = " Artificial"  logprob = -0.53
        top-2 = " artificial"  logprob = -0.98

logprob 的单位是 nats(自然对数)。它有一个关键的数学性质:logprob 差等于 logits 差——softmax 的归一化常数在相减中消掉:

logprob(a) - logprob(b) = (z_a - ln Z) - (z_b - ln Z) = z_a - z_b

这意味着 top1 与 top2 的 logprob 差直接度量了模型在两个候选之间的原始分值距离——不经过概率压缩、不受归一化干扰(前提:服务按原始 logits、即温度 1 的分布报告 logprobs;greedy 解码下主流引擎均如此,详见「边界与取舍」)。它是「翻转需要多大的力」的准确读数:要翻转 argmax,就必须施加超过这个 gap(top1 与 top2 的 logprob 之差)的 logits 偏移。

这个性质是整套判别方法的地基。下面的三个判别都建立在它之上。


判别一:gap——翻转需要多大的力

第一个判别看分歧位置的 top-2 gap——top1 与 top2 的 logprob 之差。

逻辑直接:gap 小,模型本身近等概率——小到与噪声同量级时,任何微小扰动都能翻转 argmax;gap 大,翻转就需要同量级以上的系统性 logits 偏移——远在浮点噪声之上——此时翻转只能来自数据层面的差异。

Falcon 案例的第一组数据:大小写分歧位置(Artificial vs artificial)的 gap = 0.455 nats。这是一个很高的值——模型本来相当确信大写。它却稳定地翻成了小写。仅凭这一个数字,结论就足够明确:要让 0.455 的 gap 翻转,需要约 0.5 nats 的系统性 logits 偏移,而 bf16 浮点噪声只有 1e-3 量级——差两个数量级以上——数据问题实锤,采样噪声的解释被定量排除。

经验阈值:gap < 0.3 时翻转难以归因——近等概率位置本就容易受常规波动影响,判别分辨率不足;gap > 0.4 时的翻转需要系统性偏移。这两个数不是真理,是起点——不同模型、不同任务位置的近等概率分布不同,阈值的精确校准要结合自己系统的对照实验。


判别二:跨路径差——实际偏移了多大

gap 告诉我们「翻转需要多大的力」——它证伪了噪声解释,但没有测量偏移本身。第二个判别补上这一环:同一 token 在两条执行路径上的 logprob 之差

方法:让同一个 prompt 走两条路径(例如基线路径与可疑路径),对每个生成位置取同一 token 的 logprob 相减。这个差值反映该位置的 logits 相对偏移(严格说是扣除分布整体漂移后的相对偏移——归一化常数只在同一分布内相减才消掉)。判据同样定量:bf16 浮点噪声对 logits 的扰动在 1e-3 nats 量级——跨路径差超过 0.1 nats 即排除浮点噪声(高两个数量级)。

Falcon 案例的实测:位置 1、3、4、6 的同 token 跨路径差为 -0.20/+0.28/-0.36/+0.51 nats(以基线减可疑路径)。每个可观测的 decode 步都在偏移,量级是噪声阈值的 2-5 倍。这不是「某个位置的特例」,而是整条生成序列的系统性偏移。数据差异从「怀疑」变成了「测量值」。


判别三:全局 vs 局部——偏移在哪里产生

偏移量级已定量,下一个问题是空间分布——它指向根因的位置。

全局偏移:每个 decode(解码)步都偏移相近量级(如 0.2-0.5 nats)。这意味着差异存在于 decode 开始之前的共享输入——prefill(预填充)阶段算出的 KV Cache、或续算依赖的 mamba state。所有 decode 步都从这个共享输入派生,所以每步都偏移。

局部偏移:只有个别位置偏移,其余位置正常。这指向该位置的特殊依赖——比如特定 token 组合触发的 kernel 路径差异、特定位置的注意力稀疏性。

Falcon 案例是典型的全局偏移:观测到的全部位置(1、3、4、6)都偏移 0.2-0.5 nats。这把根因锁定到 prefill 计算输入(KV 或 state),而不是 decode 期的任何随机扰动——定位范围从整个系统收敛到一条数据链路。


统一解释:随机与稳定是一回事

排查中最反直觉的现象:同一根因下,空格翻转随机出现(时有时无),大小写翻转稳定出现(每次必现)。第一反应是把它们分成两个问题——随机的是噪声,稳定的是 bug——这个二分浪费了整整一天的定位精力。

阈值模型统一解释了它们。假设存在单一的系统性 logits 偏移机制(偏移量在运行间有抖动):

  • 低 gap token(空格,gap 0.2-0.3):偏移量与 gap 同量级甚至更大——偏移大时翻、偏移小时不翻——所以表现随机。
  • 高 gap token(大小写,gap 0.455):只有大偏移(0.5 级)能翻它——偏移量不足时翻不动——所以只在偏移稳定超过阈值的路径上表现稳定。

「随机 vs 稳定」不是两个问题,是同一偏移在不同 gap 阈值下的表现。低 gap token 是灵敏但吵闹的探测器,高 gap token 是迟钝但可靠的指示器——两者组合使用:空格告诉你「这里有差异」,大小写告诉你「差异有多大」。这个认识把两个看似矛盾的现象合并为一个根因假设,是当天定位从泥潭转入正轨的转折点。


定位闭环:从现象到根因的三对照

logprobs 判别回答了「是不是数据问题」(是)和「量级多大」(0.2-0.5 nats,全局)——但它不能定位根因。判别的终点是定位的起点,而定位用数据面的三对照:

  1. 写侧对照:在数据写入缓存介质前 dump 一份 CPU 副本(物化点),与写入缓存后的读回对比——验证写路径无损。
  2. 介质对照:直接 mmap 缓存设备内存,按真实偏移读数据——验证数据确实存在于介质上。
  3. 读侧对照:load 恢复后,与写侧的 dump 逐层对比——找到差异出现的位置。

Falcon 案的三对照结果:写侧无损(dump 与物化完全一致);介质有数据;读侧丢层——load 恢复的 temporal state 只有 layer 0 有值,layer 1-43 全零。由此收敛到代码级根因:temporal state 是单个 tensor 的 44 层视图,共享同一 IPC handle。注册时只有每层长度、没有层偏移,SAVE/BLOAD 的寻址全部落在层 0。44 层写的是同一份数据,读回的也只有层 0。续算从这个半零的 state 出发,每一步都偏移 0.2-0.5 nats——与 logprobs 判别的全部结论吻合。

根因是一行修复(注册时记录层偏移),但找到它的路径是完整的判别 + 三对照链条。


边界与取舍

logprobs 判别有明确的边界,使用前应当知道:

  • API 依赖:需要服务支持 logprobs 参数(OpenAI 兼容服务基本都有);判别在 greedy 解码(temperature=0)下才有意义——采样会引入自己的随机性,污染 gap 读数;且部分引擎(如 SGLang 的非 greedy 路径、vLLM v0)在 temperature≠1 时会按 1/T 缩放报告值,gap 读数随之变形,greedy 解码恰好规避了这一问题(主流引擎在 greedy 下均按温度 1 的原始分布报告)。
  • 阈值是经验值:0.3/0.4(gap)、0.1(跨路径差)来自 bf16 噪声量级与实战标定。注意噪声量级的口径:同代码路径的 run-to-run 抖动在 1e-3 nats 量级,而不同实现/不同 kernel 路径之间的 bf16 logits 差异可能达到 1e-2 量级——0.1 阈值相对后者的余量约一个数量级。其他精度体系(fp8/fp4 量化、不同模型的 logits 标度)需要重新标定;最稳妥的做法是用两条路径各自的重复运行直接测量噪声地板。
  • 只证有不证无:大 gap 翻转强有力地证明「存在数据差异」;但小 gap 翻转不能证明「没有数据差异」——低 gap token 对偏移敏感,翻转了可能是噪声,没翻也可能只是偏移还没到阈值。判别法擅长「抓出数据问题」,不擅长「放行」。

与替代方案相比:逐字节对比最强,但成本最高——要改代码、挂诊断、跑真机轮次,适合判别之后的定位阶段。回归测试只报差异、不报差异的力,适合门禁、不适合定位。logprobs 判别几乎零成本(一个 API 参数),却能把「噪声 vs bug」这个最贵的岔路口提前分掉。三者是流水线,不是替代品:先 logprobs 分流,再逐字节定位,最后回归测试守门。


结语:把「看起来一样」变成「可度量」

一句话方法论:先定量判别,再定位根因

输出差异排查的第一动作不是 grep 代码,而是拿 logprobs——gap 告诉你翻转需要多大的力,跨路径差告诉你实际偏移了多大,两者的对比把「采样噪声」和「数据 bug」分开。分开之后,全局偏移指向 prefill 输入,三对照收敛根因。

那天花在判别上的时间,换来的是省下的数天错误定位——以及最重要的:一个可以重复使用的判断框架,而不是又一次「重启试试」。


附录:一次请求的完整判别示例

从请求到结论的完整数据流(SGLang /v1/completions,greedy 解码 + logprobs=2;数值取自 Falcon 实测,为便于演示做了简化整理:gap 取 0.45(正文 0.455 的两位近似)、跨路径差取位置 6(0-based 索引 5)的 -0.51,方向约定为基线减可疑路径,量级与正文一致):

1. 请求

curl -s http://localhost:30001/v1/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "Falcon-H1-7B-Instruct",
    "prompt": "Consider the following context about artificial intelligence and machine learning: ...  Question: what is AI?",
    "max_tokens": 20,
    "temperature": 0.0,
    "logprobs": 2
  }'

2. 响应(OpenAI completions 格式——tokens / token_logprobs / top_logprobs 三个并行数组;R1 为可疑路径的响应)

{
  "id": "cmpl-xxxxxxxx",
  "object": "text_completion",
  "created": 1756000000,
  "model": "Falcon-H1-7B-Instruct",
  "choices": [
    {
      "index": 0,
      "text": " Answer: AI stands for artificial intelligence, which refers to the simulation of human intelligence in machines that",
      "logprobs": {
        "tokens": [
          " Answer",
          ":",
          " AI",
          " stands",
          " for",
          " artificial",
          " intelligence",
          ",",
          " which",
          " refers"
        ],
        "token_logprobs": [
          -1.3, -0.02, -0.001, -0.01, -0.05, -1.04, -0.02, -0.03, -0.01, -0.04
        ],
        "top_logprobs": [
          { " Answer": -1.3, "  Answer": -1.303 },
          { ":": -0.02, ";": -5.1 },
          { " AI": -0.001, " ai": -7.2 },
          { " stands": -0.01, " Stand": -8.5 },
          { " for": -0.05, " For": -6.8 },
          { " artificial": -1.04, " Artificial": -1.49 },
          { " intelligence": -0.02, " Intelligence": -5.3 },
          { ",": -0.03, ".": -3.2 },
          { " which": -0.01, " Which": -7.9 },
          { " refers": -0.04, " Refer": -6.4 }
        ]
      }
    }
  ],
  "usage": {
    "prompt_tokens": 2206,
    "completion_tokens": 20,
    "total_tokens": 2226
  }
}

3. 从响应计算两个判别量(解析逻辑;top_r1 / top_r3 为两条路径响应的解析结果,本示例中 R1 为可疑路径、R3 为基线路径)

import json

def load(p):
    d = json.load(open(p))
    c = d["choices"][0]
    lp = c.get("logprobs", {})
    return c["text"], lp.get("tokens", []), lp.get("token_logprobs", []), lp.get("top_logprobs", [])

# 判别一:分歧位置的 top-2 gap
def top2_gap(top_logprobs, i):
    s = sorted(top_logprobs[i].items(), key=lambda kv: -kv[1])[:2]
    return s, s[0][1] - s[1][1]

_, _, _, top = load("/tmp/lp_r1.json")   # 可疑路径响应
top2, gap = top2_gap(top, 5)             # 0-based 位置 5 = " artificial"(正文的「位置 6」)
# top2 = [(" artificial", -1.04), (" Artificial", -1.49)]
# gap = 0.45  ← 大 gap:翻转需要约 0.45 nats 的力——噪声(1e-3 量级)不够 → 数据问题实锤

# 判别二:同 token 跨路径差(R1 可疑路径 vs R3 基线路径,同一 token 的 logprob)
def cross_path_diff(top1, top3, i, token):
    return top1[i].get(token) - top3[i].get(token)

# R1: " artificial" = -1.04;R3: " artificial" = -0.53
diff = cross_path_diff(top_r1, top_r3, 5, " artificial")
# diff = -0.51  ← |diff| > 0.1 nats → 排除 bf16 浮点噪声

4. 判别结论对照

步骤 计算 判据 结论
gap top1 - top2 logprob 0.45 nats > 0.4 → 翻转需系统性偏移 数据问题(非噪声)
跨路径差 同 token R1 - R3 -0.51 nats > 0.1 → 排除 bf16 偏移量级实测
全局性 各位置逐一算 每步 0.2-0.5 全部位置偏移 根因在 prefill 输入