# PR #55071 完整报告

- 仓库：`vllm-project/vllm`
- 标题：[LoRA][Refactor] Unify multimodal LoRA token count hooks
- 合并时间：2026-09-14 21:19
- 原文链接：http://prhub.com.cn/vllm-project/vllm/pull/55071

---

# 执行摘要

- 一句话：统一多模态 LoRA token 计数钩子，20+ 模型迁移到新接口
- 推荐动作：值得精读，尤其是维护多模态模型接入和 LoRA 子系统的工程师。建议重点关注三处：`vllm/model_executor/models/interfaces.py` 的契约定义与弃用策略、`ultravox.py` 中“不再反解 chunk 数”的语义澄清（最容易被忽略的行为差异）、以及 `blip2.py` / `llava_next_video.py` 的整除与守卫改动。阅读时建议对照每个模型旧钩子的链式调用结果做一次等价性核对；同时把 Codex 的 P2 作为待办跟进——要么在已迁移模型上补 legacy 包装，要么明确公告旧 API 在本次发布中失效；并推动一个后续 PR 完成 legacy 钩子的最终移除与 `modality` / `mm_kwargs` 入参的实际落地验证。

# 功能与动机

PR body 明确指出动机：多模态 tower/connector LoRA 的运行时 token 计数入口已经是 `get_mm_lora_token_counts()`，但很多模型实现仍停留在旧的两个拆分钩子上，作者原话是 “The split interface cannot cleanly represent modality-specific behavior or token counts derived from actual multimodal inputs.”。拆分接口要求调用方做链式推导（先算 `get_num_mm_encoder_tokens()`，再把结果喂回 `get_num_mm_connector_tokens()`），connector 计数只能从编码器计数反解，一旦两个方向不严格互逆就会失真（典型如 Ultravox 的 frame stacking 要把每 chunk pad 到 `stack_factor` 的整数倍）；同时旧签名既没有 `modality` 也没有真实 `mm_kwargs`，无法表达模态差异或按实际输入推导的 token 数。因此本 PR 的目标是统一到新接口，并按 body 所述 “keep the legacy hooks temporarily and mark them as deprecated instead of removing them immediately” 保留一个弃用过渡窗口，而不是直接删除旧 API。需要留意的是 PR body 的 Purpose / Test Plan / Test Result 三节均为空，测试证据完全依赖 CI。

# 实现拆解

1. **契约收敛（`vllm/model_executor/models/interfaces.py`）**：确立 `get_mm_lora_token_counts(*, modality, mm_kwargs, num_mm_embeds) -> tuple[int, int | None]` 为唯一入口，一次返回 tower 侧与 connector 侧 token 数；旧钩子 `get_num_mm_encoder_tokens()` / `get_num_mm_connector_tokens()` 加 `@deprecated` 装饰器并保留省略号占位实现，形成弃用窗口。影响面是全部多模态 LoRA 模型实现者：新增模型只需实现一个钩子。

2. **约 20 个内置模型实现替换**：每个模型把“两个方法 + 一次链式反解”合并为“一个方法返回二元组”，代表文件包括 `llava_next_video.py`、`blip2.py`、`gemma3_mm.py`、`h2ovl.py`、`internvl.py`、`mllama4.py`、`ultravox.py`、`openvla.py`、`pixtral.py`、`dots_ocr.py`、`idefics3.py`、`llava.py`、`qwen2_vl.py`、`qwen2_5_vl.py`、`qwen3_vl.py`、`glm4_1v.py`、`lfm2_vl.py`、`keye.py`、`moondream3.py`、`paligemma.py`。多数改动的返回值与原链式结果等价，例如 InternVL/H2OVL 由同一个 `num_patches` 派生出 `num_patches * (patch_tokens + 1)` 与 `num_patches * num_image_token`，Pixtral/DotsOCR 由 `num_mm_embeds * merge_size**2` 与 `num_mm_embeds` 派生，OpenVLA 直接返回 `(num_mm_embeds, num_mm_embeds)`。

