
先说一句可能有些得罪人的话Java AI 应用里异步化和高并发设计不能等项目上线后才开始补课它在建模块之前就该是基础架构的一部分。我最近接了一个大模型 Agent 项目上线第一周就遇到了 Tomcat 线程池被打满、用户排队等结果、数据库连接池被连带打挂的问题。排查到最后问题根本不在业务代码而在同步线程模型和 AI 接口的耗时特性严重不匹配。这篇文章我打算把这类问题一次讲透先说清楚为什么 AI 接口会让传统同步架构“成批死亡”再给三条异步化路线的选型建议然后重点聊大模型 API 调用链上的限流、超时、熔断、重试以及线程池、连接池的调参经验。最后会放出一套从同步接口改造成异步任务中心的完整方案并用多 Agent 编排场景做收尾。适合正在用 Java 接大模型 API、做 AI 聊天/Agent 产品、或者维护 AI 网关的开发者参考。1. AI应用为什么会让线程池“成批死亡”1.1 从Tomcat线程模型看普通接口和大模型接口的本质区别大多数 Java 老项目用的还是 Spring MVC Tomcat 这套同步模型。Tomcat 默认的maxThreads是 200也就是同时最多只有 200 个请求线程在处理业务。每个请求从进入容器到响应写回客户端会一直占着一个线程。普通 CRUD 接口 50ms 到 100ms 就能返回200 个线程理论上能支撑差不多 2000 QPS日常业务根本不会触到瓶颈。但换成大模型接口情况完全变了。一次大模型 API 调用通常要 2 到 5 秒如果走的是 Agent 编排一个请求内部可能还要多次调用模型。同样是 200 个线程3 秒一个请求系统并发上限就是 200 / 3 ≈ 66 QPS而且这 66 QPS 是理论极限只要某个上游模型响应稍慢吞吐立刻掉得更低。一个 40 路并发的小流量就能把 200 个线程全部占死。这里有个非常反直觉的点线程数加到 500 甚至 1000表面看并发上限提高了但线程多了之后每个线程都在等网络 IOCPU 大部分时间在空转上下文切换反而加剧系统吞吐并不会线性增长。1.2 AI慢请求的三个来源网络等待、流式响应、串行编排AI 服务不像是普通 RPC 调到内部服务它的慢来自三个层面。一是网络等待。大模型 API 是一次远程网络调用线程发出请求后就开始数秒级地等响应。等的过程里线程不干活但资源是实实在在占着的。二是流式响应。如果你用了 SSE 流式返回连接不是一次性读完整响应就结束而是首字节到达后还要持续接收 token 流。聊天场景里一次流式响应持续 30 秒甚至更久都很常见。这个连接期间对应线程一直被占用。三是串行编排。AI Agent 应用里规划、工具调用、总结等多个步骤常常是串行执行一步要 3 秒三步就是 9 秒。一次请求占线程的时间被成倍放大。这三个因素叠加固定线程池很容易就满了。1.3 线程池被打满后的连锁反应CPU空转、队列膨胀、连接池被连带打爆线程池快满时新的请求会先进入 Tomcat 的等待队列。等到队列也满了后续请求直接收到 Connection Refused。更隐蔽的问题是连锁反应。线程等待大模型响应期间如果某个兜底逻辑要查数据库或者 Redis所有等模型的线程会同时去抢数据库连接。数据库连接池一共就配置了 20 个这一下就能被打穿连接池的 wait timeout 大量抛出数据库本身还没到瓶颈服务已经被自己的连接池拖死了。我最初的处理办法就是简单调大线程池结果并发布控了数据库连接池先崩。这也是很多 AI 服务刚上线时的通病把目光放在“等”这个环节却没有想清楚等待的资源到底该怎么分配。2. CompletableFuture、虚拟线程、WebFlux三条异步路线怎么选2.1 CompletableFuture老项目零成本上手的编排利器如果你的项目暂时在 JDK 8 或者 JDK 11没办法上虚拟线程CompletableFuture 是最务实的选择。它能把大模型调用拆成一个个异步任务配合自定义线程池做编排。看一个基础用法ExecutorService aiPool Executors.newFixedThreadPool(48, new ThreadFactoryBuilder() .setNameFormat(ai-call-%d) .build()); CompletableFutureString summaryFuture CompletableFuture .supplyAsync(() - callLLM(prompt), aiPool) .thenApplyAsync(this::postProcess, aiPool) .orTimeout(30, TimeUnit.SECONDS) .exceptionally(ex - { log.error(AI调用失败, ex); return 系统繁忙请稍后重试; });这里有几个实际的坑我挨个说。第一不要用默认的 ForkJoinPool。CompletableFuture 如果不指定线程池默认走 commonPool它的并行度等于 CPU 核数减一。你一个 8 核机器commonPool 只有 7 个线程AI 场景下 7 个并发直接就堵死了。所以所有链路上的异步任务都要显式传线程池。第二orTimeout触发时线程池里的任务并不会被中断。你设置了 30 秒超时并返回兜底结果但那个后台线程可能还在等模型响应。这在高并发下意味着线程池里的任务还在累积只是用户侧以为已经结束了。所以超时处理一定要配合幂等设计避免对过期任务的结果再次落库。第三编排多了之后.exceptionally要针对不同阶段单独处理。大模型调用的异常和业务后处理的异常影响范围完全不一样统一处理会把“上游偶发失败”和“代码有 bug”混在一起排查问题的时候非常痛苦。2.2 虚拟线程Java 21时代最省事的IO并发方案如果你有条件升级到 JDK 21我强烈建议优先考虑虚拟线程。虚拟线程的好处在于它不占用平台线程阻塞时载体线程会自动让出不用手工调线程池大小。代码层面改造非常直观try (var executor Executors.newVirtualThreadPerTaskExecutor()) { FutureString future executor.submit(() - callLLM(prompt)); String result future.get(30, TimeUnit.SECONDS); }底层原理是虚拟线程的栈被存在 JVM 堆里执行到阻塞调用时载体线程直接去跑其他虚拟线程代价比之前线程池里的等待小了好几个数量级。你用同步的编程方式写出异步的效果心智负担最低。但我必须提醒三个问题。第一是synchronized pinning。JDK 21 默认虚拟线程在遇到 synchronized 代码块时可能会钉在载体线程上导致载体线程无法被释放。如果你的代码里用了大量 synchronized建议替换成 ReentrantLock尤其是锁内还包着网络调用的情况。第二连接池依然要按并发上限设计。虚拟线程解决的是线程等待的资源浪费但如果用的是 HTTP 连接池连接数还是要根据上游 API 的并发配额来定虚拟线程再多也不能突破连接池上限。第三ThreadLocal 传递问题。固定线程池复用线程时 ThreadLocal 会串虚拟线程不复用但跨线程切换时 ThreadLocal 也不会自动传递。链路追踪字段如果要跨异步边界传递需要显式用上下文参数或者 TransmittableThreadLocal 一类的工具。2.3 WebFlux高吞吐网关场景才值得掏出的方案WebFlux 的全链路异步非阻塞模型在理论吞吐上是三者里最高的但它也是接入成本最高的。需要把 Controller、服务层、HTTP 客户端全部改成响应式编程调试堆栈复杂很多老项目引入后反而拖慢开发效率。它的典型场景是 API 网关、BFF 层、以及需要对大模型流式响应做高并发转发的代理层。如果你是在网关层统一做模型供应商路由、限流、鉴权WebFlux 很合适但如果你是在业务服务里做 Agent 编排没必要为了异步把整层业务代码都卷成 Mono/Flux。三个方案放在一起对比选型吞吐能力改造成本心智负担适用场景CompletableFuture中等低中等存量 Spring MVC 服务渐进式改造虚拟线程高极低低同步代码为主的业务服务JDK 21 环境WebFlux最高高很高API 网关、流式代理层、BFF我的结论很简单大多数 Java AI 业务服务优先考虑虚拟线程编排复杂的存量服务用 CompletableFuture 小步改造只有网关层才需要上 WebFlux。3. 大模型API调用链上的限流、超时、熔断与重试策略3.1 为什么要做两层限流入口配额和上游配额要分开很多团队对 AI 应用的限流理解还停留在 Nginx 层限流这远远不够。因为大模型 API 的上游供应商给的 Rate Limit 往往很紧可能每分钟只允许 60 次请求这个配额比你的服务吞吐还要低。所以要做两层限流。第一层是服务入口限流保护自己的系统资源不被突发流量打穿。第二层是上游配额限流用来保证给供应商的请求量不超过 API Key 的配额避免大量请求打到上游后被 429 拒掉白交费还占用了自己的线程和连接池。限流算法上我推荐令牌桶而不是固定窗口计数器。令牌桶能平滑突发流量短时间内十几个请求不会被一刀切地拒绝而是被匀速地放行到上游。简单的内存令牌桶可以这样实现public class TokenBucketLimiter { private final double capacity; private final double refillRatePerSecond; private double tokens; private long lastRefillTime; public TokenBucketLimiter(double capacity, double refillRatePerSecond) { this.capacity capacity; this.refillRatePerSecond refillRatePerSecond; this.tokens capacity; this.lastRefillTime System.nanoTime(); } public synchronized boolean tryAcquire(int permits) { long now System.nanoTime(); double elapsedSeconds (now - lastRefillTime) / 1_000_000_000.0; tokens Math.min(capacity, tokens elapsedSeconds * refillRatePerSecond); lastRefillTime now; if (tokens permits) { tokens - permits; return true; } return false; } }多实例部署时本地令牌桶会不准因为每台机器的令牌独立计算总量可能超过上游配额。这种情况建议用 Redis Lua 脚本实现分布式令牌桶核心逻辑不变把令牌存取放到 Redis 里。一个实用的做法是给不同模型供应商分别建限流器因为不同供应商的配额是独立的A 供应商堵了不影响 B 供应商的流量。3.2 超时策略读超时才是真正的魔鬼AI 应用里连接超时基本不会出问题麻烦的是读超时。普通接口的读超时可以设为 3 秒 5 秒但大模型 API 的首字节时间TTFT可能就要 5 秒流式场景下更是如此。如果读超时设得太短模型响应稍慢就被误杀然后用户就会发现明明模型正常服务却总是断流。我实测下来的参数如下场景连接超时读超时额外说明非流式 LLM 调用5 秒60 秒长任务可能更久按模型规格调整流式 LLM 调用5 秒首包 30 秒 / 后续 10 秒无数据用逐包超时判断而不是整体一次超时多 Agent 单步调用5 秒60 秒单步超时上限编排整体另算流式调用的读超时最理想的做法是收到首包前等待时间可以放宽到 30 秒收到首包后如果持续 10 秒没有新数据才判定超时。很多 HTTP 客户端默认的读超时是整体性的无法区分首包和包间隔这时候要用流式读取并自己控制心跳计时。重试策略这块更要谨慎。只有连接失败、收到 429、以及明确的上游 5xx 错误才应该重试。超时的情况下不要贸然重试因为你不知道上游是不是已经处理了请求AI API 的计费往往不跟着响应状态走超时后重试容易造成重复扣费。如果真要重试指数退避加抖动比固定间隔重试靠谱得多。第一次等 1 秒第二次 2 秒第三次 4 秒随机上下浮动 20%。3.3 熔断与降级上游不可用时的优雅退出上游供应商和大模型 API 一样会出故障配额用尽、机房抖动、限流策略突然变更这些都可能在几分钟内发生。如果下游已经故障你的服务还在源源不断地发请求结果就是下游雪上加霜自己也把线程和连接池耗干。这种情况下熔断是必须的。我用的是 Resilience4j 的 CircuitBreaker配置核心项是这样的CircuitBreakerConfig config CircuitBreakerConfig.custom() .failureRateThreshold(50) .slowCallRateThreshold(60) .slowCallDurationThreshold(Duration.ofSeconds(10)) .waitDurationInOpenState(Duration.ofSeconds(60)) .slidingWindowSize(30) .build();意思是最近 30 次调用里超过 50% 失败或者超过 60% 慢调用超过 10 秒熔断器打开接下来的请求快速失败等 60 秒后再尝试放量试探。熔断打开之后降级逻辑比兜底话术要丰富得多。至少应该按优先级尝试这么几层第一层查 Redis 缓存里有没有相似问题的历史答案有就返回缓存并标记非实时结果第二层切换到备用的模型供应商第三层返回固定话术并记录一条任务日志提示稍后重试。降级不是在接口层面写几个 if else而是要在调用链路里把每一步的 fallback 都设计好。4. 线程池和连接池调参实录目标QPS、P99与隐藏瓶颈4.1 用流量公式推导并发数而不是拍脑袋同步线程池的核心并发数可以用一个很实用的经验公式推导同步线程总数 目标 QPS × P99 耗时秒举个例子目标 QPS 是 30P99 耗时 3.2 秒同步线程数就是 30 × 3.2 96。再考虑一定的冗余系数取 120 左右比较稳。这个公式的逻辑是每个请求平均占用线程 3.2 秒要在一秒内处理 30 个请求就必须有 96 个线程同时在工作。线程数低于这个数QPS 必然上不去高于这个数太多不会带来额外收益只会增加上下文切换和上游压力。异步化之后这个公式仍然有意义只不过它推导出的不再是线程数而是并发位同时进行的模型调用数。如果你用的是虚拟线程线程数无穷无尽真正限制系统的是信号量给同一个模型供应商的并发调用设一个上限防止自身流量把上游配额冲爆。4.2 HTTP连接池线程不阻塞了连接池开始排队异步化改造后我踩过的下一个大坑是 HTTP 连接池。之前同步模型 200 个线程假如每个线程一个连接连接池配 100 就够用。改成 CompletableFuture 加上大并发之后同时可能发起 500 个甚至更多的 HTTP 调用连接池如果不跟着涨就会出现线程明明空闲、但拿不到连接、全部排队等连接的情况。连接池的容量并不是越大越好。它要跟两个因素匹配一是线程池的并发位二是上游 API 允许的配额。理论上并发位 100连接池只需要 100但连接池的等待时间最好设小一点比如 5 秒超过就快速失败而不是让请求无限等下去。如果你的 HTTP 客户端用的是 Apache HttpClient几个关键参数需要注意PoolingHttpClientConnectionManager connectionManager PoolingHttpClientConnectionManagerBuilder.create() .setMaxConnTotal(200) .setMaxConnPerRoute(100) .evictExpiredConnections() .evictIdleConnections(Duration.ofSeconds(30)) .build();这里面最容易被忽视的是evictIdleConnections。AI 应用调用模型有高峰和低峰低峰时大量空闲连接占用对端资源高峰往前挤如果不及时清理容易造成“明明连接池显示已满实际很多连接已经不可用”的假象。4.3 任务队列的有界设计与拒绝策略异步化之后如果瞬间涌入超量请求不能直接丢弃也不能无限排队。生产环境用过无界队列LinkedBlockingQueue的团队应该都有过内存飙高、GC 时间变长、最后所有任务卡在队里出不来的体验。保险的做法是给队列设置上限。比如ThreadPoolExecutor executor new ThreadPoolExecutor( 32, 64, 60, TimeUnit.SECONDS, new ArrayBlockingQueue(2000), threadFactory, new CallerRunsPolicy() );ArrayBlockingQueue(2000)保证队列不会无限增长满了之后的核心问题是谁来处理多余任务。CallerRunsPolicy会让提交任务的线程自己执行任务虽然降了吞吐但至少任务不丢也可以自定义一个拒绝策略把任务直接标记为“系统繁忙”返回给前端。这里要特别注意拒绝策略的选择。DiscardOldestPolicy丢弃最老任务对 AI 场景不友好因为最老的任务往往已经执行了一半丢弃它会造成上游状态不一致而且用户等了好几分钟却发现任务消失了。宁可返回失败让用户重试也不要在中间环节悄悄丢任务。4.4 按业务场景做资源隔离别让一个AI任务拖死全站如果你一个服务里同时跑着 Agent 编排、普通聊天、批量生成、图像模型所有流量共用同一个线程池和同一个连接池很容易出现一个问题某个客户在跑一个很重的 Agent 编排任务长时间占用几十个并发位其他客户的聊天请求全部被挤掉。资源隔离需要大一点的粒度不能只有一个池子。可以按场景拆Agent 编排专用一个线程池并发位单独限制聊天模型调用用一个独立的信号量配额批量任务走独立队列甚至独立服务。隔离后可以在监控面板上看得更清楚哪个场景在消耗资源哪个场景在排队。出了问题也不至于全站瘫痪最多是某个功能变慢。5. 从同步接口到异步任务中心端到端改造方案5.1 什么任务适合异步化什么任务必须保持同步不是所有 AI 接口都要改成异步。聊天场景追求首字延迟你让它提交任务后再轮询结果用户是不可能接受的图片生成或文档总结这种本来就需要几十秒的耗时任务让 HTTP 请求同步挂住几十秒不如改成任务创建 查询结果的模式。我在项目里的拆分标准是交互式且响应时间小于 5 秒的保持同步/流式耗时长且对实时性要求低的一律走异步任务中心。一个比较通用的异步任务中心包含三个部分任务表存储状态、任务执行器消费内容、结果查询接口反馈进度和结果。5.2 三步改造请求入队、后台执行、结果查询改造的第一步是请求入队。客户端提交一段文本服务端生成一个任务 ID立刻把任务状态写成 PENDING返回给前端PostMapping(/api/ai/tasks) public ApiResponseTaskDTO submit(RequestBody SubmitRequest request) { AiTask task new AiTask(); task.setTaskId(UUID.randomUUID().toString()); task.setStatus(TaskStatus.PENDING); task.setRequestContent(request.getContent()); task.setCreateTime(LocalDateTime.now()); taskMapper.insert(task); // 提交到后台执行 taskExecutor.submit(new AiTaskRunner(task.getTaskId())); return ApiResponse.success(TaskDTO.from(task)); }第二步是后台执行。AiTaskRunner消费任务先更新状态为 RUNNING再调用大模型 API成功就保存结果失败就记录失败信息并设置可重试次数。public void run() { AiTask task taskMapper.findById(taskId); task.setStatus(TaskStatus.RUNNING); taskMapper.update(task); try { String result llmGateway.call(task.getRequestContent()); task.setResultContent(result); task.setStatus(TaskStatus.SUCCEEDED); task.setFinishTime(LocalDateTime.now()); taskMapper.update(task); } catch (Exception ex) { task.setStatus(TaskStatus.FAILED); task.setErrorMessage(ex.getMessage()); task.setRetryCount(task.getRetryCount() 1); taskMapper.update(task); } }第三步是结果查询。前端拿到 taskId 后轮询查询接口状态变成 SUCCEEDED 或 FAILED 就返回完整结果还在 RUNNING 就告诉前端继续等待。5.3 任务状态机与超时兜底任务状态设计成四个状态就够了PENDING已提交、RUNNING执行中、SUCCEEDED成功、FAILED失败。但线上一定要加第五个状态TIMEOUT。大模型 API 偶尔会长时间无响应慢调用把所有执行线程占满队列里的任务永远等不到执行机会。如果不做超时兜底任务会一直停留在 RUNNING用户干等到天荒地老。超时兜底的方案是做一个定时扫描任务每隔一段时间扫出 RUNNING 状态且超过超时时间的任务把它们标记为 TIMEOUT释放队列空间。这里有个问题标记 TIMEOUT 只解决了用户侧的状态展示后台那个还卡在网络调用上的任务实际上还在执行。最终还需要在模型调用层做真正的超时取消否则只能靠 tomcat 的 IO 超时和 HTTP 客户端的读超时来兜底。5.4 结果回路轮询和SSE哪种合适异步任务中心的结果通知方式项目实际中主要就两种客户端轮询和 SSE 推送。轮询简单可靠兼容性最好任何前端方案都能用但有个明显缺点客户端每隔一两秒打一次查询接口如果任务并发达到几千查询接口本身的压力也是不小。优化空间是给结果更新时间加个 ETag 或者版本号结果没变时返回 304省掉一大半响应流量。SSE 更优雅服务端在任务完成时主动把结果推给客户端。实现上可以用 Spring 的SseEmitterSseEmitter emitter new SseEmitter(60_000L); emitter.onCompletion(() - taskExecutor.removeEmitter(taskId, emitter)); submitTask(taskId, emitter);任务执行器完成后拿着 emitter 调用send推送结果。生产环境要注意代理缓冲问题Nginx 默认会缓冲 SSE 响应需要关闭 proxy_buffering否则前端拿到的是攒了好久的整包数据体验跟轮询没区别。两种方式的选择标准其实很直接想省事选轮询追求实时体验选 SSE。在线程池充足、查询接口做了缓存优化的情况下轮询完全能撑住日常流量。6. 多Agent编排的并发控制以及我最后踩的那个坑6.1 扇出并发的总量控制信号量才是主角多 Agent 编排场景比单个模型调用复杂得多。一个 Agent 任务可能拆出 5 个子任务每个子任务调用一次大模型这 5 个调用是并行的。如果同时有 20 个 Agent 任务在执行上游大模型的并发实际上就是 100这远超大多数模型配额。扇出后的并发控制不能靠线程池大小来限制因为执行线程和上游并发不是一比一的关系。更可靠的是在调用大模型那一步加一个全局信号量Semaphore modelSemaphore new Semaphore(20); public String callLLMWithLimit(String prompt) { boolean acquired modelSemaphore.tryAcquire(5, TimeUnit.SECONDS); if (!acquired) { throw new TooManyConcurrentCallsException(); } try { return llmClient.call(prompt); } finally { modelSemaphore.release(); } }信号量设成多少取决于你拿到的上游配额。上游说每分钟 60 次信号量可以放在 20 左右配合令牌桶限速保证不会瞬时把配额打爆。子任务的并行编排可以用 CompletableFuture 做扇出汇聚ListCompletableFutureSubResult futures tasks.stream() .map(t - CompletableFuture.supplyAsync(() - executeSubTask(t), aiPool)) .collect(Collectors.toList()); CompletableFutureVoid all CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])); all.get(30, TimeUnit.SECONDS);每个子任务都应该有独立的超时控制不要只在外层设一个总超时。外层超时触发时某个子任务可能已经对上付费API 发了请求你没法撤销但子任务超时可以立刻放弃对该结果的等待把当前 Agent 步骤转移到兜底分支。6.2 幂等去重和ThreadLocal清理实际项目里最容易翻车的地方异步任务中心 Agent 编排还有一个高并发陷阱回调重复。如果任务执行器失败后自动重试而重试前第一次请求已经在上游成功重复回调就会造成重复扣费、重复写入结果。我在生产环境里见过的最严重的线上事故就是重试机制没有加幂等导致大模型计费翻了三倍。解决办法不复杂任务表里用 taskId 做唯一索引所有回写结果的操作都带着 taskId 去更新更新时只允许“待执行/执行中”的状态流转到成功状态已经不为 RUNNING 时直接忽略。这样不管上游回调多少次最终只会写入一次结果。还有一个容易翻车的是 ThreadLocal 的清理。异步改造后很多请求处理被扔进线程池如果线程池线程里保留了上一次请求的 ThreadLocal 数据轻则 traceId 串号导致日志排查困难重则权限上下文串到别的用户头上这属于安全事故了。规范是每次异步任务跑完finally 块里必须 remove 掉自己塞进去的 ThreadLocal 值try { traceContext.set(traceId); // 业务逻辑 } finally { traceContext.remove(); }用固定线程池跑异步任务时这个习惯直接决定线上日志是不是一锅粥。