☰
LangChain4j 1.20响应式AI Service实测:告别线程阻塞,吞吐提升3倍
2026/10/27 20:19:13 网站建设 项目流程

说实话,项目里被 AI Service 占满线程折腾过的朋友,看到 LangChain4j 1.20 这个版本应该会眼前一亮。这版本最戳我的改动,就是 AI Service 终于可以不占线程了——声明式接口里直接返回Mono/Flux,底层链路走了响应式,不再用阻塞式 HTTP 调用把 Tomcat 线程池焊死在等待上。这篇我就把最近在真实服务里的实测过程完整写出来,包括接口改造、底层模型切换、压测对比,顺带聊聊 Spring AI 2.1-M1 的响应式路线。适合正在做 AI 应用后端、被线程池打满和超时告警反复折磨的 Java 开发者。

1. AI Service 这个“线程黑洞”到底是怎么产生的

1.1 先还原一下一次 LLM 调用的完整链路

LangChain4j 的 AI Service 本质上是动态代理。你定义一个接口,声明一个chat(String message)方法,框架在运行时给它生成代理对象。方法内部要做的事情远比你看到的复杂:先把用户消息封装成ChatMessage,再把历史对话从ChatMemory里捞出来拼进上下文,然后调用ChatLanguageModel.generate()把请求发给模型服务,拿到响应之后还要经历后处理、工具调用循环(Function Calling 可能要来回好几轮),最后才返回字符串。

问题恰恰出在generate()这一步。它是同步阻塞的,底层走的是阻塞式 HTTP 客户端,请求发出去之后线程就挂起了,干等模型响应。而这个等待时间有多长?非流式接口通常 2 到 6 秒,稍微复杂一点的带工具调用场景,10 秒以上都是家常便饭。

实测中最直观的感受是:Tomcat 默认 200 个线程,200 个并发请求进来,线程池立马见底,后面的请求全部排队。这时候 CPU 并不高,内存也还好,但接口的 P99 已经起飞了。原因就是那 200 个线程全部阻塞在generate()的等待上,寸步难行。

1.2 算一笔账:阻塞模型的代价到底有多大

我们来做一个简单的数学估算。假设单次 LLM 调用平均耗时 3 秒,Tomcat 线程池配置 200 线程,理论上最大吞吐就是 200 / 3,大约 67 QPS。注意,这是理想情况,不算任何 CPU 计算、GC 停顿和日志开销。实际线上能稳定跑到 50 QPS 已经很难得了。

更隐蔽的代价在线程栈内存。默认 JVM 线程栈大小是 1MB,200 个线程光栈内存就是 200MB 起步。如果为了抗并发把线程池扩到 500,那就是 500MB 的固定开销。到了高并发时段,线程排队等待、上下文切换频繁,CPU 时间全消耗在线程调度上,真正的业务计算反而分不到多少资源。

这个模型说白了就是:线程在等网络,网络在等模型,模型在等服务端排队。整个链路最贵的资源——线程,全用在了毫无价值的等待上。

1.3 响应式到底解决什么问题

响应式编程的核心思路是把“等待 I/O”从占用线程变成挂起任务。线程池保持很小,比如十几个线程,但是挂起的任务可以有几万个。当 HTTP 响应真正回来那一刻,Reactor 的事件循环才分配一个线程来处理后续逻辑。线程只干活,不等待。

这个模型对 LLM 调用尤其合适,因为 LLM 调用天然就有漫长的网络延迟。响应式方案下,同样的 200 并发,线程池只需要维持一二十个活跃线程就够了,其余全部是挂起状态,等响应回来再继续执行。LangChain4j 1.20 做的事情,就是让 AI Service 这一层声明式接口也能走这条路。

2. LangChain4j 1.20 非阻塞 AI Service 实测

2.1 环境准备与依赖配置

我用的环境是 JDK 21、Spring Boot 3.4(WebFlux 模块)、LangChain4j 1.20.0。模型供应商那边用了一套兼容 OpenAI 接口的部署,也测了 Ollama 本地模型。Maven 依赖主要三块:

<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>1.20.0</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-open-ai</artifactId> <version>1.20.0</version> </dependency> <dependency> <groupId>io.projectreactor</groupId> <artifactId>reactor-core</artifactId> <version>3.7.11</version> </dependency>

