# PR #55897 完整报告

- 仓库：`vllm-project/vllm`
- 标题：[LoRA] Add LoRA support for DeepSeek-V4 Flash Vision
- 合并时间：2026-09-13 22:10
- 原文链接：http://prhub.com.cn/vllm-project/vllm/pull/55897

---

# 执行摘要

- 一句话：为 DeepSeek-V4 视觉模型增加 LoRA 支持并修正连续性断言
- 推荐动作：值得精读，尤其是做多模态 LoRA / 后训练链路的同学。建议重点关注三点设计：(1) `get_mm_lora_token_counts` 为什么必须区分 tower 侧 patch token 与 LM 侧 mm embed 两套计数、以及 dummy run 下的倍率反推；(2) tower + connector LoRA 在混合 LoRA 批次下的映射语义是否真的成立（对应 P1 未解决项，建议补多图 + 多适配器的 e2e 用例再上线）；(3) “把 `.contiguous()` 兜底放进 LoRA 算子而不是模型调用点”这一分层取舍——它降低了调用方负担，但把契约变更扩散到了全部 LoRA 前向。若只是使用该模型，按文档打勾的 LoRA 列直接使用即可，但建议先单适配器验证。

# 功能与动机

Issue#55683 的诉求是“requesting to add support for https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash-Vision-Exp for fine tuning.”，即希望该视觉模型可用于微调；PR body 也明确写“Add LoRA support for DeepSeek-V4-Flash-Vision-Exp. Fixes #55683.”。此前 DeepseekV4ForConditionalGeneration 只声明了 SupportsMultiModal / SupportsPP / SupportsEagle3，既不在 LoRA 协议的识别范围内，也缺少 packed 权重映射与多模态 token 计数契约，导致 PEFT 训练出的适配器无法被 vLLM 正确加载与执行。

# 实现拆解

1. **能力声明（vllm/models/deepseek_v4/common/vl_model.py，类 DeepseekV4ForConditionalGeneration）**：基类列表加入 `SupportsLoRA`，使外层 VL 包装类进入 LoRA 管理器的识别与适配器解析范围；新增 `supports_tower_connector_lora = True`，显式放开 vision tower 与 aligner 上的适配器挂载。这是从“只有 language_model 能挂 LoRA”到“视觉分支也可微调”的关键开关，影响所有走 `SupportsLoRA` 协议的能力探测与校验路径。
2. **融合权重映射（packed_modules_mapping）**：新增 `gate_up_proj -> ["w1", "w3"]`、`fused_wqa_wkv -> ["wq_a", "wkv"]`、`fused_wkv_wgate -> ["wkv", "wgate"]`，并补上视觉编码器的 `wqkv -> ["wqkv"]`、`w1 -> ["w1"]` 自映射。原因是 Checkpoint / PEFT 适配器里的名字是融合后的投影名，而 module 上是拆分后的子投影，没有这张表，LoRA 权重无法回填到正确参数；视觉侧自映射项则为 ViT 的融合投影预留挂载点。
3. **多模态 LoRA 契约（get_mm_mapping / get_mm_lora_token_counts）**：`get_mm_mapping()` 通过 `MultiModelKeys.from_string_field(language_model="language_model", connector="aligner", tower_model="vision.")` 告知 LoRA 运行时各前缀的归属，决定适配器按子模块分组以及 `enable_tower_connector_lora` 的生效范围。`get_mm_lora_token_counts()` 返回 `(tower_tokens, num_mm_embeds)`：tower 侧优先取 `mm_kwargs["patches"].data.shape[0]` 的真实 patch 数，dummy / profile 场景退化为 `num_mm_embeds * config.vision_downsample_ratio**2` 反推；语言模型侧直接返回 `num_mm_embeds`。因为 ViT 吃的是下采样前的 patch token，语言模型吃的是 aligner 之后合并进来的 mm embedding，两者差一个 `vision_downsample_ratio**2`，LoRA 需要按两个长度分别切 token 映射。非 `image` 模态直接抛 `ValueError`，与 `get_placeholder_str` 仅支持 image 的现状一致。
4. **算子入口连续性兜底（vllm/lora/ops/triton_ops/lora_shrink_op.py 的 _lora_shrink）**：把 `assert inputs.is_contiguous()` 改为 `inputs = inputs.contiguous()`。触发场景是 ViT 中 `F.unfold(...).squeeze(0).transpose(0, 1)` 产出的非连续张量在 `w1` 被 LoRA 包裹后进入 shrink 算子；review 讨论后没有在模型调用点加 `.contiguous()`，而是把“调用方保证连续”的隐含契约下沉到算子内部。影响所有 LoRA 前向路径：上游不再需要保证连续，代价是非连续输入多一次显式拷贝（连续输入时 `.contiguous()` 是 no-op）。
5. **文档配套与验证缺口**：`docs/models/supported_models.md` 中 `DeepseekV4ForConditionalGeneration` 行的 LoRA 列由空改为 ✅︎，对应 reviewer 的 “do we need to update doc info?” 一问。本 PR 没有任何 `tests/` 变更，验证依赖 Buildkite CI 与人工 review；10 个 commit 中混有 3 次 main 合并和多次 “move” / “pre-commit” 提交，功能改动集中在最后几个 commit。

