Prhub

#51026 [Bugfix][Rust Frontend] Tolerate NaN-corrupted logprobs in engine-core

原始 PR 作者 almersawi 合并时间 2026-09-16 11:10 文件变更 3 提交数 2 评论 7 代码增减 +168 / -4

执行摘要

Rust 前端容忍 NaN 致 rank 0 logprobs,消除服务级故障

PR body 明确指出这是一个 total-outage bug:a single request asking for logprobs/prompt_logprobs while the model produces NaN logits permanently kills the client。根因链路是:vllm/v1/sample/sampler.py 中采样 rank 计算为 (logprobs >= sampled_logprob).sum(-1),当 sampled logprob 为 NaN 时每个 >= 比较都为 false,rank 恒为 0;而 Rust 客户端 PositionLogprobs::from_decoded_row 将 rank 0 视为坏 wire 数据并 bail,且失败是 frame-scoped(一个坏行拖垮整批 EngineCoreOutputs),发生在共享 output-reader 路径上并被记为 sticky 健康错误,于是单请求数据异常演变为服务级宕机。

值得精读。一是根因链路完整(NaN 比较语义 → rank 0 → frame-scoped 解码失败 → sticky health error),是典型的“边界校验把合法引擎状态误判为损坏数据”的案例,对理解 batched 协议解码的故障放大效应很有帮助;二是 BugenZhao 的 review 体现了分层设计原则:解码层保留原始语义,展示层做格式转换。阅读时重点关注 PositionLogprobs::from_decoded_row 的校验边界与 OpenAI 转换层 -9999.0 约定,并留意后续是否需要对 completions 等路径补充 NaN 序列化测试。

讨论亮点

核心讨论是 BugenZhao 对第一版实现中解码层 clamp 非有限 logprobs 的质疑。他指出:I think this is not necessary? We can preserve the original value within the frontend pipeline, but only clamp it when it's going to be serialized in the serving layer, which we already have,即 rust/src/server/src/routes/openai/utils/logprobs.rs 序列化时已有 -9999.0 转换,解码层提前 clamp 属于重复处理且丢失引擎原始数值信息。结论是采纳该建议:第二个 commit 保留 rank 0 与原始 NaN/-inf 值,形成“解码层保真、序列化层转换”的分层约定。此外 Codex 自动 review 无重大发现,BugenZhao 最终 APPROVED(Rest LGTM. Thanks!)。

实现拆解

  1. 根因定位:从 Python 采样器 vllm/v1/sample/sampler.py 的 rank 计算出发,确认 NaN logits 会让采样 rank 为 0,再追踪到 Rust 侧 rust/src/engine-core-client/src/protocol/logprobs.rs 中 PositionLogprobs::from_decoded_row 对 sampled_rank == 0 的 bail_ext_value_decode! 拒绝逻辑。关键认识是:rank 0 是引擎合法输出而非 wire 损坏,拒绝它会让整批解码失败。
  2. 解码层修复:在 PositionLogprobs::from_decoded_row 中删除 if sampled_rank == 0 { bail_ext_value_decode!(...) } 分支,并在 TokenLogprob 的 rank 字段文档中补充 NaN 语义说明;logprob 字段文档明确保留引擎原始值(含 NaN 与无穷)。
  3. 设计收敛:第一个 commit(07be0300)曾尝试在解码层把 rank 0 与 NaN 值直接 clamp 为 -9999.0;BugenZhao review 指出 serving 层已有 clamp 逻辑,前端管线应保留原始值。第二个 commit(3344086d)采纳该意见,移除解码层的 sentinel 映射,转换职责统一收口到 OpenAI 响应序列化层。
  4. 测试配套:一是 zero_sampled_rank_does_not_fail_frame_mates,构造共享批次中含 NaN/-inf/rank 0 的坏行与健康行,断言整批可解码且健康请求不受影响;二是 chat_logprobs_clamps_nan_and_negative_infinity_for_json,验证既有 HTTP 转换对 NaN/-inf 输出 -9999.0、有限值保持不变。测试结果:cargo nextest 118 passed,clippy、fmt 均通过,无需 GPU。
文件 模块 状态 重要度
rust/src/engine-core-client/src/protocol/logprobs.rs 解码层 modified 6.42
rust/src/engine-core-client/src/protocol/logprobs/tests.rs 解码层 modified 7.4
rust/src/server/src/routes/openai/utils/logprobs.rs 服务层 modified 6.25

关键符号

PositionLogprobs::from_decoded_row logprobs_value zero_sampled_rank_does_not_fail_frame_mates chat_logprobs_clamps_nan_and_negative_infinity_for_json

关键源码片段

rust/src/engine-core-client/src/protocol/logprobs.rs core-logic

核心修复文件:移除 `PositionLogprobs::from_decoded_row` 中对 sampled rank 0 的拒绝校验,并补充 TokenLogprob 的 NaN 语义文档,是消除服务级故障的关键改动。

