如果你维护过一个多模态问答服务,一定会遇到这样的场景:单看推理引擎的吞吐指标一切正常,可一旦有用户上传图片,整体延迟就开始失控,GPU显存忽高忽低,甚至偶发OOM。这不是模型的问题,也不是推理框架的问题,而是整个服务架构在设计之初根本没有把"多模态"当作一等公民来对待。
我近一年大部分精力都花在重构一套面向大规模多模态推理的高效服务架构上。这里说的"多模态",主要是视觉语言模型的推理服务化,也就是图文理解、视觉问答、多图对比这类场景。这篇文章不打算堆概念,也不会贴一堆架构图,我想用最直接的方式把整个设计过程、关键取舍和踩过的坑讲清楚。适合正在做模型服务化、推理性能优化,或者准备给团队多模态能力做平台化的工程师参考,哪怕你只是刚接触推理服务,也能从里面找到可以直接落地的思路。
1. 多模态请求的"三本账":为什么原有LLM架构扛不住
1.1 请求结构、显存占用与数据吞吐三个维度同时变化
很多人以为多模态推理就是在纯文本大模型前面加一个视觉编码器,把图片变成向量塞进去,剩下的事情和文本生成没区别。这个理解本身没错,但工程实现上差别非常大。最直观的变化体现在三笔账上。
第一笔是请求结构。纯文本请求的输入长度相对稳定,几百到几千token是常态。而图文请求的token组成是两段拼接:文本token加上图像token。一张336x336分辨率的图经过视觉编码器后,通常会产生576个图像token;五张图就是2880个token,再叠加几百个文本token,单条请求的输入可能直接冲到3000 token以上。更麻烦的是,多图请求、纯文本请求、单图长文请求会同时到达,token长度分布极其不均匀,从几百到几千都有,方差比纯文本场景大一个数量级。
第二笔是显存占用。视觉编码器本身就要吃一块显存,图像token进入大模型后还要参与KV Cache的计算和存储。射线一算,同样并发数下,多模态请求的KV Cache消耗比纯文本请求高出好几倍。如果还是按纯文本场景的经验值去给显卡分显存,K8s里没有OOM就属于运气好。
第三笔是数据吞吐。图片进来先要下载、解码、缩放、归一化,这些都是纯文本链路里根本不存在的CPU密集型操作。一张十几MB的图,光解码就可能吃掉几十毫秒CPU时间,如果这些操作堆在推理引擎的worker里做,GPU会频繁空转等待CPU把张量喂进来。
1.2 纯文本推理框架的调度假设在图文场景下失效
我们最早直接复用了之前跑文本大模型的推理架构,核心依赖的是那个比较成熟的连续批处理调度方案。这套东西在纯文本场景确实好用,但它的调度假设是"输入token数相对均匀、序列长度差异可控"。而多模态请求进来之后,同一个batch里可能同时存在一个几十token的文本短请求和一个几千token的图像长请求。调度器只能看到token总数,按token数去分配显存和算力,结果就是短请求被长请求拖着走,长请求的显存峰值又瞬间把整个batch填满。
更深层的问题在于,这套架构里没有视觉编码器的位置。要么把视觉编码器塞进推理进程里,让图像编码和文本解码抢同一块GPU的算力,导致大模型解码吞吐被视觉编码拖累;要么每个请求单独远程调用一个视觉服务,QPS稍微起来一点,视觉服务的网络IO先被打爆。两种方式我都试过,结局都不体面。
1.3 新版架构上线前必须定下的硬指标
踩过这些之后,重构一开始我们就先定了几个硬指标,后面所有设计和验证都围绕这几条来:
- TTFT(Time To First Token,首token延迟)P95控制在3秒以内;
- 单卡解码吞吐在混合负载下达到纯文本架构的85%以上;
- 长尾请求不对整体SLO产生连锁影响;
- 服务在QPS突增2倍的情况下不雪崩。
这些指标不一定适用你的场景,但它逼着我们把"多模态推理服务"拆成了"输入链路、调度策略、显存管理、解码执行"四个独立问题来分别解决,而不是笼统地调调batch size就完事。
2. 四层架构的主干设计与三个关键取舍
2.1 接入层、调度层、执行层、存储层各管什么
重构后的架构从功能上拆成四层:接入层、调度层、执行层、存储层。这四层不是物理上必须分开部署的四个服务,而是逻辑上必须职责分离的四个模块。我们的做法是前两层合并为一个无状态网关服务,执行层单独部署推理集群,存储层走对象存储加缓存。
接入层负责所有多模态输入的标准化。图片下载、格式转换、缩放、归一化、图像特征编码,全部在这一层完成,最终产出一个统一的内部请求包:文本token序列加视觉特征张量再加上请求元数据。调度层拿到这个请求包后,决定它什么时候进入推理引擎、和哪些请求组成一个batch、按什么优先级执行。执行层就是纯推理引擎,它只需要处理规整的张量,不再关心图片长什么样。存储层放原始图片、编码后的特征缓存还有推理结果。
这里有一个很容易被忽略的点:接入层和调度层都必须是无状态的。无状态意味着可以随便水平扩容,前面挂SLB或者一致性哈希都行,某台机器挂了流量自动分配到其他节点,不会影响在途请求。
2.2 把视觉预处理全部前移到接入层,理由和代价
这是整个架构里第一个关键取舍。视觉编码到底放在哪一层,我们争论过很久。放在执行层的好处是省去特征传输,GPU直出视觉token;坏处是图片下载和解码这些不可控的耗时直接占用推理引擎的worker线程,一个慢图片请求就能把一个batch卡住几秒钟。放在接入层的好处是推理引擎拿到的永远是规整的特征张量,batch吞吐非常稳定;坏处是接入层的CPU和带宽压力剧增。
最终我们选了后者。核心判断依据是:图片预处理的耗时方差极大,而推理引擎的batch调度对"一个慢成员拖住整个batch"极其敏感。把不稳定的输入处理隔离在推理引擎之外,相当于给GPU加了一层缓冲垫。
代价也不是白付的。接入层的CPU占用明显上升,尤其是JPEG解码这种操作特别吃单核性能。我们的应对办法是给接入层配一个独立的CPU worker pool,解码操作全部丢进线程池做异步处理,再用队列削峰。容量规划的时候,按"单图平均CPU耗时 × 峰值QPS × 平均图数"来估算需要的CPU核数,而不是拍脑袋开几个实例。
2.3 连续批处理和优先级调度之间怎么平衡
调度层用的还是连续批处理,但加了一个重要的前置条件:视觉编码已经在接入层完成,执行层的引擎只处理特征序列。这样引擎看到的图像请求就是一个普通的"长输入序列"请求,不需要在batch中间去等视觉编码结果。
第二个改动是给每个请求打上预估长度标签。调度器根据文本token数加图像token数,估算出请求的总长度,再结合等待时长算一个综合优先级。这样做的目的是防止几千token的长请求把短请求无限饿死。具体参数是一个加权公式:优先级 = 等待时间系数 × 预估token数系数。短请求等待超过一定阈值后,优先级会快速上升,强行插入下一个可用的batch空位。
我们在压测中发现,如果没有这个机制,当多图长请求占满batch时,纯文本短请求的TTFT会恶化到不可接受的程度。加了长度感知的优先级调度之后,短请求P95 TTFT从4秒多降到2秒以内,而长请求的吞吐只损失了不到10%,这个代价完全值得。
3. 预填充与解码分离:多模态推理的显存峰值被谁吃掉
3.1 输入token猛增后,预填充阶段成为显存大头
大模型推理分成两个阶段:预填充(Prefill)和解码(Decode)。预填充阶段一次性处理整个输入序列,并行计算所有token的KV向量,是典型的计算密集型;解码阶段逐个生成token,每步只算一个token,是典型的访存密集型。
多模态请求的输入token数量远高于纯文本,所以预填充阶段的显存峰值和耗时都会显著拉高。我们实际测过一个7B参数级的视觉语言模型,单条输入3000 token,在预填充阶段需要的显存峰值差不多是纯文本500 token请求的5倍。多条长输入并发预填充时,KV Cache的消耗会以极快的速度把显存填满。
这里算一笔账:一个24层、隐藏维度2048的模型,FP16精度下,单条2048 token的请求,KV Cache占用约384MB。如果batch size是16,那就是6GB。而这个量级的多模态输入在实际业务里非常常见,五张图加几百字文本的请求,输入轻松超过2048 token。显存预算如果没提前算清楚,OOM只是时间问题。
3.2 同一块GPU里做预填充和解码会互相拖累
传统架构里,预填充和解码共用同一块GPU,用连续批处理在两者之间切换。这在一开始是可行的,因为预填充虽然吃显存,但耗时短,decode虽然慢,但显存占用平滑。多模态请求出现后,问题来了:预填充阶段的大显存峰值会限制batch size,而decode阶段希望batch尽量大以提高吞吐,两个目标在那个共享显存的池子里直接冲突。
更隐蔽的问题是"慢请求占坑"。一个3000 token的预填充请求可能在GPU上跑几十毫秒到上百毫秒才能真正完成,期间新进来的短请求只能排队。短请求等得越久,TTFT越差,用户体验越崩。
我们最终落地的是预填充与解码分离的方案,也就是常说的PD分离。预填充单独放在一组GPU实例上,这批实例专门吃大batch高并行度;解码单独放在另一组实例上,这批实例专攻低延迟逐token生成。两组实例之间需要把KV Cache张量传输过去,解码实例收到KV Cache之后直接从生成阶段开始跑。
3.3 分离之后TTFT和吞吐的实际变化
PD分离不是银弹,它最大的代价是KV Cache跨机传输带来的网络开销。我们最初用TCP传输,实测单次传输的P95要100多毫秒,如果传输超大KV块甚至会到几百毫秒,这部分开销直接加到TTFT上,差点抵消了分离带来的收益。后来换成RDMA之后,传输时间压到30毫秒以内,整个方案才真正成立。
分离前后的对比数据(同样混合负载压测、8卡推理集群):
| 指标 | 未分离 | PD分离后 | 变化 |
|---|---|---|---|
| TTFT P95 | 4.2s | 1.8s | 下降57% |
| 解码期batch size上限 | 12 | 24 | 提升100% |
| 单卡解码吞吐 | 1480 token/s | 1980 token/s | 提升34% |
| 恶劣长尾请求占比 | 5.6% | 0.8% | 下降86% |
数据本身不关键,关键是趋势:把预填充和解码分开之后,两个阶段各自都能用更合适的batch规模和显存策略,整体吞吐反而上去了。
4. KV Cache与显存预算的工程实战
4.1 一条公式算清KV Cache能占多少显存
显存预算这件事,不能靠"感觉不够了就调低并发",必须算清楚每个组件各占多少。整体显存由四块构成:模型权重、KV Cache、激活值、临时缓冲。其中KV Cache是最大变量。
一个实用的公式:
KV Cache字节数 = 批大小 × 序列长度 × 层数 × 隐藏维度 × 2(K和V各一份)× 字节数
以24层、隐藏维度2048、FP16(2字节)的模型为例,batch 16、序列长度2048的KV Cache总量是:16 × 2048 × 24 × 2048 × 2 × 2 ≈ 6.0GB。这已经是一个很保守的多模态请求batch了,如果序列长度拉到4096,KV Cache直接翻倍到12GB。加上模型权重约6GB、激活值和视觉编码器占用,单卡44GB的显存很快就捉襟见肘。
我们在压测中发现一个关键现象:多模态请求的KV Cache消耗峰值全在预填充阶段,decode阶段的KV Cache总量反而因为缓存了预填充结果而保持稳定。所以显存预算不能按均值来配,必须按峰值来留buffer,否则偶发的长输入请求就会把整个batch拉爆。
4.2 给每条请求的KV占用加上"租约"
KV Cache是稀缺资源,多模态请求的长度方差又大,如果放任不控制,一个极端长图请求可能一次性吃光整个KV池子,把后面所有请求的TTFT全部拖爆。我们的做法是引入"租约"机制来管理KV Cache分配。
具体来说,推理引擎启动时就预分配一块大的KV池,用块式管理。每条请求进入引擎前必须先申请一个KV租约,租约里写明这条请求最多能占用的KV块数上限。申请不到就排队,等前面的请求释放了块再进来。请求结束后租约立即回收。这套机制类似内存里的malloc/free,但加了一条关键约束:块数上限。
租约上限怎么定?我们根据SLO反推,在测试环境跑了不同上限值的对比:
| 每请求KV上限(占池子比例) | 长请求成功率 | 短请求P95 TTFT | 池子利用率 |
|---|---|---|---|
| 30% | 99.2% | 3.7s | 61% |
| 15% | 99.0% | 2.1s | 78% |
| 8% | 85.0% | 1.9s | 83% |
最终选了15%这个档位。长请求偶尔等一等没问题,短请求的体验必须保住。这个比例不同场景差异很大,你们上线前一定要用自己业务的负载分布重新压一遍,别直接抄我的参数。
4.3 量化、稀疏化在视觉模型上的取舍
显存不够,第一反应是量化。但量化在视觉语言模型上不能一刀切。我们的实测结论是:大模型的decoder层做INT8量化基本无感,显存省了接近一半权重空间;但视觉编码器对量化误差非常敏感,一旦量化到INT8,图文理解类任务的效果打分会出现肉眼可见的下降。
我们最终的方案是混合精度:视觉编码器保持FP16,LLM decoder层的权重做INT8量化,KV Cache用INT8存储。KV Cache量化稍微麻烦一点,需要按块统计scale,好在现在推理框架普遍都支持这个能力。效果上,理解类任务的准确率掉了不到1个点,显存占用却实打实降下来了。
另外一个容易被忽视的点是稀疏化。视觉token之间存在大量冗余,理论上可以裁剪一部分图像token来减少KV Cache占用。我们试过简单的启发式裁剪,比如按空间位置均匀抽样,效果不稳定,裁多了影响理解,裁少了省不了多少显存。这块想做好得引入真正学习的稀疏化方案,工程成本不低,短期不是优先级。
5. 稳定性和长尾问题:踩过的三个真实的坑
5.1 视觉编码并发打满接入层CPU,GPU却在空转
第一次全链路压测,我们的GPU利用率一直上不去,QPS一升,接入层的CPU先爆了。火焰图一看,接近一半的CPU时间花在JPEG解码和图像缩放上。GPU这边因为等不到特征张量,大量时间在空转。
排查链路很清晰:接入层CPU 100% -> 定位到图像解码与归一化 -> 确认热点在libjpeg和图像缩放 -> 把解码操作移出请求处理主线程,丢进独立worker pool -> 加进程级图片解码缓存,同一张图短时间不重复解码。
这里我学到的最重要一课是:推理服务的性能瓶颈不一定在GPU。做容量规划的时候,一定要算清楚CPU和GPU的比例。一个坏的架构是GPU闲得没事干,CPU忙到冒烟,双方都在互相等待。
5.2 大图请求导致队头阻塞和内存尖刺
压测进行到一半,TTFT突然出现周期性尖刺,p50完全正常,p99一下跳到5秒以上。日志里翻到几个超大分辨率图片请求,单张原图16MB,分辨率8000x6000。
深挖下去发现两个问题。第一个是图像缩放逻辑写得太粗糙:代码先把原图完整解码到内存,再整体缩放到目标尺寸,一张16MB的图在解码后会膨胀成几百MB的RGB数组,并发一高内存直接撑爆。第二个问题是这些大图还会占住FIFO队列头,后面的短请求全部被堵住,这就是队头阻塞。
修复方案分两步:第一步,限制输入图片的原始分辨率和文件大小上限,超过阈值直接返回明确的错误码,而不是让服务硬扛。第二步,重写缩放逻辑,用流式缩略图的方式按目标尺寸做分块解码,避免原图整幅解码。上线后内存尖刺消失,TTFT尖刺也基本看不到了。
5.3 跨机传输超时演变成请求雪崩
PD分离上线后出现过最惊险的一次事故:某个时段KV传输偶尔超时,超时后解码侧拿不到结果就开始重试,重试又把预填充侧的负载推高,预填充侧处理不过来,更多请求超时,形成雪崩。那次事故持续了将近二十分钟,服务的SLO达成率掉到了80%以下。
根因有三个。第一,传输链路没有背压控制,队列无限堆积,网络一抖延迟就指数级上升。第二,请求在整个链路里没有设总预算时间,一个请求可以在预填充侧等很久,然后在传输侧再等很久,迟迟不超时。第三,重试策略太激进,解码侧一超时立刻重试,而不是退避一段时间。
修法是三层叠加:传输队列加背压,队列满了直接拒绝新请求而不是无限堆积;请求从进入预填充到输出第一个token设置总预算TTFT上限,超时直接返回降级响应;重试策略改成带随机退避的有限次重试。改完之后,即使底层网络抖动,系统也能自动降级而不是雪崩。
6. 指标、压测与扩容:怎么证明架构真的高效
6.1 TTFT、ITL、吞吐和SLO达成率,一个都不能少
一套架构说再多,最后要看指标。我们日常盯四个核心指标:
- TTFT:从请求到达网关到收到第一个token的时间,直接决定用户体感"快不快"。
- ITL(Inter-Token Latency):相邻两个token之间的延迟,决定输出流畅度。
- 吞吐:每秒生成token数,这是容量规划的依据。
- SLO达成率:TTFT和ITL在约定阈值以内的请求占比,我们通常看P95和P99。
这四个指标单看哪一个都没意义。TTFT短但吞吐上不去,高峰期就是排队;吞吐高但ITL抖动,生成质量就忽快忽慢。我习惯把它们放在同一张监控面板上看,任何一个指标偏离基线都能立刻发现问题。
不同场景的SLO阈值差距也很大:实时对话场景TTFT要求800ms以内,图文生成任务可以放宽到3秒;离线批量处理甚至不关心TTFT,只关心整体吞吐。定SLO之前一定要和业务方对齐预期,不然你自己以为服务很好,业务方却觉得体验差到不行。
6.2 混合负载压测与长时间稳定性验证
压测方案是整个工程里最容易被低估的部分。我做压测时坚持两个原则。
第一,负载必须混合。不能只测纯文本或纯多模态,要按业务真实比例构造:单图短问、多图长文、纯文本、高分辨率大图这四类请求混在一起压。我们内部脚本会先统计线上请求的token长度分布和图片分辨率分布,再按这个分布去生成压测数据。只压纯文本能跑出来的漂亮数据,上线后分分钟打回原形。
第二,压测时长必须足够长,至少30分钟以上。很多问题只有在KV Cache碎片化积累到一定程度后才会暴露。我们有一版调度器优化,前20分钟指标非常好看,到35分钟时显存碎片率达到30%,吞吐直接掉了两成。短压测根本测不出这种长尾问题。
压测流程我一般分三步走:先按预估峰值负载的80%压10分钟找基线,再突然把负载拉到200%测弹性,然后回到80%继续压20分钟看恢复情况。这个流程能同时验证稳态、弹性和恢复能力。
6.3 从光模块走向按模态拆分的资源池
最后聊聊后续扩展。当前架构解决了视觉编码和文本解码的资源隔离问题,但仍然是"一个池子跑所有模态"的逻辑。如果业务同时有图像输入和视频输入,它们对编码侧的算力需求完全不同,混在一个池子里又会互相干扰。方向是进一步按模态拆分资源池:图像编码一组实例、视频编码一组实例、LLM decode共享但按比例配额。调度器根据请求类型把任务分发到不同的池子,类似网格调度的思路,这样可以让每种模态的负载波动都不影响其他模态。
这次重构给我最大的感触是,做推理服务架构别急着上复杂方案,先把监控指标定义清楚,让数据告诉你瓶颈在哪个环节。而多模态服务里最容易出问题的往往不是模型推理本身,而是输入链路、资源调度、跨机通信这些"周围"的环节。把这几块理顺了,GPU的利用率自然就上去了。