关键文件：
- `vllm/models/deepseek_v4/common/vl_model.py`（模块 多模态模型；类别 source；类型 data-contract；符号 DeepseekV4ForConditionalGeneration, packed_modules_mapping, supports_tower_connector_lora, get_mm_mapping）: 本 PR 的功能核心：DeepseekV4ForConditionalGeneration 通过 SupportsLoRA、packed_modules_mapping、supports_tower_connector_lora 以及 get_mm_mapping / get_mm_lora_token_counts 完成 LoRA 接入。注意 review 评论记录的文件路径是 vllm/models/deepseek_v4/nvidia/vl_model.py，与最终落地的 common/vl_model.py 指向同一个类定义（PR 含多次 move 提交与 main 合并），代码与符号一致。
- `vllm/lora/ops/triton_ops/lora_shrink_op.py`（模块 LoRA 算子；类别 source；类型 core-logic；符号 _lora_shrink）: 改动只有一行，却把 LoRA shrink 算子的输入契约从“调用方必须保证连续”改为“算子内部自动连续化”，是 review 讨论后确定的修复落点（替代在 ViT 模型侧加 .contiguous()）。
- `docs/models/supported_models.md`（模块 模型文档；类别 docs；类型 documentation）: 把 `DeepseekV4ForConditionalGeneration` 的 LoRA 列由空改为 ✅︎，使支持矩阵与代码能力一致，对应 reviewer 的文档询问。

关键符号：get_mm_mapping, get_mm_lora_token_counts, _lora_shrink

## 关键源码片段

### `vllm/models/deepseek_v4/common/vl_model.py`

本 PR 的功能核心：DeepseekV4ForConditionalGeneration 通过 SupportsLoRA、packed_modules_mapping、supports_tower_connector_lora 以及 get_mm_mapping / get_mm_lora_token_counts 完成 LoRA 接入。注意 review 评论记录的文件路径是 vllm/models/deepseek_v4/nvidia/vl_model.py，与最终落地的 common/vl_model.py 指向同一个类定义（PR 含多次 move 提交与 main 合并），代码与符号一致。

```python
class DeepseekV4ForConditionalGeneration(
    nn.Module,
    SupportsMultiModal,
    SupportsPP,
    SupportsEagle3,
    SupportsLoRA,  # 新增：让外层 VL 包装类本身成为 LoRA 可挂载对象
):
    # 适配器里的名字是融合后的名字，module 上是拆分后的子投影，
    # 没有这张表，LoRA 权重无法回填到正确的参数；
    # 视觉编码器的 wqkv / w1 用自映射留出挂载点。
    packed_modules_mapping = {
        "gate_up_proj": ["w1", "w3"],
        "fused_wqa_wkv": ["wq_a", "wkv"],
        "fused_wkv_wgate": ["wkv", "wgate"],
        # for visual encoder
        "wqkv": ["wqkv"],
        "w1": ["w1"],
    }

    # 显式放开 vision tower 与 aligner 的 LoRA 挂载，
    # 否则只有 language_model 部分能收到适配器。
    supports_tower_connector_lora = True

    def get_mm_mapping(self) -> MultiModelKeys:
        """向 LoRA 运行时声明多模态子模块的前缀归属。"""
        return MultiModelKeys.from_string_field(
            language_model="language_model",  # 文本主干
            connector="aligner",  # 图像 embedding 与文本对齐的 connector
            tower_model="vision.",  # ViT 视觉塔
        )

    def get_mm_lora_token_counts(
        self,
        *,
        modality: str,
        mm_kwargs: MultiModalKwargsItem | None,
        num_mm_embeds: int,
    ) -> tuple[int, int | None]:
        # tower 侧吃下采样前的 patch token，语言模型侧吃 aligner 之后的 mm embedding，
        # 两者数量相差 vision_downsample_ratio ** 2，必须分别返回给 LoRA 运行时。
        if modality != "image":
            raise ValueError(f"Unsupported modality: {modality!r}")

        patches = mm_kwargs.get("patches") if mm_kwargs else None
        if patches is not None and isinstance(patches.data, torch.Tensor):
            # 有真实输入时以实际 patch 数为准，避免与 processor 的展开结果不一致
            tower_tokens = patches.data.shape[0]
        else:
            # dummy run / profile run 没有 patches，用下采样倍率从 mm embed 数反推
            tower_tokens = num_mm_embeds * self.config.vision_downsample_ratio**2

        return tower_tokens, num_mm_embeds

```

