多模态模型服务接入:图片和文本不要混用同一个 Endpoint
多模态模型服务接入图片和文本不要混用同一个 Endpoint一、一张 4K 图片塞进同一个 Endpoint 时文本请求也在排队多模态推理最常见的部署模式是把所有请求——纯文本、图片文本、视频帧——全部投递到同一个/v1/chat/completions端点。初看起来这是 OpenAI API 的兼容做法模型本身也能处理不同类型输入为什么要拆分问题不出在模型能否处理而出在请求规模的巨大差异。一个典型的纯文本推理请求的输入 token 数大约在 500-2000 之间。而一张 4K 分辨率图片经过视觉编码器Vision Encoder处理后等效的视觉 token 数可达 2000-4000 个。如果用户同时发送 5 张图片在多图对比场景中很常见单次请求的视觉 token 数轻松超过 10000。这意味着什么在同一个端点中一个携带多图的请求占用的 KV Cache 空间和计算时间相当于 5-10 个纯文本请求。当它占据推理队列时后面排队的纯文本请求——可能只需 0.5 秒就能完成——必须等待 10 秒以上。监控数据表明混合部署模式下纯文本请求的 P50 延迟从 0.8 秒劣化到 4.2 秒。不是推理慢了是队列被堵了。二、多模态推理的请求路径差异不同类型的多模态请求在推理引擎内部走的是截然不同的处理路径。以下是一个 LLaVA 架构下的处理流程对比sequenceDiagram participant Gateway as API 网关 participant Router as 请求路由 participant TextWorker as 纯文本推理实例 participant VisionWorker as 多模态推理实例 participant VE as Vision Encoder participant LLM as LLM Decoder Note over Gateway,LLM: 场景A纯文本推理 Gateway-Router: POST /v1/chatbr/{text only} Router-TextWorker: 路由到文本专用实例 TextWorker-LLM: Tokenize → Forward → Decode LLM--TextWorker: token stream TextWorker--Gateway: 响应 (0.5-2s) Note over Gateway,LLM: 场景B多模态推理 Gateway-Router: POST /v1/chatbr/{text 3 imgs} Router-VisionWorker: 路由到多模态实例 VisionWorker-VE: Encode image_1 (256ms) VisionWorker-VE: Encode image_2 (256ms) VisionWorker-VE: Encode image_3 (256ms) Note over VisionWorker: 拼接 visual tokens text tokensbr/总输入 ~12000 tokens VisionWorker-LLM: Forward Decode LLM--VisionWorker: token stream VisionWorker--Gateway: 响应 (5-15s)两个关键差异点视觉编码阶段。每张图片需要经过 Vision Encoder如 CLIP ViT-L/14逐 patch 编码单张图片的编码耗时在 100-500ms 之间。3 张图片累积超过 750ms 的前处理延迟。KV Cache 占用。视觉 token 文本 token 的合并序列长度远超纯文本请求。在 vLLM 中KV Cache 按 block 分配block 数量由最大序列长度决定。一个多模态请求可能占用 3-4 倍的 KV Cache block导致可并行的请求数max_num_seqs下降。三、生产级多模态路由网关实现以下代码实现一个基于请求特征路由的网关层将不同模态的请求分发到独立的推理实例池。package gateway import ( context encoding/json fmt io net/http strings sync time ) // ModalityType 定义请求模态类型。 type ModalityType string const ( ModalityTextOnly ModalityType text_only ModalityMulti ModalityType multi_modal ) // RequestClassifier 根据请求体内容判断模态类型。 // 对 OpenAI 兼容的 chat/completions 格式进行分类。 type RequestClassifier struct{} // ClassifyRequest 读取请求体的前 N 字节检测是否包含图片引用。 // 使用流式解析避免将整个请求体加载到内存。 func (c *RequestClassifier) ClassifyRequest(body io.Reader) (ModalityType, io.Reader, error) { // 限制读取量检查前 64KB 足够覆盖图片 base64 前缀。 lr : io.LimitReader(body, 64*1024) data, err : io.ReadAll(lr) if err ! nil { return , nil, fmt.Errorf(read request body: %w, err) } var req struct { Messages []struct { Content json.RawMessage json:content } json:messages } if err : json.Unmarshal(data, req); err ! nil { return , nil, fmt.Errorf(parse request: %w, err) } for _, msg : range req.Messages { // content 可能是 string纯文本或 array多模态。检测内容。 raw : string(msg.Content) if strings.HasPrefix(strings.TrimSpace(raw), [) { // Array 格式OAI 多模态 content。例如 [{type:text, ...}, {type:image_url, ...}] // 进一步检查是否包含 image_url 字段。 if strings.Contains(raw, image_url) { // 合并原始数据与未读取部分放回 reader 供下游使用。 combined : io.MultiReader( strings.NewReader(string(data)), body, // 未被 LimitReader 读取的剩余部分 ) return ModalityMulti, combined, nil } } } combined : io.MultiReader(strings.NewReader(string(data)), body) return ModalityTextOnly, combined, nil } // ModalityRouter 多模态路由网关。 type ModalityRouter struct { mu sync.RWMutex textBackend string // 纯文本推理服务地址 multiBackend string // 多模态推理服务地址 httpClient *http.Client } func NewModalityRouter(textBackend, multiBackend string) *ModalityRouter { return ModalityRouter{ textBackend: textBackend, multiBackend: multiBackend, httpClient: http.Client{ Timeout: 120 * time.Second, Transport: http.Transport{ MaxIdleConnsPerHost: 50, IdleConnTimeout: 90 * time.Second, }, }, } } // ServeHTTP 实现 http.Handler 接口完成请求分类与转发。 func (r *ModalityRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { // 仅处理推理接口其他接口直接透传。 if req.URL.Path ! /v1/chat/completions || req.Method ! http.MethodPost { http.Error(w, not found, http.StatusNotFound) return } classifier : RequestClassifier{} modality, body, err : classifier.ClassifyRequest(req.Body) if err ! nil { http.Error(w, fmt.Sprintf(classify request: %v, err), http.StatusBadRequest) return } backend : r.textBackend if modality ModalityMulti { backend r.multiBackend } // 构造转发的请求。 proxyReq, err : http.NewRequestWithContext( req.Context(), http.MethodPost, backend, body, ) if err ! nil { http.Error(w, fmt.Sprintf(create proxy request: %v, err), http.StatusInternalServerError) return } // 透传原始请求头但移除 Host否则指向后端而不是网关。 for k, vs : range req.Header { if k Host { continue } for _, v : range vs { proxyReq.Header.Add(k, v) } } // 注入路由标记头便于后端观测请求来源。 proxyReq.Header.Set(X-Modality-Type, string(modality)) resp, err : r.httpClient.Do(proxyReq) if err ! nil { http.Error(w, fmt.Sprintf(proxy request failed: %v, err), http.StatusBadGateway) return } defer resp.Body.Close() // 透传响应头和状态码。 for k, vs : range resp.Header { for _, v : range vs { w.Header().Add(k, v) } } w.WriteHeader(resp.StatusCode) io.Copy(w, resp.Body) } // SplitConfig 描述拆分后的推理实例配置。 type SplitConfig struct { TextPort int // 纯文本推理实例端口 MultiPort int // 多模态推理实例端口 } // GenerateSplitCommand 生成 vLLM 实例拆分启动命令。 // 核心思路部署两个推理实例共享同一块 GPU通过 MPS或分别绑定不同 GPU // 纯文本实例调小 max-model-len 以减少 KV Cache 碎片多模态实例单独配置。 func GenerateSplitCommand(cfg SplitConfig) []string { return []string{ // 纯文本实例较小 KV Cache更高并发。 fmt.Sprintf( vllm serve Qwen/Qwen2-VL-7B-Instruct --port %d --max-model-len 8192 --gpu-memory-utilization 0.40 --max-num-seqs 32, cfg.TextPort, ), // 多模态实例较大 KV Cache较低并发。 fmt.Sprintf( vllm serve Qwen/Qwen2-VL-7B-Instruct --port %d --max-model-len 32768 --gpu-memory-utilization 0.55 --max-num-seqs 8 --limit-mm-per-prompt image5, cfg.MultiPort, ), } }核心设计ClassifyRequest通过检测 content 数组中是否包含image_url字段来判断模态类型避免将整个大请求体加载到内存。请求体使用io.MultiReader拼接支持将分类时读取的数据和剩余部分合并后转发给上游推理服务。两个推理实例通过--gpu-memory-utilization拆分显存。纯文本实例仅分配 40% 显存但承载更高并发max-num-seqs32多模态实例分配 55% 显存但限制序列数和图片数。四、拆分部署的代价与适用条件显存利用率下降。拆分两个实例意味着每个实例的 KV Cache 独立管理无法共享。在单 GPU 场景下总可用 KV Cache 两个实例的 Cache 之和 ≤ 单个实例满载时的 Cache 量。这不是免费的——你以约 5%-10% 的显存利用率为代价换取了隔离性。额外的调度复杂度。请求分类逻辑需要感知模型的实际多模态能力。例如Qwen2-VL 支持图片输入而 Qwen2-72B 不支持。如果网关按照请求内容分类但错误地将纯文本请求路由到一个不支持文本的实例请求会失败。因此分类逻辑必须与模型注册表同步。冷启动延迟翻倍。两个实例意味着两次模型加载。在模型大小 7B 的情况下每次加载约需 30-60 秒。需要评估是否值得为了端点的隔离性承受更长的部署时间。适用于哪些场景多模态请求占比超过 30% 且存在明显尾部延迟劣化的场景。如果多模态请求占比不到 5%拆分部署的收益可能不及运维成本。不适用于哪些场景显存极度受限的单 GPU 部署如消费级 24GB 显卡运行 7B 模型两个实例的显存拆分可能导致每个实例都无法正常分配 KV Cache。五、总结多模态推理服务中图片和文本混用同一个 Endpoint 的核心问题是队列拥堵——大请求堵住小请求所有请求的性能都受拖累。解决方案的核心是请求特征感知路由在网关层做请求分类。通过前 64KB 的内容检测判断请求是否包含图片将多模态请求路由到独立实例。推理实例按模态拆分。纯文本实例配置较小的 max-model-len、较高的 max-num-seqs多模态实例配置较大的 max-model-len、较低的并发上限。显存分配需精确计算。通过gpu-memory-utilization按请求比例分配显存纯文本实例 40%、多模态实例 55%留 5% 给 CUDA context 开销。拆不拆端点取决于多模态请求的占比和集群规模。但不要默认所有请求都走同一个端点——那是 Web 服务的习惯不是推理服务的最佳实践。