AI Service 终于不占线程了:LangChain4j 1.20 非阻塞实测,兼看 Spring AI 2.1-M1

0 阅读9分钟

在这里插入图片描述

streamingChatModel cannot be null——这是我给 LangChain4j 1.20 非阻塞实测写的第一段代码,订阅流式接口时炸出来的。我以为把方法返回类型从 String 改成 Flow.Publisher<String> 就完事了,结果模型侧还得配两套。于是「马上要出结果」直接变成「回去翻文档」,十分钟没了。这个下马威反而让我看明白一件事:1.20 动的不是语法糖,是把 AI Service 的线程模型整个翻了一遍。

事实基准:本文基于 LangChain4j 1.20.2(2026-09-28 发布,9 月连发 6 版的最新一版)编写,非阻塞支持由 1.20.0(2026-09-04,PR #5527)引入、标注 @Experimental;Spring AI 侧为 2.1.0-M1(2026-09-25 发布)。文中全部实测数据来自本人四节对照实验(deepseek-chat,OpenAI 兼容端点,JDK 21),完整代码在 ai-demo 仓库 demo-t52-lc4j-async 模块。API 仍可能变,以官方文档为准。

先看结果:三种签名,三种活法

同一个 AI Service 接口,三个方法,唯一区别是返回类型——1.20 的设计是‘你选什么返回类型,就走什么执行模式’,接口其余部分不变:

interface Assistant {
    String chat(String message);                      // 同步:阻塞到底
    CompletableFuture<String> chatAsync(String message);       // 非阻塞:单响应
    Flow.Publisher<String> chatStreaming(String message);     // 非阻塞:流式
}

同一个问题“什么是响应式编程”问三遍——模型三遍都答了,没跟我计较——实测结果:

签名调用线程经历了什么实测数据
String chat()阻塞 909ms 才拿到回答全程占着 main 线程
CompletableFuture3ms 发起即返回future 在 ForkJoinPool.commonPool-worker-1 上完成
Flow.Publisher2ms 订阅即返回首 token 由 HttpClient-2-Worker-0 投递,29 个片段

异步路径的完成线程值得看一眼:ForkJoinPool worker、HttpClient worker——都不是调用线程。同步世界里,你的线程在跟 LLM 干瞪眼;异步世界里,它递过去一张写着“好了叫我”的纸条就走了。LLM 那边的慢,跟你无关。

开头那个报错的答案也在这:chatStreaming 需要在 AiServices 构建时单独配一个 OpenAiStreamingChatModel(builder 的 streamingChatModel 方法),只配 chatModel 不够。同步和流式用的是两个模型接口,三种签名不只是改接口声明。

两个 2 秒的工具:同步排队,异步抢跑

在这里插入图片描述

官方 release notes 说异步路径“多工具默认并发执行”——这句话我用两个各睡 2 秒的工具(查物流、查积分)验证了一遍。让模型一次办两件事:

同步路径的执行时间线(工具里打印线程名和相对时间戳):

[trackOrder] 进入 thread=main t= 574ms   退出 t=2579ms
[pointBal]   进入 thread=main t=2582ms   退出 t=4585ms
总耗时 5245ms

一个退出 3ms 后下一个才进,严格排队——食堂打饭的规矩,前一个打完菜你才敢上前,全在 main 线程上。

异步路径同一提问:

[trackOrder] 进入 thread=     t= 643ms   退出 t=2644ms
[pointBal]   进入 thread=     t= 643ms   退出 t=2645ms
总耗时 3436ms

同一毫秒进、同一毫秒出,整齐得我第一眼都怀疑日志是不是自己排的版——完美并发。差值 1809ms,差不多正好是一个工具的睡眠时长。

还有个细节我是看日志才发现的:异步路径工具的线程名是空的。我第一反应是打印代码写坏了,盯了两秒才反应过来——这是未命名虚拟线程的样子。官方文档说 Java 21+ 默认 offload executor 用虚拟线程,这里算是撞见了实物:一队不上工牌的临时工。同步路径全在 main 线程,异步路径连工具都搬进了虚拟线程,这个对照比任何文档描述都直观。

同一个工具报错,两种命运

1.20 最容易被忽略的变化是错误处理的默认值,官方文档直接给了对照表。我写了个必炸的工具(发票查询,抛 RuntimeException)——对,故意在自己工具里埋雷——同一提问跑两条路径:

  • 同步:错误被发回给 LLM,模型正常返回道歉文本——「抱歉,查询订单 A-1001 的发票信息时遇到了问题……发票服务返回了 connection refused,这看起来是后端……」
  • 异步:future 异常完成,RuntimeException: 模拟后端故障:发票服务 connection refused 直接炸给调用方

同一个工具、同一类错误,同步让模型体面地替你道歉——它甚至把你的异常栈翻译成人话讲给用户听,客服素质拉满;异步把异常原封不动抛给你,冷得像块冰。官方的设计理由值得引用:执行错误发回 LLM 会隐藏工具 bug、诱使模型编造答案——异步路径选择直接失败,是让你去修工具,而不是让模型替你圆场。

反过来的差异也存在:工具参数解析错误(模型生成的参数格式不对),同步路径直接失败,异步路径反而发回给 LLM——参数是模型生成的,模型被告知后通常自己能修。两个方向拧着来,不是不一致,是‘谁的锅谁修’:执行错误是工具的锅,炸给开发者;解析错误是模型的锅,退给模型。

20 路并发:5ms 发完,主线程没等过谁

单路 3ms 可能没感觉,我放大到 20 路并发 chatAsync:

20 路全部发起完毕:5 ms
全部完成:788 ms,成功 20/20

20 路 AI 调用,发起只花 5 毫秒——主线程活成了快递员,卸完 20 张单子转身就走,一张回执都不等。要是同步串行,这至少是 20 × 单次 LLM 延迟,十几秒的量级。「AI 调用很慢」这件事,从 1.20 起只该由等待结果的线程承担,不该由发起线程承担。

@Experimental 的潜台词:先看 provider 清单再上车

实测全绿,但有三盆冷水得泼在前面。

第一盆:provider 清单很短。 官方文档明确,非阻塞模式目前只支持 OpenAI(Chat Completions 和 Responses 两种)、Anthropic、Bedrock——整张菜单比有些项目的模型配置清单还短。其他 provider 编译照样过,运行时给你一个 AsyncNotSupportedException,不静默回退——官方说这是故意的(“There is no silent fallback to a blocking call — that is the point”)。我用 DeepSeek 能跑通,是因为它走 OpenAI 兼容端点,恰好落在 Chat Completions 那一行里——属于运气好,不属于我料事如神。你要是用 Ollama、Mistral 这类,先看清单再改签名。

第二盆:API 会变。 整套非阻塞 API 标注 @Experimental,官方原话「APIs and behavior may still change in future releases」,翻译过来就是「我今天说的话,明天别当真」。同步和 TokenStream 不受影响,但生产代码里如果现在就用 AiServiceStreamingEvent,得做好升级时跟进改动的准备。

第三盆:全链路都得非阻塞。 一荣俱荣一损俱损的木板——中间任何一层是阻塞的(自定义 guardrail、手写的 ToolExecutor、retriever),整条调用以同样的 AsyncNotSupportedException 失败。混搭老组件的存量项目,改造范围比你想象的大:这不是换个方法签名,是把链路上的每一层挨个审一遍。

同版还有个容易漏的配套件:Jackson 3 opt-in(PR #6184)。加一个 langchain4j-jackson3 依赖,LangChain4j 的 JSON 全走 Jackson 3,无需配置——别问,问就是 ServiceLoader;移除依赖即回退 Jackson 2,来去自由。为什么重要?Spring 生态已切到 Jackson 3(Boot 4 基线),全家桶统一后能少背一个 Jackson 2 的依赖树。代价也明确:JSON 失败从 RuntimeException 包装变成 JsonReadException/JsonWriteException(LangChain4jException 子类);langchain4j-mcp、langchain4j-guardrails 等模块的公共 API 还粘着 Jackson 2——搬不干净,还有几户的户口仍落在 Jackson 2 名下。我这次没实测它,细节以官方 Jackson 3 文档页为准。

隔壁 Spring AI 2.1.0-M1:能摸,别上生产

同一个九月,Spring AI 在 9 月 25 日发了 2.1.0-M1,三件大事:MessagePart 消息模型(按顺序保存 reasoning/工具调用/媒体等混合内容)、接入 OpenAI Responses API(一个属性就能切换)、VectorStore 新增 upsert(写入预计算 embedding)。切换方式:

spring.ai.openai.chat.api=responses

接 Responses API 的官方理由很硬:GPT-5.4 起,Chat Completions 不支持‘工具调用 + 非 none 推理档位’的组合,Responses 支持。在 OpenAI 旗舰模型上做 Agent,这就是必须换的端点。设计上它刻意无状态——每次调用发完整 Prompt,ChatMemory、advisors、RAG 行为与现有 OpenAiChatModel 一致。

但 M1 有两个已知限制,官方博客写得明明白白,连猜都不用猜:

  1. 基线是 Spring Boot 4.2.0-M2,不是 4.1 稳定线——光这一条就是生产红线;
  2. 持久化 ChatMemory 仓库还存不了新的 message parts,只有 InMemoryChatMemoryRepository 能跨轮保留推理内容,持久化支持计划在 2.1.0-RC1。

我的判断:M1 用来提前踩点正好——尤其 MessagePart 是消息模型的地基,2.1 后面所有 provider 都要迁上去(官方计划 RC1 完成全 provider 适配)。生产项目等 GA,试点项目等 RC1,现在接入的唯一理由是你需要提前准备迁移。手痒的(比如我)可以开个分支摸一摸那个属性开关,但别动生产依赖。

判断:并发模型成了框架选型的新维度

LangChain4j 1.20 和 Java 21 虚拟线程,代表了 Java AI 并发的两条路线:响应式不占线程(framework 层全链路异步,线程占用趋近于零,代价是 API 实验期 + 全链路改造)vs 虚拟线程占得起(同步代码一行不改,Loom 兜底,代价是线程还在、只是便宜了)。两条路线不冲突,甚至能叠——1.20 的异步路径在 Java 21+ 恰好就是拿虚拟线程做 offload。

分档建议:

  • 网关型、高并发扇出型服务(一轮要并发打几十路 AI 调用):值得现在就试点 1.20 异步,20 路 5ms 发起的数据就是你的天花板依据;
  • 普通业务后端、并发量不大:同步 + 虚拟线程够用,等 1.21+ API 稳了再迁;
  • 混搭存量组件多的项目:等 @Experimental 摘帽,中间件的非阻塞改造不是你一个团队的事。

九月这一波(1.20.0 到 1.20.2 连发 6 版,这个节奏搁别的项目够用半年,中间还夹着 1.19.1 那个删不掉的事故版本——前文刚写过),Java AI 框架的竞争已经从‘谁家接入的模型多’卷到了‘谁家的线程模型对’。前者是生态广度,后者是工程深度——对要上生产的团队,后者才是真门槛。