先说那场争论吧。团队里两个同学为了 SGLang Omni 服务在峰值期的 Batch Size 到底设 32 还是 64在评审群里来回对线了快一周。主张 32 的人开口就是 TPOT 不稳定主张 64 的人张口闭口吞吐量上不去。两份压测报告我都翻了数据都有但谁也没法说服谁因为两份报告的验证口径完全不一样。这其实才是绝大多数性能争论的真实状态——问题根本不在 Batch Size 这个参数本身而在我们用什么标准、什么负载、什么指标去判断一个参数到底好不好。SGLang Omni 不是普通的单模态推理服务它会同时处理文本、图像、音频等多模态输入prefill 阶段的计算特征和 decode 阶段差异很大KV Cache 的占用也更不可控。所以在这样的场景里Batch Size 的选择、性能验证的方法、调度器的取舍三者是绑在一起的。这篇文章就把这次争论当作切入点把 SGLang Omni 场景下性能验证的完整思路、调度取舍的细节、以及我在实际操作中踩过的坑都写清楚。1. 复盘那场争论Batch Size 到底在争什么1.1 两边的立场和它们各自的合理性主张 Batch Size 32 的同学核心论据是延迟表现。他当时跑了一组并发压测发现一旦把运行批次调到 64虽然吞吐确实涨了但 TPOT 的 P99 直接从 41 毫秒跳到了 78 毫秒长尾请求甚至到过 120 毫秒。对一个线上有交互式对话场景的服务来说这个延迟抖动是不可接受的所以他坚持 32。主张 Batch Size 64 的同学看的是算力利用率和吞吐。他的理由是 GPU 的 SM 占用率在 Batch Size 64 时明显比 32 高每卡每秒产出的 token 数也更多。在多模态场景里图像 token 的 prefill 计算量庞大Batch Size 太小会导致 GPU 在 prefill 阶段喂不饱算力白白浪费。他给的报告里还有一组对照相同请求量下64 能比 32 快 20% 跑完全部负载单位 token 成本也更低。两边都拿了数据但关键问题在于他们测量的根本不是同一个东西。一个人看延迟分布一个人看吞吐总量两个人都在说真话但凑不到一块去。这种争论在没有统一验证体系之前基本就是空对空。1.2 争论背后的真实问题缺少统一的验证语言我后来把两份报告并排放在一起发现连基本的压力模型都不一样。A 用固定并发数去打B 用固定 QPS 去打A 的请求里含 40% 的多图输入B 的请求集里 90% 都是纯文本A 统计的是从请求进入队列到最后一个 token 返回的端到端延迟B 统计的是引擎内部 decode 阶段的平均间隔。这已经不是 Batch Size 之争了是测试方案之争。所以争论的根源不是谁对谁错而是团队没有一套通用的性能验证协议。对于一个 SGLang Omni 这种多模态推理服务性能表现和负载模型高度相关。请求里图像 token 占比不同、并发模型不同、序列长度分布不同测出来的 Batch Size 最优值完全可能不同。在建立统一验证口径之前任何结论都只能代表特定场景没法推广到生产。那次争论最终促成了两件事一是团队沉淀了一份性能验证模板二是我们真正把「调度取舍」这件事提到了和「模型效果」一样高的优先级。这也是这篇文章想传递的核心价值。2. 性能验证先于性能优化一套可复现的测量体系2.1 第一层指标吞吐、TTFT、TPOT一个都不能少在做任何 Batch Size 调优之前先把最基础的三类指标定清楚。吞吐量指的是单位时间内完成的请求数或者生成的 token 数它衡量的是系统的整体产出能力。TTFT 是首 token 延迟它决定用户什么时候开始看到输出对交互式体验影响极大。TPOT 是每个 token 的平均生成间隔它决定了输出过程的流畅度。很多人做压测只看吞吐和平均 TPOT这个习惯在生产场景里很危险。平均 TPOT 好掩盖不了 P99 长尾吞吐量高也代表不了排队体验好。我自己的习惯是任何一轮测试都必须同时记录吞吐、TTFT 的 P50/P95/P99、TPOT 的 P50/P95/P99然后把这些值放进一张表里看全貌。Batch Size 调大之后往往吞吐上升、平均延迟不变但 P99 悄无声息地恶化只看平均值根本发现不了。对于 SGLang Omni 这种多模态场景还要额外关注一个维度prefill 和 decode 是不是在互相干扰。说白了prefill 阶段吃的是计算资源decode 阶段吃的是访存带宽两个阶段混合在一个 batch 里跑如果 Batch Size 过大prefill 的计算洪峰会把 decode 的 token 生成节奏整个拖慢。这就是长尾延迟的来源。2.2 第二层指标排队延迟、SLO 达标率、资源水位第一层的指标描述的是「服务本身跑得好不好」第二层的指标描述的是「整个系统在真实负载下稳不稳」。排队延迟和 SLO 达标率在这里非常关键。排队延迟指的是请求从进入系统到真正被调度器拉进 batch 之间的等待时间。在 SGLang 的架构里所有请求都会先进入一个队列由调度器决定什么时候把它塞进当前批次。当 Batch Size 设得很高、prefill 需要很久才能结束一个批次时后续请求可能要在队列里等很久即使引擎内部的 TPOT 很漂亮用户的实际体验也被排队延迟毁了。SLO 达标率是另一个容易被忽略的指标。比如我们规定 TPOT 的上限是 100 毫秒那么达标率就是所有请求中 TPOT 低于 100 毫秒的请求占比。我习惯看的是 P99 是否在 SLO 范围内以及达标率是否超过 99%。压测报告里如果只写平均延迟不写达标率我基本会直接打回。资源水位方面最值得盯的是 GPU 显存里的 KV Cache 占用率。SGLang Omni 处理图像输入时一张图可能产生几百甚至上千个 tokenKV Cache 的膨胀速度远快于纯文本场景。Batch Size 一旦超过显存预算不是性能下降的问题是直接 OOM 的问题。所以做性能验证时必须把显存占用曲线一并记录而且要按峰值看不能按平均值看。2.3 负载模型只有真实流量形态才能验证真实性能这是我在这一章最想强调的一点负载模型决定验证结论的有效性。很多压测是用固定 QPS 打满跑 30 分钟看结果这当然能看出系统上限但生产流量的特点往往是波峰波谷交错突发性很高。我做 SGLang Omni 验证时至少会跑三类负载场景固定低 QPS验证基准性能确认没有系统性缺陷。逐步加压 QPS找到系统从稳定到恶化的拐点。锯齿形 QPS模拟真实流量波动观察调度器在压力变化下的表现。锯齿形负载尤其能暴露调度问题。比如 QPS 从 10 突然跳到 80 时队列会瞬间积压调度器如何选择优先处理哪些请求Batch Size 上限如何限制 prefill 的规模这些都是固定 QPS 压测永远测不出来的。另外请求内容分布也要模仿生产。纯文本请求、单图请求、多图请求、长文档请求要给它们一个合理的比例。SGLang Omni 里多图请求的 prefill 复杂度可能是纯文本的几十倍如果不把它纳入负载模型测出来的 Batch Size 阈值就没有参考意义。3. 调度取舍SGLang Omni 里真正的“调度器思维”3.1 连续批处理Batch Size 不是固定数字很多人理解的 Batch Size 还是传统深度学习训练里的概念一个 batch 喂进去跑完再喂下一个。推理场景完全不是这么回事。SGLang 支持 continuous batching也就是连续批处理调度器可以在一个批次运行过程中动态地完成请求、插入新请求每个 step 结束后的批次成员都可能不一样。所以 Batch Size 在推理框架里更准确的理解是「调度器允许单个 step 中参与计算的最大请求数」它是一个上限而不是一个固定值。上一轮 30 个请求在跑其中 10 个完成了调度器立刻从队列里补 20 个进来实际运行批次可能一直在 30 上下浮动。这个机制决定了 Batch Size 调优的本质其实是「给调度器设一个合理的容积上限」而不是像训练那样去微调一个固定参数。SGLang 的调度器还有一个特点它会根据内存预算预测 KV Cache 用量动态决定能塞多少请求进批次。所以我们调 Batch Size 时表面上调的是一个数字实际上调的是「在显存和算力双重约束下的调度容积」。3.2 Prefill 与 Decode 的资源竞争调度器必须区分对待SGLang Omni 场景下prefill 和 decode 的资源竞争是关键中的关键。Prefill 阶段输入可能包含大量图像 token计算量极大一次 prefill 可能耗时几百毫秒而 decode 阶段是逐个 token 生成每个 step 的计算量相对小但要求稳定、低延迟。如果调度器不区分这两种请求把 prefill 任务和 decode 任务混在一个大 Batch 里prefill 的计算洪峰会直接影响 decode 的步进速度。这就是为什么有些 Batch Size 调大后吞吐好看但 TPOT 变差——prefill 把 GPU 打满了decode 的 token 在一个个排队等着算。我在实际项目中做的一个调整是限制单次调度中 prefill 请求的最大并发数不限制 decode 的批次容量。这样图像类的重计算请求会被打散不至于扎堆把 GPU 占死decode 的节奏就能稳下来。这个取舍牺牲了一点吞吐上限换来了可控的延迟分布对生产服务来说是完全值得的。3.3 请求级调度与作业级调度的分层设计一个完整的 SGLang Omni 服务它的调度体系其实是分层的。最外层是任务/作业调度比如很多团队会有大模型调度平台负责管理不同业务方的请求队列、按优先级或配额把任务分发到不同实例最内层才是推理引擎的请求级调度也就是 SGLang 运行时里的 batch 组装逻辑。这两层的优化目标是不同的。外层调度照顾的是业务优先级、配额公平、负载均衡内层调度照顾的是显存水位、prefill/decode 平衡、延迟和吞吐的权衡。如果只盯着内层 Batch Size 调优不考虑外层排队策略高峰期一个高优任务可能会被一堆低优任务挤到队列尾部端到端延迟照样崩。那次争论之后我们把外层调度的信息透传到内层请求头里标记优先级级别SGLang 侧对高优先级请求做插队处理。从系统整体看这比单纯调 Batch Size 带来的收益大得多。我建议所有做这类服务的人都把「调度」放到两个层面思考不要只困在引擎参数里。3.4 多模态输入对调度策略的额外挑战SGLang Omni 的多模态特性给调度带来两个额外挑战一个是 token 量预估困难一个是 mixed batch 的算力分配不好做。纯文本请求输入长度从日志里很容易统计显存和算力需求相对可预测。但图像输入的 token 数取决于分辨率、编码策略有的图像切块后产生几百个 token有的可能上千个。Model 会动态决定视觉 token 数调度器在请求刚进来时很难精确知道它的真实 cost。这会导致 Batch Size 设得比较激进时可能某个多图请求一来KV Cache 就爆了。我的经验是给不同业务线设置不同的 Batch Size 上限比如单图请求限制在 64多图请求限制在 16 到 24。这不是拍脑袋而是从线上数据统计出来的多图请求的平均 prefill 耗时是单图的三倍以上混在一个大 Batch 里必然出问题。把不同类型请求分桶处理每个桶设不同容量上限比全局用一个统一 Batch Size 科学得多。4. 实操记录一次完整的 Batch Size 验证流程4.1 实验环境与负载生成器设计我先说清楚当时的环境双卡 A100 80G 的节点SGLang 以多模态服务方式部署模型是多模态版本请求集是从线上日志采样出来的包含纯文本、单图、多图三类比例大约 5:3:2。负载生成器是一个 Python 脚本用并发协程模拟用户请求支持控制请求到达速率和请求内容分布。负载生成器的核心设计是「请求到达时间间隔服从泊松分布」而不是等间隔固定打。泊松到达更接近真实用户行为能够让排队现象自然发生否则测出来的调优结论到了线上容易失效。脚本还加了预热逻辑正式测试前先用低 QPS 跑 10 分钟把显存里的页池状态稳定下来避免冷启动干扰数据。这里补充一个关于预热的关键点如果你不预热KV Cache 的页表可能还没充分分配显存水位和性能表现会和稳态时差很多。我见过太多人直接在冷状态上跑压测得出来的 Batch Size 结论毫无参考价值。4.2 扫参过程从 8 到 128 的六组实验我采用控制变量法把 Batch Size 分别设为 8、16、32、48、64、128每组跑 20 分钟用锯齿形 QPS 负载。每一轮都固定请求集、固定负载模型、固定调度策略唯一变量就是 Batch Size 上限。每组结束后记录吞吐、TTFT、TPOT、排队延迟、显存峰值。下面是当时典型数据的简化版具体数值因模型和硬件不同会有差异但趋势值得参考Batch Size 上限吞吐 (token/s)TTFT P99 (ms)TPOT P50 (ms)TPOT P99 (ms)排队延迟 P99 (ms)显存峰值占用862022028451838GB1694024026432545GB32128025527494057GB48143031032828867GB6415104203811015076GB128155078052260390触发 OOM可以看到从 32 到 48吞吐的边际收益已经开始明显减小而 TPOT P99 和排队延迟开始快速恶化。到 64 的时候峰值吞吐虽然好看但延迟指标已经完全不可用了。128 直接 OOM。如果只报告吞吐这一项指标可能会得出「64 或 128 更好」的结论但把延迟和排队纳入视野后答案一目了然。4.3 从数据到结论最终的选择与依据最终生产配置选择了 Batch Size 32 作为全局上限同时配合多图请求分桶限制在 16。选择依据有三个层次第一SLO 优先。我们的 TPOT SLO 是 P99 小于 60 毫秒Batch Size 32 这组的数据距 SLO 有充足余量48 就已经逼近甚至超过 SLO 了。对交互式服务来说SLO 是最高优先级吞吐量再高也不能拿用户体验换。第二不是所有请求都一样重。全局上限设在 32是因为纯文本和单图请求占了 80%它们在这个容量下表现很好但多图请求要单独降低容量不然任何一个调度周期的显存曲线都可能失控。第三给突发流量留出缓冲。32 这个数值距离显存瓶颈还有相当距离即使某个时刻队列突然压进来一批多图请求调度器也有足够的缓冲空间去吸收冲击不至于直接 OOM。这就是调度的「防洪」思维好的资源水位永远要留余量。那 64 是不是完全不能用也不能这么说。如果业务是离线批量处理没有交互式延迟要求那么 64 甚至更高都有价值。取舍的关键从来不是哪个数值更「正确」而是哪个数值符合当前业务的 SLO。这也是我想让团队所有同学理解的Batch Size 是调度容积不是荣誉证书。4.4 SGLang 配置里我实际改的几个参数基于上面的实验我在 SGLang 的启动配置和服务化层做了几处调整。这里给出的是方向性参考具体参数名要按实际版本确认。调整最大运行批次容量给调度器设定容积上限。启用或调高内存页池的预分配比例减少显存碎片和动态分配的抖动。配置 prefill 请求的并发上限防止大量图像请求扎堆进入 prefill。开启显存不足时的请求排队或丢弃策略避免直接 OOM 崩掉整个进程。改完配置之后我又用同一套负载模型做了回归验证确认延迟和吞吐都在预期范围内。这里要重点提醒任何配置改动都要重新跑性能验证不要假设只改一个参数影响可控调度相关的参数往往互相耦合Batch Size 变了显存水位和排队特性都会跟着变。5. 观测、告警与生产环境中的持续验证5.1 线上监控哪些指标值得做告警Batch Size 调完之后不是一劳永逸线上流量和请求内容分布会漂移。今天我们测出来 Batch Size 32 合适下个月如果多图请求比例翻倍这个值可能就太激进了。所以一定要做持续观测和告警。我在生产环境里重点盯四个指标请求队列长度、prefill 平均耗时、decode 阶段的 TPOT 长尾值、显存峰值水位。这几个指标直接反映了调度器是否健康队列长度持续上涨说明消耗速度跟不上到达速度需要扩容或者调整容量上限。prefill 耗时突然升高说明大量重请求涌入prefill 并发上限可能失效。TPOT P99 超过 SLO 阈值说明 decode 被打扰了需要检查批次里 prefill 的占比。显存水位接近上限这是 OOM 的前兆必须第一时间介入。我见过太多团队只监控 GPU 利用率和平均延迟结果 OOM 前毫无预兆一次故障直接拖垮线上服务。调度器相关的指标必须盯着因为这些是问题的前置信号。5.2 任务失败告警把验证结论固化到告警规则里我们内部把调度相关的告警接入了企微机器人。当时背景是线上有个定时任务跑 SGLang Omni 服务的数据批处理某次调整 Batch Size 后任务执行失败但因为是异步任务到第二天复盘才发现失败白白浪费了一整晚的时间。后来我们把告警做成了两层。第一层是任务级别的失败告警任务执行失败后立刻推送企微带着任务 ID、失败节点、错误日志摘要、当时显存水位。第二层是指标越界告警队列长度超过阈值、TPOT P99 超过 SLO、显存水位超 85%这些命中就告警不等任务失败才通知。这两层告警配合起来效果非常明显。指标越界告警相当于前置哨兵任务失败告警相当于最后防线。监控脚本用一个简单的 Python 守护进程定时抓取指标判断规则后通过企微 Webhook 推送实现起来不复杂但价值非常大。5.3 定期回归让验证模板成为团队习惯那次争论之后我把性能验证模板沉淀成了团队的 wiki 文档并约定每次涉及调度参数变更时必须跑一遍完整的验证流程把数据贴到文档里存档。里面包含负载模型定义、指标采集脚本、报告模板和评审清单。这个习惯的效果比任何单次调优都大。现在团队里讨论 Batch Size不会再出现各说各话的情况。谁提了一个新值就把同一套验证跑一遍数据摆在面前行就是行不行就是不行。我个人现在觉得对一个多模态推理服务来说调度的重要性甚至超过模型本身。模型能力是上限但调度决定了这个上限能在真实流量里兑现多少。Batch Size 只是调度体系里一个小参数围绕它建立起来的验证和观测体系才是真正值得投入的地方。