Prhub

#50352 [Bugfix][Model] Reject encoder-backbone jina-embeddings-v5 checkpoints with a clear error (fixes #50337)

原始 PR 作者 woosebastian 合并时间 2026-07-31 11:43 文件变更 2 提交数 3 评论 3 代码增减 +96 / -0

执行摘要

拒绝 jina-v5 编码器骨干并给出清晰报错

issue #50337 报告 jinaai/jina-embeddings-v5-text-nano 在加载时出现 ValueError: Following weights were not initialized from checkpoint: {'model.layers.0.self_attn.q_norm.weight', ...},无法指向真正原因。PR body 说明根因:V5 家族在同一个 architectures 条目下有两个骨干,-nano 是 EuroBERT 双向编码器(is_decoder=False),-small 是 Qwen3 解码器;vLLM 的 JinaEmbeddingsV5Model 继承 Qwen3ForCausalLM,没有编码器骨干路径。issue 建议用 num_key_value_headsnum_attention_heads 对比推断,但 PR 认为那只能从“无 GQA”间接推断,未来无 GQA 的 Qwen3 变体会被误判,因此改用 checkpoint 自己声明的 is_decoder 字段,并在配置验证阶段 fail-fast。

值得精读,尤其是 is_decoder 信号选择与 mutation check 的测试方法论。实现小而完整:config-time 拒绝、注册测试防静默失效、对支持形态的双向保护,可作为 vLLM 模型配置校验 handler(VerifyAndUpdateConfig)的样板。它本质是错误信息工程加早期失败,真正的扩展点在 #47660。

讨论亮点

PR 没有实质代码评审争议,noooop 直接 approve(“thanks!”)。有价值的讨论集中在 PR 评论区:

  • CI flaky 阻塞 auto-mergewoosebastian 指出 entrypoints-integration-multimodal 中的 test_chat_streaming_audio 在本 PR 与无关的 main 提交上都失败,重试无法解决;合并 main 引入 #50451 的修复后该阻塞解除。
  • pre-commit 失败mergify[bot] 提示运行 uv pip install pre-commit>=4.5.1 && pre-commit run --all-files;后续提交已修复。
  • mypy arg-typewoosebastian 解释 #49570 将 tests/models/language 移入 mypy SILENT_GROUPS 后,SimpleNamespace stub 不再满足 ModelConfig 参数类型,遂改用单个 cast(ModelConfig, ...) 而非三处 # type: ignore
    • 设计决策(PR body 中讨论):不采用 num_key_value_heads == num_attention_heads 推断骨干,而用 is_decoder,避免误判未来无 GQA 的 Qwen3 变体。

实现拆解

变更入口

vllm/model_executor/models/config.pyMODELS_CONFIG_MAP 是模型配置校验的统一注册表,vLLM 构建 ModelConfig 时会按 architectures 声明的架构名查找 handler 并调用 verify_and_update_model_config。本 PR 为 JinaEmbeddingsV5Model 新增 handler,把原来在权重加载阶段才暴露的错误提前到配置校验阶段。

核心逻辑

  1. 新增 JinaEmbeddingsV5ModelConfig(VerifyAndUpdateConfig)verify_and_update_model_configgetattr(model_config.hf_config, "is_decoder", True) 读取字段,缺失视为解码器放行;显式 is_decoder=False 时抛出 NotImplementedError,报错信息直接点名 EuroBERT 编码器骨干并提示可用的 jina-embeddings-v5-text-small
  2. 注册键:MODELS_CONFIG_MAP["JinaEmbeddingsV5Model"] = JinaEmbeddingsV5ModelConfig,插入位置紧跟其他 Jina 系 handler,与 #47660 计划新增的 EuroBertModelConfig 错开,避免合并冲突。
  3. 判别信号选择:刻意不采用 issue 中提出的 num_key_value_heads == num_attention_heads 推断,因为该推断依赖“无 GQA”这一间接特征;is_decoder 是 checkpoint 自身声明的字段,语义更直接且对未来变体更安全。

测试与配套

新增 tests/models/language/pooling/test_jina_embeddings_v5.py(64 行,全部 cpu_test,无需 GPU):

  1. test_registered_for_the_architecture 断言注册关系,防止 handler 未生效而静默跳过;
  2. test_encoder_backbone_is_rejectedPretrainedConfig(is_decoder=False) 验证抛错并匹配 is_decoder=False 文案;
  3. test_supported_decoder_backbone_is_accepted 覆盖 is_decoder 缺失与 True 两种支持形态,并用 hasattr 断言把 PretrainedConfig 默认行为钉死。
    测试 helper 用 cast(ModelConfig, SimpleNamespace(hf_config=hf_config)) 构造最小 stub,兼容 #49570 之后 mypy 对 tests/models/language 的严格检查。PR 作者还做了 mutation check:把 guard 替换为无条件提前返回后,拒绝测试会失败,证明测试非空转。

