ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

大模型应用高并发:Java异步化架构与虚拟线程实战

大模型应用高并发:Java异步化架构与虚拟线程实战 我去年接手了一个基于大模型的客服助手项目上线第一天就被并发打崩了。当时Java侧的处理逻辑很简单Controller收到用户问题同步调一次大模型接口等结果返回再拼装成JSON回给前端。单看代码没有任何问题可一旦并发上来Tomcat线程池瞬间被占满新请求全部排队前端用户等几秒甚至几十秒才看到“思考中”。后来我才意识到Java AI应用的高并发设计和传统Web应用完全是两回事。大模型接口动辄几秒甚至几十秒的响应时长把传统“一个请求占一个线程”的模型彻底戳穿。做个15分钟咨询类接口很容易但要让AI应用能扛住真实流量必须把异步化当成架构设计的主线来对待。这篇文章把我的完整改造过程、工具选型和踩坑经验一起写出来适合正在用Java做AI应用、尤其是接大模型API时出现性能瓶颈的同学参考。1. 大模型应用与普通 Web 并发场景的本质差异1.1 不是慢是响应时长的量级完全不同我最早做Java后端时对并发的理解主要来自数据库增删改查。一个接口内部查Redis、查MySQL、做点业务计算总耗时通常在20到50毫秒。按Tomcat配置200个线程来算理论上每秒能支撑大几千请求这个量级足够应付绝大多数业务。但AI应用完全不一样。调用一个大语言模型接口从发送prompt到拿回完整结果耗时通常在5秒到30秒不等有些带长上下文或复杂推理的任务甚至能到分钟级。这不是代码性能问题而是模型推理本身的物理时间。也就是说一个线程一旦发起了大模型调用它会在接下来很长一段时间里干等外部服务返回。我常用一个对比表给团队讲这件事维度传统REST接口大模型调用单次耗时几十毫秒几秒到几十秒返回形式一次性JSON流式token资源占用CPU和数据库网络等待为主失败模式快速失败超时、半完成、连接中断这张表说明一个核心问题你在Java侧做的所有并发优化本质上都是在和“一个外部调用特别慢”这件事做斗争。1.2 同步阻塞模型为什么扛不住AI请求Java Web服务最经典的处理模型是“一个请求分配一个线程”。Tomcat默认有200个线程理论上可以同时处理200个请求。放在传统接口上200个线程每秒可以消化几万请求因为每个线程处理完请求后立刻释放。但大模型接口把这件事变成了灾难。假设每一个请求都会占用线程20秒那200个线程只能同时服务200个请求而且这批请求要整整20秒才能释放线程。换算下来系统吞吐量直接变成每秒10个请求。更糟的是如果请求还在不断进来队列会迅速堆满新的请求直接被拒绝或者无限等待。这不是调一调线程池大小就能解决的。想单纯加线程数去对抗几十秒的等待最终只会把内存耗尽。Carrier线程的代价是昂贵的每个线程要预留约1MB的栈内存开5000个线程就吃掉好几个GB。1.3 AI应用不只有HTTP还带流式长连接另一个被低估的问题是流式输出。现在主流大模型基本都支持SSEServer-Sent Events也就是让token一个接一个吐出来。用户在界面上看到打字机效果体验很好。但流式输出对后端架构提出了额外要求你不能等模型生成完整答案再一次性返回因为那会把首字延迟拖到几十秒。可如果你用传统同步Controller写法框架天然会把整个响应体缓冲起来根本无法实现边生成边推送。这逼着你在接入层就要考虑异步连接管理并且所有中间环节都不能做全量缓冲。一句话总结AI应用的高并发瓶颈不是计算密集而是大量线程长时间阻塞在外部IO等待上。异步化的目的就是让这些“发呆等待”不再占据宝贵的执行线程。2. 异步化核心工具箱怎么选Future、CompletableFuture与虚拟线程2.1 原生Future为什么在AI场景不够用Java 5就引入了Future接口但它天生不适合AI应用这种需要精细编排的场景。问题在于Future的get()方法是阻塞的。你调了future.get()当前线程还是会卡住等待结果。虽然你可以把Future交给另一个线程去get但那就得自己管理线程之间的协作。更麻烦的是AI应用中经常需要同时调用多个模型然后把结果合并。比如检索场景里一边调Embedding接口做向量化一边调Rerank接口做重排序。如果用Future你需要写一堆手工的等待和合并逻辑代码又臭又长。我在早期项目的确用过Future硬扛后来发现维护成本太高果断弃了。2.2 CompletableFuture异步编排的正确打开方式CompletableFuture才是Java侧做AI应用异步编排的主力工具。它最大的价值是让你用声明式的方式描述“多个异步任务之间如何协作”而不是手写线程同步代码。举个例子假设请求进来后需要同时执行Embedding和Rerank最后合并结果CompletableFutureEmbeddingResult embFuture CompletableFuture.supplyAsync(() - { return embeddingClient.embed(query); }, modelExecutor); CompletableFutureRankResult rankFuture CompletableFuture.supplyAsync(() - { return rankClient.rerank(candidates, query); }, modelExecutor); CompletableFutureRecallResult finalFuture embFuture.thenCombine( rankFuture, (emb, rank) - buildRecallResult(emb, rank) );这里有几个细节容易踩坑必须给supplyAsync传入独立的线程池比如示例里的modelExecutor。如果省略这个参数任务会跑到ForkJoinPool的公共池里。公共池会被所有不显式指定线程池的异步任务共享一旦某个任务阻塞整个应用的其他异步任务都会受影响。thenCombine不会阻塞当前线程。它注册了一个回调等两个异步任务都完成后再执行合并逻辑主线程可以继续做别的事。合并函数里的异常需要额外处理这个我在后面排查经验部分还会详细说。CompletableFuture对于“多个模型调用并行执行最后汇聚结果”的场景特别好用几乎就是为AI应用量身定做的。2.3 虚拟线程把同步代码变成天真的异步JDK 21正式带来了虚拟线程Virtual Threads这是Java并发模型近十年最大的变化。我现在的AI应用已经是Spring Boot 3.x 虚拟线程的组合体验非常好。虚拟线程的核心思路是单线程依然以同步阻塞的姿势写代码但阻塞发生时只挂起当个虚拟线程真正执行任务的平台线程Carrier线程会被释放去执行其他虚拟线程。这样你写的是同步代码底层却实现了非常高的并发密度。举个实际例子ExecutorService executor Executors.newVirtualThreadPerTaskExecutor(); try (executor) { var results prompts.stream() .map(prompt - executor.submit(() - llmClient.complete(prompt))) .toList(); for (var result : results) { // 这里调用 get() 时当前虚拟线程会被挂起平台线程不会阻塞 Answer answer result.get(); handle(answer); } }这段代码如果放在传统线程模型下100个prompt就要占100个平台线程。但用虚拟线程可能8个平台线程就够了因为大部分时间虚拟线程都阻塞在IO等待上平台线程一直在切换执行不同的虚拟线程。Spring Boot 3.2之后的版本开启虚拟线程很方便在application.yaml里加一行配置spring: threads: virtual: enabled: true启动后Tomcat处理HTTP请求的线程就从平台线程变成虚拟线程。Controller里的同步阻塞代码不需要改却能获得接近响应式编程的并发能力。需要注意虚拟线程不适用CPU密集型任务和大量synchronized块。AI应用的主要瓶颈是外部API等待所以很契合。如果你的代码里有大量锁竞争虚拟线程的优势会被锁抵消。2.4 WebFlux到底还要不要选谈到Java异步很多人第一反应是WebFlux和响应式编程。我在多个AI项目里用过WebFlux也有一些自己的判断。WebFlux的强项是极致的线程利用率和背压处理。它是事件驱动模型整个请求链路几乎没有阻塞点同样的内存可以支撑更高的并发连接。在做SSE网关这类需要大量长连接的组件时WebFlux确实有不可替代的优势。但它也有明显的代价——和传统Spring MVC团队的技术栈割裂感太强。验证起来很繁琐调试栈信息和ThreadLocal都要靠Reactor Context传递得整个团队都有响应式基础才好推进。我的选型策略是团队有响应式经验或者要做独立的AI网关层选WebFlux。团队以传统Spring MVC为主业务逻辑复杂优先用Spring MVC 虚拟线程局部并发编排用CompletableFuture。虚拟线程出现后我越来越觉得AI应用不一定要被迫上WebFlux。想清楚自己的场景选熟悉且能维护好的路线更重要。3. 一次从同步到异步全链路的代码级改造实录3.1 原始版本一眼就能看出问题的写法项目最开始的结构非常简单RestController public class ChatController { private final LlmClient llmClient; PostMapping(/chat) public ChatResponse chat(RequestBody ChatRequest request) { // 同步阻塞等待大模型返回这段代码会占住Tomcat线程20秒 String answer llmClient.complete(request.getPrompt()); return new ChatResponse(answer); } }这段代码在并发10以内没问题。一旦有几十个用户同时提问Tomcat默认线程池很快被占满新请求全部进队列等待。用户体验就是点完发送按钮光标转半天没反应。这里最核心的问题是Tomcat的线程被“等待大模型返回”这件事白白占住了没有做任何有价值的工作。异步化的目标就是把这个等待从Tomcat线程里剥离出去。3.2 入口异步化把Tomcat线程释放出来Spring MVC支持多种异步返回值最简单的是让Controller直接返回CompletableFuture。框架收到这个返回值后会立刻释放Tomcat线程不再傻等业务逻辑执行完再响应。改造后的ControllerPostMapping(/chat) public CompletableFutureChatResponse chat(RequestBody ChatRequest request) { return chatService.chat(request); }Service层Service public class ChatService { private final ExecutorService modelExecutor; private final LlmClient llmClient; public CompletableFutureChatResponse chat(ChatRequest request) { return CompletableFuture.supplyAsync( () - llmClient.complete(request.getPrompt()), modelExecutor ).thenApply(answer - new ChatResponse(answer)); } }这一改Tomcat线程在请求进入后立刻被释放真正的模型调用跑在modelExecutor线程池里。等结果出来再通过异步机制把响应写回客户端。需要单独定义modelExecutor我一般这样配Bean(modelExecutor) public ExecutorService modelExecutor() { ThreadPoolExecutor executor new ThreadPoolExecutor( 80, 200, 60, TimeUnit.SECONDS, new ArrayBlockingQueue(2000), new ThreadFactoryBuilder().setNameFormat(llm-call-%d).build(), new ThreadPoolExecutor.CallerRunsPolicy() ); return executor; }这里给线程池命名很关键。排查线上问题时jstack里能看到llm-call-3这样的线程名马上就能定位是哪类任务在阻塞。3.3 流式转发用SseEmitter边收边推大部分AI聊天应用不能等完整答案得做成打字机效果。这时就要用SSE把token流式推给前端。Spring MVC下最直接的做法是使用SseEmitterPostMapping(/chat-stream) public SseEmitter chatStream(RequestBody ChatRequest request) { SseEmitter emitter new SseEmitter(120_000L); chatService.streamChat(request, emitter); return emitter; }Service内部的处理public void streamChat(ChatRequest request, SseEmitter emitter) { modelExecutor.submit(() - { try (StreamString tokens llmClient.streamComplete(request.getPrompt())) { IteratorString iterator tokens.iterator(); while (iterator.hasNext()) { String token iterator.next(); emitter.send(SseEmitter.event().data(token)); } emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } }); }这里要注意几个点SseEmitter构造器里的超时时间要显式指定默认值太短模型响应稍微慢一点连接就断了。处理完必须调用emitter.complete()否则连接资源一直挂着最终会拖垮服务器。发送token过程中如果客户端断开需要捕获异常并清理资源。改造之后Tomcat线程同样被立即释放真正干活的是modelExecutor里的异步线程。连接虽然是长连接但维持连接等待写入的代价很低不会再像以前那样占死一个线程几十秒。3.4 Agent场景下的复杂异步编排如果你的AI应用已经不只是单轮问答而是涉及AI Agent、多工具调用、多模型协作那异步编排的复杂度会再上一个台阶。最典型的结构是“推理-调用工具-再推理”的循环。工具调用可能涉及搜索、查数据库、调外部API每个环节都要几秒钟。这期间如果用同步阻塞整个流程会把线程占住好几分钟。用CompletableFuture可以把这个链条拆成异步步骤CompletableFutureToolResult toolFuture CompletableFuture.supplyAsync( () - toolClient.call(toolName, args), toolExecutor ); CompletableFutureModelDecision decisionFuture toolFuture.thenCompose(toolResult - CompletableFuture.supplyAsync( () - llmClient.nextDecision(toolResult), modelExecutor ) );thenCompose的作用是等第一个异步任务完成后把结果传给第二个异步任务。整个过程没有线程阻塞主流程可以在其他线程上继续做别的事。多Agent协作本质上是把多个这样的链条并行或编排起来。这时候建议用一个大图来描述步骤依赖关系把每个节点对应到一个CompletableFuture边和边用thenCombine、thenCompose串起来。第一版可以先在代码里硬编码编排逻辑复杂后再引入工作流引擎。3.5 线程池参数到底怎么定很多人在线程池配置上很迷茫。我给团队讲过一个非常务实的公式模型调用线程池的合理大小约等于目标QPS乘以单次调用耗时的期望值。举个例子目标支持50个并发模型调用单次模型调用预期10秒完成那同时在工作中的线程数就是50。考虑排队和重试核心线程设80、最大线程设200基本够用。但传统线程池面对AI这种长时间阻塞的任务开200个线程在资源消耗上已经很不小了。这也是我最终转向虚拟线程的原因。虚拟线程没有池的概念每个请求来了直接开一个新虚拟线程用完即弃支持几十万并发而不需要几十万个平台线程。如果你的项目已经用了JDK 21强烈建议优先尝试虚拟线程。4. 异步化之外的并发配套上下文传递、幂等、限流与背压4.1 ThreadLocal在异步链路里失效必须显式传递上下文异步化之后最容易踩的坑是上下文丢失。以前你在拦截器里往MDC里放traceId在Controller里读ThreadLocal里的用户信息这一切在异步场景下全都不好使了。因为异步任务跑在不同线程里ThreadLocal的内容过不去。我吃过一次大亏排查线上问题时日志里的traceId对不上同一个用户请求在模型调用链路里散成好几个片段。现在的做法是入口处把用户ID、链路ID、额度信息封装成一个RequestContext对象。这个对象作为参数显式传给所有异步分支绝不依赖ThreadLocal。需要日志关联时在每个异步任务开始处手动MDC.put。public class RequestContext { private final String requestId; private final String userId; private final int quotaRemaining; // getters / constructors }用起来是这样CompletableFuture.supplyAsync(() - { MDC.put(traceId, context.getRequestId()); try { return llmClient.complete(prompt); } finally { MDC.remove(traceId); } }, modelExecutor);虽然多写几行代码但排查问题的效率直线上升。如果不想手动传参也可以引入TransmittableThreadLocal这种库但我觉得显式传参更可靠也更容易被团队成员理解。4.2 幂等设计别让用户的重复点击浪费模型调用AI接口很贵高并发场景下用户也可能因为页面卡顿疯狂点击提交按钮。如果每个重复请求都真调到模型接口不仅浪费钱还会把并发打满。我的方案是在网关或Controller入口做幂等过滤前端生成X-Request-Id或者后端在接入层生成全局唯一请求ID。请求进来后先用Redis做SETNX requestId判断是否是重复请求。如果是首次请求正常放行如果是重复请求直接返回上一次的结果可以把结果缓存在Redis里或者直接丢弃。public boolean tryAcquireRequestId(String requestId) { Boolean success redisTemplate.opsForValue() .setIfAbsent(requestId, 1, Duration.ofMinutes(30)); return Boolean.TRUE.equals(success); }这套机制对异步链路尤其重要因为异步任务可能被重试多次没有幂等保护一次用户操作可能触发三次模型调用。4.3 用消息队列削峰保护模型供应商的QPS限制大模型供应商通常有严格的QPS限制比如单组织限制100 QPS。一旦你的业务流量出现尖峰直接调用很容易触发限流反而造成大量请求失败。我的做法是把模型调用改造成异步任务队列。请求进来先落消息队列比如RocketMQ或RabbitMQ专门的消费任务按照供应商QPS限制去拉取消息并调用模型结果通过回调或者SSE返回给用户。这样做有三个好处流量尖峰被队列吸收不会直接冲击模型API。消费者可以灵活调整并发数适配供应商不同套餐的QPS限制。失败的消息可以可靠重试不会因为一次网络抖动就丢失请求。4.4 缓存与降级减少不必要的模型调用别小看缓存的作用。有些AI应用的问题高度重复比如客服场景里“怎么退款”“如何开发票”这类问题语义基本一致。如果你能对用户输入做归一化处理小写化、去掉空格、同义词替换再计算文本相似度命中缓存后直接返回历史答案既能省钱又能极大缓解并发压力。降级策略也建议提前规划。我在生产环境里维护了一个主模型和一个备用小模型主模型超时或限流时自动切换到备用模型。用CompletableFuture可以非常优雅地实现CompletableFutureAnswer primary CompletableFuture .supplyAsync(() - mainModel.chat(prompt), modelExecutor) .completeOnTimeout(null, 8, TimeUnit.SECONDS); CompletableFutureAnswer answerFuture primary.thenApply(answer - { if (answer null) { return fallbackModel.chat(prompt); } return answer; });这里有一个必须知道的坑completeOnTimeout只是让主链快速返回默认值但底层那个超时的任务依然在跑继续占用线程和模型配额。如果超时任务频繁出现要考虑真正的取消机制或降低超时任务的优先级。5. 线上失败模式与排查经验5.1 线程池耗尽一开始只看得到延迟飙升线程池耗尽的现象很有迷惑性应用看起来没挂但接口延迟疯狂上涨错误率不高但每个请求都慢。我踩过的真实案例某次上线后监控显示P99延迟从2秒飙升到30秒。当时第一反应是模型API变慢了结果查了上游指标发现一切正常。最后用jstack抓了一下线程堆栈发现大量线程都卡在llm-call-开头的线程里等模型响应而线程池队列里还堆着几千个任务。排查心法先看线程池活跃线程数和队列长度指标。如果活跃线程数长期等于maxPoolSize队列又有积压基本可以断定线程池不够用。抓jstack确认线程都在干什么。如果全部阻塞在外部IO等待说明不是计算瓶颈而是并发度不够。确认并发来源后要么扩大线程池要么改用虚拟线程。我强烈建议每个线程池都起一个可识别的名字这能让排查时间从几小时缩到几分钟。5.2 CompletableFuture的静默失败问题CompletableFuture最坑的一点是thenApply、thenCombine这些链式方法内部的异常如果没有被最外层捕获会被静默吞掉。尤其是当你用allOf合并多个Future时一个分支失败了整个聚合可能一直等不到结果。我的习惯是永远在异步链尾部挂异常处理future.exceptionally(ex - { log.error(async task failed: {}, ex.getMessage(), ex); return null; });或者用whenComplete统一记录完成情况。生产环境里不能指望编写装配代码的人一定记得处理异常不如写一个统一的Future包装工具类把日志逻辑收敛起来。5.3 SSE连接泄漏连接数只增不减SSE场景有一个隐蔽的坑客户端明明已经关了页面但SseEmitter还活着上游模型还在继续吐数据。连接数越积越多最终把应用服务器的连接资源耗尽。关键措施在网关层Nginx或SLB设置合理的read timeout低于应用层的SseEmitter超时时间让网关先断开无响应的连接。给SseEmitter注册onTimeout和onError回调直接调用complete()释放连接。监控当前活跃的SseEmitter连接数设置告警。如果连接数异常攀升优先排查是否有资源未释放。5.4 HTTP连接池配置不当异步化之后依然会等这是我最容易忽略的一个点。异步化改造完成后明明线程池没爆接口延迟还是高。后来才发现卡在“从HTTP连接池获取连接”这个环节。Apache HttpClient和OkHttp默认的连接池上限都很保守。如果你的模型并发是500但连接池给那个域名的最大连接只有20那200个线程就会卡在等待连接上。解决方案PoolingHttpClientConnectionManager connManager new PoolingHttpClientConnectionManager(); connManager.setMaxTotal(400); connManager.setDefaultMaxPerRoute(200);总连接数和高并发数之间最好保留大概1.5倍的余量避免连接频繁重建的开销。我建议把连接池和线程池的指标放到同一个监控面板排查时会直观很多。5.5 压测的正确姿势线上高并发问题最好的预防手段是提前压测。但不少团队的压测还是拿几百个并发请求打死一个普通接口这测不出AI应用的真实瓶颈。我的建议压测脚本里要模拟真实用户的思考时间和流式读取行为不能等整个响应结束后再记录耗时。重点关注这几个指标最大并发模型调用数以及是否触发线程池排队。P50、P95、P99首字延迟和完整响应延迟。Tomcat活跃线程数是否随着并发上升而打满。线程池队列长度是否无限增长。SSE连接数上限和release速度。模型供应商侧的实际QPS是否触碰了限流阈值。把这些指标压出来再对照阈值调整线程池、连接池和限流配置上线就有底气多了。6. 一点个人的实战心得做Java AI应用高并发改造这一年我的体会是异步化不是目的而是让Java应用在等待外部模型时不再空耗资源的手段。技术选型上如果项目还在JDK 17或更早老老实实把CompletableFuture用好比盲目上WebFlux更稳妥如果已经升级到JDK 21虚拟线程加上Spring Boot 3.x几乎可以无痛处理大部分AI应用的并发问题。最后再分享一个小技巧在所有异步链路的入口和出口都打印一条包含请求ID和耗时的日志。这样配合线程名和上下文参数线上出问题时你总能快速判断是模型太慢、线程池不够还是连接池瓶颈。这套打法我现在每接手一个新AI项目都会第一时间落地省下来的排查时间非常可观。
返回列表