// rust/src/engine-core-client/src/protocol/logprobs.rs
// PositionLogprobs::from_decoded_row:将一行 logprobs wire 数据转换为按位置分组的形式。
impl PositionLogprobs {
    fn from_decoded_row(token_ids: &[u32], logprobs: &[f32], sampled_rank: u32) -> Result<Self> {
        // 先校验 token_ids 与 logprobs 长度一致,防止 wire 数据被截断导致错位。
        if token_ids.len() != logprobs.len() {
            bail_ext_value_decode!(
                "logprobs row length mismatch: token_ids={}, logprobs={}",
                token_ids.len(),
                logprobs.len()
            );
        }
​
        // 注意:这里不再拒绝 sampled_rank == 0。
        // 引擎在 vllm/v1/sample/sampler.py 中把采样 rank 计算为
        // `(logprobs >= sampled_logprob).sum(-1)`;当 sampled_logprob 为 NaN 时,
        // IEEE-754 规定 NaN 与任何值的比较都为 false,因此 rank 恒为 0。
        // 这是引擎的合法输出而非 wire 数据损坏:如果在此处 bail,
        // 整个批次的 EngineCoreOutputs 帧都会解码失败(frame-scoped),
        // 进而触发共享输出读取路径的 sticky 健康错误,导致服务级故障。
        let mut entries = Vec::with_capacity(token_ids.len());
        for (index, (&token_id, &logprob)) in token_ids.iter().zip(logprobs.iter()).enumerate() {
            // 第一个条目是采样 / 选中 token,携带引擎返回的真实 vocab rank;
            // 其余条目按引擎返回的 top-k 候选顺序编号(从 1 开始)。
            let rank = if index == 0 {
                sampled_rank
            } else {
                index as u32
            };
            entries.push(TokenLogprob {
                token_id,
                // 保留引擎原始数值(含 NaN 与正负无穷):
                // sentinel 转换(-9999.0)统一由 HTTP 序列化层
                // 在 rust/src/server/src/routes/openai/utils/logprobs.rs 中完成。
                logprob,
                rank,
            });
        }
        Ok(Self { entries })
    }
}
rust/src/engine-core-client/src/protocol/logprobs/tests.rs test-coverage

新增核心回归测试 `zero_sampled_rank_does_not_fail_frame_mates` 与 wire 构造辅助 `logprobs_value`,验证 NaN/-inf/rank 0 的坏行不再拖垮同批次健康请求。

