Prhub

#42887 [Bugfix] Fix top logprobs token placeholders in `/inference/v1/generate`

原始 PR 作者 sagearc 合并时间 2026-05-19 18:21 文件变更 2 提交数 3 评论 2 代码增减 +33 / -3

执行摘要

修复 disagg 服务中 top_logprobs token ID 占位符错误

在 disaggregated pipeline 中(PR #24261, #42729),GenerateResponse 携带 token_id:N 格式的占位符字符串,后续的 derender 步骤会将其解析为真实 token。但由于 bug,所有 top_logprobs 替代项都得到的是被选定 token 的占位符,而不是自身的。PR body 给出了明确的 JSON 示例说明错误行为。

建议合并。该 PR 修复了一个数据损坏 bug,并补充了必要的单元测试,代码简洁清晰。值得精读的是其修复方式——通过修改循环变量解包避免作用域污染,这种命名冲突导致的问题在实际开发中常见,可作为一个教训案例。

讨论亮点

该 PR 的 review 讨论较少,主要来自 gemini-code-assist[bot] 的自动评论确认变更正确,以及 DarkLight1337 的批准。无重大争议。

实现拆解

  1. 修复核心逻辑vllm/entrypoints/serve/disagg/serving.py):在 _create_tokens_logprobs 方法中,列表推导式的循环变量 p 被解读为 (token_id, logprob),但原始代码中 p 实际是 step_top_logprobs.items() 的元组,却错误地复用了外部作用域中已绑定为选定 token ID 的 token 变量。修复方法是将循环变量显式解包为 (token_id, logprob),并使用 f"token_id:{token_id}" 作为 ChatCompletionLogProb.token 的值。
  2. 添加单元测试tests/entrypoints/serve/disagg/test_tokens_logprobs.py):新增两个测试函数:
    • test_top_logprobs_alternatives_have_own_token_ids: 构造包含 3 个候选 token 的输入,请求 num_output_top_logprobs=2,断言返回的两个 token 占位符分别是 token_id:262token_id:257,而不是都等于 token_id:262
    • test_logprobs_zero_emits_sampled_token: 验证当 num_output_top_logprobs=0 时,输出恰好 1 个条目(被选定 token),确保边界正确。
  3. 测试无 GPU 依赖:两个测试都直接调用 ServingTokens._create_tokens_logprobs,不需启动推理,可在 CI 中快速运行。
文件 模块 状态 重要度
vllm/entrypoints/serve/disagg/serving.py 请求路由 modified 5.45
tests/entrypoints/serve/disagg/test_tokens_logprobs.py 测试 added 6.4

关键符号

_create_tokens_logprobs

关键源码片段

vllm/entrypoints/serve/disagg/serving.py core-logic

核心修复文件,修复了 top_logprobs 替代项 token 占位符错误问题。

    def _create_tokens_logprobs(
        self,
        token_ids: GenericSequence[int],
        top_logprobs: GenericSequence[dict[int, Logprob] | None],
        num_output_top_logprobs: int | None = None,
    ) -> ChatCompletionLogProbs:
        """Create OpenAI-style logprobs."""
        logprobs_content: list[ChatCompletionLogProbsContent] = []
​
        for i, token_id in enumerate(token_ids):
            token = f"token_id:{token_id}"
            step_top_logprobs = top_logprobs[i]
            if step_top_logprobs is None or step_top_logprobs.get(token_id) is None:
                logprobs_content.append(
                    ChatCompletionLogProbsContent(token=token))
            else:
                step_token = step_top_logprobs[token_id]
                logprobs_content.append(
                    ChatCompletionLogProbsContent(
                        token=token,
                        logprob=max(step_token.logprob, -9999.0),
                        # 修复:显式解包 (token_id, logprob),避免误用外层
                        # 的 token 变量(即选定 token 的占位符)
                        top_logprobs=[
                            ChatCompletionLogProb(
                                token=f"token_id:{token_id}",
                                logprob=max(logprob.logprob, -9999.0),
                            )
                            for i, (token_id, logprob) in enumerate(
                                step_top_logprobs.items()
                            )
                            if num_output_top_logprobs is not None
                            and i < max(num_output_top_logprobs, 1)
                        ],
                    )
                )
​
        return ChatCompletionLogProbs(content=logprobs_content)
tests/entrypoints/serve/disagg/test_tokens_logprobs.py test-coverage

新增的单元测试文件,覆盖修复的核心场景和边界条件。

# SPDX-License-Identifier: Apache-2.0
from vllm.entrypoints.serve.disagg.serving import ServingTokens
from vllm.logprobs import Logprob
​
​
def test_top_logprobs_alternatives_have_own_token_ids():
    """每个 top_logprobs 替代项必须携带自身的 token_id 占位符"""
    result = ServingTokens._create_tokens_logprobs(
        None,
        token_ids=[262],
        top_logprobs=[{262: Logprob(-0.1), 257: Logprob(-1.2), 428: Logprob(-2.3)}],
        num_output_top_logprobs=2,
    )
    tokens = {e.token for e in result.content[0].top_logprobs}
    # 断言:两个替代项分别对应 token_id:262 和 token_id:257,而不是都等于 token_id:262
    assert tokens == {"token_id:262", "token_id:257"}, f"got {tokens}"
​
​
def test_logprobs_zero_emits_sampled_token():
    """logprobs=0 时仍必须输出 1 个条目(即采样的 token)"""
    result = ServingTokens._create_tokens_logprobs(
        None,
        token_ids=[7],
        top_logprobs=[{7: Logprob(-0.9), 8: Logprob(-1.1)}],
        num_output_top_logprobs=0,
    )
    assert len(result.content[0].top_logprobs) == 1

评论区精华

没有提炼出高价值讨论线程

当前评论区没有形成足够清晰的争议点或结论,后续有更多讨论时会体现在这里。

风险与影响

风险很低。变更仅影响 _create_tokens_logprobs 内部的一小段列表推导式,修改前后行为差异只在 top_logprobs 替代项的 token 占位符字符串上,且当前无消费者依赖这些字符串(derender 尚未实现)。单元测试覆盖了主要场景,包括 num_output_top_logprobs=0 的边界情况。但需要注意:该修复改变了 API 返回的 top_logprobs 中 token 字段的格式,如果未来 derender 实现或现有代码已经(误)依赖旧的错误格式,则可能出现兼容性问题。

影响范围:仅限于 vLLM 的 disaggregated serving 模式下的 /inference/v1/generate 接口。修复后,当客户端请求 logprobs > 1 时,返回的 top_logprobs 中每个替代项会携带正确的 token ID 占位符,而非统一的选定 token 占位符。
影响程度:中等。虽然当前无实际用户受影响,但该 bug 会破坏未来 derender 功能的正确性,属于提前修复的隐患。

未来功能依赖此修复 无用户受影响

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论