一直在做 AI 应用的后端,我这两年踩过最多的坑,就是“模型调用太多导致接口响应慢”。一个看起来平平无奇的内容处理接口,背后可能要同时调两三家模型供应商的接口:文本摘要走 A 厂商,意图分类走 B 厂商,实体抽取又走 C 厂商。如果还按最早那种“串行调用、逐个等返回”的写法,用户体验直接垮掉;如果图省事自己起线程池并发,又会发现每家 SDK 的超时、重试、限流语义都不一样,代码越写越像补丁套补丁。
直到我把目光集中在 RunnableBatch 这个接口上,才真正把跨模型供应商的并行请求合并做成了一套可维护的工程方案。Batch Inference 优化不是什么花哨的黑魔法,说白了就是把原本散落的单条模型请求收拢到一个统一的批处理通道里,通过批量调度、共用线程池、统一结果回收,把系统吞吐量顶上去。今天这篇文章不聊概念,就把我实际整合多家模型供应商时用到的思路、代码、参数推导和踩坑记录全部分享出来。适合正好在做 AI 应用接入层、被多供应商调用折腾得够呛的后端同学。
1. 为什么要做 Batch Inference 优化
1.1 逐条调用到底慢在哪
很多人以为模型调用慢,纯粹是模型本身推理耗时太长。这个观点对了一半。模型推理确实慢,但工程层面的慢往往来自“重复开销”。
每条模型请求,从你的服务发出去,要经历建立连接、HTTP 握手、鉴权、请求序列化、等待响应、结果反序列化这么一串流程。如果你的并发模型是“一个请求起一个线程”,线程猛增之后 CPU 大量浪费在线程切换和内存分配上;连接层每个线程都要维护自己的连接状态,冷连接建立一次就是几十毫秒的额外损耗。
打个比方,你是小区楼下负责送货的,每位住户单独下一单,你就为了这一单单独跑一趟楼,哪怕十个订单全在同一栋楼。Batch Inference 就是让你把同一条路线上的十单一次性装车带走。它不会让“单户货物”本身变快,但让单位时间内的总运送量上去了。
在模型调用的场景里,“装车带走”具体表现为:复用线程池线程、复用 Tomcat/Undertow 底层的 HTTP 连接池、降低上下文切换开销、把鉴权和序列化这类固定成本从 N 次摊薄到接近 1 次。
1.2 跨模型供应商场景的三道坎
如果只在同一个供应商内部做批量优化,事情会简单很多。真正的复杂度,来自“跨模型供应商”。
我接触过的供应商 SDK,没有一个长得一样的。有的用 REST 风格接口,有的用 gRPC,有的给 Java 的 SDK 封装得特别复杂,有的干脆让你自己拼 JSON。就算框架层把它们的 HTTP 请求都统一了,下面这些坑依然躲不掉:
第一,请求与响应的数据模型不统一。A 厂商的 prompt 字段叫prompt,B 厂商叫messages,C 厂商可能要套一层input。响应结构更是五花八门,有的是{ "text": ... },有的是{ "choices": [{ "message": { "content": ... } }] }。
第二,超时和重试语义不统一。A 厂商 30 秒不返回算超时,B 厂商 60 秒才算;C 厂商返回 429 之后建议你等待 10 秒,D 厂商的 429 反而什么都不建议,只会一直报错。
第三,限流策略不统一。大部分供应商按分钟维度限流,但有的按并发数限,有的按 token 数限,有的同时按多个维度限。你只写一套重试逻辑去面对这些乱七八糟的限制,基本等于拿一个万能钥匙开所有锁,必然翻车。
这些坎没有扛过去,就算你上了任何批处理框架,也会被底层乱飞的异常打得措手不及。所以跨供应商合并且能跑得稳的前提,是先做一层统一的供应商适配抽象。这也是我后面单独拿一整节讲适配器模式的原因。
2. RunnableBatch 是怎么把批量请求做起来的
2.1 和单条调用、流式调用的区别
我第一次看到 RunnableBatch 这个接口的时候,下意识以为它就是“把 N 个请求丢进线程池然后 join”,后来仔细看才发现它的设计要比这完整得多。
先看和普通单条调用的区别。普通调用是你调一次模型方法,同步阻塞等结果;流式调用是边生成边返回,适合打字机效果。这两者都只解决“一次请求”的问题。而 RunnableBatch 解决的是“一组请求”的问题:我把一组任务提交进来,框架负责任务的并行调度、超时控制、结果回收,最后把整批结果交还给你。
我用一个表格把它们放在一起对比:
| 方式 | 核心关注点 | 返回形态 | 典型使用场景 |
|---|---|---|---|
| 普通单条调用 | 一次请求的结果正确、快速 | 完整响应对象 | 在线低并发接口 |
| 流式调用 | 首字延迟和体验 | 逐段输出 | 对话、写作辅助 |
| RunnableBatch | 单位时间吞吐、资源可控 | 批量结果集合 | 多模型并行、批量推理通道 |
在实际项目中,这三者可以共存。普通单条调用负责交付型 API,流式调用负责交互型 API,RunnableBatch 负责内部批处理通道。
2.2 “合并请求”的关键:协议层 batch 与应用层调度 batch
这里必须澄清一个经常被误解的点,也是我当时研究最久的地方:RunnableBatch 所谓的“合并”,到底合并了什么?
有两条路线。一条叫协议层 batch。某些模型供应商提供了专门的批量接口,允许你把一堆 prompt 塞到一次 HTTP 请求里,然后服务端异步处理完,再统一返回。这种方式确实把网络请求减少了十倍、百倍,效果好,但缺点也很明显:不同供应商的批量接口格式完全不同,而且通常只适合离线的、不要求实时返回的场景。
另一条叫应用层调度 batch,RunnableBatch 走的是这条路线。它做的事情是:把你的 N 个模型调用任务提交给一个共享的任务执行通道,由执行器按你配置的并发度并行执行,最后统一收集结果。
你说这不就是“并发执行”吗?对,但问题在于“并发执行”这四个字太模糊了。自己手动写的并发循环,线程池参数怎么定?任务排队策略是什么?某条任务超时之后,是放弃整批还是只放弃单条?三个任务里两个成功一个失败,结果怎么聚合成一个响应?这些问题如果全留在业务代码里手工处理,最终代码一定是灾难。RunnableBatch 把这一整套生命周期收拢成了一个接口,你只需要提供任务列表和执行配置。
协议层 batch 关注的是减少网络往返,应用层调度 batch 关注的是管理并发资源。两者并不冲突,甚至可以嵌套:如果某个供应商支持协议层 batch,那么它的适配器内部可以先把请求聚合成一个批量调用,再去走你自己的批量执行通道。
2.3 为什么适配器模式是跨供应商合并的前提
现在回到最实际的问题:一堆供应商,接口各不一样,我怎么让它们在一个 RunnableBatch 里被统一调度?
我的答案是:把所有供应商的调用能力包装成一个统一的模型适配接口。
这个适配接口不关心你底层是 REST 还是 gRPC,不关心响应 JSON 长什么样,它只对外暴露一个方法:给定一个标准请求,返回一个标准响应。在这个接口的每个实现类内部,你才去处理某一个供应商特有的鉴权、超时、重试和参数映射。
有了这层适配,RunnableBatch 的任务列表就不再区分“这是 A 厂商的请求”或“那是 B 厂商的请求”,在它眼里只有一种东西:Runnable 任务。恰好 RunnableBatch 接口的名字本身也暗示了这一点:它跑的是 Runnable。
到这一步,“跨模型供应商的并行请求合并”这个目标才算是具备了骨架。下一步就是搭血肉,把注册表、线程池参数、超时重试这些细节填进去。
3. 搭建跨供应商批量推理通道的步骤与参数推导
3.1 先定义模型路由与统一抽象
我在项目里先定义了一个非常薄的适配接口:
public interface ModelAdapter { String modelName(); ModelResponse invoke(ModelRequest request); }这个接口不需要花哨。“modelName”负责任命名的标识,“invoke”负责任何一次模型调用。实际接入时,每个供应商就是一个实现类:
public class VendorAAdapter implements ModelAdapter { private final RestClient restClient; @Override public String modelName() { return "vendor-a-chat"; } @Override public ModelResponse invoke(ModelRequest request) { VendorARequest body = VendorARequest.from(request); VendorAResponse result = restClient.post() .uri("https://vendor-a.api.example.com/v1/chat") .body(body) .retrieve() .body(VendorAResponse.class); return ModelResponse.from(result); } }Vendor B、Vendor C 也是一样的写法,只是内部参数映射和端点地址不同。这里有个很容易忽略的设计点:适配器必须是无状态的。不要在适配器里保存任何请求相关的临时变量,否则并发一上来,不同请求的数据串了,排查起来极其痛苦。
有了适配器,还需要一个注册表,用来按模型名路由:
@Component public class ModelRegistry { private final Map<String, ModelAdapter> adapters; public ModelRegistry(List<ModelAdapter> adapterList) { this.adapters = adapterList.stream() .collect(Collectors.toMap(ModelAdapter::modelName, a -> a)); } public ModelAdapter resolve(String modelName) { ModelAdapter adapter = adapters.get(modelName); if (adapter == null) { throw new IllegalArgumentException("unknown model: " + modelName); } return adapter; } }这样业务侧想要新增一个供应商,只需要实现一个 ModelAdapter 并注册成 Spring Bean,注册表自动就把它纳入路由了。批量执行逻辑一行都不用改。
3.2 RunnableBatch 接入的关键配置与线程池计算
接下来是真正的核心:如何把一批请求丢给 RunnableBatch。
不同版本的框架,RunnableBatch 的 API 细节有差异,我这里说的是我在工程中使用的接入形式,核心概念是一样的。我的代码大致长这样:
ThreadPoolTaskExecutor batchExecutor = new ThreadPoolTaskExecutor(); batchExecutor.setCorePoolSize(30); batchExecutor.setMaxPoolSize(45); batchExecutor.setQueueCapacity(120); batchExecutor.setThreadNamePrefix("batch-inference-"); batchExecutor.initialize(); RunnableBatch batchRunner = RunnableBatch.builder() .taskExecutor(batchExecutor) .timeout(Duration.ofSeconds(5)) .build();线程池参数不是拍脑袋定的,这里我说明一下推导过程。
首先定业务目标:这个批处理通道需要在高峰时处理 30 个批任务/秒,每个批任务内部有 3 个供应商调用,整批任务在用户侧感知是 3 秒内必须返回。
每个批任务的内部 3 个调用是并行执行的,所以关键路径时间取决于最慢的那个供应商调用。假设最慢厂商的 p95 延迟是 2 秒,那么一个批任务的处理时间约 2 秒。根据 Little's Law,系统维持 30 个批任务/秒、每个任务驻留 2 秒,平均需要的同时处理任务数为:
并发任务数 = 每秒任务数 × 单任务平均驻留时间 = 30 × 2 = 60如果你的批处理通道内,一个请求对应一个线程,那理论上需要 60 个线程。但 RunnableBatch 的任务内部还有 3 个并行调用,每个调用如果也各自占一个线程,实际线程消耗会更复杂。所以我没有把线程池核心线程数直接设成 60,而是分了两层:
- 第一层:线程池核心线程数设为 45,队列容量设为 120。45 可以覆盖非高峰期的并发需求,队列用于平滑瞬时波峰。
- 第二层:每个批任务内部的 3 个供应商调用,直接复用批任务线程,用虚拟线程或者再套一个轻量级执行器来做并行等待。核心原则是,不要让一个批任务同时持有 3 个池线程,避免池资源被内部调用占死。
这个细节是我在压测里反复调出来的。之前我把核心线程数拍成 30,结果高峰期队列堆积严重;后面拍成 60,高峰期线程数又太高,大量线程都等在对供应商的 IO 上,CPU 浪费明显。最终落在 45 到 50 之间,配合队列泄洪,效果最稳。
再一个关键参数是超时。我把 RunnableBatch 的全局超时设置成 5 秒,但这个值不能一刀切。供应商 A 的 p95 是 1.8 秒,供应商 B 的 p95 是 2.4 秒,那么适配器内部的单次调用超时应该分别设成 2.5 秒和 3.5 秒左右。全局超时只用来做整批兜底,单供应商超时要在适配器内控制。
这里的核心思想是:不要让一个慢供应商拖死整批请求,也不要用全局超时去处理本应该在供应商层面就拦截掉的异常。每一层只处理自己那一层的问题。
4. 实操记录:多供应商请求合并的落地与性能变化
4.1 完整接入步骤
第一步,先把所有供应商的 API 文档翻一遍,整理出一个大表,包含鉴权方式、请求模型、响应模型、限流阈值、错误码、建议重试时间。这一步看起来繁琐,但后面所有适配器设计都依赖这张表。
第二步,实现每个供应商的 ModelAdapter。在实现时我建议把“HTTP 调用”和“请求映射”拆成两个方法,方便单独写单元测试。HTTP 调用可以用 MockWebServer 模拟,不用真的打供应商接口。
第三步,启动一个简单接口做冒烟测试。我通常会写这样一个测试入口:
@RestController public class BatchDemoController { @PostMapping("/batch/infer") public List<ModelResponse> batchInfer(@RequestBody List<BatchItem> items) { List<ModelTask> tasks = items.stream() .map(item -> ModelTask.builder() .model(item.modelName()) .request(ModelRequest.of(item.text())) .build()) .toList(); List<CompletableFuture<ModelResponse>> futures = tasks.stream() .map(task -> CompletableFuture.supplyAsync( () -> registry.resolve(task.model()).invoke(task.request()), batchExecutor)) .toList(); return futures.stream() .map(CompletableFuture::join) .toList(); } }第四步,接入监控。至少要把每个适配器的调用耗时、成功数、失败数、重试次数暴露成指标,按模型名打标签。这样哪天某个供应商变慢,你能第一时间从监控面板上看出来,而不是等用户反馈。
第五步,逐步放量。先拿低峰流量跑半天,观察线程池的 activeCount 和 queueSize 是否平稳,再逐步放开流量。
4.2 压测数据:串行、手动并发、RunnableBatch 对比
为了验证优化效果,我用同一套业务场景做了三种实现方式的压测。场景是:每个请求需要依次/并行调用 3 家供应商模型,单次调用 p50 约 1.2 秒,请求量峰值 30 次/秒。
第一版是串行调用,代码最简单,三个模型方法一个接一个执行:
| 实现方式 | p50 总耗时 | 峰值线程数 | 吞吐上限 | 代码维护成本 |
|---|---|---|---|---|
| 串行调用 | 3.6 秒 | 210 | 严重受限 | 低 |
| 手动线程池并发 | 1.3 秒 | 85 | 提不上去 | 中 |
| RunnableBatch 批处理通道 | 1.2 秒 | 45 | 稳定达到目标 | 低 |
串行版本为什么会用到 210 个线程?因为容器线程池被每个 HTTP 请求占满了,每个线程在等供应商返回时都处于阻塞状态。手动并发版本比串行好,但每个业务请求都申请了 3 个线程做内部并发,线程开销还是不小,而且一旦其中某个供应商超时,我的异常逻辑很容易让线程泄漏。
RunnableBatch 版本的效果最好,不是因为什么黑科技,而是因为它在“任务提交”和“底层执行”之间加了一层有界的调度控制。线程数被压到 45,队列有界,超时可配,某一家的异常也不会轻易污染其他任务。
4.3 结果顺序与异常隔离怎么处理
批处理最常见的两个翻车点:一是结果顺序对不上,二是某一个任务失败导致整批都失败。
关于顺序,我的做法是:提交任务时,保持原始请求列表的顺序,返回时按同样的顺序收集结果。哪怕某个任务先完成,也要放在它对应的槽位上。RunnableBatch 返回的 Future 集合天然保序,你只要按顺序 take 就行。不要在这个地方用 Map 组装结果,不然你会发现自己花了两小时调一个本来不存在的问题。
关于异常隔离,我的原则是:批任务失败,不等于整批失败。每个任务在提交时,需要显式捕获异常并转成结果状态:
CompletableFuture<ModelResponse> future = CompletableFuture.supplyAsync(task, executor) .exceptionally(ex -> ModelResponse.failed(ex.getMessage()));这样即使某个供应商坏了,批处理也能继续运行,只是这条结果上带着失败标记。上层业务拿到标记后,可以做降级或者部分重试。这个设计在真实环境里救了我很多次,因为模型供应商不可能永远稳定,总有一两个会在半夜抽风。
5. 常见问题与排查技巧实录
5.1 供应商限流与重试风暴
跨供应商批量推理刚上线那阵,我们遇到过最典型的问题:某个供应商短暂限流,返回 429,我的适配器一看 429 就重试,结果所有批任务里的同款请求同时重试,直接把供应商的限流阈值打穿,造成大面积超时。
这不是供应商的问题,是我自己的重试策略没有做全局协调。后来我给重试逻辑加了两层限制:
第一层:每个适配器内部只对“可重试错误”重试,最多重试 2 次,而且第一次重试必须退避至少 500ms,第二次退避至少 1s。第二层:在批处理层维护一个简易的限流闸门,根据最近 10 秒内的失败率动态调整向某家供应商的提交速率。失败率高就放慢,正常了再放行。
经验是:重试要有退避,退避要有随机抖动,批量场景下尤其如此。所有人同时退避 1 秒,等于什么都没退。
5.2 线程池被打满、队列无限膨胀
有一次线上报警,批处理接口的响应时间从 1.2 秒飙到 8 秒。我看监控,线程池的 activeCount 一直顶在 maxPoolSize,queueSize 也在涨。
根因是队列容量设置太大,高峰期间所有超时任务都堆在队列里,旧任务还没执行完,新任务又进来了。线程池的“核心线程 + 有界队列 + 拒绝策略”这个组合,任何一环设错都会引发雪崩。
我的调整方式:队列容量设成“单批任务数 × 预期并发批数”的 1.5 倍左右,拒绝策略从默认的丢弃改成 CallerRunsPolicy。这样即使队列满了,新任务会由提交线程自己跑,不会悄悄丢任务,也不至于无边界堆积。
5.3 长尾供应商拖慢整批结果
三家里有两家只要 500ms,第三家平均要 2 秒。批任务总共需要 2 秒多。这就是木桶效应,整个批处理通道的耗时被最慢的供应商锁死。
我在生产环境里的解法是:把供应商按延迟档位分组,慢速组走短的超时和单独的线程池,快速组走另一个线程池。批任务提交进来之后,按模型名把请求拆成两组并行跑。这样快手不会等慢手,整批任务耗时从“最慢供应商耗时”变成“最慢组耗时”,组内并行。
如果还想再激进一点,可以在业务允许的情况下,对慢速组的结果做异步获取,先返回快速组的部分结果给上层,慢速组的后续补齐。这种方案要看业务能不能接受部分结果,不能盲目套用。
5.4 部分任务失败与结果错序
还有一个低频但很烦的问题:某个供应商在高峰期偶尔返回 200 但响应内容缺失,比如 choices 数组是空的。我之前实现 ModelResponse.from(result) 时没有做防御性判断,一旦空数组出现,直接抛空指针。这个顶层异常又触发了整批任务的重跑,白白浪费了大量模型调用。
排查到根因之后,我在适配器里加了一条硬性校验:响应必须包含非空内容,否则视为失败响应,而不是抛出异常。失败响应走上层的降级逻辑,不会触发整批重试。这种做法在批量推理里非常重要:协议上的 200,不代表业务上的成功,你要在适配层就挡住这种“伪成功”。
排查这种问题最快的手段,是把每一次供应商调用的原始响应体按模型名打日志,只记录 HTTP 状态码和响应体大小,不记录 prompt 内容。日志太小看不出问题,日志太大容易泄露业务数据。
5.5 几个值得长期坚持的小习惯
最后分享几个我踩了很多坑才沉淀下来的习惯。
第一,所有适配器保持无状态。批处理的核心是共享线程池,如果适配器里藏了可变状态,并发一上去,间歇性 bug 会把你逼疯。
第二,缓存要做在批量通道之前。很多业务请求其实是在重复问类似的问题,在 RunnableBatch 之前加一层短时语义缓存,能直接砍掉一部分模型调用量。我上线缓存后,高峰期对某家供应商的请求量降了 20% 多,成本也省下来了。
第三,日志和业务执行必须解耦。批量任务内部不要边调模型边写日志,把这些 IO 操作放到任务结束时统一异步写,不然日志本身就会成为新的延迟瓶颈。
第四,如果框架支持虚拟线程,建议给批处理通道里的轻量并行任务启用虚拟线程,省下线程池管理的复杂度。但要注意,虚拟线程还是会占用连接和内存资源,限流和重试策略不能省。
跑完这一轮,我越来越觉得 RunnableBatch 的价值不是“把请求变快”,而是把“批量推理”这件原本散落在业务代码里的活收拢成了一条标准通道。它不能替你解决模型供应商本身的延迟,却能让你在供应商变慢、限流、抽风的时候,还维持住整个系统的吞吐稳定。跨模型供应商的并行请求合并,真正难的不是并发,而是把不同语义的调用抽象成统一的可调度任务,再用一套有界的执行机制兜住底线。这个思路后续还可以继续扩展,比如在批处理层加语义缓存、优先级队列、动态限流,那是另一个值得写很多篇文章的话题了。