Skip to main content

09 - 通用框架怎么 serve 多模态

点单机器人加了个新功能:用户可以拍一张菜单照片,问「这个有什么推荐」。

你手上的推理框架(vLLM 或者 SGLang)本来就支持图片输入,所以这事看起来只是换个请求格式。上线之后你发现两个怪现象:

第一个:同一个用户对着同一张菜单连问五个问题,五次请求耗时几乎一样。按理说第二次开始那张图不用再算了 —— 文本那边不是有前缀缓存吗,一样的开头能直接跳过。图片这边好像完全没生效。

第二个:只要有带图的请求进来,那一瞬间所有正在打字的用户都会卡一下。看监控,GPU 有个尖峰。

两件都不是 bug,是多模态请求在框架内部走的路跟纯文本不一样。这一篇讲通用框架为此加了哪些东西 —— 它们是所有多模态服务的公共底座,包括后面 SGLang-Omni 那套。

一、多模态请求多出来的四步

纯文本请求进到框架里,路径很短:分词、预填充、逐字解码。带图片或音频的请求在最前面多插了四步,而开场那两个怪现象分别出在第三步和第四步上。

先看这四步各自在干什么、各自会怎么坏:

纯文本请求只有「分词 → prefill → decode」,多模态在分词前后各插了两步① 拉取媒体URL / base64 / 本地路径解码图片、视频抽帧网络 IO,可能超时② 跑 processor缩放、归一化、算 mel纯 CPU,可能很慢最容易堵住事件循环③ 展开占位符一个标记 → N 个坑位N 由分辨率/时长决定数量算错就整段错位④ 编码并注入跑塔,得到向量按坑位散射进嵌入GPU 密集,可缓存照常 prefill到这一步之后跟纯文本没区别复用全部现有机制这四步的成本分布很不均匀,优化的着力点也因此不同① 和 ② 是 CPU 和 IO,解法是挪到独立线程/进程去做,别占着 GPU 调度循环。SGLang-Omni 干脆把它做成一个独立的 preprocessing 阶段。③ 是纯逻辑,但错了很难查 —— 坑位数和向量数对不上,轻则输出乱码,重则越界。框架通常在这里加断言而不是容错。④ 是唯一吃 GPU 的,也是唯一值得做缓存的:同一张图在多轮对话里会被反复编码,一次编码的结果可以跨请求复用。「④ 可缓存」这件事需要一个前提 —— 得先有办法判断「这两张图是同一张」。这就是下一节的内容哈希。
把 ① ② 挪出主循环这件事,在单进程框架里靠线程池,在 SGLang-Omni 里直接是一个独立阶段。这也是「多阶段」相对「单进程」最先兑现的收益 —— 它把一类 CPU 工作彻底移出了 GPU 进程。

二、内容哈希

现在来解开场那个「同一张图问五遍,五遍一样慢」的谜。

纯文本的前缀缓存是这么工作的:把请求的 token 编号一个个比过去。

如果前 500 个跟上一个请求一样,那这 500 个的计算结果直接拿来用就行,不用重算。省下的是实打实的。

图片这边为什么失灵?回到 02 篇:一张图进到序列里,占的是几百个占位符。占位符只是个「这里将来要填图」的记号,它的编号是固定的那一个,跟图的内容毫无关系。

于是问题来了 —— 你上传菜单 A 和我上传菜单 B,展开出来的占位符序列完全相同。缓存一比对,「前缀一样」,于是它把 A 的计算结果给了 B。

把两个用户的请求并排画出来,问题一眼就看见了:

