# PR #49417 完整报告

- 仓库：`vllm-project/vllm`
- 标题：[Bugfix] MiniCPM-V 4.6: fix ViT self-attn qkv weight loading
- 合并时间：2026-09-14 21:18
- 原文链接：http://prhub.com.cn/vllm-project/vllm/pull/49417

---

# 执行摘要

- 一句话：MiniCPM-V 4.6 qkv 融合映射上移，修复权重加载失败
- 推荐动作：值得精读，尤其是想搞清楚 vLLM 权重加载分层的工程师：本 PR 用 6 行新增讲清了 " 映射该放在哪一层 " 这个反复踩坑的问题。重点看三处：一是 `MiniCPMV4_6ViTWindowAttentionSelfAttn` 为什么不能持有自己的 `hf_to_vllm_mapper`（前缀剥离子模块与映射键层级的错配）；二是去掉前导点会级联成 `qkqkv_proj` 的匹配语义陷阱；三是 hmellor 否决 " 恢复显式 `stacked_params_mapping` 循环 " 的思路、改为上移 mapper 的设计取舍。建议后续补一个纯权重名层面的 CPU 单测（断言 `...self_attn.q_proj.weight` → `...self_attn.qkv_proj.weight` 且 shard id 为 q/k/v），否则该模块仍会在下一次加载框架重构中静默回归。

# 功能与动机

PR 描述开门见山："`openbmb/MiniCPM-V-4.6` currently fails to load under vLLM — engine init aborts, so the model cannot be served at all"。在 main 上报 `ValueError: There is no module or parameter named 'k_proj' in MiniCPMV4_6ViTWindowAttentionSelfAttn`，在已发布的 0.25.x 上同一条代码路径则产出错误的 `qkqkv_proj`。该回归由 #47058（把显式 stacked 循环换成 `WeightsMapper`）引入，#49193 只把 `qkqkv` 变成 `k_proj`，并未真正修好，因此模型长期处于不可用状态。MiniCPM-V 团队成员 tc-mb 在讨论中催办："This model cannot be used in vllm. There have been feedback from different people that everyone in the community cannot be used recently."，修复具有明确的社区可用性动机。

# 实现拆解

1. **定位失败入口**：问题集中在 `vllm/model_executor/models/minicpmv4_6.py` 的 `MiniCPMV4_6ViTWindowAttentionSelfAttn`。该模块把 checkpoint 中分离的 `q_proj`/`k_proj`/`v_proj` 融合为一个 `QKVParallelLinear`（`self.qkv_proj`），原先用类属性 `hf_to_vllm_mapper = WeightsMapper(orig_to_new_stacked={...})` 配合自定义 `load_weights()` 内的 `AutoWeightsLoader(self)` 表达这一融合。
2. **删除错层的映射与加载钩子**：移除该子模块的 `hf_to_vllm_mapper` 与 `load_weights()`，模块退回普通 `nn.Module`。原因是 `AutoWeightsLoader` 已递归进子模块，`load_weights()` 收到的是前缀被剥掉的名字（`"q_proj.weight"`），带前导点的键 `".q_proj"` 无法命中；而去掉前导点后 `orig_to_new_stacked` 会在一次替换中反复匹配，`"q_proj"→"qkv_proj"` 之后 `"v_proj"` 又在新名字里命中，得到 `qkqkv_proj`。两种写法都不可用，所以问题不在于映射内容而在于应用层级。
3. **上移融合规则到模型级 mapper**：在 `MiniCPMV4_6ForConditionalGeneration.hf_to_vllm_mapper` 中新增 `orig_to_new_stacked={".q_proj": (".qkv_proj", "q"), ".k_proj": (".qkv_proj", "k"), ".v_proj": (".qkv_proj", "v")}`，与既有的 `orig_to_new_prefix`（`model.vision_tower.`→`vpm.`、`model.language_model.`→`language_model.model.` 等）并存，并补上结尾逗号。模型级 mapper 由顶层 `AutoWeightsLoader` 应用，看到的仍是完整名字（如 `vit_merger.<i>.self_attn.q_proj.weight`），`.q_proj` 能正确命中且三个键互不为子串，分片标识 q/k/v 会被透传给 `QKVParallelLinear.weight_loader`。
4. **与既有契约保持一致**：模型类上的 `packed_modules_mapping` 已声明 `"qkv_proj": ["q_proj", "k_proj", "v_proj"]`，融合规则上移后，权重加载、LoRA/adapter 以及量化路径对 `qkv_proj` 的认知统一在同一个层级，避免子模块再做一次局部映射造成双重解释。
5. **测试与验证配套**：本 PR 未新增任何单测，验证完全依赖人工端到端复现——hmellor 本地验证后推送 "Move mapper to right place" 提交并请作者复测，arkohut 在 RTX 3090（PR 正文记录为 4090）上 serve 官方 checkpoint，确认 `Model loading took 2.47 GiB memory`、`/v1/models` 返回 `minicpm-v-4_6`、文本对话与 `video_url` base64 视频请求均正确。CI 侧经历多轮 `main` rebase、mergify 冲突提示、pre-commit 失败重跑，最终由 hmellor 触发 Buildkite #88805 后合并。