部署与配置

无部署配套;行为变更仅限于失败时机与错误信息——从 AutoWeightsLoader 的未初始化权重错误提前到配置验证的明确拒绝,受支持的 -small 变体行为完全不变。

文件 模块 状态 重要度
vllm/model_executor/models/config.py 配置层 modified 7.07
tests/models/language/pooling/test_jina_embeddings_v5.py 模型测试 added 6.9

关键符号

JinaEmbeddingsV5ModelConfig.verify_and_update_model_config JinaEmbeddingsV5ModelConfig _model_config test_registered_for_the_architecture test_encoder_backbone_is_rejected test_supported_decoder_backbone_is_accepted

关键源码片段

vllm/model_executor/models/config.py config-validation

核心修复文件:新增 `JinaEmbeddingsV5ModelConfig` 并在 `MODELS_CONFIG_MAP` 注册,在配置验证阶段拒绝 `is_decoder=False` 的编码器骨干检查点,把误导性的权重加载错误提前为明确的 `NotImplementedError`。

class JinaEmbeddingsV5ModelConfig(VerifyAndUpdateConfig):
    """Config handler for Jina Embeddings V5 embedding models."""
​
    @staticmethod
    def verify_and_update_model_config(model_config: "ModelConfig") -> None:
        # V5 家族在同一个 architectures 入口下有两类骨干: `-small` 是 Qwen3 解码器 ,
        # `-nano` 是 EuroBERT 双向编码器(is_decoder=False)。vLLM 实现基于 Qwen3,
        # 因此遇到编码器骨干必须在配置期直接拒绝,避免拖到权重加载阶段才爆出几十个
        # 未初始化权重(q_norm/k_norm)的误导性错误。
        # 默认行为:is_decoder 缺失时按解码器放行(-small 的 config.json 就没有该字段),
        # 只有显式声明 is_decoder=False 才拒绝。刻意不用 num_key_value_heads 与
        # num_attention_heads 的 GQA 对比推断骨干,避免未来无 GQA 的 Qwen3 变体被误判。
        if getattr(model_config.hf_config, "is_decoder", True):
            return
​
        raise NotImplementedError(
            "This jina-embeddings-v5 checkpoint uses a bidirectional encoder "
            "backbone (is_decoder=False). JinaEmbeddingsV5Model is built on "
            "Qwen3 and cannot serve it, so only the Qwen3-based decoder "
            "variants are supported, such as jina-embeddings-v5-text-small."
        )
​
​
# 注册进 MODELS_CONFIG_MAP,key 为 HF architectures 的条目名。注册缺失时 handler
# 不会执行且顶层校验会静默跳过,因此测试专门断言注册关系,防止“校验从未生效”。
MODELS_CONFIG_MAP["JinaEmbeddingsV5Model"] = JinaEmbeddingsV5ModelConfig
tests/models/language/pooling/test_jina_embeddings_v5.py test-coverage

新增回归测试:覆盖 registry 接线、编码器拒绝路径、解码器接受路径,并用 mutation check 证明测试非空转;纯 CPU 测试,可作为 config-time 校验的回归保护。

from types import SimpleNamespace
from typing import castimport pytest
from transformers import PretrainedConfigfrom vllm.config import ModelConfig
from vllm.model_executor.models.config import (
    MODELS_CONFIG_MAP,
    JinaEmbeddingsV5ModelConfig,
)
​
​
def _model_config(hf_config: PretrainedConfig) -> ModelConfig:
    """Minimal stand-in for ModelConfig; only hf_config is read."""
    # #49570 之后 tests/models/language 进入 mypy SILENT_GROUPS,import 会被跟踪,
    # 因此这里用 cast 而非三处 type: ignore,让 stub 满足 ModelConfig 参数类型。
    return cast(ModelConfig, SimpleNamespace(hf_config=hf_config))
​
​
@pytest.mark.cpu_test
def test_registered_for_the_architecture():
    # 如果 handler 没有注册进 MODELS_CONFIG_MAP,顶层校验会静默跳过,
    # 因此单独断言注册关系,防止“校验逻辑存在但从未生效”的失效模式。
    assert MODELS_CONFIG_MAP["JinaEmbeddingsV5Model"] is JinaEmbeddingsV5ModelConfig
