Prhub

#54192 [Bugfix] Avoid MistralCommonBackend for HF tokenizers

原始 PR 作者 juliendenize 合并时间 2026-09-11 01:57 文件变更 7 提交数 6 评论 11 代码增减 +129 / -157

执行摘要

hf 模式禁用 mistral-common 回退

Issue #50706 报告:HF 格式的 Mistral3 checkpoint 用默认参数初始化时,在 multimodal memory profiling 阶段(用户尚未发请求)就抛 ValueError: Failed to apply PixtralProcessor on data={'text': '[IMG]'};根因是 transformers 5.x 对双格式仓库的 AutoTokenizer 返回 MistralCommonBackend,而 mistral-common 刻意不为文本中的 special token 编码,encode("[IMG]") 得到字面量而非 [IMG] 的 id,PixtralProcessor._check_special_mm_tokens 在文本里数到 1 个占位符、在 input_ids 里数到 0 个。作者 juliendenize 在 #50706 评论中提出:tokenizer_mode="hf" 是用户显式要求走 HF 格式,这条路径本就不该被触达,因此“removing MistralCommonBackend support for HF tokenizer path”,并引用 transformers 新能力(huggingface/transformers#47507 允许 mistral_format=False 永不回退到 MistralCommonBackend);hmellor 回复 “I agree that this path should never be reached by vLLM”,并要求在 #50706 对齐后再合。PR body 同时声明本 PR supersedes #50752——后者在 processor 层给 tokenizer 打补丁(换一个会编码 special token 的子类),本 PR 改为在注册层直接不让该后端出现。显式 tokenizer_mode="mistral" 的行为保持不变。