| 位置 | 改动前 | 改动后 |
| --- | --- | --- |
| `MiniCPMV4_6ViTWindowAttentionSelfAttn` | 类属性 `hf_to_vllm_mapper` + 自定义 `load_weights()` | 两者删除，退回普通模块 |
| `MiniCPMV4_6ForConditionalGeneration.hf_to_vllm_mapper` | 仅 `orig_to_new_prefix` | 新增 `orig_to_new_stacked`（并补 `},`） |

关键文件：
- `vllm/model_executor/models/minicpmv4_6.py`（模块 权重加载；类别 source；类型 data-contract；符号 MiniCPMV4_6ViTWindowAttentionSelfAttn, MiniCPMV4_6ViTWindowAttentionSelfAttn.load_weights, MiniCPMV4_6ViTWindowAttentionSelfAttn.hf_to_vllm_mapper, MiniCPMV4_6ForConditionalGeneration.hf_to_vllm_mapper）: 唯一变更文件。删除 `MiniCPMV4_6ViTWindowAttentionSelfAttn` 上的 `hf_to_vllm_mapper` 与自定义 `load_weights()`，把 q/k/v→`qkv_proj` 的融合规则（`orig_to_new_stacked`）上移到 `MiniCPMV4_6ForConditionalGeneration.hf_to_vllm_mapper`，让顶层 `AutoWeightsLoader` 在完整 checkpoint 名上完成融合，从而修复模型无法加载的问题。

关键符号：MiniCPMV4_6ViTWindowAttentionSelfAttn.load_weights, MiniCPMV4_6ForConditionalGeneration.hf_to_vllm_mapper

## 关键源码片段

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

唯一变更文件。删除 `MiniCPMV4_6ViTWindowAttentionSelfAttn` 上的 `hf_to_vllm_mapper` 与自定义 `load_weights()`，把 q/k/v→`qkv_proj` 的融合规则（`orig_to_new_stacked`）上移到 `MiniCPMV4_6ForConditionalGeneration.hf_to_vllm_mapper`，让顶层 `AutoWeightsLoader` 在完整 checkpoint 名上完成融合，从而修复模型无法加载的问题。

修复后的 ViT window attention 子模块：不再自己持有映射、也不覆写 `load_weights`，融合交由模型级 mapper 统一处理。

```python
class MiniCPMV4_6ViTWindowAttentionSelfAttn(nn.Module):
    # 修复要点 1：这里不再挂 hf_to_vllm_mapper，也不再覆写 load_weights()。
    # 原实现在子模块内用 AutoWeightsLoader(self) 应用映射，但进入子模块后权重名
    # 已被剥掉前缀（形如 "q_proj.weight"），带前导点的键 ".q_proj" 永远匹配不上，
    # 于是 k_proj 找不到、引擎在加载阶段直接退出；
    # 去掉前导点也不行："q_proj" 先被替换成 "qkv_proj"，随后 "v_proj" 又在新名字里
    # 二次命中，最终得到错误的 "qkqkv_proj"。
    def __init__(
        self,
        config,
        quant_config: QuantizationConfig | None = None,
        prefix: str = "",
    ):
        super().__init__()
        use_data_parallel = is_vit_use_data_parallel()
        self.embed_dim = config.hidden_size
        self.num_heads = config.num_attention_heads
        self.head_dim = self.embed_dim // self.num_heads
        self.scale = self.head_dim**-0.5

        tp_size = 1 if use_data_parallel else get_tensor_model_parallel_world_size()
        assert self.num_heads % tp_size == 0
        self.num_heads_per_partition = self.num_heads // tp_size

        # checkpoint 中 q_proj / k_proj / v_proj 是分开的，这里合并为一个 qkv_proj；
        # 融合所需的 q/k/v 分片标识由模型级 hf_to_vllm_mapper 的 orig_to_new_stacked 提供
        self.qkv_proj = QKVParallelLinear(
            self.embed_dim,
            self.head_dim,
            self.num_heads,
            quant_config=quant_config,
            prefix=f"{prefix}.qkv_proj",
            disable_tp=use_data_parallel,
        )
        self.out_proj = RowParallelLinear(
            self.embed_dim,
            self.embed_dim,
            bias=True,
            quant_config=quant_config,
            prefix=f"{prefix}.out_proj",
            disable_tp=use_data_parallel,
        )
        self.attn = MMEncoderAttention(
            self.num_heads_per_partition,
            self.head_dim,
            self.scale,
            prefix=f"{prefix}.attn",
        )

    def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
        qkv, _ = self.qkv_proj(hidden_states)
        q, k, v = qkv.chunk(3, dim=-1)
        attn_out = self.attn(q, k, v)
        out, _ = self.out_proj(attn_out)
        return out

```