​
​
@pytest.mark.cpu_test
def test_encoder_backbone_is_rejected():
    # 编码器骨干必须 fail-fast 并给出指向根因的报错;否则加载会一路走到
    # AutoWeightsLoader 才爆出几十个 q_norm/k_norm 未初始化权重。
    model_config = _model_config(PretrainedConfig(is_decoder=False))
​
    with pytest.raises(NotImplementedError, match="is_decoder=False"):
        JinaEmbeddingsV5ModelConfig.verify_and_update_model_config(model_config)
​
​
@pytest.mark.cpu_test
def test_supported_decoder_backbone_is_accepted():
    # -small 的 config.json 不含 is_decoder 字段,缺失时必须按解码器处理。
    # 第一个断言把 PretrainedConfig 的默认行为钉死,防止未来默认值变化
    # 导致受支持的 checkpoint 被静默拒绝。
    absent = PretrainedConfig()
    assert not hasattr(absent, "is_decoder")
​
    JinaEmbeddingsV5ModelConfig.verify_and_update_model_config(_model_config(absent))
    JinaEmbeddingsV5ModelConfig.verify_and_update_model_config(
        _model_config(PretrainedConfig(is_decoder=True))
    )

评论区精华

is_decoder 与 GQA 推断的选择 设计

issue 建议用 num_key_value_heads 与 num_attention_heads 对比区分 nano(12 == 12)与 small(16 != 8),但 PR body 说明这是从“无 GQA”间接推断,未来无 GQA 的 Qwen3 变体会被误判;改用 checkpoint 自己声明的 is_decoder 字段。

结论:采用 is_decoder 作为判别信号,缺失时默认视为解码器,测试中对 PretrainedConfig 默认行为做了断言固定。 · 已解决

flaky 测试阻塞 auto-merge test

woosebastian 指出 entrypoints-integration-multimodal 的 test_chat_streaming_audio 在 main 的无关提交上也失败,重试同构失败,与本 PR 无关;等合并 main 纳入 #50451 修复后解除。

结论:合并 main 引入 #50451 后阻塞解决。 · 已解决

mypy arg-type 与测试 stub 测试

#49570 将 tests/models/language 移入 mypy SILENT_GROUPS 后,import 被跟踪,SimpleNamespace stub 不再满足 ModelConfig 参数类型;woosebastian 修复为单个 cast(ModelConfig)。

结论:用 cast(ModelConfig, SimpleNamespace(hf_config=hf_config)) 替代三处 type: ignore。 · 已解决

pre-commit 失败提示 style

mergify[bot] 提示 pre-commit 检查失败,要求运行 uv pip install pre-commit>=4.5.1 并 pre-commit run --all-files。

结论:后续提交通过 pre-commit。 · 已解决

风险与影响

  1. 判别信号假设:整条校验依赖 is_decoder 的语义。若未来出现显式 is_decoder=False 的 Qwen3 变体,会被误拒;PR 选择该字段正是因为它是 checkpoint 自己声明的,且测试用 hasattr 断言钉住 PretrainedConfig 默认行为,可提前暴露默认值漂移,但仍无法覆盖未来模型发布的语义变化。
  2. 端到端覆盖缺口:测试用 PretrainedConfig 模拟配置,没有加载真实 checkpoint 验证整条链路;真实 -nano checkpoint 仍会在后续阶段失败,只是错误提示已可读。test_registered_for_the_architecture 能兜底注册丢失,但只在 CPU 测试中运行。
  3. 影响面:仅新增 32 行源码 handler,注册表变更影响所有 JinaEmbeddingsV5Model 的加载路径;校验逻辑只读,不修改 hf_config 状态,对正常模型无副作用。

用户侧:加载 jina-embeddings-v5-text-nano 时从几十个未初始化权重的误导性 ValueError,变成指向根因的 NotImplementedError,并提示替代模型。系统侧:改动局限配置层与一个测试文件,运行时开销为一次 getattr 查询,可忽略;不改变任何模型输出与采样行为。团队侧:为后续 #47660 EuroBERT 支持铺平了判别路径(is_decoder 标志),但明确不等同于支持——真正的编码器骨干支持仍需把 JinaEmbeddingsV5Model 从继承 Qwen3 改为按骨干分派;同时减少维护者处理类似“同一架构名对应多骨干”问题时的排查成本。

依赖 is_decoder 语义 真实 checkpoint 无端到端覆盖 错误路径行为变更 配置注册防静默失效

关联 Issue

#47660 [Model] Add EuroBERT embedding model
#50337 [Bug]: JinaEmbeddingsV5Model fails on jina-embeddings-v5-text-nano: EuroBERT backbone misrouted to Qwen3 code path

完整报告

参与讨论