# PR #56573 完整报告

- 仓库：`vllm-project/vllm`
- 标题：[Pooling] Report actual input token usage for scoring APIs
- 合并时间：2026-09-13 21:20
- 原文链接：http://prhub.com.cn/vllm-project/vllm/pull/56573

---

# 执行摘要

- 一句话：/score 与 /rerank 改用引擎实际输入统计 token usage
- 推荐动作：值得一读，但属于小而精的修复而非架构级变更：重点看 `_build_response` 如何把「计量口径」从后处理产物切换到引擎真实输入，以及为什么 cross-encoder 期望值不动、bi-encoder/late-interaction 期望值整体减 1。审阅时建议额外关注两点：一是 `assert ctx.engine_inputs is not None` 是否应替换为显式错误处理，以覆盖 flash late-interaction 与多模态路径；二是是否需要为 `/score`、`/rerank` 的 usage 增加独立单测（对齐 PR body 中 tokenizer 对照脚本），避免未来再次回归到后处理累加。

# 功能与动机

PR body 指出：`/score` 与 `/rerank` 原先基于后处理产物 `PoolingRequestOutput.prompt_token_ids` 计算 usage。对 bi-encoder 和 late-interaction 模型，后处理会为每个 document 复制同一份共享 query，并插入一个合成分隔符 token，于是 usage 被实现细节放大——「Those implementation details inflated reported token usage for 1:N requests even though the engine encoded the query only once」。复现数据给出确定性偏差公式 `(3 - 1) * 5 query tokens + 3 synthetic separator tokens`，即 tokenizer 口径真实总量 21，而 main 上两个端点都返回 34。这种偏差会让下游基于 usage 的计费、配额、可观测性指标与实际编码量脱节，因此需要把口径绑定到「引擎真正提交了什么」。

# 实现拆解

1. **统计入口统一上移到 `_build_response`**（`vllm/entrypoints/pooling/scoring/serving.py`）：旧实现在 `_request_output_to_score_response` 与 `_request_output_to_rerank_response` 内各自 `num_prompt_tokens = 0` 并在循环中累加；新实现改为在 `_build_response` 内先 `assert ctx.engine_inputs is not None`，再对 `ctx.engine_inputs` 逐项调用 `self._extract_prompt_len(engine_input["prompts"])` 求和，得到一次请求级别的真实输入用量。
2. **以显式参数注入两个响应构造函数**：两个函数签名都新增 `num_prompt_tokens: int`，并删除循环内的 `prompt_token_ids = final_res.prompt_token_ids` 与 `num_prompt_tokens += len(prompt_token_ids)`，改为把入参直接填入 `UsageInfo` / `RerankUsage`。这样统计口径只依赖引擎提交的渲染输入，不再受后处理是否复制 query、是否插入分隔符影响；`_extract_prompt_len` 为既有辅助方法，本 PR 未改动其实现。
3. **业务路径保持不变**：`ScoreResponseData`、`RerankResult` 的构造、按 relevance 排序与 `top_n` 截断都未触碰，因此只有 usage 数值变化，分数与输出顺序不变；cross-encoder 的 `engine_inputs` 本来就是 query、document 拼接后的成对输入，累加结果与旧口径一致。
4. **测试配套 **（`tests/models/language/pooling/test_max_tokens_per_doc.py`）：`RERANK_CONFIGS` 中 bi-encoder（`intfloat/multilingual-e5-small`）与 late-interaction（`answerdotai/answerai-colbert-small-v1`）的四个期望值整体下调 1，例如 e5-small 的 `without_truncated_prompt_tokens` 从 `(285, 286)` 变为 `(284, 285)`，ColBERT 从 285/155/155/25 变为 284/154/154/24；cross-encoder（Qwen3 reranker）与 Jina reranker 期望值不变，反证口径修正只作用于共享 query 的场景。`assert_prompt_tokens` 的单值 / 区间容忍逻辑未改。

关键文件：
- `vllm/entrypoints/pooling/scoring/serving.py`（模块 评分服务；类别 source；类型 core-logic；符号 _build_response, _request_output_to_score_response, _request_output_to_rerank_response）: 唯一的源码改动文件，承载 usage 口径从后处理产物切换到引擎真实输入的全部逻辑，包括 `_build_response` 的求和与两个响应构造函数的签名变更。
- `tests/models/language/pooling/test_max_tokens_per_doc.py`（模块 截断测试；类别 test；类型 test-coverage；符号 RERANK_CONFIGS, TestConfig, assert_prompt_tokens）: 唯一测试配套文件，通过下调 bi-encoder 与 late-interaction 的期望 prompt token 数，锁定新的 usage 口径；cross-encoder 与 Jina reranker 期望值不变，构成对照。

关键符号：_build_response, _request_output_to_score_response, _request_output_to_rerank_response

