Prhub

#53939 [Bugfix][Rust Frontend] Fix LogprobsTensors wire schema mismatch

原始 PR 作者 jungjiyu 合并时间 2026-08-27 07:16 文件变更 4 提交数 3 评论 9 代码增减 +56 / -3

执行摘要

修复 Rust 前端 LogprobsTensors 线协议字段不匹配

53932 记录 Rust Frontend OpenAI Coverage 中 test_prompt_logprobs_e2e_server 稳定失败:请求带 prompt_logprobs 的 /v1/completions 返回 HTTP 500。根因是 #52242 为 Python LogprobsTensors 追加了第五个位置字段 cu_num_generated_tokens_tensor,而 NamedTuple 序列化会包含全部位置字段,Rust 侧 Deserialize_tuple 仍期望 4 元素导致解码失败。PR body 明确选择 '#53886 addresses the same regression by reverting #52242. This PR instead keeps #52242 and applies the narrower Rust-side fix that #53886 itself recommends',即保留特性、在 Rust 侧做兼容修复。

值得精读,尤其适合关注 vLLM Rust 前端与 Python engine-core 之间 msgpack 线协议演进的工程师。核心看点:(1) NamedTuple 位置序列化 + serde(default) 尾部字段的组合如何同时保证新旧负载兼容;(2) 对「当前解码器尚不能处理的数据」选择显式拒绝而非静默接受的设计取舍;(3) 跨语言协议变更的测试配套模式(Rust 单元测试 + Python fixtures 双覆盖)。