### `vllm/lora/ops/triton_ops/lora_shrink_op.py`

改动只有一行，却把 LoRA shrink 算子的输入契约从“调用方必须保证连续”改为“算子内部自动连续化”，是 review 讨论后确定的修复落点（替代在 ViT 模型侧加 .contiguous()）。

```python
# vllm/lora/ops/triton_ops/lora_shrink_op.py
# _lora_shrink(...) 内部的参数校验段

assert weight.dtype in [torch.float16, torch.bfloat16]
assert inputs.size(1) == lora_a_weights[0].size(-1)

# 变更点：原本这里是 assert inputs.is_contiguous()。
# ViT 中 F.unfold(...).squeeze(0).transpose(0, 1) 的产物是非连续张量，
# 一旦 w1 被 LoRA 包裹就会进入本算子，原断言会直接失败；
# review 中 jeejeelee 建议 “If so, we should add this in lora code”，
# 因此把“调用方保证连续”的隐含契约收敛到算子入口兜底，
# 对连续输入而言 contiguous() 是 no-op，不产生额外拷贝。
inputs = inputs.contiguous()

assert output_tensor.is_contiguous()
# metadata sanity check
# ... 之后按 prompt_mapping 切分 token 并调用 Triton shrink 内核

```

# 评论区精华

- **P1（chatgpt-codex-connector[bot]）**：“When `enable_tower_connector_lora` serves a batch containing images from requests with different LoRA IDs, this flag is unsafe … later images receive the first request's adapter (or a mixed prefix), silently corrupting their embeddings.” 指出 v1 多模态路径只安装一份拼接后的 token 映射，而 `_process_image_input` 是逐图调用 `_encode_image`，混合 LoRA 批次下后序图像会取错映射；建议“Process the mapped tokens in one forward or advance/install the mapping per image”。合并时未见作者回复或代码处理。
- **P2（chatgpt-codex-connector[bot]）**：“this newly LoRA-capable outer class does not define `lora_skip_prefixes = ["mtp."]`, so adapter validation sees projections such as `e_proj` and `h_proj` as unexpected and rejects the entire adapter.” 建议与文本主干保持一致，跳过本就被 weights mapper 丢弃的 MTP 投影。
- **jeejeelee 与作者的修复位置之争**：jeejeelee 对 `common/vision.py` 中 `self.w1(x)` 改为 `self.w1(x.contiguous())` 直接发问“QQ: why change this line”；作者解释 `F.unfold(...).transpose` 产物非连续、而 LoRA shrink op 有 `assert inputs.is_contiguous()`，开启视觉 LoRA 会断言失败；jeejeelee 回“If so, we should add this in lora code”，作者回复“addressed”。最终 `.contiguous()` 落在 `lora_shrink_op.py`，模型侧改动被撤销，是本 PR 最有价值的一处分层取舍。
- **文档确认**：jeejeelee 问“do we need to update doc info?”，作者答“Yes, it has been added.”，对应 `supported_models.md` 的 LoRA 列打勾；随后 jeejeelee 以 APPROVED 结束 review。此外 mergify 提示合并冲突、jeejeelee 要求“please address the conflict”，作者通过 `/ci run`、`/ci retry` 驱动 CI（重试 35 个失败 job）后由 vllm-bot 合并。

 - tower/connector LoRA 在混合 LoRA 批次下会取错 token 映射 (correctness): 合并时未看到作者回复或代码处理，`supports_tower_connector_lora = True` 仍按原样合入，属于遗留的静默正确性风险。
 - VL 包装类缺少 lora_skip_prefixes = ["mtp."] (correctness): 建议镜像文本主干的 MTP skip 设置；合并时未见对应改动，使用 all-linear 适配器的用户可能遇到加载被拒。
 - 非连续张量的 contiguous 修复应该放在模型调用点还是 LoRA 算子内 (design): 最终模型侧改动被撤销，`.contiguous()` 下沉到 `lora_shrink_op.py`，所有 LoRA 调用方受益，属于正确的分层决策。
 - 是否需要同步更新支持模型文档 (documentation): 已完成并获 reviewer 认可，jeejeelee 最终 APPROVED。

