Prhub

#56090 [Frontend][EPD] Use JSON arrays for multimodal metadata

原始 PR 作者 gty111 合并时间 2026-09-10 13:04 文件变更 6 提交数 2 评论 9 代码增减 +167 / -62

执行摘要

将 EPD 代理中多模态元数据从 torch/base64 序列化改为 JSON 数组,提升吞吐。

动机主要来自性能优化和跨组件集成的需要。PR body 明确指出,使用 JSON 数组可以避免 torch.save/torch.load 和 base64 编解码的开销,这对于小型元数据数组尤其有效。此外,提供一种语言中立的格式,为 vllm-project/router 和 Rust 前端(#55842)的未来 EPD 支持铺平道路,它们可以处理 JSON 数组而无需依赖 PyTorch 或实现其序列化格式。这是从 #55842 中提取出的纯 Python 前置变更。

此 PR 是一个设计良好、有明确性能收益和前瞻性架构考量的改进。它实现了从特定平台序列化格式到语言中立的 JSON 格式的迁移,为 vllm 的分布式推理组件提供了更清晰的接口。实现中包含了良好的验证逻辑和全面的测试。对于关注 EPD 分离架构、多模态处理性能优化或 Rust 前端集成的工程师来说,此 PR 值得精读。主要关注点是确保下游组件(如 Rust 前端 PR #55842)能正确处理新的 JSON 元数据格式。

讨论亮点
  1. 命名争议:审核者 Isotr0py 指出最初提议的类型别名 MultiModalEmbeds 可能与 MultimodalEmbeddings 接口混淆,建议更清晰的命名。作者 gty111 随后将其改为 MultiModalEmbedsPayload,审核者认可了这一更改。
  2. 代码组织建议:Isotr0py 建议将数值验证逻辑提取为独立的工具函数 is_list_of_numbers,放置在 vllm/utils/collection_utils.py 中,以提高代码的可复用性和清晰度。作者接受了这个建议并实施了更改。

实现拆解

  1. 引入新类型和验证工具:在 vllm/entrypoints/chat_utils.py 中定义了 MultiModalEmbedsPayload 类型别名,以支持字符串(base64)或包含数字列表的字典。新增异步函数 _load_embeds_dict 用于分离加载字典中的 base64 张量和直接传递的 JSON 数组。新增同步函数 _parse_metadata_array 用于验证和转换 JSON 数组为 torch 张量,它依赖于新引入的 is_list_of_numbers 工具函数。
  2. 添加数值验证辅助函数:在 vllm/utils/collection_utils.py 中新增 is_list_of_numbers 函数,用于严格检查一个列表是否只包含有限的 int 或 float 类型(排除布尔值、NaN、Inf 和嵌套列表),确保输入到元数据字段的数据是有效的。
  3. 修改多模态嵌入解析流程:在 vllm/entrypoints/chat_utils.py 的 _get_embeds_data 函数中,当处理 dict 类型的 data_items 时,首先通过模型处理器获取元数据字段名 (metadata_fields),然后对字典中的每个值,如果是列表类型则调用 _parse_metadata_array 进行验证和转换,否则保持原样。这确保了只有模型声明的元数据字段才能接收 JSON 数组输入。
  4. 更新 EPD 代理示例代码:在 examples/disaggregated/disaggregated_encoder/disagg_epd_proxy.py 的 rewrite_for_decode 函数中,移除了对 _b64_tensor 的调用和相关导入(io, pybase64)。元数据字段的值直接作为扁平化的数字列表传递,而不是序列化为 base64 字符串。
  5. 补充全面测试:为新的工具函数 _parse_metadata_array 和 _load_embeds_dict 添加了单元测试,验证其正确性、错误处理和性能旁路行为。修改了现有的集成测试 test_parse_chat_messages_multiple_dict_image_embeds 以参数化方式同时测试 base64 和 JSON 数组两种路径。为 EPD 代理的测试用例 test_decode_rewrite_preserves_engine_reported_ec_hash 增加了断言,验证元数据现在以字典形式(而非 base64 字符串)传递。
文件 模块 状态 重要度
vllm/entrypoints/chat_utils.py 前端入口 modified 8.04
examples/disaggregated/disaggregated_encoder/disagg_epd_proxy.py EPD 代理 modified 6.89
vllm/utils/collection_utils.py 工具库 modified 6.21

关键符号

_parse_metadata_array _load_embeds_dict is_list_of_numbers _get_embeds_data rewrite_for_decode

关键源码片段

vllm/entrypoints/chat_utils.py core-logic

核心变更文件,定义了新的类型别名 `MultiModalEmbedsPayload`,实现了元数据解析新逻辑 `_parse_metadata_array` 和嵌入加载函数 `_load_embeds_dict`,并更新了多模态嵌入解析主流程 `_get_embeds_data`。

# vllm/entrypoints/chat_utils.py
​
# 新增类型别名,明确定义多模态嵌入载荷可以接受 base64 字符串或包含数字列表的字典
MultiModalEmbedsPayload: TypeAlias = str | dict[str, str | list[int | float]]
​
​
def _parse_metadata_array(key: str, value: list, metadata_fields: set[str]):
    """将 JSON 数组验证并转换为 torch 张量。"""
    # 仅允许模型声明的元数据字段接收 JSON 数组,防止误用
    if key not in metadata_fields:
        raise VLLMValidationError(f"JSON arrays are only supported for metadata: {key}")
    # 使用 is_list_of_numbers 严格校验输入,确保是有限的数值列表
    if not is_list_of_numbers(value):
        raise VLLMValidationError(f"Metadata {key} must be a finite numeric array.")
    # 根据内容自动选择最精确的 dtype:int64 或 float64
    dtype = torch.float64 if any(isinstance(v, float) for v in value) else torch.int64
    try:
        return torch.tensor(value, dtype=dtype)
    except (ValueError, TypeError, OverflowError, RuntimeError) as error:
        raise VLLMValidationError(f"Invalid metadata array: {key}") from error
​
​
async def _load_embeds_dict(
    data: dict[str, str | list[int | float]],
    fetch: Callable[[str], Awaitable["torch.Tensor"]],
) -> dict[str, Any]:
    """异步加载嵌入字典,分离 base64 张量和直接传递的 JSON 数组。"""
    # 筛选出值为字符串(即 base64 编码的张量)的项
    encoded = {key: value for key, value in data.items() if isinstance(value, str)}
    # 异步获取所有 base64 张量
    tensors = await asyncio.gather(*(fetch(value) for value in encoded.values()))
    # 合并结果:原始数据(包含 JSON 数组) + 新获取的张量
    return {**data, **dict(zip(encoded, tensors))}
examples/disaggregated/disaggregated_encoder/disagg_epd_proxy.py core-logic

EPD 代理示例的关键变更,移除了 `torch.save`/base64 序列化路径,改为直接传递扁平化的数字列表,是性能收益的主要来源点。

# examples/disaggregated/disaggregated_encoder/disagg_epd_proxy.py
​
def rewrite_for_decode(req_data: dict, item_meta: dict[int, dict]) -> dict:
    """..."""
    # ... ( 循环逻辑省略 )
    for item in content:
        # ... ( 省略前置判断 )
        meta = dict(item_meta.get(idx) or {})
        # ... ( 弹出特殊键 )
        # 变更:直接构造包含数字列表的字典,而不是调用 _b64_tensor 进行序列化
        # 这样下游(如 Rust 前端)可以原生地解析 JSON 数组
        metadata = {
            k: [
                x
                for item in v
                for x in (item if isinstance(item, list) else [item])
            ]
            for k, v in meta.items()
        }
        if not metadata or not item_uuid:
            # ... ( 省略回退逻辑 )
        embeds_type = EMBEDS_TYPES[item["type"]]
        new_content.append(
            {"type": embeds_type, embeds_type: metadata, "uuid": item_uuid}
        )
    # ... ( 省略后续逻辑 )
vllm/utils/collection_utils.py core-logic

新增了可复用的数值验证工具函数 `is_list_of_numbers`,为元数据校验提供了安全的基础。

# vllm/utils/collection_utils.py
import math
​
def is_list_of_numbers(value: object) -> bool:
    """Check every item is an int or finite float, excluding booleans."""
    # 使用 type(v) is int 而不是 isinstance(v, int),因为 bool 是 int 的子类
    return isinstance(value, list) and all(
        type(v) is int or (type(v) is float and math.isfinite(v)) for v in value
    )

评论区精华

类型别名命名 设计

审核者 Isotr0py 指出 `MultiModalEmbeds` 可能与 `MultimodalEmbeddings` 接口混淆,建议更清晰的命名。作者 gty111 响应并将其改为 `MultiModalEmbedsPayload`。

结论:同意,已重命名为 `MultiModalEmbedsPayload`。 · 已解决

提取验证逻辑为工具函数 设计

审核者 Isotr0py 建议将内联的数值验证逻辑(`type(v) is int or ...`)提取为 `is_list_of_numbers` 工具函数,放置在 `vllm/utils/collection_utils.py` 中。

结论:同意,已提取为可复用的函数。 · 已解决

风险与影响

  1. 接口变更与兼容性:变更了 MultiModalEmbedsPayload 类型别名,影响了 ChatCompletionContentPartImageEmbedsParam 等多个 TypedDict 的类型签名。虽然向后兼容(旧格式仍支持),但依赖这些接口的下游代码可能需要类型检查更新。
  2. 数据验证边界:is_list_of_numbers 和 _parse_metadata_array 中的验证逻辑是安全的入口点,确保了非数字、NaN、Inf、嵌套列表或大整数不会被错误地传递。但需注意 2**1024 这样的大整数虽然通过验证,但在后续 torch.tensor 转换时可能引发 OverflowError,这已被捕获并转化为 VLLMValidationError。
  3. EPD 代理路径:示例代码 disagg_epd_proxy.py 的变更移除了 torch.save/base64 路径,改为直接传递数字列表。这要求下游的解码器端(Rust 前端或 router)必须支持解析新的 JSON 数组格式。PR body 明确指出这是为 #55842 准备的,因此存在对后续 PR 的依赖。
  4. 测试覆盖:新增的测试覆盖了核心的新逻辑和边界条件,但 _get_embeds_data 函数的修改是通过现有的集成测试间接验证的。

用户影响:对终端用户无直接影响,但为使用 EPD 分离部署模式的用户提供了更高的推理吞吐量(基准测试显示约 2.59% 提升)。系统影响:主要影响 EPD 代理 (disagg_epd_proxy.py) 和多模态前端 (chat_utils.py) 的数据序列化路径,减少了对 PyTorch 序列化和 base64 的依赖,使元数据传输更轻量、更跨平台。团队影响:为后续在 Rust 前端 (#55842) 和 Router 中支持 EPD 奠定了协议基础,需要关注这些下游组件的集成进度。同时,引入的 MultiModalEmbedsPayload 类型和 _parse_metadata_array 函数成为了处理多模态嵌入和元数据的新的公共契约,可能会影响其他开发相关功能。

接口类型变更 跨组件依赖(需 Rust 前端集成) 示例代码非生产核心但影响部署路径

关联 Issue

#52409 EPD (EC connector) Tracker

完整报告

参与讨论