并行配图生成的实现思路一句话讲清:正文生成完成后,配图分析智能体先产出 N 个画面描述,配图生成器把 N 个生成任务一次性提交到独立线程池并行执行,再用 CompletableFuture.allOf 聚合结果,总耗时从「N 张图的耗时相加」收敛为「最慢一张图 + 编排开销」。项目里 4 张配图的场景,串行要等 4 倍单图耗时,并行后压到 1 倍出头,实测整体提速 3 到 4 倍。下面拆解实现细节、线程池调参、异常兜底和 SSE 进度回传,照这套写就能直接在项目里落地。
一、为什么配图必须并行
文章正文按 1500 字算,配图分析智能体通常产出 4 到 6 个画面描述。如果串行执行,每张图生成算 10 秒,4 张图就是 40 秒,用户盯着进度条干等,期间 SSE 连接还容易超时。配图任务彼此独立、没有数据依赖,天然适合并行。这个阶段是整条生成流水线里唯一能并行加速的环节,不动它等于白送性能。
1.1 配图描述从哪来
配图描述由配图分析智能体生成,输入是正文全文,输出是一组结构化的画面描述,每个描述包含主体、场景、风格三个要素。这一步是并行的前提:先产出全部描述,再统一派发生成任务。如果描述本身要串行等正文,配图阶段还是瓶颈,所以配图分析放在正文完成后一次性完成,生成阶段再并行。
1.2 串行实现的真实代价
串行版本代码短、好调试,但每加一张图就线性加一份等待。加上模型接口的读超时设置,单张图生成超过 60 秒直接超时,串行场景下一张超时后面全排队,最坏情况整篇卡住。并行之后,单张失败只影响自己,其它图照常完成,鲁棒性跟着提上来。
二、串行改并行的改造过程
改造前的写法是 for 循环逐张调用,简单但慢:
List<String> imageUrls = new ArrayList<>();
for (ImagePrompt prompt : prompts) {
imageUrls.add(imageApi.generate(prompt)); // 串行,10s * N
}
改成并行只需要三步:
- 每个 prompt 用
CompletableFuture.supplyAsync包装成异步任务,提交到专用线程池; - 用
CompletableFuture.allOf等待全部任务完成; - 遍历 future 用
join收集结果,保持顺序与 prompt 一致。
ExecutorService imagePool = Executors.newFixedThreadPool(5);
List<CompletableFuture<String>> futures = prompts.stream()
.map(p -> CompletableFuture.supplyAsync(() -> imageApi.generate(p), imagePool))
.collect(Collectors.toList());
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
List<String> imageUrls = futures.stream()
.map(CompletableFuture::join)
.collect(Collectors.toList());
代码改动不大,收益是线性的。N 张图并行后,理论上限就是单张最慢耗时。join 抛的是运行时异常,配合 Stream 收集比 get 少一层 try-catch,代码更干净。
三、编排细节:线程池、超时与兜底
3.1 必须用自定义线程池
supplyAsync 默认走 ForkJoinPool.commonPool,它是全局共享的,一旦配图任务阻塞,会拖累整台机器所有异步任务。项目里单独声明一个定长线程池,并配置拒绝策略。
| 参数 | 项目取值 | 说明 |
|---|---|---|
| corePoolSize | 5 | 并发配图数,与模型 QPS 上限对齐 |
| maxPoolSize | 8 | 峰值并发 |
| queueCapacity | 20 | 排队任务数 |
| 拒绝策略 | CallerRunsPolicy | 打满时由调用线程兜底执行 |
ThreadPoolExecutor imagePool = new ThreadPoolExecutor(
5, 8, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(20),
new ThreadPoolExecutor.CallerRunsPolicy());
线程池大小参考图片 API 的 QPS 限制来定,一般 5 到 10 是安全区间,开大了反而把模型服务打爆。并发数超过接口 QPS 上限时,请求排队反而增加整体耗时,调参前先确认上游限流。
3.2 超时与异常兜底
单张图生成超过 60 秒直接判失败,不能让最慢的任务拖着整条流水线。get(timeout) 超时后 cancel 任务,收集阶段把失败的图替换成占位图,保证文章不因为一张图失败就整体返工。
try {
return future.get(60, TimeUnit.SECONDS);
} catch (TimeoutException e) {
future.cancel(true);
return FALLBACK_IMAGE_URL; // 占位图
} catch (Exception e) {
return FALLBACK_IMAGE_URL;
}
四、SSE 进度回传与失败处理
并行任务每完成一张,立即通过流通道推送一次进度事件,前端据此更新「已生成 2/4」的进度条,而不是等全部完成一次性通知。实现方式是每张图完成后调用 allOf 之外单独挂一个 whenComplete 回调,在回调里推进度。
CompletableFuture<Void> all = CompletableFuture.allOf(
futures.toArray(new CompletableFuture[0]));
futures.forEach(f -> f.whenComplete((url, ex) ->
StreamHandlerContext.send(
JsonUtils.toJson(Map.of("done", count.getAndIncrement(),
"total", prompts.size())))));
all.join();
全部完成后推送 done 事件并回传图片 URL 列表,任一任务异常则推送 error 事件,前端恢复按钮。整体节奏是:串行等最慢,失败不阻塞,进度实时可见。
4.1 配图渠道降级
图库接口偶发 5xx,重试一次仍失败就切备用渠道。降级逻辑放在 supplyAsync 的异常兜底里,主渠道失败先试备用渠道,再不行才用占位图。渠道切换对调用方不可见,调用方只拿 URL 列表。这样并行编排和渠道容错解耦,每张图最多多花一次重试时间,不拖累其它图。
五、实测效果
项目压测数据(单图约 10 秒):
| 配图数 | 串行耗时 | 并行耗时 | 提升 |
|---|---|---|---|
| 2 张 | 约 20 秒 | 约 11 秒 | 约 1.8 倍 |
| 4 张 | 约 40 秒 | 约 13 秒 | 约 3 倍 |
| 6 张 | 约 60 秒 | 约 15 秒 | 约 4 倍 |
并行后总耗时增长放缓,瓶颈从「图的数量」转移到「单张最慢图的耗时」。再往上加图,收益趋近于上限,这时该优化的是单张生成速度而不是继续堆并发。接入量上来后,还要用监控盯线程池队列长度和任务拒绝次数,队列长期打满说明并发度定低了,拒绝次数增加说明上游扛不住,两个方向调参依据不同。
5.1 上游配额与限流
并行把压力集中到一个时间窗,上游图库接口的 QPS 配额要提前确认。项目用的图库按分钟限流,并发 5、单图 10 秒,一分钟内实际打出的请求数有限,远在配额内。如果单图耗时短、并发高,超出配额的任务会立刻 429,要把限流策略前置到提交阶段,超额任务排队而不是疯狂重试,否则重试风暴比失败本身更伤系统。
常见问题(FAQ)
Q1:并行配图能提升多少性能?
N 张图从 N 倍单图耗时降到 1 倍出头,4 张图实测约 3 倍提升。
Q2:线程池开多大合适?
按图片 API 的 QPS 限制定,5 到 10 是安全区间,开大易打爆服务。
Q3:一张图失败会影响整篇吗?
不会,超时或异常的单张替换为占位图,流水线继续完成。