# 风险与影响

- 风险：
 1. **混合 LoRA 批次下 tower / connector 的 token 映射错配（高）**：`supports_tower_connector_lora = True` 打开后，若同一批次里有带不同 LoRA ID 的图片请求，v1 多模态路径安装的是一份拼接映射，而 `_process_image_input` 逐图调用 `_encode_image`，每张图都会向 Punica 索取前 `x.size(0)` 条映射，后续图像可能拿到第一个请求的适配器，图像 embedding 静默算错且无报错。
 2. **VL 包装类缺少 `lora_skip_prefixes = ["mtp."]`（中高）**：`target_modules="all-linear"` 训练出的适配器若包含 `mtp.*` 投影（`e_proj`、`h_proj` 等），适配器校验会判为 unexpected 并整体拒绝，表现为直接加载失败；文本主干已有该 skip，外层类未同步。
 3. **`_lora_shrink` 行为变更的拷贝开销（中）**：`inputs = inputs.contiguous()` 位于所有 LoRA 前向的 shrink 路径，连续输入无开销，但一旦上游传入非连续张量（如 ViT 的 unfold + transpose 产物）就会多一次显式拷贝，需要观察其对显存与带宽的影响。
 4. **缺少测试覆盖（中）**：无任何测试文件变更，`get_mm_lora_token_counts` 的计数分支（真实 patches / 倍率反推）与 tower LoRA 的映射语义只能靠端到端用例暴露问题，回归成本转嫁给社区。
 5. **packed 映射的维护耦合（低）**：视觉侧 `"wqkv": ["wqkv"]`、`"w1": ["w1"]` 是自映射，若 ViT 权重命名或融合方式调整而未同步，LoRA 挂载会静默失效。
 - 影响：对用户：DeepSeek-V4-Flash-Vision-Exp 从“可推理”变成“可加载 LoRA 适配器微调”，包含 vision tower 与 aligner 的适配器（enable_tower_connector_lora），属于明确的能力解锁；但多份不同 LoRA 与图片请求混合的线上场景需要先规避 P1 风险。对系统：LoRA 运行时首次为 DeepSeek V4 视觉模型明确声明多模态前缀分组与 token 计数契约，`get_mm_mapping` + `get_mm_lora_token_counts` 的组合可被其他 VLM 复用；`lora_shrink_op` 把输入契约从“必须连续”放宽为“自动连续化”，改变的是全仓 LoRA 前向的公共算子语义。对团队与文档：支持矩阵（docs/models/supported_models.md）与代码能力保持一致，减少“文档说支持、实际报错”的沟通成本，但零测试意味着后续重构缺少护栏。
 - 风险标记：多 LoRA 批次映射错配 , 缺少测试覆盖 , MTP skip 前缀缺失 , LoRA 算子行为变更

# 关联脉络

- PR #56349 [ROCm] Auto-enable breakable CUDA graphs for DeepseekV41ForCausalLM: 同属 DeepSeek-V4 模型族的可用性启用改动（vllm/config/vllm.py 层为该模型补齐运行能力），与本 PR 在模型接入链路上前后呼应。
- PR #56157 [PCP][DCP] Enable PCP+DCP on sparse-MLA models: 同样是 DeepSeek-V4 系列在 v1 推理路径上的能力扩展（稀疏 MLA / 并行），说明该模型族仍在密集补齐特性，本 PR 补的是微调侧的 LoRA 能力。
- PR #56594 [Bugfix][CI] Update CuTeDSL indexer Q sentinel test for migrated wrappers: 改动落在 vllm/models/deepseek_v4/nvidia 目录（fused_indexer_q_cutedsl.py），与本 PR 的 deepseek_v4/nvidia 包装层属同一近期维护面。