## 关键源码片段

### `vllm/entrypoints/pooling/scoring/serving.py`

唯一的源码改动文件，承载 usage 口径从后处理产物切换到引擎真实输入的全部逻辑，包括 `_build_response` 的求和与两个响应构造函数的签名变更。

```python
    def _build_response(
        self,
        ctx: ScoringServeContext,
    ) -> JSONResponse:
        final_res_batch = ctx.final_res_batch
        request_id = ctx.request_id
        created_time = ctx.created_time
        model_name = ctx.model_name

        # 本次修复的核心：usage 不再从后处理后的 final_res_batch 反推，
        # 而是直接统计真正提交给引擎的渲染输入 engine_inputs。
        # 对 bi-encoder / late-interaction，query 只作为独立输入出现一次，
        # 不会被 1:N 的文档循环重复计数，也不含后处理插入的合成分隔符。
        assert ctx.engine_inputs is not None
        num_prompt_tokens = sum(
            self._extract_prompt_len(engine_input["prompts"])
            for engine_input in ctx.engine_inputs
        )

        if isinstance(ctx.request, ScoreRequest):
            return self._request_output_to_score_response(
                final_res_batch,
                request_id,
                created_time,
                model_name,
                num_prompt_tokens,  # 真实用量改为显式入参，取代函数内的循环累加
            )
        elif isinstance(ctx.request, RerankRequest):
            return self._request_output_to_rerank_response(
                final_res_batch,
                request_id,
                model_name,
                ctx.request.documents,
                ctx.request.top_n if ctx.request.top_n > 0 else len(final_res_batch),
                num_prompt_tokens,
            )
        else:
            raise ValueError(f"Invalid {self.request_id_prefix} request type")

```

```python
    def _request_output_to_rerank_response(
        self,
        final_res_batch: list[PoolingRequestOutput],
        request_id: str,
        model_name: str,
        documents: ScoreInput | list[ScoreInput],
        top_n: int,
        num_prompt_tokens: int,  # 新增：由 _build_response 基于 engine_inputs 计算
    ) -> JSONResponse:
        if not isinstance(documents, list):
            documents = [documents]

        results: list[RerankResult] = []
        for idx, final_res in enumerate(final_res_batch):
            classify_res = ScoringRequestOutput.from_base(final_res)

            document = documents[idx]
            if isinstance(document, str):
                rerank_document = RerankDocument(text=document)
            else:
                rerank_document = RerankDocument(
                    multi_modal=document.get("content", [])
                )

            results.append(
                RerankResult(
                    index=idx,
                    document=rerank_document,
                    relevance_score=classify_res.outputs.score,
                )
            )
            # 旧实现会在这里执行 num_prompt_tokens += len(final_res.prompt_token_ids)；
            # 该字段来自后处理结果，N 个文档会重复计入同一份 query 以及合成分隔符。

        # 排序与 top_n 截断逻辑保持原样，本次只改 usage 口径，不改打分行为
        results.sort(key=lambda x: x.relevance_score, reverse=True)
        if top_n < len(documents):
            results = results[:top_n]

        response = RerankResponse(
            id=request_id,
            model=model_name,
            results=results,
            usage=RerankUsage(
                total_tokens=num_prompt_tokens,
                prompt_tokens=num_prompt_tokens,
            ),
        )
        return JSONResponse(content=response.model_dump())

```

### `tests/models/language/pooling/test_max_tokens_per_doc.py`

唯一测试配套文件，通过下调 bi-encoder 与 late-interaction 的期望 prompt token 数，锁定新的 usage 口径；cross-encoder 与 Jina reranker 期望值不变，构成对照。

```python
    # 3. bi-encoder：usage 口径切换为「引擎真实输入」后，期望总量整体下移 1，
    # 括号区间仍用于容忍 tokenizer 在 CI / 本地缓存下的边界差异。
    TestConfig(
        model="intfloat/multilingual-e5-small",
        args=[
            "--enforce-eager",
            "--max-model-len",
            "512",
            "--trust-remote-code",
        ],
        without_truncated_prompt_tokens=(284, 285),
        with_max_tokens_per_query_prompt_tokens=(154, 155),
        with_max_tokens_per_doc_prompt_tokens=154,
        with_max_tokens_per_query_and_doc_prompt_tokens=24,
    ),
    # 4. late-interaction：同样属于共享 query 的模型，四个期望值同步减 1
    TestConfig(
        model="answerdotai/answerai-colbert-small-v1",
        args=[
            "--enforce-eager",
            "--max-model-len",
            "512",
            "--trust-remote-code",
        ],
        without_truncated_prompt_tokens=284,
        with_max_tokens_per_query_prompt_tokens=154,
        with_max_tokens_per_doc_prompt_tokens=154,
        with_max_tokens_per_query_and_doc_prompt_tokens=24,
    ),
    # 5. jinaai/jina-reranker-v3 与 Qwen3 cross-encoder 的期望值未变，
    # 说明口径修正只影响后处理会复制 query 的模型族

```

