执行摘要
本 PR 为 vLLM Rust 前端的 gRPC Inference 服务补齐图像多模态推理:proto 契约新增 MediaItem/Modality,支持 HTTP(S) URL、data URI 与 raw bytes 三种传图方式;服务端通过 convert.rs 翻译为 MediaContentPart 后,复用 vllm-chat 既有的多模态预处理管线完成抓取、placeholder 展开与 MmFeatures 生成,并将 unary/stream 两条生成路径统一收口到 prepare_request,同时引入请求级 tracing 日志与 32 MB 消息上限。功能边界清晰(仅图像,音频/视频返回 UNIMPLEMENTED),但 SSRF 校验缺失和 ChatError 未归类是遗留问题。
功能与动机
PR body 明确写:“Add image inputs to the Rust frontend gRPC Inference service”、“Reuse the existing multimodal preprocessing pipeline to fetch and process images, expand placeholder tokens, and attach engine-facing multimodal features”,并声明“This PR implements image inference only. Audio and video support will be added in a follow-up PR”。补充动机:此前 gRPC GenerateRequest 只有文本/token_ids prompt,HTTP 侧的 /v1/chat/completions 已具备多模态能力,gRPC 侧缺口明显;review 中 BugenZhao 还指出 tonic 默认 4 MB 消息上限会拒绝带大图的请求(“bump the default max_decoding_message_size to avoid typical requests being rejected”),最终在合并前直接修掉。
实现拆解
- proto 契约扩展(
rust/proto/inference.proto):新增 Modality 枚举(IMAGE/VIDEO/AUDIO)与 MediaItem 消息(oneof source:url/data_uri/raw_bytes,外加 mime_type、uuid),并在 GenerateRequest 追加 repeated MediaItem media = 14;VIDEO/AUDIO 枚举提前预留但服务端不实现,为后续 PR 留出契约空间。
- 转换层(
rust/src/server/src/grpc/convert.rs):新增 media_parts_from_request 与 validate_media_uri。按 item.modality() 访问器分支:Unspecified 报 INVALID_ARGUMENT、非 Image 模态报 UNIMPLEMENTED 并带索引定位、缺 source 报 INVALID_ARGUMENT;URL/data URI 映射为 MediaContentPart::ImageUrl(scheme 白名单 http/https/data),raw bytes 映射为 ImageData 并透传 mime_type 与 uuid。
- 会话层 API(
rust/src/chat/src/lib.rs 与 rust/src/chat/src/multimodal.rs):ChatRequestProcessor 新增私有 prepare_media,ChatLlm 暴露同名 pub 方法委托实现,避免在 server 层重复多模态逻辑;MultimodalModelInfo::prepare_multimodal 由私有改为 pub(crate) 可见性;对外导出 MediaContentPart 与 MmFeatures。
- gRPC 服务层重构(
rust/src/server/src/grpc/inference.rs):新增 PreparedGrpcRequest 与统一入口 prepare_request,unary 与 streaming 共用同一准备路径;自动生成缺失的 request_id、写入 arrival_time、建立 info_span! 请求级日志贯穿 preparation/submission/collection/stream;媒体场景强制 Prompt::TokenIds(placeholder 展开依赖 token_ids),否则返回 INVALID_ARGUMENT;调用 chat.prepare_media 原位展开 placeholder 并挂载 mm_features;新增 log_text_error 统一记录下游错误。
- 测试与配置配套:
tests.rs 新增 FakeMultimodalBackend(基于 qwen2_vl 配置构建 MultimodalModelInfo)与 setup_grpc_service_with_backend(支持注入 backend 与请求断言闭包),新增 3 个测试覆盖媒体展开结果、文本 prompt 拒绝、超过 tonic 默认上限的大请求;server/lib.rs 将 gRPC max_decoding_message_size 与 HTTP JSON body limit 共用 DEFAULT_REQUEST_BODY_LIMIT_BYTES(32 MB),routes.rs 同步复用;cli.rs/config.rs 修正服务名注释。
统一请求准备路径(inference.rs)
关键源码片段
## 媒体请求转换与 URI 校验(convert.rs)
/// Convert gRPC MediaItem 的列表为内部 MediaContentPart 列表。
///
/// 目前只支持 Image 模态;视频与音频在 proto 中已预留枚举,
/// 但服务端实现会以 UNIMPLEMENTED 拒绝,错误信息带索引便于定位。
pub fn media_parts_from_request(
media: Vec<pb::MediaItem>,
) -> Result<Vec<MediaContentPart>, Status> {
let mut parts = Vec::with_capacity(media.len());
for (index, item) in media.into_iter().enumerate() {
// proto3 缺省值为 0,用 modality() 访问器把未声明的请求挡在门外。
match item.modality() {
pb::Modality::Image => {}
pb::Modality::Unspecified => {
return Err(Status::invalid_argument(format!(
"media[{index}].modality is required"
)));
}
other => {
return Err(Status::unimplemented(format!(
"media[{index}].modality {other:?} is not supported by the gRPC service"
)));
}
}
// proto3 空字符串与“未设置”无法区分,按业务约定空值视为缺省元数据。
let uuid = (!item.uuid.is_empty()).then_some(item.uuid);
let mime_type = (!item.mime_type.is_empty()).then_some(item.mime_type);
let part = match item.source {
Some(pb::media_item::Source::Url(url)) => {
// gRPC 侧只约束 scheme,host 级校验(防 SSRF)尚未落地。
validate_media_uri(index, "url", &url, &["http", "https"])?;
MediaContentPart::ImageUrl {
url,
detail: None,
uuid,
}
}
Some(pb::media_item::Source::DataUri(uri)) => {
validate_media_uri(index, "data_uri", &uri, &["data"])?;
MediaContentPart::ImageUrl {
url: uri,
detail: None,
uuid,
}
}
Some(pb::media_item::Source::RawBytes(bytes)) => MediaContentPart::ImageData {
data: bytes,
mime_type,
uuid,
detail: None,
},
None => {
return Err(Status::invalid_argument(format!(
"media[{index}].source is required"
)));
}
};
parts.push(part);
}
Ok(parts)
}
/// 仅校验 URI 是否可解析且 scheme 在白名单内。
///
/// 注意:没有解析 host 或阻断内网地址,存在 SSRF 面,
/// 见 review 中 depthfirst-app[bot] 的讨论。
fn validate_media_uri(
index: usize,
field: &str,
value: &str,
allowed_schemes: &[&str],
) -> Result<(), Status> {
let uri = Url::parse(value).map_err(|_| {
Status::invalid_argument(format!("media[{index}].{field} is not a valid URI"))
})?;
if !allowed_schemes.contains(&uri.scheme()) {
return Err(Status::invalid_argument(format!(
"media[{index}].{field} must use the {} scheme",
allowed_schemes.join(" or ")
)));
}
Ok(())
}
评论区精华
- 委托位置:BugenZhao 建议“Shall we make it a method on
self.processor and perhaps delegate it here?”,作者答复“Fixed.”,最终 ChatLlm::prepare_media 只做委托,实现落在 ChatRequestProcessor。
- 代码风格:BugenZhao 指出“We can directly call
item.modality()”,以及日志中传 status.message() 拷贝“doesn't seem very necessary”,作者均接受并修复。
- 错误归类:BugenZhao 询问“Shall we also categorize the
ChatError returned here?”,作者说明“This was intentionally deferred to a follow up PR to reduce the scope of this PR”,维护者接受并合并。
- 消息上限:BugenZhao 在 proto 行内提问是否提升
max_decoding_message_size 以避免典型请求被拒,作者想做可配置 flag;BugenZhao 最终直接 push 一个 commit:“just pushed a commit to use that hardcoded value for gRPC server as well”,复用 HTTP 侧 32 MB 常量。
- 安全告警:
depthfirst-app[bot] 标记 MEDIUM 级 SSRF——validate_media_uri 只校验 scheme 不校验 host,MediaConnector 的 reqwest 客户端默认跟随重定向,可被诱导访问 169.254.169.254 云 metadata 或内网服务;未见维护者公开回应,属于未解决风险。
风险与影响
- SSRF(高):
convert.rs 仅做 scheme 白名单,聊天层的 MediaConnector 抓取 URL 时无 host/IP 限制且默认跟随重定向;若 gRPC 服务具备内网访问能力,恶意客户端可探测内网与云 metadata,错误信息可能泄露内部拓扑。建议后续补 host 校验与禁重定向。
- 核心路径重构(中):
prepare_request 统一了 unary/stream,行为变化点包括自动生成 request_id、arrival_time 赋值和日志结构,回归面覆盖所有既有 gRPC 文本请求;现有测试主要覆盖新功能,TLS、超时等旧边界未逐项回归。
- 行为兼容性:媒体请求必须提供 token_ids prompt,文本 prompt + media 会直接返回
INVALID_ARGUMENT,客户端需适配;proto 字段为纯增量,旧客户端不受影响。
- 资源开销:gRPC 消息上限由 tonic 默认 4 MB 提至 32 MB,raw bytes 大图会驻留内存;URL 抓取引入出网依赖,重定向放大可能被滥用。
- 团队影响:为 audio/video、媒体错误分类与可配置消息上限三个 follow-up 打好了契约与服务层基础,
Modality 枚举已预留。
关联脉络
本 PR 是 Rust 前端 gRPC 服务能力补全的一环,与近期 Rust 侧 PR 存在明确演进关系:#50746 加固 gRPC stop 字符串校验(同样改动 grpc/tests.rs),#48048 在 GenerateRequest 中引入 session_id 字段(与本 PR 的 media = 14 同属请求契约演进),#50868 则演进 Rust benchmark 流处理。整体方向是让 Rust 前端与 Python 侧的多模态、会话能力逐步对齐。
参与讨论