两个用户,两张完全不同的菜单用户 A菜单图 A展开成占位符 →151881518815188…共 1024 个,全是同一个编号用户 B菜单图 B展开成占位符 →151881518815188…一模一样缓存拿编号当依据「前 1024 个 token 完全一样」→ 判定为同一前缀于是把 A 的图像计算结果直接给了 B后果:B 问「这个有什么推荐」,模型看到的是 A 的菜单缓存拿内容哈希当依据A 的图算出 3f7a…,B 的图算出 c210…两个键不同,各查各的图片内容变了哈希就变,URL 变了但图没变哈希不变占位符只是个「这里将来要填图」的记号,它的编号跟图的内容毫无关系 —— 这就是整个问题的根源。纯文本时代不存在这个坑:文字的 token 编号本来就携带内容,一样的编号就是一样的字。
这不是理论风险。用内容哈希之后,「同一个用户对同一张图连问五个问题」也终于能命中缓存了 —— 开场那个「五次一样慢」的现象跟这个是同一个根因的两面。

所以真实情况比「没生效」更糟。

它不是不命中,是会错误命中

占位符编号一样,缓存就认为「前缀相同」,于是把 A 的图片计算结果交给了 B。这两年真出过这类事故:一个用户看到了另一个用户图片的内容

堵法是别拿占位符编号当依据,改成给图片内容本身算一个哈希值

这里要先立两个词,后面到处都是:

意思
mmmultimodal 的缩写,「多模态」。框架代码里这两个字母满地都是,mm_hashmm_inputsmm_schedule 全是它
mm item一条多模态输入。一张图是一个 item,一段音频是一个 item —— 一个请求带三张图就是三个 item。它是缓存和调度的基本单位:不按请求算,也不按 token 算,按 item 算
mm hash一个 mm item 内容的哈希值。图片的像素变了它就变,URL 变了但图没变它就不变

有了 mm hash,缓存键就跟内容绑定了:菜单 A 和菜单 B 算出来是两个不同的值,再也不会互相命中。它在多模态世界里扮演的角色,就是纯文本世界里「前缀」的角色 —— 判断「这份东西我算过没有」的依据。

直接拿 token id 做键的后果在04 篇提过:A 用户的图像 KV 会被 B 用户命中。所以框架必须给每个 mm item 算一个内容哈希,把它塞进缓存键里。

SGLang 的实现(srt/managers/mm_utils.py)分几种情况:张量走 tensor_hash,CUDA 上的张量直接用 GPU kernel 算(gpu_tensor_hash)避免拷回主机,CPU 张量则逐个增量喂进 sha256 而不先拼接(省一次大内存分配),最后取摘要前 8 字节当 64 位整数。共享内存里的张量如果带了预计算哈希就直接用,不重算。

为什么是 8 字节而不是完整摘要:这个值要参与 radix 树的键,做整数比较,越短越快;64 位的碰撞概率对这个用途足够。但框架并不完全信任它 —— 后面会看到,跨请求批处理去重时,键里除了哈希还带上了 token 数,专门用来挡住「两个哈希碰撞但占位跨度不同」的情况。

三、三级缓存的分工

多模态服务里同时存在三层缓存,它们缓存的东西、键、命中省掉的成本都不一样。混为一谈是理解这一层的最大障碍。

对比项① 处理器缓存② 编码器嵌入缓存③ 前缀 KV 缓存
缓存什么processor 的输出张量塔算出的嵌入向量注意力的 K 和 V
键是什么原始媒体的哈希mm item 的哈希token id 序列 + mm 哈希
命中省掉CPU 预处理时间一次塔的前向整段 prefill 的注意力计算
容量按什么算字节数嵌入个数,不含中间文本 tokenKV 页数
淘汰策略LRU引用计数为零的先淘汰LRU + 引用计数
跨请求共享是,同一张图不同请求都能命中是,但前缀要完全相同
「容量按嵌入个数算」是个容易踩的换算

这是 vLLM 编码器缓存文档专门澄清的一条:它管理的是多模态嵌入本身,穿插在中间的文本 token 既不占它的容量,也不占它的空闲槽位

把它跟 KV cache 的页数混算,容量规划会差出很远。

三层的命中率是独立的:一张图可能命中 ① 和 ②,但因为提示词换了而完全命中不了 ③。调优时先分清是哪一层没命中。

四、编码器缓存的两个约束