讨论亮点
  1. python_compat fixtures 对齐(therealnaveenkamal 提出):src/tests/python_compat.py 仍编码 4 元素 LogprobsTensors,建议 bump 到 5 元素使 fixtures 与 #52242 后的真实负载一致;作者随即更新两个 prompt-logprobs fixtures 并标注字段名,本地 pre-commit 通过。
  2. 修复路线之争(#53886):回滚 #52242 虽能快速解阻塞 CI,但更优方案是给 WireLogprobs 增加第五字段;本 PR 采纳该路线,njhill 与 tlrmchlsmth 均核准。

实现拆解

  1. 线协议结构体扩字段:在 rust/src/engine-core-client/src/protocol/logprobs/wire.rs 的 WireLogprobs 尾部新增 #[serde(default)] cu_num_generated_tokens_tensor: Option<OpaqueValue>。OpaqueValue 只承接槽位、不解读内容;serde(default) 保证旧 4 字段负载继续可解码。文档中补充「NamedTuple 位置序列化要求 Rust 结构体同步追加尾部字段」的兼容性约定。
  2. 语义解码拒绝非 None:在 rust/src/engine-core-client/src/protocol/logprobs.rs 的 WireLogprobs::resolve() 中新增校验,非 None 的第五字段直接报错;错误消息刻意不打印字段值,因为 opaque 值可能内嵌完整 tensor 数据。
  3. 构造路径配套:同一文件中构造 WireLogprobs 的位置补上 cu_num_generated_tokens_tensor: None,保证 Rust 侧发出的负载也是 5 元素形状。
  4. 回归测试:rust/src/engine-core-client/src/protocol/logprobs/tests.rs 中 prompt-logprobs fixture inline_prompt_logprobs_value 增加尾部 Value::Nil;新增 decodes_five_field_prompt_logprobs_payload 与 rejects_non_none_cu_num_generated_tokens_tensor 两个用例。
  5. Python 兼容 fixtures:rust/src/engine-core-client/src/tests/python_compat.py 中两个 prompt-logprobs fixtures 增加尾部 None 注释,与真实负载对齐;new_logprobs fixtures 保持 4 字段(LogprobsLists 未受 #52242 影响)。
文件 模块 状态 重要度
rust/src/engine-core-client/src/protocol/logprobs/wire.rs 协议层 modified 6.45
rust/src/engine-core-client/src/protocol/logprobs.rs 协议层 modified 6.12
rust/src/engine-core-client/src/protocol/logprobs/tests.rs 协议测试 modified 6.4
rust/src/engine-core-client/src/tests/python_compat.py 兼容测试 modified 4.16

关键符号

WireLogprobs::resolve rejects_non_none_cu_num_generated_tokens_tensor decodes_five_field_prompt_logprobs_payload inline_prompt_logprobs_value

关键源码片段

rust/src/engine-core-client/src/protocol/logprobs/wire.rs data-contract

核心线协议结构体修改:为 `WireLogprobs` 增加第五个 `#[serde(default)]` 字段,是本次修复的关键所在。

// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
​
use serde_tuple::{Deserialize_tuple, Serialize_tuple};
​
use crate::protocol::OpaqueValue;
use crate::protocol::tensor::WireNdArray;
​
/// Python 侧的 `LogprobsLists` / `LogprobsTensors` 在解析 aux frame 引用和
/// raw view 之前对应的 wire 表示。
///
/// 原 Python 定义:
/// <https://github.com/vllm-project/vllm/blob/d5cadcee8641a9fcec15facb5a9157d157daa207/vllm/v1/outputs.py#L30-L106>
///
/// # Wire compatibility(线协议兼容性约定)
///
/// Python 的 `LogprobsLists` / `LogprobsTensors` 是 `NamedTuple`:每个字段
/// 都会按位置序列化,而这里的 `Deserialize_tuple` 会在负载比本结构体更长时
/// 直接硬失败。为保持位置兼容,新增 Python 字段时必须在同一变更中为这里的
/// 结构体追加匹配的尾部 `#[serde(default)]` 字段。
#[derive(Debug, Clone, PartialEq, Serialize_tuple, Deserialize_tuple)]
pub struct WireLogprobs {
    /// 形状为 `[num_positions, max_num_logprobs + 1]` 的 wire 数组。
    pub logprob_token_ids: WireNdArray,
    /// 形状为 `[num_positions, max_num_logprobs + 1]` 的 wire 数组。
    pub logprobs: WireNdArray,
    /// 形状为 `[num_positions]` 的 wire 数组。
    ///
    /// Python 侧 sample logprobs 的字段名是 `sampled_token_ranks`,prompt
    /// logprobs 则是 `selected_token_ranks`;Rust 统一用中性字段名,
    /// 因为两种 payload 共用同一 wire 表示。
    pub token_ranks: WireNdArray,
    /// 仅为兼容 batch 级 Python tensor 而保留。
    /// 按调度切分后的 per-request 输出应在此处传 `None`,语义解码器
    /// 会拒绝任何其他值。
    #[serde(default)]
    pub cu_num_generated_tokens: Option<Vec<usize>>,
    /// `cu_num_generated_tokens_tensor` 是 #52242 给 `LogprobsTensors`
    /// 追加的设备侧生成边界。仅为 wire 兼容保留:`NamedTuple` 即使该槽位
    /// 为 `None` 也会编码出来,因此 5 元素 payload 必须有此字段才能解码。
    /// per-request 输出应为 `None`,语义解码器会拒绝其他值。
    #[serde(default)]
    pub cu_num_generated_tokens_tensor: Option<OpaqueValue>,
}
rust/src/engine-core-client/src/protocol/logprobs.rs core-logic

语义解码路径:`resolve()` 新增对非 `None` 第五字段的 fail-fast 校验,并同步构造路径。

    /// 将 wire 格式的 logprobs 解析为语义化的 [`Logprobs`] 记录:
    /// 查找 aux frames、解码 raw views,并把每一行归并为一个
    /// [`PositionLogprobs`]。
    fn resolve<Frame>(self, frames: &[Frame], field_prefix: &str) -> Result<Logprobs>
    where
        Frame: AsRef<[u8]>,
    {
        // batch 级索引 cu_num_generated_tokens 不允许出现在 per-request 输出中
        if let Some(indices) = self.cu_num_generated_tokens {
            bail_ext_value_decode!(
                "{field_prefix}.cu_num_generated_tokens: expected None for per-request engine-core logprobs payload, got {indices:?}"
            );
        }
​
        // #52242 追加的 cu_num_generated_tokens_tensor 同样只能为 `None`。
        // 注意不要像上面那样 Debug 打印值:非 `None` 的不透明值可能内嵌
        // 完整的 tensor 数据块,打印会导致日志爆炸。
        if self.cu_num_generated_tokens_tensor.is_some() {
            bail_ext_value_decode!(
                "{field_prefix}.cu_num_generated_tokens_tensor: expected None for per-request engine-core logprobs payload"
            );
        }
​
        // 通过 aux frame 查找与 raw view 解码,把 wire 数组转成语义记录
        let token_ids = array::decode_array2_u32(
            self.logprob_token_ids,
            &format!("{field_prefix}.logprob_token_ids"),
            frames,
        )?;
        let logprobs = array::decode_array2_f32(
            self.logprobs,
            &format!("{field_prefix}.logprobs"),
            frames,
        )?;
        let token_ranks = array::decode_array1_u32(
            self.token_ranks,
            &format!("{field_prefix}.token_ranks"),
            frames,
        )?;
​
        // 形状一致性校验:token 行 / 列与 logprobs 对齐、token_ranks 长度行数
        if token_ids.rows != logprobs.rows || token_ids.cols != logprobs.cols {
            bail_ext_value_decode!(
                "{field_prefix}: row shape mismatch between token ids ({}, {}) and logprobs ({}, {})",
                token_ids.rows,
                token_ids.cols,
                logprobs.rows,
                logprobs.cols
            );
        }
        if token_ids.rows != token_ranks.len() {
            bail_ext_value_decode!(
                "{field_prefix}: token_ranks length {} does not match row count {}",
                token_ranks.len(),
                token_ids.rows
            );
        }
​
        // 后续逻辑:处理空位列表编码([0, 0] 或 [0, k + 1]),并将每行
        // 分组为 PositionLogprobs,这里省略。
    }

评论区精华

python_compat.py fixtures 未对齐 5 字段负载 测试

therealnaveenkamal 指出 `src/tests/python_compat.py` 仍编码 4 元素 `LogprobsTensors`,建议 bump 到 5 元素使 fixtures 与 #52242 后 Python 真实发送的负载一致。

结论:jungjiyu 更新两个 prompt-logprobs fixtures,补充尾部 `None` 并标注字段名;`new_logprobs` fixtures 保持 4 字段。本地 pre-commit 通过。 · 已解决

修复方案选择:Rust 侧窄修复 vs 回滚 #52242 设计

#53886 以回滚方式解阻塞 CI,但明确指出更窄的方案是给 `WireLogprobs` 加第 5 字段;本 PR 采纳该建议,保留 #52242 特性。

结论:采用 Rust 侧修复,在 `WireLogprobs` 尾部新增带 `#[serde(default)]` 的第五字段,保持与旧 4 字段负载兼容。 · 已解决

非 None 第五字段的处理策略 设计

PR body 说明 `resolve()` 主动拒绝非 `None` 值,且错误信息不包含被拒绝的 opaque 值,因为可能包含完整 tensor payload。

结论:选择 fail-fast 拒绝而非静默接受,避免当前解码器无法处理的数据被悄悄丢弃。 · 已解决

风险与影响

  1. 跨语言协议同步风险:#[serde(default)] 仅能容忍「旧的、更短的负载」,若 Python 侧未来再新增字段而 Rust 未同步,Deserialize_tuple 仍会硬失败;PR 已将同步约定写入 WireLogprobs 文档,后续变更需遵守。
  2. fail-fast 策略:resolve() 对非 None 第五字段直接报错,而当前解码器并不支持该 tensor;这是刻意的保守选择,避免静默丢弃数据。若 DSpark 后续真在 per-request 输出中填充该字段,Rust 前端需要另行实现解码。
  3. 测试覆盖:Rust 单元测试(101 个)全部通过,Python 兼容 fixtures 也已对齐;但作者未在本地运行 H200 E2E,端到端恢复依赖 CI 的 test_prompt_logprobs_e2e_server 覆盖。
  4. 范围外事项:dispatcher 的 decode-error 处理与 CI 依赖覆盖仍由 #53932 跟踪,不因本 PR 关闭。

用户影响:修复 Rust 前端在请求 prompt_logprobs 时 100% 失败(HTTP 500)的回归,启用 #52242 的 DSpark 特性后可正常返回 logprobs。系统影响:保留 #52242 的功能,避免回滚造成功能回退;Rust 解码器对第五字段采用 fail-fast 校验,避免未来负载变化时的静默兼容问题。团队影响:建立了 Python NamedTuple 与 Rust WireLogprobs 位置字段同步的文档约定,并新增回归测试 fixture,降低后续跨语言协议变更的踩坑概率。

跨语言协议同步 decode 失败回归 E2E 未本地验证 fail-fast 拒绝策略

关联 Issue

#53886 Revert "[Feature][DSpark]: Logprobs adaptive verification" (#52242)
#53932 [CI Failure]: (H200) Rust Frontend OpenAI Coverage — WireLogprobs rejects 5-field LogprobsTensors

完整报告

参与讨论