3. **顺带修正的数值与边界语义**：`blip2.py` 把 `num_image_tokens / num_query_tokens` 的浮点除法改为整除并删除 connector 方向断言；`llava_next_video.py` 新增 `patch_tokens_per_frame <= 0` 的短路守卫；`ultravox.py` 彻底去掉“先把 tower token 数反解成 chunk 数”的往返，并在注释里把 connector 每 chunk 的 `ceil(max_source_positions / stack_factor)`（whisper 为 188）与朴素的 `tower_tokens // stack_factor`（187）区分开；`gemma3_mm.py` 保留 `num_image_tokens * 16` 的展开语义但把 30 行 docstring 压缩为 3 行。这些是本 PR 中唯一可能改变数值行为的部分，值得逐点复核。

4. **兼容回落与弃用窗口**：提交序列为 `init` → `add deprecated` → `fallback`，说明作者在收到反馈后补了兼容路径，让仍只实现 legacy 钩子的实现方继续可用；但提供的材料没有展示该回落逻辑的落点文件，无法确认它是否覆盖“已迁移内置模型 + 外部直调旧 API”这一组合。

5. **测试与配套**：材料显示源码与测试同时改动（23 个文件中除接口与约 20 个模型外还有测试文件），但未列出具体测试路径；CI 侧由 Isotr0py 触发 `/ci run`，Buildkite #88789 有 1 个失败任务被作者 `/ci retry` 重试，最终并入 main。

关键文件：
- `vllm/model_executor/models/interfaces.py`（模块 模型接口；类别 source；类型 data-contract；符号 get_num_mm_encoder_tokens, get_num_mm_connector_tokens, get_mm_lora_token_counts）: 契约收敛点：新统一入口 `get_mm_lora_token_counts` 在此定义，旧拆分钩子在此加 `@deprecated` 保留过渡；Codex 的 P2 兼容性讨论（line 426）正落在此文件。
- `vllm/model_executor/models/ultravox.py`（模块 多模态模型；类别 source；类型 data-contract；符号 get_num_mm_encoder_tokens, get_num_mm_connector_tokens, get_mm_lora_token_counts）: 语义最微妙的一处：新实现彻底去掉“先把 tower token 数反解成 chunk 数”的往返，并在注释中澄清 connector 每 chunk 188 与朴素相除得到 187 的差别，是唯一把旧反解链路本质问题显性化的文件。
- `vllm/model_executor/models/llava_next_video.py`（模块 多模态模型；类别 source；类型 data-contract；符号 get_num_mm_encoder_tokens, get_num_mm_connector_tokens, get_mm_lora_token_counts）: 展示了新契约的核心收益：同一份 `num_mm_embeds` 同时逆推 vision encoder 与 projector 两种粒度（`patch_grid_length²` / `pooled_grid_length²`），并新增 `patch_tokens_per_frame <= 0` 守卫。
- `vllm/model_executor/models/blip2.py`（模块 多模态模型；类别 source；类型 data-contract；符号 get_num_mm_encoder_tokens, get_num_mm_connector_tokens, get_mm_lora_token_counts）: 唯一改动数值运算类型的模型：浮点除法 `/` 改为整除 `//`，并删除 connector 方向断言，是核对等价性时必须确认的一处。
- `vllm/model_executor/models/gemma3_mm.py`（模块 多模态模型；类别 source；类型 data-contract；符号 get_num_mm_encoder_tokens, get_num_mm_connector_tokens, get_mm_lora_token_counts）: 展示了“等价迁移 + 文档收缩”的极端案例：`num_image_tokens * 16` 的展开语义被完整保留，但 30 行 docstring 被压缩成 3 行，旁边紧邻 `SupportsEncoderCudaGraph` 相关方法。
- `vllm/model_executor/models/internvl.py`（模块 多模态模型；类别 source；类型 data-contract；符号 get_num_mm_encoder_tokens, get_num_mm_connector_tokens, get_mm_lora_token_counts）: 基类实现被约 20 个衍生模型复用（含 H2OVL），其 `num_patches` 派生逻辑是新契约能否保持等价的基准点。
- `vllm/model_executor/models/h2ovl.py`（模块 多模态模型；类别 source；类型 data-contract；符号 get_num_mm_encoder_tokens, get_num_mm_connector_tokens, get_mm_lora_token_counts）: 继承场景的代表：直接删除子类覆写，依赖父类 `InternVLChatModel` 的新实现，是继承链风险最集中处。
- `vllm/model_executor/models/mllama4.py`（模块 多模态模型；类别 source；类型 data-contract；符号 get_num_mm_encoder_tokens, get_num_mm_connector_tokens, get_mm_lora_token_counts）: 换算最复杂的一处：`raw_patches + 1`（含 CLS）与 `get_patch_per_chunk` 的组合被压成一次 `num_chunks` 计算，斜杠相关注释是理解连接器语义的关键。