上一节的三层缓存里,中间那层(编码器嵌入缓存)最容易写错,因为它有两个不太直觉的约束。

先想一个具体情形:一张图的嵌入正被请求 A 用着,这时候缓存满了,需要腾地方。能把它淘汰掉吗?

不能 —— 而且原因比「A 还在用」更微妙:A 可能刚做完分块预填充的前半段,后半段还要拿同一份嵌入。淘汰了它,A 后半段就得重编一次,而重编出来的嵌入未必和前半段拼得上。

约束一:分配时才淘汰,零引用的先走。缓存条目是 mm_hash → 引用它的请求 id 集合。集合为空说明没人在用,可以淘汰;不为空则必须留着 —— 因为某个请求可能刚做完分块预填充的前半段,后半段还要用同一份嵌入。淘汰发生在「新条目分配不下」的时刻,而不是定时清理。

约束二:编码器预算限制一次调度能编多少。这是防止一个塞了 20 张图的请求把整个调度步占满。vLLM 用 compute_mm_encoder_budget 从「模型每种模态单项最多产出多少 token」推出预算上限,调度器据此决定这一步编几个 item。没有这个预算,编码器会周期性地把解码批饿死 —— 这正是03 篇提到的 ASR 现象的通用版本:每一次准入都要先跑 28~46 ms 的编码器+prefill,而这段时间整个运行中的解码批全部停摆。

五、分块预填充切在图像中间

分块预填充是把一次长 prefill 拆成 4096 token 一块,块之间插别人的 decode 步,避免长请求把短请求饿死。纯文本时代这没什么难的 —— 切在哪都行。有多模态 item 之后就麻烦了:一个 1024 token 的图像块可能刚好横跨两个 chunk 的边界

一个横跨块边界的图像 item序列文本 380 token图像占位符 1024 个文本 220 token块边界落在这里做不到的:把图切两半分别编码ViT 的注意力是全局的,半张图编出来的向量跟整张图的前半段完全不是一回事强行这么做的结果是图像语义被破坏,而且不会报错 —— 只是回答变得驴唇不对马嘴实际做法:整张编码,按区间切片① 整张图编一次,得到完整的 1024 个向量② 按当前块的 [prefix_len, prefix_len+seq_len) 区间 从这 1024 个里切出该块需要的那一段③ 整份嵌入留在编码器缓存里,等下一块来取第二块到来时不会重编,直接从缓存拿同一份嵌入再切后半段 —— 这就是上一节「引用计数不为零不能淘汰」那条约束的直接用途。SGLang 的 get_embedding_chunk 做的就是这个区间换算:把序列坐标映射到嵌入坐标,处理「完全在块前」「跨越块」「完全在块后」三种情况。
还有个衍生约束:分块预填充期间不能往外吐流式增量。中间块出来的还不是完整状态,发出去就是错的。SGLang-Omni 的 ASR 路径明确写了「分块预填充期间抑制流式输出」。

六、跨请求批处理 ViT

再看一个具体场景:这一个调度步里,有 5 个请求各带一张图,都没命中缓存。

最朴素的做法是挨个编码 —— 跑 5 次 ViT。但 ViT 跟 01 篇讲的解码一样,也是「搬权重的时间远多于算的时间」,跑 5 次等于把权重搬了 5 趟。

SGLang 主线的做法是把这 5 张图合并成一次 ViT 调用

同一个调度步里的 5 个请求,各带一张图请求 1请求 2请求 3请求 4请求 5查嵌入缓存2 命中 · 3 未命中未命中的去重键 =(哈希, 期望 token 数)一次 ViT 调用权重只搬一趟按各自 token 数切开写回缓存为什么值得合并:ViT 跟解码一样,搬权重的时间远多于算的时间。跑 5 次等于把权重搬了 5 趟。去重键为什么要带上 token 数:防止两个哈希碰撞、但占位跨度不同的图被错误地合并成一个。切开时不能用张量视图 —— 视图会把整块大缓冲钉住,只要还有一张图在缓存里,那块内存就释放不掉。
这三个细节都写在 SGLang 的注释里,每一条都对应一个真实踩过的坑。最后一条尤其隐蔽:功能完全正确,只是显存一直下不去。