注意一点:reactor-core最好显式声明版本,别只靠框架传递依赖。我在项目里遇到过传递版本不一致,结果Flux的构建器和框架内部用的不是同一个版本,运行期报方法找不到。这种问题极难排查,强烈建议显式引入。

2.2 接口声明:从 String 到 Mono/Flux

AI Service 接口定义很简洁,核心就是方法返回类型决定执行路径:

public interface AiAssistant { // 老写法:同步返回,阻塞占线程 String chat(String userMessage); // 非阻塞写法:返回 Mono,异步执行 Mono<String> chatReactive(String userMessage); // 流式写法:返回 Flux,边生成边推 Flux<String> chatStream(String userMessage); }

构建方式和以前一样,用AiServices.builder():

AiAssistant assistant = AiServices.builder(AiAssistant.class) .chatLanguageModel(model) .chatMemory(MessageWindowChatMemory.withMaxMessages(20)) .build();

这里有个很容易误解的点:不是把方法返回类型改成Mono就万事大吉了。底层模型构建方式必须配合异步链路,否则换汤不换药。

2.3 关键开关:底层模型必须走异步实现

我一开始图省事,直接用原来的OpenAiChatModel.builder()构建模型,只改了接口返回类型,结果压测一看,线程照样被占满。排查半天才发现问题:OpenAI 模块默认走的是阻塞式 HTTP 客户端,即使外层套了Mono,底层调用generate()时线程还是阻塞在网络等待上。异步编排遇到同步源头,等于白搭。

正确做法是在构建模型时开启异步模式,或者直接使用流式模型。实测可行的几种方式:

OpenAiChatModel model = OpenAiChatModel.builder() .apiKey(System.getenv("OPENAI_API_KEY")) .modelName("gpt-4o-mini") .async(true) .build();

如果用的是 Ollama 或者其他兼容 OpenAI 的服务,同样找异步构建入口。或者换成StreamingChatLanguageModel的实现类,流式模型天然异步,配合Flux返回类型效果最理想。我当时图稳妥,直接换成了流式模型,让两种返回类型都走同一条异步链路,压测数据一下就正常了。

2.4 压测对比:线程占用究竟降了多少

压测场景是模拟用户提问,模型回复非流式返回,平均响应时长约 3.2 秒。200 并发持续压 1 分钟,分别测阻塞方案和非阻塞方案,数据如下:

指标阻塞模式(String + Tomcat)非阻塞模式(Mono + WebFlux)
线程池大小200(Tomcat 默认)12(Reactor 默认)
实测吞吐58 QPS175 QPS
线程活跃度100%(打满)约 22%
P99 响应时间5.8s2.9s
CPU 占用高(上下文切换严重)中(事件循环为主)

这个对比很能说明问题。同样的并发压力,非阻塞方案的吞吐提升了大约 3 倍,P99 从接近超时阈值降到了平均响应水平。最关键的是线程活跃度:12 个线程承载了 200 并发,这在阻塞模型下完全不可想象。

不过得说句公道话:如果你原本是 50 QPS 的小服务,线程池根本打不满,阻塞改非阻塞的收益感知不会这么强。只有到了并发上来了、线程池告警频繁的阶段,这个差异才是质的区别。

2.5 WebFlux 接口层怎么写

模型层改造完之后,接口层如果还是 Spring MVC + Tomcat,那还是白搭。要让整条链路真正非阻塞,HTTP 层得用 WebFlux:

@RestController public class ChatController { private final AiAssistant assistant; public ChatController(AiAssistant assistant) { this.assistant = assistant; } @PostMapping("/chat") public Mono<String> chat(@RequestBody ChatRequest request) { return assistant.chatReactive(request.message()) .timeout(Duration.ofSeconds(30)) .onErrorResume(e -> Mono.just("服务繁忙,请稍后再试")); } @PostMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> chatStream(@RequestBody ChatRequest request) { return assistant.chatStream(request.message()); } }

timeout和onErrorResume是必须加的,LLM 调用受模型服务端负载影响,响应时长波动大,超时兜底不能省略。返回Flux的接口用TEXT_EVENT_STREAM_VALUE做 SSE 输出,前端可以逐字展示模型生成结果,体验比等完整响应好不少。

3. Spring AI 2.1-M1 的响应式路线对比

3.1 Spring AI 的响应式 API 长什么样

Spring AI 2.1-M1 明显在往响应式方向发力。核心入口是ChatClient的链式 API,同时提供了对应的响应式版本。先看熟悉的链式写法:

ChatClient chatClient = ChatClient.builder(chatModel).build(); // 同步调用 String answer = chatClient.prompt() .user("解释一下什么是响应式编程") .call() .content(); // 流式调用 Flux<String> stream = chatClient.prompt() .user("解释一下什么是响应式编程") .stream() .content();

到了 2.1-M1,响应式能力进一步增强,提供了独立的ReactiveChatClient,从构建到调用全程响应式:

ReactiveChatClient reactiveClient = ReactiveChatClient.builder(chatModel).build(); Flux<String> answer = reactiveClient.prompt() .user("用一句话总结虚拟线程和响应式的区别") .stream() .content();

这种封装思路和 LangChain4j 的 AI Service 明显不同。LangChain4j 走的是“接口声明 + 动态代理”,Spring AI 走的是“客户端链式调用”。从代码形态上看,Spring AI 更像传统 HTTP 客户端的演进版,LangChain4j 则更接近 MyBatis 那种 Mapper 接口风格。

3.2 两条路线的设计取舍

实际对比使用之后,我的感受是各有侧重,选型取决于项目情况:

维度LangChain4j 1.20 AI ServiceSpring AI 2.1-M1 ReactiveChatClient
声明方式接口 + 注解 + 动态代理Builder + 链式 API
响应式入口返回 Mono/Flux 即可stream() / ReactiveChatClient
上下文管理ChatMemory 内置,开箱即用需要自行处理历史消息
工具调用支持 Function Calling,集成方便支持,但配置更偏手动
Spring Boot 生态需要自己装配 Bean自动配置,Starter 一条龙
学习曲线理解代理机制即可需要熟悉链式 API 和响应式

LangChain4j 的优势在于“声明式”带来的简洁。一个接口、一次构建,ChatMemory、工具调用、RAG 全部帮你接好,业务代码只需要专注方法签名。Spring AI 的优势在于和 Spring Boot 生态的整合更深,自动配置、可观测性、Starter 管理都更原生。

3.3 两个框架在实际项目中怎么配合

我在项目里踩出来的组合方式是:HTTP 接口层交给 Spring AI 的响应式链,负责接收请求、组织 prompt、做超时和错误处理;内部复杂的多轮对话、工具编排交给 LangChain4j 的 AI Service,因为它对 ChatMemory 和 Function Calling 的支持更顺手。

接口层拿到 LangChain4j 的Mono<String>直接透传返回给前端,不需要任何转换。两个框架之间的粘合点就是返回类型,只要都是Mono/Flux,衔接几乎没有成本。

这种组合也带来一个额外好处:LangChain4j 的模型层实现可以被 Spring AI 复用,反之亦然。虽然两个框架的 model 抽象不同,但底层都是 HTTP 调用,实际项目中通常只保留一套底层模型客户端,上层两个框架各自封装。

4. 常见问题与避坑实录

4.1 返回 Mono 了线程还是被占满

如果代码改成Mono返回后压测发现线程依旧被打满,按照这个顺序排查:

  • 第一步:检查底层模型是不是异步实现。OpenAiChatModel默认阻塞,必须async(true)或换成StreamingChatLanguageModel。
  • 第二步:检查整个链路里有没有block()调用。有些人图省事在 Service 层调mono.block()拿值再返回,等于把异步又憋回了同步。
  • 第三步:检查 HTTP 容器是不是 Tomcat。如果还是 Spring MVC + Tomcat,即使业务代码是响应式的,接口层的线程模型还是阻塞的,得换 WebFlux + Netty。

注意:上面这三步缺一不可。好几个人改了接口返回类型没换 WebFlux,压测数据没变化,还以为是框架版本不行。

4.2 流式响应把 ChatMemory 搞乱了

这是我踩过最深的坑。流式调用场景下,模型的输出是分片返回的,如果你在Flux的每个onNext里都把当前片段追加到 ChatMemory,那历史消息里存的就是一句句碎片,而不是完整回答。下一次对话时模型看到的历史消息是残缺的,回答质量直线下降。

正确做法是只在流结束的时候追加一次完整消息:

assistant.chatStream("你好") .doOnComplete(() -> memory.add(AssistantMessage.from(lastFullText))) .subscribe();

另外,MessageWindowChatMemory默认不是线程安全的,在响应式链路里可能被不同 Reactor 线程访问。实测发现并发对话时偶尔出现历史消息错乱,后面给 ChatMemory 加了同步包装或者用每个会话独立实例,问题才消失。

4.3 超时和重试参数别乱配

LLM 调用耗时波动非常大,超时参数需要区分场景。如果只配一个总超时,容易误伤。我的做法是分三层:

  • 连接超时:3 秒,用于快速失败,避免模型服务不可达时长时间等待。
  • 首字节超时:看模型的流式输出能力,一般 10 秒足够,超过就说明模型侧排队严重。
  • 总超时:30 秒,包含完整生成和工具调用链。

重试策略要用退避而不是固定间隔。模型服务返回 429(限流)或 5xx(过载)时,立刻用Retry.backoff(2, Duration.ofMillis(500))做指数退避重试。如果并发上来之后大量请求同时重试,反而会加重模型服务负载。

4.4 虚拟线程和响应式到底选哪个

Java 21 的虚拟线程也让“不占线程”这件事有了另一条路。两种方案的本质区别是:

虚拟线程的价值在于:代码保持熟悉的同步风格,不需要改造成响应式,底层阻塞点在 JDK 客户端支持挂起时,虚拟线程能被调度器腾出来。老项目改造成本低,维护成本也低。

响应式的价值在于:事件循环在超高并发下调度开销更小,背压控制更精细,流式交互天然契合。但是代码风格变化大,调试链路更复杂,对团队要求更高。

我的选择建议是:现有同步代码多、团队没有响应式经验,优先考虑虚拟线程;新写的高并发流式交互服务,优先考虑响应式。两个框架在这个维度上其实不冲突——LangChain4j 在虚拟线程下同样能跑,而且如果底层的 HTTP 客户端换成 JDK 客户端,虚拟线程的挂起效果非常接近响应式的资源占用水平。

4.5 响应式链路里的上下文传播问题

AI Service 的调用链中经常需要传递 traceId、userId 等上下文。传统同步代码习惯用 ThreadLocal,在响应式链路里不能这样用——同一个请求的多个处理阶段可能在不同的 Reactor 线程上执行,ThreadLocal 根本传不过去。

如果用的 Spring Boot 3.4 和 Micrometer Tracing,它已经处理好响应式上下文传播了,正常打日志没问题。但如果业务代码自己有 ThreadLocal 传参的习惯,到响应式这层必须改掉,改用方法参数显式传递,或者把上下文塞进prompt的变量里。

5. 实测之后我的几点实际体会

改造完成后跑了快两周,几个细节感受比较深。

第一,线上线程监控告警基本消停了。以前高峰期线程池活跃度持续 100%,现在稳定在 20% 上下,同一个 Pod 能扛的并发量明显上升。对于 AI 类应用来说,响应等待占大头,响应式方案几乎是白捡的性能。

第二,流式输出带来的体验提升比预期大。接口改成 SSE 逐字推送之后,用户端首字延迟降到 1 秒以内,不再需要干等三五秒。这个变化在业务反馈里比后端性能指标更直观。

第三,别指望一次改造就彻底告别线程问题。底层模型调用链路远比你想象的复杂,有些第三方 SDK 内部就是同步阻塞的,外部包了异步也无济于事。真正要做到全面非阻塞,从模型客户端到业务编排到 Web 容器,整条链路都得同步升级。

最后分享一个调试技巧:响应式链路排查问题比同步代码难得多,建议从一开始就在关键环节加上doOnNext、doOnError日志,把每个阶段的状态打出来。不然等出了问题再查,一串Mono/Flux堆栈看半天也定位不到具体是哪一环出的问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询