关键符号：get_mm_lora_token_counts, get_num_mm_encoder_tokens, get_num_mm_connector_tokens, UltravoxModel.get_mm_lora_token_counts, Blip2ForConditionalGeneration.get_mm_lora_token_counts, LlavaNextVideoForConditionalGeneration.get_mm_lora_token_counts, Mllama4ForConditionalGeneration.get_mm_lora_token_counts, InternVLChatModel.get_mm_lora_token_counts

## 关键源码片段

### `vllm/model_executor/models/interfaces.py`

契约收敛点：新统一入口 `get_mm_lora_token_counts` 在此定义，旧拆分钩子在此加 `@deprecated` 保留过渡；Codex 的 P2 兼容性讨论（line 426）正落在此文件。

```python
# vllm/model_executor/models/interfaces.py

# 统一入口：一次调用返回 (tower 侧 token 数 , connector 侧 token 数 )，
# 调用方不再需要“先算编码器 token、再把它喂回 connector 钩子”的链式推导。
# `modality` 与 `mm_kwargs` 为后续按模态 / 按真实预处理结果计算留出的入参。
#
# 旧的 `get_num_mm_connector_tokens` 与下面的编码器钩子同构处理：
# 保留声明 + 打上 @deprecated，默认实现是 `...`。
@deprecated(
    "get_num_mm_encoder_tokens is deprecated; use get_mm_lora_token_counts instead."
)
def get_num_mm_encoder_tokens(self, num_image_tokens: int) -> int:
    # 迁移后的内置模型不再覆写它，因此外部仍直调旧 API 时会拿到 None ——
    # 这正是 Codex 评审中标记的 P2 兼容性缺口。
    ...

```

### `vllm/model_executor/models/ultravox.py`

语义最微妙的一处：新实现彻底去掉“先把 tower token 数反解成 chunk 数”的往返，并在注释中澄清 connector 每 chunk 188 与朴素相除得到 187 的差别，是唯一把旧反解链路本质问题显性化的文件。

```python
# vllm/model_executor/models/ultravox.py
def get_mm_lora_token_counts(
    self,
    *,
    modality: str,
    mm_kwargs: MultiModalKwargsItem | None,
    num_mm_embeds: int,
) -> tuple[int, int | None]:
    # Ultravox 的 token 数只取决于占位符数量，暂时用不到模态与实际输入。
    del modality, mm_kwargs

    # 启用 tower/connector LoRA 时会强制 `pad_audio_to_max_context`，
    # 因此每个 chunk 在 LoRA 包装的线性层上恒定跑 `max_source_positions`
    # 个 token，与有效帧数无关。
    num_chunks = math.ceil(num_mm_embeds / self._get_max_tokens_per_chunk())
    tower_tokens = num_chunks * self.config.audio_config.max_source_positions

    # connector 跑在按 `stack_factor` 堆叠后的 tower 输出上：
    # 堆叠会把每个 chunk pad 到 `stack_factor` 的整数倍，
    # 所以每 chunk 是 `ceil(max_source_positions / stack_factor)`
    #（whisper 为 188），而不是 `tower_tokens // stack_factor`（187）。
    # 旧实现必须先由 tower token 数反解出 chunk 数才能算 connector，
    # 新接口直接复用同一个 `num_chunks`，消除了这次有损回代。
    connector_tokens = num_chunks * self._get_max_tokens_per_chunk()
    return tower_tokens, connector_tokens

