那次压测我到现在都记得。一个基于大模型做智能客服的项目,代码写完了,功能也通了,联调环境跑得挺欢快。结果一压测,50个并发请求进来,服务直接雪崩,线程池被打满,CPU飙到90%以上,接口响应从200ms飙升到30秒。当时团队里有个同事盯着监控面板说了句:"这不是我们的代码有问题,是模型接口太慢了。"话是没错,但问题恰恰出在这里——在Java里构建AI应用,如果还用传统Web开发那套同步调用的思路,大模型接口这种秒级延迟的IO操作,会把你的线程池、内存、连接池全部拖垮。
这篇文章我不打算讲太多理论,就从一个真实项目的角度,聊聊Java AI应用做异步化与高并发设计时,我实际踩过哪些坑、改过哪些代码、最后沉淀出来的方案是什么。适合正在搞AI应用后端、或者在传统Java项目中接入了大模型API的工程师参考。核心就一句话:在Java AI应用里,异步化不是优化手段,而是生存前提。
1. AI应用并发设计,为什么和传统Web服务完全不是一回事
1.1 你以前那套并发模型,根本扛不住秒级IO
传统Web服务的典型IO是数据库查询,快的话3ms,慢一点也就几十毫秒。在这个延迟级别下,即使你用同步Servlet模型,一个线程处理一个请求,撑个几百并发问题不大,毕竟线程大部分时间在等待数据库返回,CPU非常空闲。
但AI应用调的什么?大模型API。以GPT级别的模型为例,一次非流式对话的接口延迟通常在2到10秒之间,有些复杂推理场景甚至到20秒往上。假设你有200个并发用户,每个用户一次对话要等5秒,同步模型下你至少需要200个线程同时阻塞等待大模型返回。Java默认的Tomcat线程池是200,也就是说仅仅200个并发请求就能吃掉整个后端服务全部线程。此时哪怕有一个健康检查、一个其他业务的简单查询进来,也只能排队等线程释放,表现就是服务"卡死"。
1.2 数据库连接池策略,在大模型场景下也不适用
传统服务里,遇到慢查询要加索引、调SQL,因为数据库连接是很贵的资源。但大模型API这个"数据库"你用不了索引,你只能用它的HTTP接口。它慢,你只能等。
我见过很多团队在接大模型接口时下意识地配了一个大连接池,比如HTTP连接池给了500。表面上看是"提高并发能力",实际上你只是把阻塞从线程池转移到了连接池,底层逻辑一模一样:请求在大模型那边排队,你的Java服务也在疯狂堆积连接。
1.3 AI应用特有的并发挑战:流式输出与长连接
如果你做的是流式对话(也就是打字机效果),那并发模型就更复杂了。传统的"请求-响应"是一次性的,而流式接口意味着连接要维持更长的时间。以SSE为例,一个连接可能从建立到结束要维持30秒甚至几分钟,这期间虽然你的线程不需要一直计算,但连接不能断。同步模型下一万个流式连接,就需要一万个线程去维护。如果底层用的还是Tomcat的传统连接器,这些线程的栈空间会吃掉巨量内存,每个线程默认栈1MB,一万个线程就是10GB。
所以我说,在Java AI应用里,异步化和高并发不是"要不要做"的问题,而是"不做行不行"的问题。接下来我讲具体的改造思路。
2. 第一步改造:把线程模型和IO模型从根上换掉
2.1 从Servlet阻塞模型切换到虚拟线程
如果项目还是Spring Boot 3.0以下版本,第一步先升级到Spring Boot 3.0以上,因为从JDK 21开始,Java引入了Virtual Threads(虚拟线程)。这个技术的意义在于:线程的创建不再依赖操作系统,而是在JVM层面实现,一个平台线程可以承载成千上万个虚拟线程。
听起来是不是正好解决我们上面的困境?确实。传统同步代码最大的问题是"一个请求占一个线程",虚拟线程让这个"占"变便宜了。你在代码里写的方法依然是同步的、阻塞的,但底层执行的时候,虚拟线程遇到阻塞操作会主动让出平台线程,等IO回来再挂上去继续跑。
配置方式很简单。Spring Boot里用虚拟线程,只需要在配置类里加一个Bean:
@Bean public TomcatProtocolHandlerCustomizer<?> protocolHandlerCustomizer() { return protocolHandler -> { protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor()); }; }如果你用的是Jetty或者Undertow,也有对应的虚拟线程配置方式。核心效果是:200个并发请求,以前需要200个原生线程,现在可能只需要2个平台线程。
2.2 异步HTTP客户端:别再用RestTemplate了
线程模型换完之后,下一个瓶颈在HTTP客户端。同步的RestTemplate底层是阻塞IO,每次HTTP调用前要独占一个连接等待响应。在高并发大模型场景下,我会优先选择异步的HTTP客户端。
这里我只推荐两类:
- WebClient(Spring WebFlux自带):响应式编程模型,非阻塞IO,底层是Netty。
- JDK内置的HttpClient:从JDK 11开始,它就支持异步调用,接
CompletableFuture,非常适合和虚拟线程配合使用。
我自己在实际项目中,Spring MVC + 虚拟线程 + JDK HttpClient异步API的组合,是最省心智负担的异步化方案。为什么不用WebClient?因为响应式编程有学习成本,而且一旦用了响应式堆栈,整个链路都得改,事务、拦截器这些都别扭。
JDK HttpClient的调用长这样:
HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .build(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("你的大模型API地址")) .header("Content-Type", "application/json") .POST(BodyPublishers.ofString(payload)) .build(); CompletableFuture<HttpResponse<String>> future = client.sendAsync(request, BodyHandlers.ofString());2.3 用CompletableFuture编排多个模型调用
当你同时调用多个大模型做对比或者做"多角色协作"时,异步化的价值就更明显了。比如我做一个AI Agent,需要先调用"规划模型"生成计划,再并行调用"执行模型"、"评估模型",最后汇总结果。
这时CompletableFuture可以写出堪比响应式代码的编排逻辑,但看代码的方式还是同步的、顺序的,对团队里大部分Java工程师非常友好:
CompletableFuture<String> task1 = CompletableFuture .supplyAsync(() -> callLLM("模型A", prompt1)); CompletableFuture<String> task2 = CompletableFuture .supplyAsync(() -> callLLM("模型B", prompt2)); CompletableFuture<String> combined = task1 .thenCombine(task2, (result1, result2) -> { return mergeResults(result1, result2); }); String finalResult = combined.get(15, TimeUnit.SECONDS);这里的supplyAsync可以指定自定义的线程池,也可以用默认的ForkJoinPool。但我更推荐指定一个专门用于调用大模型API的线程池,这样能隔离大模型慢调用的影响,不会占满整个应用的计算资源。
3. 高并发下的自我保护:限流、熔断、重试一个都不能少
3.1 为什么必须做"双端限流"
传统Web服务限流,通常是为了保护自己的数据库和应用,防止流量打爆。AI应用多了一个变数:你的下游——大模型API——有速率限制(Rate Limit)。OpenAI的API限制是每分钟多少TPM(Token Per Minute)或RPM(Request Per Minute),其他平台也类似。你以为自己在限流保护下游,其实是下游在限流保护自己,你超了别人的配额,接口直接返回429。
实战中的教训是:必须在自己这边做双端限流。
- 一端是入口限流,控制用户请求量不超过系统容量。
- 另一端是出口限流,控制发往大模型API的速率,避免429。
3.2 令牌桶限流器的实现与参数计算
限流我用阿里巴巴的Sentinel比较多,它的流控规则支持QPS限流,直接能把数据往模型API发送的速率控制住。但有时候规则不好配,我就自己写一个轻量级的令牌桶。令牌桶的好处是允许短时间内的突发流量,又不会长期超过下游处理能力。
核心实现逻辑大概是:
public class TokenBucketRateLimiter { private final long capacity; // 桶容量,即最大突发请求数 private final long refillRate; // 每秒补充令牌数 private double tokens; private long lastRefillTimeNanos; public synchronized boolean tryAcquire() { long now = System.nanoTime(); tokens = Math.min(capacity, tokens + (now - lastRefillTimeNanos) / 1_000_000_000.0 * refillRate); lastRefillTimeNanos = now; if (tokens < 1) { return false; } tokens -= 1; return true; } }参数怎么定?假设你的大模型API支持每分钟6000次请求,也就是每秒100。那么capacity可以设到150,允许短时突发;refillRate设为100。每次请求发出前调用tryAcquire(),返回false就把请求拒掉,并进入排队逻辑。
3.3 重试策略:不是所有失败都值得重试
大模型接口抛异常的场景和传统接口很不一样。我总结下来,值得重试的失败只有三种:
- 429限流:等一小段时间再重试;
- 503服务暂不可用:可能瞬时过载;
- 超时且响应不明确:比如网关超时,但可能模型已经在生成了,重试容易产生重复计费。
不值得重试的是4xx类错误,特别是400(请求参数错误),这就说明是代码问题,重试一万次也一样。
重试时你要用指数退避(Exponential Backoff)加抖动(Jitter)。我见过最朴素的错误重试就是固定sleep 2秒再试,结果下游还在恢复期,你这一波重试又把别人打挂了。正确姿势是:
long delay = (long) (baseDelayNanos * Math.pow(2, attempt - 1)) + ThreadLocalRandom.current().nextLong(0, jitterNanos); Thread.sleep(delay);3.4 熔断:避免雪崩的最后防线
限流解决"来的请求太多"的问题,熔断解决"下游已经坏了,别再往坑里跳"的问题。大模型API有时会故障,比如被攻击、机房断电、版本更新出问题等,如果此时你的服务还硬着头皮继续向它发请求,你的线程池等待队列会越堆越长,最终把内存撑爆。
熔断我用的是Resilience4j,它的CircuitBreaker组件配置很灵活。核心参数就三个:
- failureRateThreshold:失败率阈值,默认50,表示连续超过50%的请求失败就熔断。
- waitDurationInOpenState:熔断后等待多久,默认60秒。
- minimumNumberOfCalls:最少请求数,比如10,不到10个请求不参与熔断判定。
用一个例子说明。我把对某个大模型供应商的调用封装成一个CallLLMFunction,外表看起来和同步调用没区别,但内部已经套了熔断和重试:
CircuitBreaker cb = CircuitBreaker.of("llm-provider-A", CircuitBreakerConfig.custom() .failureRateThreshold(40) .waitDurationInOpenState(Duration.ofSeconds(30)) .minimumNumberOfCalls(5) .build()); Supplier<String> decorated = CircuitBreaker.decorateSupplier(cb, () -> callLLM(prompt)); Try<String> result = Try.ofSupplier(decorated) .recover(throwable -> fallbackResponse("模型暂不可用,这是我的应急话术"));熔断打开时,系统不再调用真实模型,而是直接返回兜底内容。等30秒后进入半开状态,放一个试探请求,看看下游恢复了没有。
4. 实战案例:异步化改造一个AI客服系统的完整链路
4.1 原始同步设计的崩溃点
这是一个真实项目的简化版本。业务是一个AI客服系统,用户提问,后端调用大模型RAG(检索增强生成),生成答案返回。原始设计的链路非常"标准":Controller -> Service -> 检索知识库 -> 调用大模型API -> 返回。
压测时180个并发请求,3秒内,Tomcat的200个线程全部被阻塞。线上表现是用户感觉系统"完全没反应",因为连静态页面的请求都要排队等线程。
4.2 异步化改造后的整体链路
改造后的链路是这样的:
- 接入层:保留Spring MVC同步接口,因为Controller层很薄,只是接收参数。
- 线程池层:接入虚拟线程,替换默认Tomcat执行器。
- 服务编排层:用
CompletableFuture并行执行"知识库检索"和"无关情况判断"两个任务。知识库检索是本地ES查询,大约20ms;大模型调用是远程IO,耗时约3秒。 - 调模型层:使用JDK HttpClient异步API发送请求,并在发出前用令牌桶限流控制对供应商的整体请求速率。
- 兜底层:对模型调用包了Resilience4j熔断器和带指数退避的重试器。
核心代码骨架大致这样:
@Service public class AiReplyService { @Autowired private KnowledgeBaseClient knowledgeBaseClient; @Autowired private LlmGateway llmGateway; public CompletableFuture<AiReply> generateReply(String question) { // 第一步:并行检索知识库 + 生成最终Prompt CompletableFuture<KnowledgeResult> kbFuture = CompletableFuture.supplyAsync(() -> knowledgeBaseClient.search(question)); CompletableFuture<String> promptFuture = kbFuture.thenApplyAsync(knowledge -> buildPrompt(question, knowledge)); // 第二步:把prompt发往大模型 return promptFuture.thenComposeAsync(prompt -> llmGateway.callAsync(prompt)); } }在这个代码里,callAsync返回的就是CompletableFuture<String>,内部就是JDK HttpClient的异步调用。Controller接到这个Future后,直接join()等待结果。虚拟线程下,join()不会占满平台线程,因为Java运行时知道当前虚拟线程在等一个IO,会自动把底层平台线程让出来。
4.3 压测数据对比
改造前后来了一轮对比压测,参数都是500并发,持续3分钟,随机延迟1秒模拟真实业务:
| 指标 | 改造前(同步+线程池) | 改造后(虚拟线程+异步IO) |
|---|---|---|
| 最大吞吐量 | 120 req/s | 720 req/s |
| 平均响应时间 | 5.8s | 2.1s |
| P99响应时间 | 14.2s | 4.5s |
| 线程堆栈内存占用 | 约1.5GB | 约380MB |
| 大模型API 429错误率 | 9.3% | 0.4% |
其实这里最让我意外的是429错误率。同步模型下,大家不用限流,全凭"打多少算多少";改造后由于出口令牌桶的存在,发给大模型的请求是严格匀速的,下游不再被怼爆,这比吞吐量提升更值钱。
4.4 改造过程中我踩过的三个坑
第一个坑是虚拟线程和synchronized的兼容问题。不是不能用,而是如果synchronized包裹的代码里有大量阻塞操作,虚拟线程被pin在平台线程上,无法释放。解决办法是尽量把synchronized缩小范围,或者改用ReentrantLock。
第二个坑是错误的线程池配置。有同事把虚拟线程用在自定义的ExecutorService里,调用new Thread()去执行,结果虚拟线程没生效,还是走老路。虚拟线程必须通过Executors.newVirtualThreadPerTaskExecutor()或者Thread.ofVirtual()来创建。
第三个坑是异常处理位置不对。异步链路里,异常不会自动打印,它被封装在CompletableFuture内部。如果你在thenApplyAsync里忘了处理异常,最后join时才抛出来,排查起来非常难受。我的习惯是每一层异步动作里都加上exceptionally日志,至少把调用ID、模型名、异常类型打出来,否则线上发现问题时根本不知道是模型超时还是代码bug。
5. 基于个人实操经验的几个配置参考
5.1 HTTP连接池的超时配置
大模型API的超时配置要精细。我一般设成三层:connectTimeout=3s(建连不能太长)、requestTimeout=15s(非流式请求要留有富余)、readTimeout=30s(防止模型停顿)。如果你的模型可能做流式生成,超时时间要更长,但建议不要超过60秒,因为超时后用户其实早就失去耐心了。
5.2 线程池隔离策略
很多团队在接AI时,会把大模型调用线程池和业务计算线程池混在一起。高并发时,模型调用拖慢业务计算,业务计算占满CPU又加剧了模型调用的排队。我现在的做法是:
- 模型调用专用线程池:5~10个线程就够,配合异步IO。
- 虚拟线程池:跑所有的业务代码,数量不限制,由JDK管理。
- 消息队列消费线程池:如果你用MQ接收触发AI的请求,建议单独一个池,避免消费堵死导致消息积压报警。
5.3 压测工具的选型提示
高并发验证AI应用不能只用ab,因为ab不会模拟真实的慢IO下游。我的建议是用JMeter或者k6,并且务必Mock大模型API的延迟。我通常给Mock接口设成3秒固定延迟和10%的500错误率,这样能一次性验证:延迟、超时、重试、熔断四条链路。如果Mock不出错,那真实环境大概率会出事。
5.4 关于异步日志
异步化后,日志框架也会成为瓶颈。高并发下如果你还用同步日志,大量线程都卡在写文件上。我推荐使用Logback的异步Appender,把日志事件放到队列里,后台线程批量写入。队列大小合理设成1024或2048即可,太大反而会造成日志积压占用内存。
写在最后,我自己的感受是:Java AI应用的高并发设计,核心并不是某个框架或代码技巧,而是一个思维转变——你要把大模型API当成一个"特别慢的下游数据库"去设计整条链路。异步化、限流、熔断这些技术,传统Web开发也讲究,但到了AI场景,它们从"可选优化"变成了"核心架构"。你可以不用虚拟线程,可以不用CompletableFuture,但如果不做异步化,大模型应用的并发天花板就是一两百,这是物理限制。
最后分享一个小细节:改造完一定要做一次长时间的稳定性压测,至少跑15分钟以上,不要只看前3分钟的数据。大模型接口的延迟波动特别大,有时候前5分钟P99是3秒,后10分钟就飙升到8秒。只有长时间跑下来,你才能发现你的线程池、限流阈值、熔断参数是不是真的匹配真实流量。这套链路我在三个项目上验证过,稳定性和吞吐量的提升都是实打实的,大家可以放心参考。