// rust/src/engine-core-client/src/protocol/logprobs/tests.rs
// logprobs_value:把 token_ids / probs / ranks 组装成引擎 wire 层的
// ndarray 元组(ids 行 x 列、probs 行 x 列、ranks 每行一个),
// 用于构造 EngineCoreOutputs 帧。
fn logprobs_value(ids: &[i64], probs: &[f32], ranks: &[i64]) -> Value {
    let rows = ranks.len();
    let cols = ids.len() / rows;
    Value::Array(vec![
        ndarray_value(
            "<i8",
            &[rows, cols],
            Value::Ext(3, ids.iter().flat_map(|v| v.to_le_bytes()).collect()),
        ),
        ndarray_value(
            "<f4",
            &[rows, cols],
            Value::Ext(3, probs.iter().flat_map(|v| v.to_le_bytes()).collect()),
        ),
        ndarray_value(
            "<i8",
            &[rows],
            Value::Ext(3, ranks.iter().flat_map(|v| v.to_le_bytes()).collect()),
        ),
        Value::Nil,
    ])
}
​
#[test]
fn zero_sampled_rank_does_not_fail_frame_mates() {
    // 构造一个共享 EngineCoreOutputs 批次:req-nan 的 sample 与 prompt
    // logprobs 均为 NaN/-inf 且 rank 为 0,req-ok 是完全正常的数据行。
    let frames = vec![Bytes::from(encode_value(&Value::Array(vec![
        Value::from(0),
        Value::Array(vec![
            Value::Array(vec![
                Value::from("req-nan"),
                Value::Array(vec![Value::from(7), Value::from(8)]),
                logprobs_value(&[1, 2], &[f32::NAN, f32::NEG_INFINITY], &[0]),
                logprobs_value(&[7, 8], &[f32::NAN, f32::NEG_INFINITY], &[0]),
                Value::Nil,
                Value::from(EngineCoreFinishReason::Length as u8),
            ]),
            Value::Array(vec![
                Value::from("req-ok"),
                Value::Array(vec![Value::from(9)]),
                logprobs_value(&[3, 4], &[-3.0, -0.5], &[7]),
                Value::Nil,
                Value::Nil,
                Value::from(EngineCoreFinishReason::Length as u8),
            ]),
        ]),
        Value::Nil,
        Value::from(0.0),
        Value::Nil,
        Value::Nil,
    ])))];
​
    // 修复前:整批解码返回 ExtValueDecode 错误,healthy 请求一起失败;
    // 修复后:rank 0 与 NaN/-inf 原样保留,healthy 行(-3.0/-0.5、rank 7/1)
    // 不受影响。expect_test 快照断言三个 Logprobs 的完整内容:
    // - bad 行:TokenLogprob { token_id: 1, logprob: NaN, rank: 0 }、
    // TokenLogprob { token_id: 2, logprob: -inf, rank: 1 }
    // - prompt 行:TokenLogprob { token_id: 7, logprob: NaN, rank: 0 }、
    // TokenLogprob { token_id: 8, logprob: -inf, rank: 1 }
    // - good 行:TokenLogprob { token_id: 3, logprob: -3.0, rank: 7 }、
    // TokenLogprob { token_id: 4, logprob: -0.5, rank: 1 }
    let decoded = decode_engine_core_outputs(&frames).unwrap().into_request_batch().unwrap();
    let bad = decoded.outputs[0].new_logprobs.as_ref().unwrap();
    let prompt = decoded.outputs[0].new_prompt_logprobs_tensors.as_ref().unwrap();
    let good = decoded.outputs[1].new_logprobs.as_ref().unwrap();
    expect_test::expect![[r#"
        (
            Logprobs { /* ... 见上方注释 ... */ },
            Logprobs { /* ... 见上方注释 ... */ },
            Logprobs { /* ... 见上方注释 ... */ },
        )
    "#]]
    .assert_debug_eq(&(&**bad, &**prompt, &**good));
}
rust/src/server/src/routes/openai/utils/logprobs.rs test-coverage

在 OpenAI 响应转换层补充测试,锁定既有 `-9999.0` sentinel 行为,为解码层保留 NaN/-inf 提供序列化安全网。

// rust/src/server/src/routes/openai/utils/logprobs.rs
// 验证 OpenAI chat 响应边界对 NaN 与负无穷的既有 sentinel 转换:
// 解码层保留的 NaN/-inf 在这里统一变成 -9999.0,避免 JSON 出现非法数值。
#[test]
fn chat_logprobs_clamps_nan_and_negative_infinity_for_json() {
    let mut logprobs = sample_logprobs();
    logprobs.positions[0].entries[0].logprob = f32::NAN;
    logprobs.positions[0].entries[0].rank = 0;
    logprobs.positions[0].entries[1].logprob = f32::NEG_INFINITY;
​
    let response = decoded_logprobs_to_openai_chat(&logprobs, -1, false).unwrap();
    let json = serde_json::to_string_pretty(&response).unwrap();
​
    // 快照预期:content[0].logprob 与 top_logprobs 中 A/B 均为 -9999.0,
    // 有限值 C = -2.0 保持不变,bytes 65/66/67 对应 "A"/"B"/"C"。
    expect_test::expect![[r#"
        {
          "content": [
            {
              "token": "A",
              "logprob": -9999.0,
              "bytes": [65],
              "top_logprobs": [
                { "token": "A", "logprob": -9999.0, "bytes": [65] },
                { "token": "B", "logprob": -9999.0, "bytes": [66] },
                { "token": "C", "logprob": -2.0, "bytes": [67] }
              ]
            }
          ]
        }
    "#]]
    .assert_eq(&json);
}

评论区精华

解码层是否应提前把非有限 logprobs 转为 -9999.0 设计

BugenZhao 在 review 中质疑第一版实现中对非有限 logprobs 的提前 clamp:前端管线中应保留引擎原始值,序列化前的 sentinel 转换在 serving 层已存在(`rust/src/server/src/routes/openai/utils/logprobs.rs` 的既有逻辑),无需在解码层重复处理,且提前 clamp 会丢失数值信息。

结论:采纳建议。第二个 commit 移除解码层的 -9999.0 映射,改为保留 rank 0 与原始 NaN/-inf 值,统一由 OpenAI 响应序列化层转换。 · 已解决

风险与影响

  1. 解码校验放宽:rank 0 现在会流入 serving 层,任何假设 rank >= 1 的下游逻辑(如排序、展示、结构化输出)需要兼容 rank 0;当前 chat 路径按候选顺序组织 top_logprobs 而不依赖 rank,风险有限,但流式与非 chat 路径未见显式测试。
  2. NaN 序列化覆盖不足:解码层保留 NaN/-inf 后,若某条序列化路径未做 clamp(测试只覆盖 chat JSON 转换),serde_json 可能输出非标准 JSON(NaN 在严格 JSON 中非法),需确认 completions、streaming 等路径的 clamp 行为。
  3. 根因未消除:本修复只解决前端容错,引擎层 NaN logits 的产生根源(如模型数值溢出)仍在,原样保留的 NaN 值可能继续出现在响应数据中对客户端造成困惑,仅靠 -9999.0 约定兜底。

用户与运营:Rust 前端服务不再被单个 NaN logits 请求永久杀死,/health 保持可用,后续请求正常处理;异常请求的响应中 NaN/-inf 以 -9999.0 展示,与 Python 前端行为对齐。系统层面:批量解码不再因单行数据 abort,故障隔离粒度从“进程级”降为“请求级”,降低 MTTF 压力。团队层面:确立了 Rust 前端“解码层保留语义、序列化层做格式转换”的分层职责,后续新增 logprobs 消费方应沿用该约定,避免在两处重复实现 sentinel 逻辑。

单请求可致服务级故障 rank 0 校验放宽 NaN 序列化覆盖不足

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论