```

### `vllm/model_executor/models/llava_next_video.py`

展示了新契约的核心收益：同一份 `num_mm_embeds` 同时逆推 vision encoder 与 projector 两种粒度（`patch_grid_length²` / `pooled_grid_length²`），并新增 `patch_tokens_per_frame <= 0` 守卫。

```python
# vllm/model_executor/models/llava_next_video.py
def get_mm_lora_token_counts(
    self,
    *,
    modality: str,
    mm_kwargs: MultiModalKwargsItem | None,
    num_mm_embeds: int,
) -> tuple[int, int | None]:
    del modality, mm_kwargs

    # 反向还原 `vision_resampler` 做的空间池化：
    # 语言模型占位符里每帧只贡献 `pooled_grid_length ** 2` 个 token，
    # 但它离开 vision encoder 时每帧是 `patch_grid_length ** 2` 个 token。
    pooled_tokens_per_frame = self.pooled_grid_length**2
    patch_tokens_per_frame = self.patch_grid_length**2

    # 相比旧实现多了一层 `patch_tokens_per_frame` 守卫：
    # 两个方向共用同一次校验，避免除零后再继续推导。
    if (
        num_mm_embeds <= 0
        or pooled_tokens_per_frame <= 0
        or patch_tokens_per_frame <= 0
    ):
        return 0, 0

    num_frames = num_mm_embeds // pooled_tokens_per_frame

    # 返回值顺序固定为 (tower tokens, connector tokens)：
    # projector 是长度保持的，且运行在 `vision_resampler` 之后，
    # 因此 connector 侧仍是每帧 `pooled_grid_length ** 2`。
    return (
        num_frames * patch_tokens_per_frame,
        num_frames * pooled_tokens_per_frame,
    )

```

# 评论区精华

讨论量很少（4 条 Issue 评论、1 条 review comment），主要交锋只有一条：Codex 机器人在 `interfaces.py:426` 提出 P2 级兼容性意见——被标为 deprecated 的旧钩子在迁移后的模型上会解析到协议默认实现（`...` 体返回 `None`），任何在弃用窗口内仍使用旧 API 计算 token 大小的外部集成都会失败，建议在真正移除前保留兼容包装；材料中没有作者回复或修复记录。claude[bot] 因这是 fork PR 而跳过自动审查，最终 Isotr0py 以 “LGTM! Sorry for the late response!” 批准合并，属于“单一维护者快速通道”式合并。

- 已迁移模型上 deprecated 旧钩子返回 None，破坏弃用窗口内的外部调用方 (correctness): 材料中没有作者回复或修复记录，PR 随后被批准合并；作者在此之前的 `fallback` commit 是否覆盖该组合无法从材料确认，该风险在合并时仍开放。
- 旧拆分钩子是立即删除还是保留弃用窗口 (design): 方案被维护者 Isotr0py 以 “LGTM! Sorry for the late response!” 接受，明确了“先弃用、后移除”的两步走节奏，但移除时间点未在讨论中确定。
- 审查覆盖不足与 PR 描述缺项 (testing): 在约 20 个模型同步改造的变更规模下，验证证据偏薄，尤其是 blip2 整除、ultravox 反解移除、llava_next_video 新守卫这三处数值边界缺少显式测试说明。

# 风险与影响

- 风险：
 1. **接口兼容缺口（未解决）**：`vllm/model_executor/models/interfaces.py:426` 处旧钩子的默认实现是 `...`，迁移后的模型类不再覆写，外部集成直调 `get_num_mm_encoder_tokens()` / `get_num_mm_connector_tokens()` 会拿到 `None`；`None` 参与后续 token 尺寸计算时可能静默算错而非直接报错。第三个 commit（`fallback`）似乎提供了回落路径，但材料未展示其是否覆盖该组合，合并时也未见针对 Codex 意见的回复。

2. **数值语义变化点**：`blip2.py` 的 `/` → `//` 影响返回类型（旧实现注解为 `int` 但实际返回浮点）；`ultravox.py` 的 connector 计数不再经由 tower 计数反解；`llava_next_video.py` 新增了 `patch_tokens_per_frame <= 0` 的提前返回。这三处是唯一可能改变运行时数值的改动，恰好也是 tower/connector LoRA 映射最容易出错的位置（错位通常表现为精度下降而非崩溃，难以发现）。

