执行摘要
- 一句话:hf 模式禁用 mistral-common 回退
- 推荐动作:值得精读,尤其是负责 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 能否删除。
功能与动机
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" 的行为保持不变。
实现拆解
- 入口参数归一(
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=...),使私有仓库鉴权在注册阶段生效。
- 版本闸门(
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,属有明确回收期限的临时闸门。
- 删除兼容层(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。
- 测试配套(一删一增):删除
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、无网络、不需权重。
- 顺带清理:
_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(模块 分词器注册;类别 source;类型 core-logic;符号 resolve_tokenizer_args, get_tokenizer, _MIN_TRANSFORMERS_VERSION_FOR_HF_MISTRAL, _TokenizerRegistry.register): 本 PR 的核心:在 resolve_tokenizer_args 强制 hf 模式 mistral_format=False、拒绝冲突的 mistral_format=True,并在 get_tokenizer 新增 transformers < 5.15 的版本闸门;同时补上 token 透传。
vllm/tokenizers/hf.py(模块 分词器缓存;类别 source;类型 refactor;符号 get_cached_tokenizer, CachedTokenizer.convert_ids_to_tokens, CachedTokenizer.convert_tokens_to_string, mistral_tekkenizer): get_cached_tokenizer 移除 mistral_tekkenizer 探测,CachedTokenizer 去掉 tekken 特化的 convert_ids_to_tokens/convert_tokens_to_string 与 NotImplementedError 兜底,是“兼容层删除”最实质的一处(+2/-30)。
tests/tokenizers_/test_registry.py(模块 分词器测试;类别 test;类型 test-coverage;符号 test_resolve_tokenizer_args_forces_hf_mistral_format_false, test_resolve_tokenizer_args_rejects_hf_mistral_format_true, test_get_tokenizer_handles_hf_mistral_transformers_compatibility): 新增 84 行测试,用参数化矩阵锁定新契约:hf/slow 都强制 mistral_format=False、显式 True 被拒绝、transformers 5.14/5.15 下的兼容性差异。
vllm/v1/structured_output/utils.py(模块 结构化输出;类别 source;类型 cleanup;符号 maybe_wrap_mistral_common_tokenizer): 删除 maybe_wrap_mistral_common_tokenizer —— 结构化输出侧把 MCommon 包成 MistralTokenizer 的唯一入口;其消失是“hf 路径不再产出该后端”的直接后果。
vllm/v1/structured_output/backend_guidance.py(模块 引导后端;类别 source;类型 cleanup;符号 GuidanceBackend.post_init): GuidanceBackend.__post_init__ 删除 MistralCommonBackend + mistral_common.guidance.tokenizer.from_mistral_tokenizer 分支,语法后端只保留 mistral 原生与 llguidance HF 两条路径。
vllm/v1/structured_output/__init__.py(模块 结构化引擎;类别 source;类型 dependency-wiring;符号 StructuredOutputManager.init): StructuredOutputManager.__init__ 不再调用 maybe_wrap_mistral_common_tokenizer,改为直接 cached_tokenizer_from_config,是兼容层删除的调用点收口。
tests/v1/structured_output/test_mistral_common_tokenizer.py(模块 输出测试;类别 test;类型 deletion;符号 loaded_tokenizer, wrapped_tokenizer, test_wrapping_fixes_the_outlines_vocabulary, test_backend_accepts_a_valid_document): 整文件删除(102 行),是唯一直接验证 MCommon 包装后 vocab 等价性与 xgrammar/outlines grammar 接受度的测试,删除后语法后端侧端到端覆盖实际下降。
关键符号: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
本 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
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
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))
)
评论区精华
- 版本闸门由 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 中最实质的设计补充。
- “卸载 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”。结论是保留显式报错 + 升级提示,而不是让用户卸载依赖。
- 路径归属的共识:issue #50706 讨论中作者主张 hf 路径不应触达 MCommon,hmellor “I agree that this path should never be reached by vLLM”,随后才允许推进合并;这是本 PR 以“删除兼容层”而非“继续打补丁”的决策依据。
- 未解决的疑虑:没有任何 reviewer 追问
kwargs["mistral_format"] = False 会被写到所有 hf 模式加载上(包括非 Mistral 模型)这一副作用,也未讨论 vllm/tokenizers/mistral.py 中 mistral_common_tekkenizer 等辅助函数是否会变成死代码。
- transformers < 5.15 时需要额外的显式报错 (correctness): 采纳,新增版本闸门与可操作错误信息,并留 TODO 待 transformers 版本依赖提升到 >= 5.15 后删除。
- 是否可以让用户卸载 mistral-common 作为第三种解法 (design): 不采用卸载方案,保留显式 ValueError 与升级/删除文件的提示。
- hf 路径是否应该完全不再触达 MistralCommonBackend (design): 达成共识:在注册层从源头阻断该后端,删除三处兼容层;显式 tokenizer_mode="mistral" 路径不动。
风险与影响
- 风险:
- 兼容性/破坏性变更:在 transformers < 5.15 且仓库含 tekken 文件时,
tokenizer_mode="hf" 从“能启动”变为启动即 ValueError(vllm/tokenizers/registry.py::get_tokenizer)。这是有意的显式失败,但对钉住旧版 transformers 的部署是升级阻断,需要用户改依赖或删除 Mistral 分词器文件。
- 参数外溢风险:
resolve_tokenizer_args 对所有 hf 模式加载都注入 mistral_format=False,非 Mistral 模型(如 GPT-2、Qwen)也会带着这个 kwarg 进入 AutoTokenizer.from_pretrained,其可靠性依赖 transformers 对未知 kwargs 的容错;相关测试把 CachedHfTokenizer.from_pretrained mock 掉了,未覆盖真实 AutoTokenizer 路径。
- 结构化输出回归面:删除
maybe_wrap_mistral_common_tokenizer(vllm/v1/structured_output/utils.py)与 GuidanceBackend.__post_init__ 的 MCommon 分支后,若仍有其它入口把 MistralCommonBackend 送进语法后端(例如未来 transformers 行为变化或用户自行指定 tokenizer_cls),错误会退化为 xgrammar 的 “Unsupported tokenizer type”,可诊断性低于原来的自动包装。
- 解码兜底被移除:
vllm/tokenizers/hf.py 删除 convert_tokens_to_string 的 NotImplementedError → "".join(tokens) 兜底,而该函数被 _reduced_vocabulary 依赖;若仍有不实现该方法的底层 tokenizer 走 CachedTokenizer,会由静默降级变为抛异常。
- 测试覆盖结构变化:被删的 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 辅助函数死代码
关联脉络
- PR #50752 [Bugfix] Encode special tokens in HF processors for transformers-native mistral-common tokenizers: 被本 PR 显式 supersede 的同因修复:它在 processor 层把 MistralCommonBackend 替换为会编码文本内 special token 的子类,本 PR 改为在 tokenizer 注册层强制 mistral_format=False,从源头避免该后端出现,因此删除了那类包装需求。
- PR #45525 修复 vLLM MistralTokenizer 路径的 special-token 编码(材料仅给出编号,未提供标题): PR body 说明其以 is_mistral_tokenizer() 守卫修复同一崩溃的另一条路径(--tokenizer-mode mistral),与本 PR 互补;#50752 中提及的 _text_to_ids special-token 编码思路也源自它,本 PR 使两条路径在语义上收敛。
参与讨论