
1. 从一次线上事故说起为什么Java AI应用不能照搬传统Web那套去年底我接手了一个Java AI应用的重构项目背景很简单团队用Spring Boot搭了一套AI推理服务上线初期用户量不大响应也还算稳定。但接入方从3个涨到20多个之后问题开始集中爆发——高峰期接口平均响应从800毫秒飙到12秒线程池被打满Tomcat的连接队列直接顶到上限最后整个服务雪崩。运维那边重启了三次每次撑不过十分钟又挂。排查下来根因并不复杂AI推理是典型的长耗时、高资源占用操作一次大模型调用动辄3到30秒而团队用的是最传统的同步阻塞写法——请求进来Controller直接调ServiceService里同步等模型返回返回后再写库、再响应。这套写法在普通CRUD业务里没问题但放到AI场景下每个请求都死死占着一个Tomcat工作线程200个线程池容量意味着最多只能同时处理200个请求第201个就得排队。更糟的是模型推理本身还吃CPU和内存线程堆得越多单次推理反而越慢形成恶性循环。这就是我想聊的核心问题Java AI应用的异步化与高并发设计本质上不是把接口改成异步这么简单而是要重新设计整条请求链路的资源模型。传统Web应用的瓶颈通常在数据库IO而AI应用的瓶颈在计算资源和模型调用的不确定性上。前者可以用连接池、缓存缓解后者必须靠异步编排、背压控制和资源隔离来解决。这篇文章适合三类人看一是正在用Spring Boot做AI应用、已经遇到或预感到并发问题的后端工程师二是准备把AI能力集成进现有Java系统的架构师三是对Java多线程、高并发有基础、想了解AI场景特殊性的开发者。我会从同步阻塞的真实瓶颈讲起一路拆到异步编排、线程池隔离、流式响应、背压与降级最后给出一套可以直接参考的落地结构。全程用大白话加真实代码不堆概念。2. 同步阻塞到底卡在哪把AI请求链路拆开看2.1 一个请求从进来到返回线程都经历了什么先别急着上异步得先搞清楚同步模式下线程到底在干什么。假设你有一个/api/chat接口用户发一句话后端调大模型拿到结果返回。用Spring Boot默认的Tomcat线程模型这条链路是这样的Tomcat的Acceptor线程接收到TCP连接交给Poller线程Poller把请求包装成任务丢进Tomcat的工作线程池默认max-threads200某个工作线程叫它T1拿到请求执行DispatcherServlet进入你的ControllerController调ServiceService里发起对模型服务的HTTP调用或本地推理T1在这里阻塞等待短则几百毫秒长则几十秒模型返回后T1继续执行写数据库、组装响应T1把响应写回客户端释放回线程池。问题就出在第5步。T1在等待模型返回的这段时间里什么也干不了但它占着线程池的一个名额。如果同时有200个请求都在等模型线程池就满了第201个请求连Controller都进不来只能在Tomcat的accept队列里排队队列满了就直接拒绝连接。这里有个很多人忽略的细节Tomcat的accept队列默认长度是100acceptCount参数也就是说线程池满了之后还能再缓冲100个连接超过就拒绝。所以真实的服务容量是max-threads acceptCount 300但第201到300个请求的等待时间会非常长用户体验极差。2.2 为什么加线程数不是解法第一反应通常是那把max-threads调大不就行了。我试过从200调到800结果是灾难性的。原因有三第一线程不是免费的。每个Java线程默认栈大小1MB-Xss控制800个线程光栈内存就吃掉800MB。而且线程上下文切换是有成本的CPU核数有限的情况下线程越多切换越频繁真正用于计算的时间比例反而下降。第二AI推理本身是资源密集型的。如果模型跑在同一个JVM里比如用DJL加载本地模型那它吃的是CPU和堆内存。你开800个线程去抢有限的CPU核只会让每次推理都变慢吞吐量不升反降。这跟IO密集型任务完全不同——IO等待时线程是闲着的多开线程有意义计算密集时线程都在抢CPU多开就是互相拖累。第三下游模型服务可能扛不住。如果你的模型是远程服务比如独立的推理集群800个并发打过去对方直接被打挂。这时候你的线程再多也没用只是把压力转嫁了。所以结论很明确同步阻塞模型下单纯加线程是死路。必须让等待模型返回的线程去干别的事或者干脆不占用宝贵的请求处理线程。2.3 异步化的本质把等待从线程里剥离出去异步化的核心思想一句话就能说清不要让线程在等待IO或计算时闲着而是把它还给系统去处理别的请求等结果就绪了再通过回调或事件通知的方式继续处理。打个比方。同步模式就像你去餐厅点餐点完站在柜台前一直等到菜做好期间服务员没法接待下一个人。异步模式是你点完餐拿个号找个座位坐下菜好了叫号你去取。柜台线程在等菜的时间里可以接待更多人。在Java里实现这个拿号等叫号的机制主流有几套方案CompletableFutureJDK8引入的异步编排工具适合组合多个异步任务Spring的Async基于AOP的异步方法调用配置简单WebFluxReactor响应式编程从Servlet容器到业务逻辑全链路非阻塞Servlet 3.0的异步Servlet在传统Servlet栈上做异步改动相对小虚拟线程JDK21用轻量级线程承载阻塞操作写法接近同步但资源开销极低。这几套方案不是互斥的实际项目里经常混用。选哪套取决于你的技术栈、团队熟悉度和改造范围。下一节我会详细对比并给出选型逻辑。3. 异步方案选型CompletableFuture、WebFlux还是虚拟线程3.1 四套方案的适用边界对比先上一张表把关键维度列清楚后面再逐条展开。方案编程模型改造范围学习成本适合场景主要坑点CompletableFuture链式回调局部中已有同步栈只想异步化模型调用回调地狱、异常传播复杂SpringAsync注解局部低简单异步任务、通知类线程池默认配置差、事务失效WebFluxReactor响应式流全链路高高并发网关、流式AI输出阻塞代码会毒化整个链路虚拟线程同步写法局部到全局低JDK21新项目、IO密集需注意pin住、同步块问题3.2 CompletableFuture改造范围最小的务实选择如果你的系统已经是一套成熟的Spring MVC应用不想大动干戈那CompletableFuture是最务实的切入点。它的思路是Controller层还是同步的但把耗时的模型调用包装成异步任务提交到独立线程池然后join等待结果。RestController public class ChatController { private final ExecutorService modelExecutor; private final ChatService chatService; public ChatController(ChatService chatService) { this.chatService chatService; // 专门给模型调用用的线程池与Tomcat线程池隔离 this.modelExecutor new ThreadPoolExecutor( 32, 64, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue(200), new ThreadFactoryBuilder().setNameFormat(model-call-%d).build(), new ThreadPoolExecutor.CallerRunsPolicy() ); } PostMapping(/api/chat) public CompletableFutureResponseEntityChatResponse chat(RequestBody ChatRequest req) { return CompletableFuture .supplyAsync(() - chatService.callModel(req), modelExecutor) .thenApply(ResponseEntity::ok) .exceptionally(ex - ResponseEntity.status(500).build()); } }这段代码有几个关键点值得说第一线程池隔离。模型调用用的是独立的modelExecutor不是Tomcat的工作线程池。这样即使模型调用慢也不会把Tomcat的线程占满其他接口比如健康检查、静态资源还能正常响应。第二队列容量和拒绝策略。队列设了200拒绝策略用CallerRunsPolicy——队列满了之后由提交任务的线程也就是Tomcat工作线程自己执行。这是一种天然的背压当模型线程池扛不住时压力会传导回Tomcat线程让请求变慢而不是直接失败。当然如果你希望快速失败可以换成AbortPolicy配合降级逻辑。第三返回CompletableFuture。Spring MVC从4.0开始支持Controller方法返回CompletableFuture它会自动异步处理不阻塞Tomcat线程。这是很多人不知道的一个点——返回CompletableFuture比在方法里join再返回结果要高效得多因为前者真正释放了Tomcat线程。但CompletableFuture也有明显的坑。多个异步任务组合时thenCompose、thenCombine嵌套几层之后代码可读性急剧下降异常处理也容易漏。而且它没有背压机制任务提交速度超过处理速度时队列会无限增长除非你设了有界队列。所以它适合单次模型调用异步化这种简单场景不适合复杂的多步编排。3.3 WebFlux全链路非阻塞但代价不小WebFlux是Spring的响应式栈底层用Netty或Undertow从容器到业务逻辑全程非阻塞。它的优势在于用极少的线程就能支撑极高的并发连接数。Netty默认的工作线程数是CPU核数乘以2比如8核机器就是16个线程理论上能扛住成千上万的并发连接。但WebFlux的代价也很明显。首先整条链路都必须是响应式的任何一处阻塞调用比如JDBC、同步的HTTP客户端都会毒化整个链路让响应式的优势荡然无存。其次Reactor的操作符flatMap、concatMap、zipWith等学习曲线陡峭团队不熟悉的话很容易写出bug。最后调试困难响应式流的调用栈跟传统同步代码完全不同出问题时排查成本高。如果你的AI应用需要支持流式输出比如ChatGPT那种逐字返回的效果WebFlux配合Server-Sent Events或WebSocket是很自然的选择。但如果是普通的请求-响应模式且团队没有响应式经验我不建议为了异步而强行上WebFlux。3.4 虚拟线程JDK21之后最值得关注的新选项JDK21正式引入了虚拟线程Virtual Threads这是Java并发模型的一次重大变革。虚拟线程由JVM调度不直接映射到操作系统线程创建成本极低可以轻松创建百万个。它的最大好处是你可以用同步的写法获得接近异步的性能。// 用虚拟线程执行器写法跟同步完全一样 ExecutorService executor Executors.newVirtualThreadPerTaskExecutor(); PostMapping(/api/chat) public ChatResponse chat(RequestBody ChatRequest req) { // 每个请求一个虚拟线程阻塞等待模型返回也不怕 return chatService.callModel(req); }配合Spring Boot 3.2只需要在配置里加一行spring.threads.virtual.enabledtrueTomcat就会用虚拟线程处理请求。这意味着你几乎不用改代码就能让每个请求的阻塞等待不再占用宝贵的平台线程。但虚拟线程不是银弹。有两个坑必须注意一是synchronized块会pin住虚拟线程JDK21中进入synchronized块时虚拟线程会被固定到平台线程上失去轻量优势JDK24已改善此问题所以要用ReentrantLock替代二是CPU密集型任务用虚拟线程没意义因为计算本身就要占CPU虚拟线程解决的是IO等待的线程占用问题。对于AI应用如果模型调用是远程HTTP请求IO密集虚拟线程非常合适如果是本地推理CPU密集虚拟线程帮助有限还是得靠线程池隔离加限流。3.5 我的选型建议综合下来我的建议是分场景新项目、JDK21优先用虚拟线程写法简单收益直接存量Spring MVC项目、改造范围有限用CompletableFuture 独立线程池局部异步化需要流式输出、超高并发连接上WebFlux但要有响应式经验的团队简单异步任务发通知、写日志Async够用但记得自定义线程池。4. 线程池隔离与背压别让一个慢接口拖垮整个服务4.1 为什么必须做线程池隔离我见过太多项目所有异步任务共用一个默认的ThreadPoolTaskExecutor结果一个慢的AI推理任务把线程池占满连发短信验证码这种轻量任务都执行不了。这就是典型的故障扩散。线程池隔离的核心思想是按业务重要性或资源类型划分独立的线程池互不影响。在AI应用里至少应该划分出这几类模型调用池专门跑模型推理容量根据模型服务的承载能力设定IO任务池处理数据库、缓存、文件等IO操作轻量任务池发通知、记日志、埋点等要求快速执行兜底池处理降级逻辑、补偿任务。每个池独立配置核心线程数、最大线程数、队列容量和拒绝策略。这样即使模型调用池被打满轻量任务池依然能正常工作保证核心业务不中断。4.2 线程池参数怎么算别拍脑袋线程池参数不是拍脑袋定的得根据任务类型算。公式分两种IO密集型任务比如调远程模型API大部分时间在等网络核心线程数 CPU核数 × (1 平均等待时间 / 平均计算时间)假设8核CPU模型调用平均等待2秒本地处理耗时200毫秒那核心线程数 ≈ 8 × (1 2000/200) 8 × 11 88。当然这是理论上限实际还要考虑下游模型服务的承载能力不能真开88个并发去打。CPU密集型任务比如本地模型推理核心线程数 CPU核数 18核就是9个线程多了反而因为上下文切换降低效率。队列容量也要想清楚。用无界队列LinkedBlockingQueue不设容量是危险的任务堆积会导致内存暴涨最后OOM。建议用有界队列容量根据可接受的最大等待时间反推。比如你希望请求最多等30秒单任务平均耗时3秒那队列容量大概设10左右30/3超过就触发拒绝策略。4.3 背压让压力有地方传导而不是直接崩背压Backpressure是个听起来很玄的词其实道理很简单当消费速度跟不上生产速度时要让生产方感知到压力并主动降速而不是无限堆积。在AI应用里背压有几个落地点第一线程池的拒绝策略。CallerRunsPolicy就是一种背压——队列满了提交任务的线程自己执行这样Tomcat线程被占用新的请求进不来自然就限流了。AbortPolicy则是快速失败配合降级返回兜底结果。第二信号量限流。用Semaphore控制同时进行的模型调用数量超过就等待或拒绝。private final Semaphore modelSemaphore new Semaphore(50); public ChatResponse callModelWithLimit(ChatRequest req) { if (!modelSemaphore.tryAcquire(3, TimeUnit.SECONDS)) { // 3秒内没拿到许可走降级 return ChatResponse.degraded(当前请求较多请稍后重试); } try { return chatService.callModel(req); } finally { modelSemaphore.release(); } }第三响应式流的背压。如果用ReactorFlux天然支持背压订阅者可以通过request(n)控制消费速度。这是响应式编程相比传统异步的一大优势。第四网关层限流。在Spring Cloud Gateway或Nginx层做限流把压力挡在服务之外。常用算法有令牌桶、漏桶、滑动窗口Sentinel和Resilience4j都是成熟的Java限流组件。4.4 一个真实的参数调优过程说个我实际调优的例子。某AI问答服务8核16G模型是远程调用平均响应1.5秒P99是5秒。初始配置是Tomcat默认200线程模型调用用Async默认线程池核心8、最大Integer.MAX_VALUE、队列无界。压测结果QPS到50就开始劣化100时大量超时。排查发现两个问题一是Async默认队列无界任务堆积到几千个内存吃紧二是模型调用没有限流把下游打挂了。调整方案模型调用独立线程池核心32、最大64、队列100、CallerRunsPolicy加Semaphore限流最多40个并发模型调用Tomcat线程数降到100因为大部分请求已经异步化了不需要那么多加降级逻辑模型调用超过3秒直接返回缓存或兜底话术。调整后QPS稳定在200左右P99控制在4秒内内存也稳了。这个过程中最关键的不是某个参数而是把资源边界想清楚下游能扛多少、本机CPU能跑多少、用户能等多久三个约束一交叉参数范围就出来了。5. 流式响应与AI场景的特殊处理5.1 为什么AI应用特别需要流式输出传统接口是请求-等待-完整响应用户盯着转圈等几秒甚至几十秒体验很差。AI生成内容的特点是逐token产出模型每生成一个词就可以返回给前端。流式输出让用户看到内容一点点长出来感知延迟大幅降低——虽然总耗时没变但首字节时间TTFB从几秒降到几百毫秒体验天差地别。在Java里实现流式输出主流方案有三种SSEServer-Sent Events基于HTTP长连接服务端单向推送实现简单浏览器原生支持EventSourceWebSocket全双工适合需要双向交互的场景分块传输Chunked Transfer直接往HttpServletResponse的OutputStream写手动flush。对于AI对话场景SSE是最常用的因为它是单向推送正好匹配服务端持续输出、客户端接收的模式。5.2 Spring MVC下用SSE实现流式输出在传统Spring MVC里用SseEmitter就能实现SSE不需要上WebFlux。GetMapping(/api/chat/stream) public SseEmitter streamChat(RequestParam String question) { SseEmitter emitter new SseEmitter(60_000L); // 60秒超时 // 提交到模型调用线程池不阻塞Tomcat线程 modelExecutor.execute(() - { try { chatService.streamModel(question, token - { try { emitter.send(SseEmitter.event() .name(message) .data(token)); } catch (IOException e) { emitter.completeWithError(e); } }); emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } }); return emitter; }这里有几个实操要点第一超时时间要设够。SseEmitter默认超时是30秒AI生成长文本可能超过要显式设置。但也不能无限长否则连接泄漏。第二异常处理要完整。客户端断开连接时emitter.send会抛IOException必须捕获并completeWithError否则线程会卡住。第三线程池要隔离。流式任务占用线程时间长必须用独立线程池不能和普通请求混用。第四注意Nginx缓冲。如果前面有Nginx默认会缓冲响应导致流式效果失效。需要配置proxy_buffering off;和X-Accel-Buffering: no响应头。5.3 流式场景下的并发控制流式输出有个特殊问题每个连接占用时间长并发连接数容易堆积。一个用户开着对话页面连接可能持续几十秒。如果有1000个用户同时在线就是1000个长连接。这时候线程模型的选择很关键。如果用传统Tomcat线程1000个连接就是1000个线程直接爆。用SseEmitter的话Tomcat线程在返回emitter后就释放了实际占用的是模型调用线程池的线程但那个池也不能无限大。更优雅的方案是WebFlux SSE用少量事件循环线程支撑大量长连接。或者用虚拟线程每个连接一个虚拟线程成本极低。另外流式场景下要特别注意连接清理。用户关闭页面时服务端要能感知到并停止模型生成否则白白消耗资源。可以通过emitter.onCompletion和emitter.onTimeout注册回调在回调里取消模型调用。5.4 模型调用的超时与重试策略AI模型调用有两个特点一是延迟不确定同样的输入响应时间可能差好几倍二是可能失败网络抖动、模型服务过载都会导致失败。超时设置要分层连接超时一般设1到3秒连不上就快速失败读超时根据模型P99延迟设比如P99是5秒那读超时设8到10秒总超时整个请求的最大耗时超过就降级。重试要谨慎。AI推理通常不是幂等的同样的输入可能生成不同结果而且重试会加重下游负担。我的建议是只对明确的网络错误重试且最多重试1次重试要加退避比如等500毫秒再试。对于超时不要重试直接降级——因为超时说明下游已经压力很大了再重试就是雪上加霜。RetryTemplate retryTemplate RetryTemplate.builder() .maxAttempts(2) .fixedBackoff(500) .retryOn(ConnectException.class) // 只对连接异常重试 .build();6. 监控与压测异步化之后怎么确认真的稳了6.1 异步化之后传统监控指标不够用了同步模式下你看Tomcat的活跃线程数、请求响应时间就能大致判断健康状况。异步化之后请求处理被拆成了多个阶段Tomcat线程可能很快就释放了但实际任务还在线程池里排队。这时候如果只看Tomcat指标会误以为服务很健康实际上线程池队列已经堆了几千个任务。所以异步化之后必须补充这几类监控线程池指标活跃线程数、队列大小、已完成任务数、拒绝任务数。Spring Boot Actuator配合Micrometer可以自动采集ThreadPoolTaskExecutor的指标但自定义的ThreadPoolExecutor需要手动注册。Bean public ThreadPoolTaskExecutor modelExecutor(MeterRegistry registry) { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(32); executor.setMaxPoolSize(64); executor.setQueueCapacity(100); executor.setThreadNamePrefix(model-); executor.initialize(); // 注册监控指标 new ExecutorServiceMetrics( executor.getThreadPoolExecutor(), modelExecutor, Collections.emptyList() ).bindTo(registry); return executor; }模型调用指标调用次数、成功率、P50/P95/P99延迟、超时次数。这些指标要按模型、按接口维度细分才能定位问题。队列等待时间任务从提交到开始执行的时间。这个指标最能反映系统是否过载——如果等待时间持续增长说明消费速度跟不上生产速度该扩容或限流了。降级触发次数每次降级都意味着有用户没拿到完整服务这个数字要重点关注。6.2 压测怎么做才有效异步系统的压测比同步系统复杂因为瓶颈可能在多个地方Tomcat线程、模型线程池、下游模型服务、数据库连接池。压测的目标是找到真正的瓶颈点而不是简单看QPS数字。我的压测步骤通常是第一步单接口基准测试。用JMeter或wrk对单个AI接口施压从低并发逐步增加观察QPS和延迟曲线。当延迟开始非线性上升时那个点就是当前配置的容量上限。第二步定位瓶颈。容量上限出现时看各个线程池的指标是Tomcat线程满了还是模型线程池队列满了还是下游模型服务响应变慢了。用Arthas的thread命令可以实时看线程状态dashboard能看整体情况。第三步混合场景测试。真实系统不会只有一个接口要把AI接口和其他普通接口混在一起压验证线程池隔离是否生效——理想情况下AI接口被打满时普通接口的延迟不应该有明显变化。第四步故障注入。模拟下游模型服务变慢或不可用验证降级逻辑是否按预期工作服务是否还能保持基本可用。有个容易忽略的点压测时要监控GC。异步化之后对象创建速率可能变化比如CompletableFuture、回调对象如果GC频繁会拖累整体性能。用-Xlog:gc*或者VisualVM观察GC日志必要时调整堆大小和GC器。6.3 一个监控看板的指标清单我习惯给每个AI服务配一个监控看板核心指标如下指标类别具体指标告警阈值建议请求层QPS、P99延迟、错误率P99 5s 或错误率 1%Tomcat活跃线程数、队列长度活跃线程 80% 容量模型线程池活跃线程、队列大小、拒绝数队列 80% 容量 或 拒绝数 0模型调用成功率、P99延迟、超时数成功率 99% 或 超时数突增降级降级触发次数每分钟 10次JVMGC次数、GC耗时、堆使用率Full GC 1次/分钟这些指标用Prometheus Grafana采集展示告警接到钉钉或企业微信。关键是阈值要根据实际压测结果设定不能照搬网上的数字。7. 一套可以直接参考的落地结构7.1 分层设计与职责划分把前面讲的东西串起来我给出一套实际项目在用的结构。核心原则是按资源类型分层每层职责单一层与层之间通过明确的接口交互。Controller层 ├─ 接收请求参数校验 ├─ 提交任务到对应的线程池 └─ 返回 CompletableFuture / SseEmitter / 同步结果 编排层Orchestration ├─ 组合多个异步任务 ├─ 处理超时、重试、降级 └─ 不包含业务逻辑只做流程控制 业务层Service ├─ 纯业务逻辑 ├─ 调用模型客户端、数据库、缓存 └─ 不关心线程和异步 资源层Client / Repository ├─ 模型调用客户端带超时、重试配置 ├─ 数据库访问 └─ 缓存访问这样分层的好处是异步和并发控制集中在Controller和编排层业务层保持纯净方便测试和复用。业务层的方法可以在同步和异步场景下都能用不需要为了异步而改写。7.2 线程池配置集中管理线程池不要散落在各个类里集中到一个配置类方便统一调整和监控。Configuration public class ExecutorConfig { Bean(modelExecutor) public ThreadPoolTaskExecutor modelExecutor(MeterRegistry registry) { return buildExecutor(registry, model, 32, 64, 100); } Bean(ioExecutor) public ThreadPoolTaskExecutor ioExecutor(MeterRegistry registry) { return buildExecutor(registry, io, 16, 32, 500); } Bean(lightExecutor) public ThreadPoolTaskExecutor lightExecutor(MeterRegistry registry) { return buildExecutor(registry, light, 8, 16, 1000); } private ThreadPoolTaskExecutor buildExecutor( MeterRegistry registry, String name, int core, int max, int queue) { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(core); executor.setMaxPoolSize(max); executor.setQueueCapacity(queue); executor.setThreadNamePrefix(name -); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(30); executor.initialize(); new ExecutorServiceMetrics( executor.getThreadPoolExecutor(), name Executor, Collections.emptyList() ).bindTo(registry); return executor; } }注意setWaitForTasksToCompleteOnShutdown(true)和setAwaitTerminationSeconds(30)这两个配置保证服务关闭时正在执行的任务能优雅完成不会突然中断导致数据不一致。7.3 降级策略的落地降级不是简单的try-catch返回默认值要有策略。我一般分三级一级降级返回缓存结果。如果之前有相同或相似请求的结果直接返回缓存。AI场景下可以用语义缓存相似问题命中同一答案。二级降级返回简化结果。比如用更小的模型、更短的max_tokens快速生成一个简版回答。三级降级返回兜底话术。明确告诉用户当前服务繁忙请稍后重试并记录日志用于后续分析。降级要可配置、可开关通过配置中心动态调整出问题时能快速切换。public ChatResponse callWithFallback(ChatRequest req) { try { return circuitBreaker.executeSupplier(() - modelClient.call(req)); } catch (Exception e) { log.warn(模型调用失败触发降级, e); // 一级查缓存 ChatResponse cached cache.get(req); if (cached ! null) return cached; // 二级简化调用 try { return modelClient.callSimplified(req); } catch (Exception ex) { // 三级兜底 return ChatResponse.fallback(); } } }7.4 几个容易踩的坑最后分享几个我在实际项目里踩过的坑都是文档里不会写的。坑一Async方法的事务失效。Async和Transactional一起用时如果异步方法内部调用了带事务的方法事务可能不生效因为异步执行在新线程里事务上下文没传过去。解决办法是把事务逻辑放在被调用的同步方法里或者手动管理事务。坑二CompletableFuture的异常被吞掉。如果只调thenApply不调exceptionally或handle异常会被包装成CompletionException如果不处理可能悄无声息地丢失。一定要在链路末端加异常处理。坑三线程池的CallerRunsPolicy在特定场景下会死锁。如果提交任务的线程本身就在等待这个任务的结果CallerRunsPolicy会让它自己执行但它在等待就死锁了。这种场景要用AbortPolicy配合降级。坑四SSE连接没清理导致内存泄漏。SseEmitter如果客户端异常断开而服务端没感知emitter对象会一直挂在内存里。要设置合理的超时并在onCompletion、onTimeout、onError里都做清理。坑五虚拟线程里用ThreadLocal要小心。虚拟线程数量巨大如果每个都持有ThreadLocal的大对象内存会爆。JDK21引入了ScopedValue作为替代但还在预览阶段。现阶段建议在虚拟线程场景下避免使用重量级ThreadLocal。异步化和高并发设计这件事说到底是对系统资源的精细化管理。AI应用因为模型调用的长耗时和不确定性把这个问题放大了。但只要你把请求链路拆清楚、把线程池隔离好、把背压和降级做到位再配合监控和压测验证稳定性是完全可以保障的。我在多个项目里用这套思路从最初的频繁雪崩到后来的平稳运行最大的体会是不要指望某个框架或某个参数能一劳永逸真正的稳定来自于对每个环节边界的清晰认知。