实现在 mm_schedule.py_batch_encode_per_image_misses

实现在 mm_schedule.py_batch_encode_per_image_misses,流程是:先遍历每个请求,找出与当前块有重叠的 item;对每个 item 查嵌入缓存;缺失的按键去重收集;最后所有缺失项一次 ViT 前向,结果按各自的 token 数切开,逐个写回缓存。

三个值得抄的细节:

  • 去重键是 (哈希, 期望 token 数) 而不是单纯的哈希。注释写得很直白:带上 token 数是为了防止两个碰撞的哈希、但占位跨度不同的 item 被错误地合并。这是对 64 位哈希碰撞风险的一道具体防线。
  • 缓存里取出来的嵌入要校验 token 数。取到了但长度对不上,说明这条缓存是脏的(比如上次是别的分辨率),直接丢弃重编,而不是将错就错。
  • 按 item 返回的嵌入不能用 torch.split 的视图。注释解释得很好:视图会把整个拼接后的大缓冲区钉住,只要还有任何一个 item 在缓存里,那块大内存就释放不掉。所以要 reshape 出各自独立持有存储的张量。

这最后一条是很典型的「显存泄漏不报错」型 bug —— 功能完全正确,只是显存一直下不去。

七、编码器数据并行与 EPD 分离

上面所有机制都在一个进程里。当图像流量变大时,还有两级往外拆的手段。

第一级:编码器走数据并行,语言模型走张量并行。 ViT 相对语言模型小得多,做张量并行的收益很小,但每层之后的 all-reduce(把各张卡算出的部分结果汇总相加,再把完整结果发回每张卡 —— 张量并行每一层结束都要做一次)通信开销一点不少。SGLang 的 --mm-enable-dp-encoder 让视觉前端复制多份并行处理不同批,把宝贵的互联带宽全留给语言模型。这是个纯粹的「按模块选并行方式」的判断,值得推广到任何「大模型 + 小前置模块」的结构。

第二级:EPD 三层分离。 把编码器、预填充、解码彻底拆成三种服务:

拆开之后:编码器服务按图像流量扩,语言服务按文本负载扩,两边的卡型甚至可以不同。SGLang 还给编码器服务接了一个 Mooncake 支撑的全局嵌入缓存 —— 单机的嵌入缓存只对本实例有效,全局缓存让「同一张图在不同实例上重复出现」也能命中。它的适用条件文档写得很老实:图像输入有重复或重叠、编码器确实是瓶颈、集群里本来就有 Mooncake。三条都不满足就别上。

什么时候不该拆:图像流量不大、单机就能吃下的时候。拆开引入了一次跨服务的张量传输(一张图的嵌入是几百 KB 到几 MB),以及一整套服务发现、健康检查、故障传播。这些成本是确定的,收益只在编码器真的成为瓶颈时才出现。

八、这一层的四种典型故障

  • 占位符数与嵌入数不一致。动态分辨率模型上高发,通常源于服务端和 processor 对「合并策略」的理解不一致。表现是断言失败或越界。
  • 缓存串音。哈希粒度错了(比如按 URL 而不是按内容),换了内容但 URL 没变,命中到旧嵌入。
  • 编码器把解码批饿死。没有编码器预算,或者预算给得太大。表现是解码延迟周期性尖刺,而 GPU 利用率并不高。
  • 显存降不下来。嵌入缓存持有的是大缓冲区的视图,或者引用计数没有正确释放。表现是长时间运行后显存单调上升。
四个里有三个不会报错

只有第一个会抛异常,其余三个都只是让指标慢慢变差。这一层的可观测性做得值不值,基本决定了排查它们要花几小时还是几天。

下一篇01 - SGLang-Omni 总览