修复后的模型级 mapper：融合规则与前缀重写放在同一处，由顶层 `AutoWeightsLoader` 在完整 checkpoint 名上应用。

```python
class MiniCPMV4_6ForConditionalGeneration(
    nn.Module,
    SupportsMultiModal,
    SupportsLoRA,
    SupportsPP,
    HasInnerState,
    IsHybrid,
    SupportsMRoPE,
):
    supports_encoder_tp_data = True

    # 修复要点 2：融合规则上移到模型级 mapper。顶层加载器看到的仍是完整 checkpoint
    # 名（如 "vit_merger.<i>.self_attn.q_proj.weight"），".q_proj" 能正确命中；
    # 且 ".q_proj"/".k_proj"/".v_proj" 互不为子串，不会级联成 "qkqkv_proj"。
    hf_to_vllm_mapper = WeightsMapper(
        orig_to_new_stacked={
            ".q_proj": (".qkv_proj", "q"),  # 分别写入 qkv_proj 的 q/k/v 分片
            ".k_proj": (".qkv_proj", "k"),
            ".v_proj": (".qkv_proj", "v"),
        },
        orig_to_new_prefix={
            # transformers v5.7+ 使用 `vision_tower`，并把 `vit_merger` 嵌在其中；
            # 顺序敏感：更具体的前缀必须排在前面。
            "model.vision_tower.vit_merger.": "vit_merger.",
            "model.vision_tower.": "vpm.",
            "model.vpm.": "vpm.",
            "model.vit_merger.": "vit_merger.",
            "model.merger.": "merger.",
            "model.language_model.": "language_model.model.",
            "lm_head.": "language_model.lm_head.",
            "mtp.": None,
        },
    )

```

# 评论区精华

1）最有价值的交锋是 **实现路线的替换**。作者最初主张 "Restore the explicit `stacked_params_mapping` loop with `break`"，即恢复 #47058 之前的显式融合循环；hmellor 直接以 CHANGES_REQUESTED 否决："I still don't want to add back the `load_weights` boilerplate. I've just noticed that this `load_weights` method is on the Attention module, not the model. That's why it's not working, it's ben applied in the wrong place." 他亲自推送 `63645932` "Move mapper to right place"，把映射搬到模型级并删掉子模块的 `load_weights()`，最终合入的正是这一版。
2）**验证闭环**：hmellor "@arkohut please can you try the fix I just pushed? I've tested it locally and it works for me"，作者复测确认 "this does the trick... No more `k_proj` / `qkqkv_proj`. LGTM"。
3）**上游催办**：MiniCPM-V 团队成员 tc-mb 两度发声，指出模型对社区完全不可用，并暗示愿意另开 PR 尽快合入；hmellor 回应 CI 卡住并致歉，随后 rebase + 重跑 CI。
4）**风格细节**：hmellor 留下唯一一条行内 review 建议（给 `orig_to_new_prefix` 字典补结尾逗号 `},`），由提交 "Apply suggestion from @hmellor" 落实。
5）**自动化审查缺位**：claude[bot] 注明 "This pull request is from a fork — automated review is disabled"，本 PR 没有机器审查兜底，正确性完全依赖人工复现。

- 融合映射该放在子模块还是模型级 (design): 采纳维护者方案：映射上移模型级，子模块不再持有 mapper、不再覆写 `load_weights()`；作者随后复测通过。
- 修复的端到端验证 (testing): 两位维护 / 贡献者在真实 checkpoint 上确认加载与服务正常；但验证仅限人工 serve，无自动化测试固化。
- 上游模型方催办与合并节奏 (other): 在外部压力下加快节奏：多轮 rebase、修 pre-commit、重跑 Buildkite #88805 后合并。
- 字典结尾逗号风格建议 (style): 由提交 "Apply suggestion from @hmellor"（61b5935）采纳。
- fork PR 缺少自动化代码审查 (testing): 未启用一次性机器审查，本次改动的正确性判断完全依赖维护者与作者的本地复现。

