# PR #42887 完整报告

- 仓库：`vllm-project/vllm`
- 标题：[Bugfix] Fix top logprobs token placeholders in `/inference/v1/generate`
- 合并时间：2026-05-19 18:21
- 原文链接：http://prhub.com.cn/vllm-project/vllm/pull/42887

---

# 执行摘要

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

# 功能与动机

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

# 实现拆解

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:262` 和 `token_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`（模块 请求路由；类别 source；类型 core-logic）: 核心修复文件，修复了 top_logprobs 替代项 token 占位符错误问题。
- `tests/entrypoints/serve/disagg/test_tokens_logprobs.py`（模块 测试；类别 test；类型 test-coverage；符号 test_top_logprobs_alternatives_have_own_token_ids, test_logprobs_zero_emits_sampled_token）: 新增的单元测试文件，覆盖修复的核心场景和边界条件。

关键符号：_create_tokens_logprobs

## 关键源码片段

### `vllm/entrypoints/serve/disagg/serving.py`

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

```python
    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`

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

```python
# 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

```

# 评论区精华

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

- 暂无高价值评论线程

# 风险与影响

- 风险：风险很低。变更仅影响 `_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 功能的正确性，属于提前修复的隐患。

- 风险标记：未来功能依赖此修复 , 无用户受影响

# 关联脉络

- PR #24261 TODO: placeholder: PR body 提及该 PR 引入了 disaggregated pipeline，是此 bug 的上下文来源
- PR #42729 TODO: placeholder: 同样与 disaggregated pipeline 相关，PR body 提及