值得精读,尤其是负责 tokenizer、结构化输出与 Mistral 模型的同学。关注三个设计决策:(1) 为什么选择“在注册层强制 mistral_format=False”而不是继续在 processor 层包装 tokenizer(对比被 supersede 的 #50752);(2) 对老版本 transformers 采用“显式报错”而非“静默降级”,以及反对“让用户卸载 mistral-common”的理由(mistral 工具解析器与 hf 分词器是合法组合);(3) 删除兼容层时同步收缩了哪些隐式契约(CachedTokenizer 不再覆写解码函数、语法后端不再接受 MCommon)。阅读时建议同时看 tests/tokenizers_/test_registry.py 的 8 组参数矩阵,理解闸门的触发条件;并在升级 transformers 到 5.15+ 后确认那个 TODO 能否删除。

讨论亮点
  1. 版本闸门由 review 直接催生:hmellor 在 vllm/tokenizers/registry.py 上评论 “We need another error for the situation where the user wants hf tokenizer mode on a Mistral model when transformers version is <5.15. 5.15 is the earliest version with mistral_format”。这直接产生了 commit ca8b7aa48676 “Guard HF Mistral tokenizers on old Transformers” 与 _MIN_TRANSFORMERS_VERSION_FOR_HF_MISTRAL 常量及对应参数化测试,是本次 review 中最实质的设计补充。
  2. “卸载 mistral-common”这条替代方案被否决:hmellor 提出 “There is a third solution, uninstalling mistral-common”;juliendenize 反驳称 mistral-common 是不加守卫的依赖,“mixing tool call parser mistral and hf tokenizer (which is valid combination) would not be possible by uninstalling mistral-common as it would crash”,hmellor 回 “Good point”。结论是保留显式报错 + 升级提示,而不是让用户卸载依赖。
  3. 路径归属的共识:issue #50706 讨论中作者主张 hf 路径不应触达 MCommon,hmellor “I agree that this path should never be reached by vLLM”,随后才允许推进合并;这是本 PR 以“删除兼容层”而非“继续打补丁”的决策依据。
  4. 未解决的疑虑:没有任何 reviewer 追问 kwargs["mistral_format"] = False 会被写到所有 hf 模式加载上(包括非 Mistral 模型)这一副作用,也未讨论 vllm/tokenizers/mistral.py 中 mistral_common_tekkenizer 等辅助函数是否会变成死代码。

实现拆解

  1. 入口参数归一(vllm/tokenizers/registry.py::resolve_tokenizer_args):在模式解析末尾新增 hf 分支——若 kwargs 中已有 mistral_format=True 则直接 raise ValueError("mistral_format=True is not supported with tokenizer_mode='hf'"),否则统一写入 kwargs["mistral_format"] = False。这样即便仓库同时存在 tekken.json 与 tokenizer.json,transformers 也不会把 AutoTokenizer 解析成 MistralCommonBackend;slow 模式先归一到 hf,自然继承同一约束;auto 模式仍保留“Mistral 仓库 + tekken 文件 → mistral 模式”的既有优先逻辑。顺带把 token 透传给 is_mistral_model_repo、any_pattern_in_repo_files 与新增的 get_config(..., token=...),使私有仓库鉴权在注册阶段生效。
  2. 版本闸门(vllm/tokenizers/registry.py::get_tokenizer):新增模块常量 _MIN_TRANSFORMERS_VERSION_FOR_HF_MISTRAL = Version("5.15.0")(5.15 是首个支持 mistral_format 的版本)。当 tokenizer_mode == "hf"、transformers 版本偏低、is_mistral_common_available() 为真、_HF_TOKENIZER_MAPPING_NAMES.get(config.model_type) == "MistralCommonBackend" 且仓库含 tekken.json/tokenizer.model.v* 时抛出可操作的 ValueError(升级 transformers 或删除 Mistral 分词器文件),并留 TODO: delete when Transformers version dependency is bumped >= 5.15.0,属有明确回收期限的临时闸门。
  3. 删除兼容层(4 个文件):vllm/tokenizers/hf.py::get_cached_tokenizer 不再探测 tokenizer.tokenizer.instruct_tokenizer 构造 mistral_tekkenizer,CachedTokenizer 同时去掉 convert_ids_to_tokens / convert_tokens_to_string 覆写(含 NotImplementedError → "".join(tokens) 兜底),代理退回“纯缓存”职责;vllm/v1/structured_output/utils.py 删除 maybe_wrap_mistral_common_tokenizer 与 from transformers import MistralCommonBackend;vllm/v1/structured_output/backend_guidance.py::GuidanceBackend.__post_init__ 删除 isinstance(self.tokenizer, MistralCommonBackend) + mistral_common.guidance.tokenizer.from_mistral_tokenizer 分支;vllm/v1/structured_output/__init__.py 调用点改为直接用 cached_tokenizer_from_config。
  4. 测试配套(一删一增):删除 tests/v1/structured_output/test_mistral_common_tokenizer.py(102 行,原覆盖 wrap 后 MistralTokenizer 类型、_reduced_vocabulary 的等价 id 去重、xgrammar/outlines 接受合法 JSON 与拒绝非法 token、xgrammar 拒绝未包装 tokenizer);在 tests/tokenizers_/test_registry.py 新增三个参数化用例——test_resolve_tokenizer_args_forces_hf_mistral_format_false(hf/slow × 带不带 mistral_format=False)、test_resolve_tokenizer_args_rejects_hf_mistral_format_true、test_get_tokenizer_handles_hf_mistral_transformers_compatibility(transformers 5.14/5.15 × mistral/mistral3/mixtral/无 tekken 文件共 8 组矩阵)。测试通过 patch get_config、vllm.tokenizers.registry.transformers.__version__、CachedHfTokenizer.from_pretrained 实现,CPU-only、无网络、不需权重。
  5. 顺带清理:_TokenizerRegistry.register 去掉多余的 return None;import vllm.envs as envs 改为更规范的 from vllm import envs。
文件 动作 关键符号
vllm/tokenizers/registry.py +41/-4 resolve_tokenizer_args、get_tokenizer、_MIN_TRANSFORMERS_VERSION_FOR_HF_MISTRAL
vllm/tokenizers/hf.py +2/-30 get_cached_tokenizer、CachedTokenizer.convert_ids_to_tokens/convert_tokens_to_string(删)
vllm/v1/structured_output/utils.py +0/-13 maybe_wrap_mistral_common_tokenizer(删)
vllm/v1/structured_output/backend_guidance.py +0/-5 GuidanceBackend.__post_init__
vllm/v1/structured_output/__init__.py +2/-3 StructuredOutputManager.__init__ 调用点
tests/tokenizers_/test_registry.py +84/-0 三个新参数化测试
tests/v1/structured_output/test_mistral_common_tokenizer.py +0/-102(删除) 旧 MCommon 结构化输出测试
文件 模块 状态 重要度
vllm/tokenizers/registry.py 分词器注册 modified 7.25
vllm/tokenizers/hf.py 分词器缓存 modified 7.21
tests/tokenizers_/test_registry.py 分词器测试 modified 6.78
vllm/v1/structured_output/utils.py 结构化输出 modified 6.39
vllm/v1/structured_output/backend_guidance.py 引导后端 modified 5.67
vllm/v1/structured_output/__init__.py 结构化引擎 modified 5.3
tests/v1/structured_output/test_mistral_common_tokenizer.py 输出测试 removed 6.98

关键符号

resolve_tokenizer_args get_tokenizer get_cached_tokenizer CachedTokenizer.convert_ids_to_tokens(删除) CachedTokenizer.convert_tokens_to_string(删除) maybe_wrap_mistral_common_tokenizer(删除) GuidanceBackend.__post_init__ StructuredOutputManager.__init__

关键源码片段

vllm/tokenizers/registry.py core-logic

本 PR 的核心:在 `resolve_tokenizer_args` 强制 hf 模式 `mistral_format=False`、拒绝冲突的 `mistral_format=True`,并在 `get_tokenizer` 新增 transformers < 5.15 的版本闸门;同时补上 `token` 透传。

# vllm/tokenizers/registry.py(PR 后整理出的关键实现,含两处核心改动)
​
# HF 原生 Mistral 分词器要接受 `mistral_format` 参数,需要 transformers >= 5.15;
# 低于该版本时 transformers 仍会把双格式仓库解析成 MistralCommonBackend,
# 这正是 #50706 崩溃的根因,因此设一个带 TODO 的版本闸门。
_MIN_TRANSFORMERS_VERSION_FOR_HF_MISTRAL = Version("5.15.0")
​
​
def resolve_tokenizer_args(
    tokenizer_name: str | Path,
    *args,
    runner_type: "RunnerType" = "generate",
    tokenizer_mode: str = "auto",
    **kwargs,
):
    # ...(revision / download_dir / truncation_side 等未改动逻辑略)
​
    if tokenizer_mode == "slow":
        if kwargs.get("use_fast", False):
            raise ValueError("Cannot use the fast tokenizer in slow tokenizer mode.")
        # slow 模式只是 use_fast=False 的 hf,后续同样受 mistral_format 约束
        tokenizer_mode = "hf"
        kwargs["use_fast"] = False
​
    # auto 模式下,只有 Mistral 仓库且带 tekken.json / tokenizer.model.v* 才选官方分词器;
    # token= 是本次新增的透传,用于私有仓库的 Hub 鉴权
    if (
        tokenizer_mode == "auto"
        and is_mistral_model_repo(
            model_name_or_path=str(tokenizer_name),
            revision=revision,
            token=kwargs.get("token"),
        )
        and any_pattern_in_repo_files(
            model_name_or_path=str(tokenizer_name),
            allow_patterns=["tekken.json", "tokenizer.model.v*"],
            revision=revision,
            token=kwargs.get("token"),
        )
    ):
        tokenizer_mode = "mistral"
​
    if tokenizer_mode == "auto":
        tokenizer_mode = "hf"
​
    # 核心改动:hf 模式显式声明 mistral_format=False,
    # 让 transformers 走 HF 的 tokenizer.json、永不回退到 MistralCommonBackend;
    # 用户显式要求 mistral_format=True 时直接报错,而不是静默改写其语义
    if tokenizer_mode == "hf":
        if kwargs.pop("mistral_format", False):
            raise ValueError(
                "mistral_format=True is not supported with tokenizer_mode='hf'"
            )
        kwargs["mistral_format"] = False
​
    return tokenizer_mode, tokenizer_name, args, kwargs
​
​
def get_tokenizer(tokenizer_name, *args, tokenizer_cls=TokenizerLike, **kwargs):
    # ...(tokenizer_cls 解析、config 加载、config_format 选择等逻辑略)
​
    # TODO: delete when Transformers version dependency is bumped >= 5.15.0
    # 老版本 transformers 没有 mistral_format,无法保证走 HF 后端;
    # 与其继续崩在 PixtralProcessor,不如在加载前给出可操作的报错
    if (
        tokenizer_mode == "hf"
        and Version(transformers.__version__) < _MIN_TRANSFORMERS_VERSION_FOR_HF_MISTRAL
        and is_mistral_common_available()
        and _HF_TOKENIZER_MAPPING_NAMES.get(getattr(config, "model_type", None))
        == "MistralCommonBackend"
        and any_pattern_in_repo_files(
            model_name_or_path=str(tokenizer_name),
            allow_patterns=["tekken.json", "tokenizer.model.v*"],
            revision=revision,
            token=kwargs.get("token"),
        )
    ):
        raise ValueError(
            "Loading Mistral models with tokenizer_mode='hf' requires "
            f"transformers>={_MIN_TRANSFORMERS_VERSION_FOR_HF_MISTRAL}. "
            "Please upgrade transformers or delete Mistral tokenizer files."
        )
vllm/tokenizers/hf.py refactor

`get_cached_tokenizer` 移除 mistral_tekkenizer 探测,`CachedTokenizer` 去掉 tekken 特化的 `convert_ids_to_tokens`/`convert_tokens_to_string` 与 NotImplementedError 兜底,是“兼容层删除”最实质的一处(+2/-30)。

# vllm/tokenizers/hf.py — 缓存代理在 PR 之后的形态(净删 28 行)
​
def get_cached_tokenizer(tokenizer: HfTokenizer) -> HfTokenizer:
    """transformers 默认每次调用都重算属性,这里用子类代理把它们缓存下来。"""
    cached_tokenizer = copy.copy(tokenizer)
​
    tokenizer_all_special_ids = tokenizer.all_special_ids
    tokenizer_all_special_tokens = tokenizer.all_special_tokens
    tokenizer_vocab = tokenizer.get_vocab()
    tokenizer_len = len(tokenizer)
    # 底层 tokenizer 可能是任意特定 backend,不保证实现 is_fast,保留 getattr 兜底
    tokenizer_is_fast = getattr(tokenizer, "is_fast", True)
​
    # 删除点 1:这里原本会探测 tokenizer.tokenizer.instruct_tokenizer,
    # 为 MistralCommonBackend 构造 mistral_tekkenizer,
    # 供下面被删掉的 convert_* 覆写做 byte-fallback 感知的解码。
    # hf 路径不再产出 MistralCommonBackend,这段探测整体删除。
​
    max_token_id = max(tokenizer_vocab.values())
    max_chars_per_token = max(len(tok) for tok in tokenizer_vocab)
​
    class CachedTokenizer(tokenizer.__class__): # type: ignore
        @property
        def all_special_ids(self) -> list[int]:
            return tokenizer_all_special_ids
​
        @property
        def all_special_tokens(self) -> list[str]:
            return tokenizer_all_special_tokens
​
        @property
        def max_token_id(self) -> int:
            return max_token_id
​
        @property
        def max_chars_per_token(self) -> int:
            return max_chars_per_token
​
        @property
        def is_fast(self) -> bool:
            return tokenizer_is_fast
​
        # 删除点 2:原来的 convert_ids_to_tokens / convert_tokens_to_string
        # 覆写(含 NotImplementedError -> "".join(tokens) 兜底)被移除,
        # CachedTokenizer 退回“纯缓存”职责,解码统一走父类实现。
​
        def get_vocab(self) -> dict[str, int]:
            return tokenizer_vocab
​
        def __len__(self) -> int:
            return tokenizer_len
​
        def __reduce__(self):
            return get_cached_tokenizer, (tokenizer,)
​
    CachedTokenizer.__name__ = f"Cached{tokenizer.__class__.__name__}"
    cached_tokenizer.__class__ = CachedTokenizer
    return cached_tokenizer
vllm/v1/structured_output/backend_guidance.py cleanup

`GuidanceBackend.__post_init__` 删除 `MistralCommonBackend` + `mistral_common.guidance.tokenizer.from_mistral_tokenizer` 分支,语法后端只保留 mistral 原生与 llguidance HF 两条路径。

# vllm/v1/structured_output/backend_guidance.py
​
@dataclass
class GuidanceBackend(StructuredOutputBackend):
    def __post_init__(self):
        self.disable_any_whitespace = (
            self.vllm_config.structured_outputs_config.disable_any_whitespace
        )
        self.disable_additional_properties = (
            self.vllm_config.structured_outputs_config.disable_additional_properties
        )
​
        if is_mistral_tokenizer(self.tokenizer):
            # --tokenizer-mode mistral:直接用 MistralTokenizer 自带的 llguidance 分词器
            self.ll_tokenizer = self.tokenizer.llg_tokenizer
        else:
            # 删除点:这里原本还有一条分支
            # elif isinstance(self.tokenizer, MistralCommonBackend):
            # from mistral_common.guidance.tokenizer import from_mistral_tokenizer
            # self.ll_tokenizer = from_mistral_tokenizer(self.tokenizer.tokenizer)
            # hf 模式强制 mistral_format=False 之后,MistralCommonBackend 不会再流入
            # 语法后端,这条兜底分支连同 mistral-common 的导入一起删除。
            self.ll_tokenizer = llguidance_hf.from_tokenizer(
                self.tokenizer, max(self.vocab_size, len(self.tokenizer))
            )

评论区精华

transformers < 5.15 时需要额外的显式报错 正确性

hmellor 在 registry.py 上指出:“We need another error for the situation where the user wants `hf` tokenizer mode on a Mistral model when transformers version is <5.15. 5.15 is the earliest version with `mistral_format`”。作者随后补上 `_MIN_TRANSFORMERS_VERSION_FOR_HF_MISTRAL` 常量、`get_tokenizer` 中的四条件闸门以及 8 组参数化测试(commit `ca8b7aa48676` “Guard HF Mistral tokenizers on old Transformers”)。

结论:采纳,新增版本闸门与可操作错误信息,并留 TODO 待 transformers 版本依赖提升到 >= 5.15 后删除。 · 已解决

是否可以让用户卸载 mistral-common 作为第三种解法 设计

hmellor:“There is a third solution, uninstalling `mistral-common`”;juliendenize 回应 mistral-common 是不加守卫的依赖,“mixing tool call parser mistral and hf tokenizer (which is valid combination) would not be possible by uninstalling mistral-common as it would crash”,并补充 “this is because mistral-common is always installed so we don't guard the imports”;hmellor 最终回 “Good point”。

结论:不采用卸载方案,保留显式 ValueError 与升级 / 删除文件的提示。 · 已解决

hf 路径是否应该完全不再触达 MistralCommonBackend 设计

作者在 #50706 评论中提出 “I agree that this path should never be reached by vLLM” 的反向确认:既然用户显式选择 hf 格式,就不该被 transformers 静默换成 mistral-common 后端;hmellor 回复 “Let's discuss more in #50706 so that everyone is aligned, then we can look at getting this merged”,对齐后 PR 才进入合并流程,也因此选择删除兼容层而非沿用 #50752 的 processor 级补丁。

结论:达成共识:在注册层从源头阻断该后端,删除三处兼容层;显式 tokenizer_mode="mistral" 路径不动。 · 已解决

风险与影响

  1. 兼容性/破坏性变更:在 transformers < 5.15 且仓库含 tekken 文件时,tokenizer_mode="hf" 从“能启动”变为启动即 ValueError(vllm/tokenizers/registry.py::get_tokenizer)。这是有意的显式失败,但对钉住旧版 transformers 的部署是升级阻断,需要用户改依赖或删除 Mistral 分词器文件。
  2. 参数外溢风险:resolve_tokenizer_args 对所有 hf 模式加载都注入 mistral_format=False,非 Mistral 模型(如 GPT-2、Qwen)也会带着这个 kwarg 进入 AutoTokenizer.from_pretrained,其可靠性依赖 transformers 对未知 kwargs 的容错;相关测试把 CachedHfTokenizer.from_pretrained mock 掉了,未覆盖真实 AutoTokenizer 路径。
  3. 结构化输出回归面:删除 maybe_wrap_mistral_common_tokenizer(vllm/v1/structured_output/utils.py)与 GuidanceBackend.__post_init__ 的 MCommon 分支后,若仍有其它入口把 MistralCommonBackend 送进语法后端(例如未来 transformers 行为变化或用户自行指定 tokenizer_cls),错误会退化为 xgrammar 的 “Unsupported tokenizer type”,可诊断性低于原来的自动包装。
  4. 解码兜底被移除:vllm/tokenizers/hf.py 删除 convert_tokens_to_string 的 NotImplementedError → "".join(tokens) 兜底,而该函数被 _reduced_vocabulary 依赖;若仍有不实现该方法的底层 tokenizer 走 CachedTokenizer,会由静默降级变为抛异常。
  5. 测试覆盖结构变化:被删的 102 行测试是唯一直接验证“Mistral tokenizer + xgrammar/outlines grammar 接受度”的用例,新测试只覆盖参数解析与版本闸门(且大量依赖 mock),对语法后端行为的端到端覆盖实际是下降的。
  • 用户侧:Mistral/Mistral3/Mixtral 双格式仓库在默认(auto)模式下仍优先走 mistral 模式,行为不变;但显式 --tokenizer-mode hf 的用户必须以 transformers >= 5.15 为前提,否则启动即报错。修复后默认参数初始化不再崩溃,多模态 profiling 可正常完成。
  • 系统侧:分词器与结构化输出之间少了一层隐式类型转换,CachedTokenizer、GuidanceBackend、StructuredOutputManager 的输入约束更窄、更可预测;token 透传补齐了私有仓库在注册阶段鉴权的缺口。
  • 维护侧:净删 28 行代码 + 102 行测试,去掉“在 HF processor 层给 MCommon 打补丁”这条路(#50752 的方案),团队不再需要维护 processor 级兼容包装;代价是新增一处带 TODO 的版本依赖检查,需要 transformers 版本升级后回收。
破坏性版本门槛 所有 hf 加载都注入 mistral_format=False 结构化输出兼容层删除 真实 AutoTokenizer 路径测试盲区 可能遗留 tekken 辅助函数死代码

关联 Issue

#50706 [Bug]: Mistral3 (HF format): default text-only LLM() init fails in multimodal profiling — "Failed to apply PixtralProcessor on data={'text': '[IMG]'}"
#50752 [Bugfix] Encode special tokens in HF processors for transformers-native mistral-common tokenizers

完整报告

参与讨论