执行摘要
- 一句话:加固 Transformers 多模态处理,修复占位符与字段归属 bug
- 推荐动作:值得精读。核心设计是
_partition_keys_by_modality:利用子处理器声明的 model_input_names 做字段归属,既避免硬编码键名又提供未认领告警。理解该模式有助于后续处理视频模态或新增 HF 处理器兼容层。建议关注 [1/N] 系列后续 PR,特别是 text_replacement_offsets 对相邻音频占位符的根本性修复。
功能与动机
PR body 列举了四个已修复的问题:文本仅 prompt 会崩溃(KeyError: "Modality 'image' not found")、Token-ID prompt 重编码时特殊 token 被加两次(Gemma3 [2] 变 [2, 2])、内存 profiling 为 3 个音频项只分配 1 个占位符(get_dummy_text 无分隔符导致 run-length 扫描合并)、音频与图像字段配置互相覆盖。此外 Transformers 后端之前被排除在 _test_processing_correctness 之外,没有任何 cached-vs-uncached 覆盖。
实现拆解
- 修复占位符触发条件:
vllm/model_executor/models/transformers/multimodal.py 的 apply() 中,_apply_audio/_apply_vision 的调用条件从模型是否支持该模态(_is_audio_model()/_is_image_model())改为本次输入是否实际包含该模态项(mm_items.get_count("audio", strict=False)),从根源消除文本仅 prompt 崩溃;_apply_vision 的 mm_token_type_ids is None 守卫因 HF 返回全零张量而失效的问题也随之避免。
- 修复 Token-ID prompt:
apply() 在把 token IDs 解码为字符串后,设置 tokenization_kwargs["add_special_tokens"] = False,防止重新 tokenize 时再次插入特殊 token,保证 ID 与文本 prompt 一致。
- 修复音频占位符合并与字段覆盖:
get_dummy_text 用空格连接多个 audio_token,使 run-length 扫描能区分相邻占位符;_apply_audio 新增 num_audios 参数,检测到的 run 数不匹配时抛出 ValueError(不再静默合并),同时移除冗余的 is_embed=True mask;新增 _get_modality_field_names 与 _partition_keys_by_modality,按子处理器 model_input_names 把 hf_inputs 的 key 归属到具体模态,重写 _get_mm_fields_config,避免音频与图像分支互相覆盖,未认领 key 仅告警。
- 测试配套:
test_common.py 新增 test_processing_correctness_transformers(参数化所有 Transformers 多模态模型 ID,覆盖三个 hit_rate),把该后端纳入处理一致性测试;test_transformers_audio.py 重构出 _process_granite_speech(separator),新增相邻输入抛 ValueError、音频字段不被图像分支抢占、未认领字段告警测试;test_transformers_image.py 重构出 _process_two_images(separator),新增相邻图像隔离与文本仅 prompt 测试。
关键文件:
vllm/model_executor/models/transformers/multimodal.py(模块 多模态处理;类别 source;类型 data-contract;符号 _get_modality_field_names, _partition_keys_by_modality, _get_mm_fields_config, _apply_audio): 核心文件:修复 apply() 占位符触发条件、Token-ID 重复特殊 token、_apply_audio 占位符合并,并新增按模态分区字段的 _partition_keys_by_modality 机制。
tests/models/multimodal/processing/test_common.py(模块 通用测试;类别 test;类型 test-coverage;符号 get_transformers_backend_model_ids_to_test, test_processing_correctness_transformers): 新增 test_processing_correctness_transformers,首次将 Transformers 后端纳入处理一致性测试,提供 cached-vs-uncached 回归覆盖。
tests/models/multimodal/processing/test_transformers_audio.py(模块 音频测试;类别 test;类型 test-coverage;符号 _process_granite_speech, test_audio_multiple_inputs, test_audio_fields_not_claimed_by_image, test_unclaimed_fields_warn_rather_than_raise): 重构辅助函数并新增相邻音频报错、字段归属、未认领告警用例,验证核心修复。
tests/models/multimodal/processing/test_transformers_image.py(模块 图像测试;类别 test;类型 test-coverage;符号 _process_two_images, test_image_multiple_inputs, test_image_adjacent_inputs, test_text_only_prompt): 新增相邻图像隔离与文本仅 prompt 用例,确保图像路径不回归。
关键符号:_get_modality_field_names, _partition_keys_by_modality, _get_mm_fields_config, _apply_audio, get_dummy_text, apply
关键源码片段
vllm/model_executor/models/transformers/multimodal.py
核心文件:修复 apply() 占位符触发条件、Token-ID 重复特殊 token、_apply_audio 占位符合并,并新增按模态分区字段的 _partition_keys_by_modality 机制。
def _get_modality_field_names(self, modality: str) -> set[str]:
"""返回指定模态的子处理器会产生的字段名集合。"""
# TODO: 等 huggingface/transformers#44394 落地后只用 else 分支
if modality == "audio":
sub_processor = self.info._get_audio_processor()
else:
processor = self.info.get_hf_processor()
sub_processor = getattr(processor, f"{modality}_processor", None)
# 预计算 embedding 会绕过子处理器,因此始终视为该模态的字段
names = {f"{modality}_embeds"}
for name in getattr(sub_processor, "model_input_names", None) or ():
# 配套 mask 会被输出但不一定声明在 model_input_names 中
names.update((name, f"{name}_mask"))
return names
def _partition_keys_by_modality(
self,
keys: list[str],
modalities: list[str],
) -> dict[str, list[str]]:
"""把 HF 处理器输出的每个 key 归属到产生它的模态。"""
if len(modalities) == 1:
return {modalities[0]: keys}
claimed = {m: self._get_modality_field_names(m) for m in modalities}
owned: dict[str, list[str]] = {modality: [] for modality in modalities}
unclaimed = []
for key in keys:
for modality in modalities:
if key in claimed[modality]:
owned[modality].append(key)
break
else:
# 没有任何模态声明的 key 直接丢弃,避免后续报错
unclaimed.append(key)
if unclaimed:
logger.warning_once(
"Unable to attribute %s to any of the modalities %s, so they "
"will not be passed to the model. Add them to the relevant "
"sub-processor's `model_input_names` to fix this.",
tuple(unclaimed),
tuple(modalities),
)
return owned
def _get_mm_fields_config(
self,
hf_inputs: "BatchFeature",
hf_processor_mm_kwargs: Mapping[str, object],
) -> Mapping[str, MultiModalFieldConfig]:
# HF 处理器总会返回 attention_mask,但 vLLM 不需要它
hf_inputs.pop("attention_mask", None)
# 大小信息由 _apply_audio/_apply_vision 写入;模态没有输入时为 None
sizes = {
"audio": hf_inputs.get("num_audio_tokens"),
"image": hf_inputs.get("num_image_patches"),
}
modalities = [m for m, size in sizes.items() if size is not None]
size_keys = {"num_audio_tokens", "num_image_patches"}
keys = [key for key in hf_inputs if key not in size_keys]
owned = self._partition_keys_by_modality(keys, modalities)
mm_fields: dict[str, MultiModalFieldConfig] = {
key: MultiModalFieldConfig.flat_from_sizes(modality, sizes[modality])
for modality in modalities
for key in owned[modality]
}
# 这些字段总是 batched,因为 batch size 是第一维
if "audio" in modalities:
mm_fields["num_audio_tokens"] = MultiModalFieldConfig.batched("audio")
if "image" in modalities:
mm_fields["image_grid_thw"] = MultiModalFieldConfig.batched("image")
# TODO: 支持 video 模态后改路由到 "video"
mm_fields["video_grid_thw"] = MultiModalFieldConfig.batched("image")
mm_fields["num_image_patches"] = MultiModalFieldConfig.batched(
"image", keep_on_cpu=True
)
return mm_fields
评论区精华
唯一的 review 讨论围绕 get_dummy_text 的分隔符假设:DarkLight1337 质疑默认用 \n 是否通用(例如单空格也可以),hmellor 回应分隔符是任意的、仅供 profiling 使用,最终改用空格。两位 reviewer(DarkLight1337、Isotr0py)均已批准,DarkLight1337 在批准时表示"Let's get this fix in first"。
- get_dummy_text 中音频占位符分隔符的选择 (design): 最终采用空格分隔;该选择不影响功能,只影响 dummy profiling 的占位符识别。
风险与影响
- 风险:
- 依赖 HF
model_input_names 契约:_partition_keys_by_modality 依赖各 HF 子处理器的 model_input_names,HF 版本升级或处理器变更可能导致字段无法归属而被丢弃(有 logger.warning_once 提示)。代码已标注 TODO 跟踪 huggingface/transformers#44394。
- 错误模式变更:相邻音频占位符从静默合并变为显式
ValueError,对之前能跑通但结果错误的用户是破坏性变更,但更安全。
- 测试覆盖扩大:所有 Transformers 多模态模型进入处理正确性测试,CI 时间增加;个别模型可能因网络或兼容性跳过(已有 skip 逻辑)。
- 行为回归风险:
_apply_audio 现在要求 run 数与音频项数严格一致,若某个 HF 处理器对单个音频产生多个 run(例如内含分隔符),会误报;当前测试仅覆盖 Granite Speech,广度有限。
- 影响:用户侧:文本仅 prompt 不再崩溃,Token-ID 与文本 prompt 行为一致,多音频/多图像请求的占位符定位正确。系统侧:Transformers 后端多模态字段处理从"按模型能力猜测"变为"按实际数据归属",为后续新增视频模态、
text_replacement_offsets 奠定基础。团队侧:建立了该后端的 cached-vs-uncached 一致性回归防线,类似问题未来可被通用测试捕获。
- 风险标记:核心路径变更, 依赖 HF 契约, 错误模式变更
关联脉络
参与讨论