# 评论区精华

该 PR 的评审讨论非常轻量，没有逐行技术交锋：

- `claude[bot]` 明确表示自动化 review 被跳过：「This pull request is from a fork — automated review is disabled.」——fork PR 需要维护者手动 `@claude review` 才会触发一次性评审，本 PR 未触发。
- 维护者 `yewentao256` 给出 `APPROVED` 并评论「LGTM, thanks for the work!」，随后完成合并。
- Issue 评论仅包含 CI 触发流程（作者 `/ci run`，github-actions[bot] 返回 Buildkite CI #88545 链接），没有设计层面的质疑或返工要求。

也就是说，评审结论主要建立在 PR body 自带的复现脚本、tokenizer 对照数据（34 → 21）以及作者在 H100 上的端到端手测（e5-small、ColBERT flash/vanilla 路径、TinyBERT cross-encoder）之上，而非 code review 讨论。

- fork PR 自动 review 被禁用，由维护者单点批准 (other): 变更以作者自测数据（tokenizer 对照 34 → 21、三个模型端到端验证）为依据被接受合并，没有产生设计层面的争议或返工。

# 风险与影响

- 风险：
 1. **断言做输入校验的健壮性**：`assert ctx.engine_inputs is not None` 依赖 `-O` 下不被剥离的假设；若某些入口（例如 flash late-interaction 分支或未来新增的 serving 子类）未填充 `engine_inputs`，会直接抛 `AssertionError` 导致请求 500，而在 `python -O` 下更会退化为对 `None` 迭代的 `TypeError`。更稳妥的做法是显式判空并返回结构化错误。
 2. **对外可见的计量语义变更**：`/score` 的 `prompt_tokens` / `total_tokens` 与 `/rerank` 的 `RerankUsage` 在 1:N 场景下会显著变小（示例 34 → 21）。依赖该字段做计费、配额或日志对账的下游必须同步口径，否则会出现「同一个请求用量突然下降」的对账差异。
 3. **对 `engine_input["prompts"]` 结构与 `_extract_prompt_len` 实现的隐式依赖**：多模态 document（`RerankDocument(multi_modal=document.get("content", []))`）的渲染输入结构与纯文本不同，本 PR 未新增多模态 scoring 的 usage 用例，若 `_extract_prompt_len` 对 dict 形态 prompt 处理不周，可能出现少算而非报错，属静默偏差。
 4. **回归保护偏弱**：没有新增专门的 usage 单元测试（如 `tests/entrypoints/pooling/scoring/test_io_processor_unit.py`），只通过 `test_max_tokens_per_doc.py` 的期望值间接覆盖；若日后 usage 又被改回从后处理结果累加，只有这两个模型会暴露问题，cross-encoder 用例无法兜底。
 5. **性能与并发**：改动把 O(N) 的累加从响应构造阶段提前到 `_build_response`，量级相同，无性能回归风险。
 - 影响：**用户侧**：`/score` 与 `/rerank` 返回的 usage 更贴近真实编码量，1:N rerank 场景下数值明显下降；分数、排序、`top_n` 与响应结构不变，因此对只看 score 的调用方零感知，对按 usage 计费或监控的调用方是一次需要同步的口径切换。**系统侧**：改动局限在 API server 进程的响应组装层，不涉及调度器、KV 缓存或 worker 执行路径，无吞吐 / 显存影响；cross-encoder 行为完全保持。**团队侧**：scoring 系列接口的 usage 口径统一为「引擎实际输入」，为后续其它 pooling 端点（embed、pooling）对齐同一计量口径提供了模板与先例。
 - 风险标记：对外计量语义变更 , assert 做输入校验 , 缺少专用单测 , 多模态路径未覆盖

# 关联脉络

- PR #56639 [Pooling] Support prompt embeddings in MRV2 decoder pooling: 同属 MRV2 pooling 请求处理链路的修复，说明 pooling serving 路径近期在持续收敛输入端与后处理之间的语义错位，本 PR 是同一脉络下 usage 口径的补正。
- PR #55508 [Bugfix][Benchmark] Make streaming TTFT/E2E latency accounting consistent across endpoints: 同为「计量口径不一致」类修复：前者统一跨端点计时口径，本 PR 统一 scoring API 的 token 计量口径，设计思路一致（指标必须绑定真实执行事实而非派生数据）。
- PR #56432 [Bugfix][EPD] Preserve explicit multimodal UUIDs with caches disabled: 同样涉及前端渲染 / 输入处理层与后处理语义的对齐问题，且同样以「补单元测试」方式固化行为，可作为同类修复模式的参考。