
上个月我接了个 AI 问答功能的优化任务初版实现非常简单用户发请求进来后端直接同步调用大模型接口等结果返回再响应前端。功能是通的但压测一跑就露馅了——线程池被打满Tomcat 直接拒绝连接那口锅最后自然落到了我头上。排查完之后我意识到一个问题Java AI 应用的高并发设计跟传统 Web 应用完全不是一回事。普通接口的瓶颈是数据库连接、业务计算AI 接口的瓶颈是大模型动辄几秒甚至几十秒的推理延迟。这个差异决定了我们不能用老一套思路去处理 AI 场景的并发问题。这篇文章就把我这段排查、重构、压测的完整过程记录下来重点聊聊怎么用异步化扛住高并发给同样在搞 Java AI 应用的同学一个可落地的参考。1. 先说清楚为什么 AI 应用的高并发比普通 Web 应用难搞很多 Java 开发者第一次写 AI 应用时都会习惯性地按照传统接口的思路来请求进来调 AI 服务返回结果。表面看没毛病但一上压力就发现处处是坑。这里我把 AI 应用区别于普通 Web 应用的核心差异拆开讲。1.1 长时延线程被占用的时间远超你想象普通接口的时延一般在几十到几百毫秒即使这样Tomcat 默认的 200 个线程也能轻松扛住每秒几千的请求量。但 AI 接口呢以 GPT 系列或国产大模型的调用为例一次对话请求的推理时间普遍在 2 到 10 秒复杂一点的 agent 任务涉及多轮工具调用跑个三十秒也很正常。这里做个简单的估算你就明白了假设你的接口平均时延是 5 秒Tomcat 默认线程池 200 个线程。那么理论上这个服务能同时支撑的请求数是 200 个换算成吞吐量就是 200 除以 5 秒每秒只有 40 个请求。一旦高峰期请求量超过这个数字后续的请求就得排队排队线程多了内存暴涨最后服务直接假死。这就是同步调用的致命伤线程在等待网络 IO 时被白白占着什么正事也干不了。传统的 Web 应用可以用数据库连接池、缓存来缩短时延但 AI 的推理延迟是模型侧的你没法通过加机器、加索引来缩短只能从线程释放这个方向去优化。1.2 流量冲击用户不会因为你慢就少点几下AI 应用还有个特点用户对AI 回答慢的容忍度其实很低但解决方式不是耐心等待而是反复点击、刷新、重新提问。也就是说一旦系统出现卡顿流量反而会加剧形成恶性循环。这一点在高并发 IM、AI Agent 这类交互频繁的场景里尤为明显。我做过一次统计某个 AI 客服入口在上线初期单用户平均一次会话会触发 3.8 次请求其中很大一部分是用户在等待过程中的重复提交。这些重复请求如果都打到后端再同步透传给大模型那成本不说光是线程就扛不住。1.3 成本约束你不能像扛普通流量一样随意扩容如果是普通接口扛不住加服务器是最直接的办法。但 AI 应用不一样大模型的调用是按 token 计费的。你可以在网关层挡住恶意流量却没法用随便加机器这种土豪方案来解决所有并发问题因为真正的瓶颈在模型服务那边而且每一笔调用都是真金白银。所以 Java AI 应用的高并发设计本质上是在有限的线程资源、有限的模型调用预算、不可控的推理时延这三重约束下做架构取舍。而异步化就是解开第一重约束的那把钥匙。2. 异步化不是换个 API 那么简单Java 并发工具链的选型逻辑常规的异步化改造很多人的第一反应是把同步调用丢到线程池里用Future接一下以为这样就完事了。但代码真落下去你会发现Future的短板非常明显。这里我把工具链的演进和各方案的取舍讲透。2.1 从 Future 到 CompletableFutureAI 场景需要的是编排能力假设你有下面这段代码要向大模型发送一个请求并拿到结果ExecutorService executor Executors.newFixedThreadPool(10); FutureString future executor.submit(() - callLLM(你好)); String result future.get();问题在哪里future.get()是阻塞的。如果调用大模型花了 8 秒那这个线程还是被卡住了 8 秒。你说我可以把get()放到另一个线程池里啊——那还不如直接在业务线程里调用绕了一圈没解决任何问题。而且Future只有获取结果这一个能力你没法把多个请求组合起来。但 AI 应用的逻辑恰恰是多变的一个 agent 任务可能需要先调用大模型决定调用哪个工具等工具返回结果后再调用一次大模型做总结。这两次调用之间有复杂的时序关系和依赖Future根本表达不了。所以真正合适的工具是CompletableFuture。它支持回调、编排、组合用一两行代码就能处理多个 AI 调用并行执行后汇总结果这类需求。举个例子假设你要让两个大模型分别总结一篇文章的上下两半再合并输出CompletableFutureString future1 CompletableFuture.supplyAsync(() - callLLM(articleFirstHalf), aiExecutor); CompletableFutureString future2 CompletableFuture.supplyAsync(() - callLLM(articleSecondHalf), aiExecutor); String combined future1 .thenCombine(future2, (result1, result2) - result1 \n result2) .get(15, TimeUnit.SECONDS);thenCombine这个方法在传统开发里用得不多但在 AI 应用里几乎是标配并行调用、结果合并、超时兜底一气呵成。2.2 虚拟线程异步化的一种另类解JDK 21 引入虚拟线程之后Java 社区对异步化的讨论风向变了不少。虚拟线程的核心价值是让线程不再稀缺。传统线程动辄占用 1MB 左右的栈空间200 个线程就是 200MB 内存而虚拟线程的栈可以按需增长一个 JVM 里起几十万个虚拟线程都没问题。这可不得了。这意味着很多场景下你并不需要去重构业务流程、引入事件驱动框架只要把Thread.ofVirtual().start()替换掉原来的线程创建再配合Semaphore限流就能扛住极高的并发。但我的个人建议是分场景使用如果项目已经基于 Spring MVC 这类阻塞模型代码量又大直接引入虚拟线程收益很高改动还小。如果你的 AI 应用本来就是长连接、流式传输这种 IO 密集型场景虚拟线程也能明显降低内存占用。比如这个简单的例子try (var executor Executors.newVirtualThreadPerTaskExecutor()) { ListFutureString futures requests.stream() .map(req - executor.submit(() - callLLM(req))) .toList(); // 所有任务都通过虚拟线程并发执行 }虚拟线程不是银弹——如果你需要的是请求进来先返回 ticket后台慢慢执行那还是得走任务队列那一套。但如果你只是想让更多的请求并发地进入模型调用层它绝对是性价比最高的方案。2.3 工具选型CompletableFuture 与虚拟线程的搭配实际项目里我不是二选一而是两个一起用。CompletableFuture负责业务编排虚拟线程负责承载任务本身。Configuration public class AIExecutorConfig { Bean(aiVirtualExecutor) public Executor aiTaskExecutor() { return Executors.newVirtualThreadPerTaskExecutor(); } }然后在业务代码里所有 AI 相关的异步任务都丢给这个虚拟线程执行器由它来跑CompletableFuture.supplyAsync。这样既保留了响应式编排的便利又没有传统线程池带来的资源浪费。跑了几轮压测之后虚拟线程在内存占用上的优势特别明显同样 8 核 16G 的机器传统线程池方案跑满 500 并发就频繁 Full GC虚拟线程方案能稳到 2000 并发左右。3. AI 场景中常用的四种异步化模式与落地实现工具聊完接下来是核心问题你的 AI 功能到底适合哪种异步化模式。我见过不少团队把异步化做成一刀切——所有请求都丢队列里返回 ticket前端轮询结果把本来应该实时的聊天体验搞得支离破碎。所以一定要按场景选模式我这边总结了实战中最常见的四类。3.1 模式一同步转异步任务队列最保守但最稳定适用场景离线分析、批量处理、后台生成这类不要求立即返回结果的 AI 任务。比如邮件分类、工单摘要、报表生成用户提交一个请求等个十几秒甚至几分钟都没关系。落地方式Spring Boot 里用消息队列RabbitMQ / RocketMQ接到 AI 调用服务前业务接口只做一件事——把任务塞进队列。Service public class AITaskProducer { Resource private RabbitTemplate rabbitTemplate; public void submitTask(AIRequest request) { String taskId UUID.randomUUID().toString(); rabbitTemplate.convertAndSend(ai.task.queue, new AIRecord(taskId, request.getContent(), LocalDateTime.now())); // 立即返回业务线程不参与AI调用 } }消费者组件负责接收队列消息调用大模型结果写入存储或回调。这个模式对业务流程侵入最小而且天然具备削峰填谷的能力不会因为瞬时流量把大模型接口打爆。注意队列消费端一定要做幂等消息重复投递是 MQ 的老问题。AI 调用是花钱的如果你收到同一条消息调了两次大模型浪费就大了。3.2 模式二流式响应SSE保住实时体验适用场景聊天对话、AI 写作、搜索摘要。这类场景用户要的是看到字一个个蹦出来的实时体验你让他等十秒然后一次性把结果吐出来交互上就是不及格。实现方式基于 Servlet 3.1 的异步线程 SSEServer-Sent Events。前端通过EventSource建立连接后端用SseEmitter分段推送文本。RestController public class ChatController { private final AIStreamService aiStreamService; PostMapping(/api/chat/stream) public SseEmitter chatStream(RequestBody ChatRequest request) { SseEmitter emitter new SseEmitter(0L); // 不设超时 aiStreamService.streamAnswer(request.getQuestion(), emitter); return emitter; } }服务端在拿到大模型流式返回的 token 后逐个写入emitterpublic void streamAnswer(String question, SseEmitter emitter) { llmClient.streamChat(question).doOnNext(token - { try { emitter.send(SseEmitter.event().data(token)); } catch (IOException e) { emitter.completeWithError(e); } }).doOnComplete(emitter::complete).subscribe(); }这个模式的精髓在于HTTP 连接本身是异步处理器的不会像同步阻塞那样占用一个 Tomcat 线程等完全部输出。每个 SSE 连接只是挂在那里等数据Tomcat 的线程早就释放了。我这边压测下来同样的 4C8G 机器同步模式只能扛 100 个并发对话SSE 模式能扛到 1200 以上差距非常直观。3.3 模式三任务提交 状态轮询应对不可控的推理时长适用场景AI Agent 多步骤任务、文档解析、复杂工作流。这些任务往往不是一次大模型调用能完成的可能涉及多轮工具调用、代码执行、结果验证总耗时会波动很大一分钟到十分钟都有可能。此时的做法是提交任务后返回taskId前端拿着taskId轮询状态接口拿到SUCCESS后再拉取结果。GetMapping(/api/task/{taskId}) public TaskStatus getTaskStatus(PathVariable String taskId) { return taskStore.getStatus(taskId); // 状态缓存如 Redis String }这个模式比 MQ 方案巧妙的地方在于它对前端很友好而且天然支持断线恢复——任务在服务端执行前端断网重连后依然可以拿到最终结果。对 AI Agent 这种长耗时的场景特别合适用户刷新页面也不怕丢上下文。3.4 模式四事件驱动WebSocket 消息广播适用场景多 Agent 协作、实时推荐系统、共享白板。这些场景的特点是服务端需要主动推送消息而不是等客户端来请求。AI 分析的进度、多个 Agent 之间的中间结果都要实时反馈到页面上。实现时用 WebSocket 或者 Spring 的 Reactor 风格做事件流服务端在处理链路中不断发送事件ServerEndpoint(/ws/agent) Slf4j public class AgentWebSocket { OnOpen public void onOpen(Session session) { // Agent任务启动把中间事件通过session推送 agentService.runTask(session); } OnMessage public void onMessage(String message, Session session) throws IOException { session.getBasicRemote().sendText(收到指令开始处理); } }模式对比我整理成了表格方便你直接做决策场景特性推荐模式核心优势典型代价离线批处理、耗时容忍任务队列削峰填谷、系统稳定无实时反馈聊天、逐字输出SSE 流式实时性好、连接轻量需要处理断连重试长流程、需断线恢复任务状态轮询状态明朗、可恢复交互相对滞后多端同步、实时推送WebSocket 事件双向通信、信息同步实现复杂、运维成本高4. 高并发不可少的防线限流、熔断、缓存与线程池调优异步化解决的是线程占用的问题但它不解决大模型服务被巨大的并发调用打挂的问题。所以高并发设计里还有一层更关键的保障以下这套防线缺一不可。4.1 限流别让流量毫无节制地冲向大模型你想想看即使你把业务接口做成了异步如果不加以限制高峰期的所有请求还是会挤到模型调用中间件那一层。所以限流必须做在入口和 AI 调用层两个位置。入口限流我习惯用令牌桶Redis Lua 脚本或者本地 GuavaRateLimiter都行确保单机 QPS 不超过预设阈值。AI 调用层的限流则更精细——针对不同模型配不同的速率Bean public RateLimiter chatRateLimiter() { // 每秒生成10个令牌最多等待5秒 return RateLimiter.create(10.0); } public String callLLMWithLimit(String prompt) { boolean acquired chatRateLimiter.tryAcquire(5, TimeUnit.SECONDS); if (!acquired) { throw new BizException(当前AI服务繁忙请稍后再试); } return llmClient.call(prompt); }这里的经验是限流阈值得根据大模型服务的实际吞吐来定而不是拍脑袋。先跑一轮压测看模型服务在不报错的前提下能稳定处理多少并发再把这个数值乘以 0.7 作为兜底阈值。4.2 熔断防止连环雪崩大模型服务也有不稳定的时候比如模型侧的高负载、网络瞬断、超时率升高。此时如果你的服务还在继续硬扛大量超时的请求会堆积在调用层线程池被占满之后整个应用就雪崩了。熔断我用的是 Resilience4j参数上有一点要特别注意AI 调用时延高熔断器的事件窗口不能按普通接口那样设置成几秒钟因为一次 AI 调用本身就可能是 5 秒。我常用的配置是 30 秒窗口内失败率达到 40% 就打开熔断器熔断开启后等待 15 秒再尝试放行少量请求。Bean public CircuitBreaker aiCircuitBreaker() { CircuitBreakerConfig config CircuitBreakerConfig.custom() .failureRateThreshold(40) .waitDurationInOpenState(Duration.ofSeconds(15)) .slidingWindowSize(30) .build(); return CircuitBreaker.of(aiService, config); }熔断的意义不只是保护自己也是在保护大模型服务方——你这边打过去的大量无效请求实际上会加剧模型服务的不稳定。维护好这层保护对双方都有价值。4.3 语义缓存真正的免费午餐AI 应用里有个很容易被忽视的高并发利器——缓存。但 AI 应用不能像传统接口那样只做 KV 缓存因为用户的问法千变万化哪怕是同一个意思表达方式可能完全不同。这时候我推荐的是语义缓存用户提问进来先用向量化模型把问句转成向量然后去向量数据库如 Milvus / Redis 的向量模块里找相似度超过 0.95 的相似问题命中就直接返回之前的回答不再调用大模型。public String smartRespond(String question) { float[] embedding embeddingService.embed(question); SearchResult similar vectorStore.searchSimilar(embedding, 0.95); if (similar ! null) { return similar.getAnswer(); } String answer callLLM(question); vectorStore.save(question, answer, embedding); return answer; }这套方案在 FAQ 类问答场景里效果极其夸张。我做过的一个客服机器人项目40% 的请求都命中了语义缓存大模型调用量直接砍了一半成本降下来了并发压力也小了一大截。唯一要注意的是动态性强的内容不要缓存太久建议给缓存加个时效策略。4.4 线程池调优核心参数放在 AI 场景的配置无论你用不用虚拟线程只要涉及传统的ThreadPoolExecutor就得单独为 AI 调用场景调一套参数。我的建议配置如下核心线程数CPU 核数 1AI 调用 IO 密集别设太大因为阻塞式调用多线程越多切换成本越夸张。最大线程数核心线程数的 2 倍左右超过就说明异步化没做到位该考虑队列削峰了。队列容量不用太长1000 以内足够队列越长请求等待越久超时的体验比拒绝更难看。拒绝策略CallerRunsPolicy不适合 AI 场景因为回调线程会阻塞业务线程。用AbortPolicy配合前端重试更合理。ExecutorService aiPool new ThreadPoolExecutor( 9, 18, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue(1000), new NamedThreadFactory(ai-worker), new ThreadPoolExecutor.AbortPolicy() );注意这个线程池的拒绝异常一定要捕获并转成友好的 HTTP 503。让用户感知到服务繁忙请重试比前端一直转圈等超时舒服得多。5. 完整案例一个 AI 问答服务的异步化重构全过程到这里原理都说完了我拿一个真实的项目案例把整个流程串起来。这个项目是一个面向内部员工的 AI 问答助手上线第一版时用的是最朴素的同步调用结果被一场全员营销活动打了个措手不及。5.1 原架构是怎么崩的第一版的结构很简单Controller直接调LLMClientTomcat 线程默认 200在模型返回前全程被占用。那场营销活动10 点半流量峰值一到监控面板上的活跃线程数瞬间飙到 200随后大量请求排队CPU 居高不下最后 Tomcat 直接拒绝连接。我后来看了日志印象最深的是一条请求等了 15 秒才拿到连接然后大模型又跑了 6 秒整个过程花了 21 秒体验根本没法看。问题的根子就是同步阻塞占住了线程系统天然只能支撑 200 / 5 ≈ 40 TPS。5.2 重构方案三层异步化改造这次重构没有推倒重来而是围绕三个层次逐步改造第一层接口异步化。由于对话场景需要实时感我选了 SSE 流式响应方案请求进来立即返回SseEmitterTomcat 线程马上释放真正的模型调用在虚拟线程里跑token 逐个推给前端。第二层模型调用限流。在 SSE 服务里内置令牌桶同时对模型服务做熔断防止模型侧抖动传导到业务侧。第三层热点问答语义缓存。员工问答里很多高频问题基本是重复的我上线了语义缓存命中后直接从向量库返回答案连模型调用都省了。5.3 压测数据对比重构完成之后我做了三轮压测数据对比非常直观同步模式200 并发下平均响应 7.8 秒Tomcat 线程池 100% 占满大量超时。SSE 虚拟线程200 并发下平均首 token 返回时间 1.2 秒完全无超时1000 并发下依旧稳定内存占用没有明显增长。加语义缓存后3000 并发下缓存命中请求几乎瞬时返回模型调用量下降约 32%整体体验平滑得很。值得注意的是重构之后连接资源反而变成了瓶颈跟上文提到的一样。所以我把系统层面的文件描述符上限和 Spring Boot 内嵌 Tomcat 的maxConnections都做了调整server: tomcat: max-connections: 10000 accept-count: 800 threads: max: 4005.4 过程中踩过的坑这里分享几个实操中遇到的坑属于文档里不写但一定会遇到的那种。第一个坑SseEmitter 忘记超时设置。Spring Boot 的 SseEmitter 默认 30 秒超时AI 推理经常超过这个时间导致连接被服务端主动断开前端报错。后来统一改成0L不限时由业务逻辑控制结束时机。第二个坑线程池拒绝策略导致线程转圈。第一次调优用了CallerRunsPolicy本意是怕丢任务结果回调线程在业务线程里同步跑起模型调用把入口线程全卡死了。这个案例让我彻底明白 AI 场景不能用这个策略。第三个坑缓存了不该缓存的动态内容。语义缓存上线初期没做内容时效控制结果模型知识更新后老问题依然命中旧答案。后来给语义缓存加了 TTL热点问题 12 小时过期非热点问题 2 小时过期既保住命中率又避免内容陈旧。6. 一些压箱底的实操建议项目做完后我把整套方案沉淀了下来这里做个最终的经验总结也是我每次做 Java AI 应用时都会先过一遍的检查清单。第一异步化没有银弹先想清楚用户能不能等。能等的走任务队列不能等的走流式别为了异步而异步。异步化方案的选择本质上是产品体验和系统复杂度之间的权衡把这个权衡摆在明面上讨论比盲目套用模式靠谱得多。第二虚拟线程适合 IO 密集但别乱用于 CPU 密集。大模型调用属于 IO 密集虚拟线程如鱼得水但如果你在服务里还跑着本地模型推理比如 embedding 模型那是 CPU 密集型的活虚拟线程的优势发挥不出来反而可能增加调度开销。第三限流和熔断要一起上只上一个是自欺欺人。限流挡住未发生的流量熔断拦截正在变坏的流量两者配合AI 应用的稳定性才算真正有保障。只靠限流打满阈值之后剩下的流量该崩溃还是崩溃。第四一切都要可观测。AI 应用的可观测性比传统应用更重要也更复杂模型的 token 数、首 token 时延、推理总耗时、缓存命中率、熔断器状态这些指标全部接入监控。我事后复盘时如果没有这套指标根本没法精准定位瓶颈到底在模型侧还是自己的线程池侧。第五先做容量评估再定并发目标。很多人一上来就问你能扛多少并发实际上这个问题反了。要先问目标 QPS 是多少平均时延预算多少模型方允许多少并发这三个数据定了架构方案基本就浮出水面了。我对异步化最直观的体感是它改的不是代码而是整个团队对资源的理解方式。Java 后端的核心竞争力从来不是把代码写得多么炫技而是在有限的资源里设计出最大化吞吐和稳定性的闭合链路。AI 应用给这个老命题出了一道新题——时延从毫秒变成了秒级流量从平稳变成了脉冲成本从可忽略变成了不可忽视。但只要把异步化、限流、熔断、缓存这几个工具用到位Java 在 AI 应用的后端世界里依然是最稳的那块基石。