# 风险与影响

- 风险：
 - **映射作用域扩大（主要风险）**：`orig_to_new_stacked` 从 ViT window attention 子模块上移到 `MiniCPMV4_6ForConditionalGeneration` 后，凡是名字里含 `.q_proj`/`.k_proj`/`.v_proj` 的权重都会被这条规则接管，包括语言主干与 merger 中的同名权重。需要确认这些权重同样期望融合进 `qkv_proj` 且分片顺序为 q/k/v；良性证据是人工验证包含文本对话且回复连贯，说明主干未被打乱，但这一结论没有自动化断言支撑。
 - **缺少测试覆盖**：新增 0 个测试文件，也没有针对 `MiniCPMV-V 4.6` 权重名映射的单元测试。回归已经发生过两次（#47058 引入、#49193 修一半），说明该路径在 CI 中不可见，任何 `WeightsMapper` 语义调整都可能再次静默破坏加载。
 - **对 `WeightsMapper` 语义的隐式依赖**：修复成立的前提是带前导点的键在完整名字上按预期匹配，且三个键不会互相级联替换。这是对工具类匹配语义的隐性契约，一旦匹配实现改为 `endswith` 式或收紧子串规则，`.q_proj` 这类写法需要重新评估。
 - **子模块加载钩子被删除的兼容性**：`MiniCPMV4_6ViTWindowAttentionSelfAttn.load_weights()` 被移除，若 LoRA/adapter 或量化加载依赖该子模块的自定义加载行为，理论上会受影响；PR 明确声称 "no shared machinery is touched"，且 `supports_encoder_tp_data`/`is_vit_use_data_parallel()` 的 TP/DP 分支未改动，实际风险较低但未在测试中覆盖。
 - **流程风险**：8 个提交里多数是 rebase 合并提交，期间出现 merge conflict、pre-commit 失败与 CI 卡住，修复从提出到合入被拖延数周，单模型 bugfix 的反馈链路明显偏长。
- 影响：
 - **用户侧**：直接解除 `openbmb/MiniCPM-V-4.6` 的 " 完全不可服务 " 状态，文本与视频（`video_url` base64）请求均恢复；受影响面是所有尝试部署该模型的社区用户，正是 tc-mb 反馈的群体。
 - **系统侧**：改动限定在 `vllm/model_executor/models/minicpmv4_6.py` 单文件，不触碰 `AutoWeightsLoader`、`WeightsMapper` 等共享机制，对其他模型零影响；TP/DP 与量化路径因 `QKVParallelLinear` 仍通过 stacked shard id 加载而保持行为一致。
 - **维护侧**：确立了一条可复用的判断规则——权重融合映射必须放在能看到完整 checkpoint 名的模型级 `hf_to_vllm_mapper`，而不是子模块的 `load_weights()`，对后续新增多模态模型有示范意义。
 - **无测试带来的长期影响**：模型可用性依赖人工 serve 验证，后续重构缺乏回归护栏，同等事故可能再次发生。
 - 风险标记：映射作用域扩大到整模型 , 缺少自动化测试覆盖 , 依赖 WeightsMapper 匹配语义 , 删除子模块加载钩子 , 验证仅靠人工复现

# 关联脉络

- PR #47058 （标题未在上下文中提供；PR 描述指明其把显式 stacked 循环替换为 `WeightsMapper`）: 本 PR 修复的回归来源：该改动用 `WeightsMapper(orig_to_new_stacked=...)` 替换了原有的显式融合循环，使 MiniCPM-V 4.6 的 ViT qkv 加载路径失效。
- PR #49193 （标题未在上下文中提供；PR 描述指明其把 `qkqkv` 变为 `k_proj`）: 在 0.25.x 上把报错从 `qkqkv_proj` 变成 `k_proj`，但未真正解决 MiniCPM-V 4.6 的加载失败，本 PR 才是完整修复。
- PR #55071 [LoRA][Refactor] Unify multimodal LoRA token count hooks: 同属 `vllm/model_executor/models/` 下多模态模型的接口 / 加载路径统一工作，同样处理 " 子模块接口放置层级 " 问题，与本 PR 的 " 映射该放哪一层 " 主题呼应。
- PR #56741 [Refactor] Normalize DeepSeek V4.1 model package naming: 同期的模型定义与注册规范化改动，反映仓库正在统一模型文件命名与加载契约；本 PR 同样属于模型定义层的一致性维护。