3. **大范围同步改造的回归面**：单 PR 触及约 20 个模型文件，覆盖 image / video / audio 三类模态与大量继承关系（`H2OVLChatModel` 继承 `InternVLChatModel` 后不再单独覆写钩子，依赖父类属性 `num_image_token`、`patch_tokens`），任何属性初始化顺序或命名差异都会在运行时才暴露。

4. **参数预埋未验证**：新签名要求 `modality` 与 `mm_kwargs`，但所有内置实现都直接 `del modality, mm_kwargs`，即这套“支持模态感知”的能力尚无任何内置用例验证；返回值第二个元素声明为 `int | None`，材料中也未见消费方如何处理 `None`。

5. **审查覆盖不足**：fork PR 禁用 claude 自动审查，Codex 之外只有一句 LGTM；PR body 的 Test Plan / Test Result 为空，缺少人工验证证据。
- 影响：对使用者：多模态 + LoRA（tower/connector LoRA）用户直接受影响，尤其是使用 LLaVA、InternVL、Qwen-VL、Gemma3、Mllama4、Ultravox、Pixtral、BLIP2 等内置模型的场景；若 token 计数与旧链式结果不一致，LoRA 参数映射会错位，通常表现为精度退化而非显式报错。对外部扩展：仍只实现 legacy 钩子的第三方模型依赖 `fallback` 回落路径存活，而依赖“已迁移模型仍提供旧钩子”的调用方在弃用窗口内就可能拿到 `None`。对系统：这是 LoRA 子系统与多模态模型层之间的数据契约变更，运行时代码路径本身未改，风险集中在契约边界而非热路径性能。对团队：模型接入侧收益明确——新增多模态模型从“实现两个钩子并保证互逆”降为“实现一个返回二元组的方法”，`gemma3_mm.py` 等文件的样板注释大幅收缩；代价是需要在后续版本完成旧钩子的真正移除，并补齐外部扩展的兼容验证。
- 风险标记：接口兼容缺口 , 旧 API 返回 None, 20+ 模型同步改造 , 数值边界变化 , 缺外部扩展验证 , 审查覆盖不足

# 关联脉络

- PR #56741 [Refactor] Normalize DeepSeek V4.1 model package naming: 同为跨模型包的大范围机械式重构：同步修改多个模型文件与 `registry.py` 引用，可对照其“批量改名 + 同步引用”的执行与验证方式；两者都可能引入引用 / 继承层面的静默回归。
- PR #55176 [Frontend] Replace `VLLM_ENABLE_SCALE_OUT_ENDPOINTS` with `--enable-scale-out`: 弱关联：同样是“替换旧机制、保留过渡兼容”的契约迁移范式，其测试与弃用策略可作为本 PR 中 legacy 钩子过渡窗口的对照参考。