用同一接口和测试比较 Java AI 框架
文章分别用 Spring AI、Spring Boot 上的 LangChain4j,以及 Quarkus 上的 LangChain4j 实现同一个问答接口。三种实现共享框架无关的 Java 契约和测试,将差异限定在模型调用层。
AI 深度解读
选 Spring AI 还是 LangChain4j,不能只看功能清单。作者把同一个问答接口分别放到 Spring AI、Spring Boot 上的 LangChain4j、Quarkus 上的 LangChain4j 中,再用一套 HTTP 测试验收,比较调用方式、依赖和启动表现。
结论没有指向一个通吃的赢家。更值得程序员带走的是这套选型方法:先固定业务契约和验收标准,再观察框架与运行时各自带来了什么。不过,原文提供的证据还不足以支撑完整的性能排名。
先固定业务接口,才能看清框架的差别
三个应用做的事情都很简单:接收一个问题,交给模型,再返回答案。它们共同实现 QuestionAnsweringService,接口只有 Answer askQuestion(Question question) 一个方法;Question 和 Answer 都是简单的 Java record。
这个边界很关键。共享代码不知道 Spring AI 或 LangChain4j 的存在,具体实现才负责调用模型。更换框架时,需要比较的是适配代码和配置,而不是把业务逻辑一起重写。
作者还增加了一组有用的对照:让 LangChain4j 同时运行在 Spring Boot 和 Quarkus 上。前一组比较同一运行时中的两个 AI 库,后一组比较同一个 AI 库在两个运行时中的表现。
把业务契约固定下来,框架差异才有机会被单独观察。
但这里的应用只是一次模型调用。它能说明最小接入成本,无法代表复杂 AI 应用的全部开发成本。
两种调用方式,差别没有名字看起来那么大
Spring AI 使用自动配置好的 ChatClient.Builder 构建客户端,通过链式 API 完成请求。下面这段实现解决的就是「把问题发给模型,并取回文本」:
@Service
public class SpringAiQuestionAnsweringService implements QuestionAnsweringService {
private final ChatClient chatClient;
public SpringAiQuestionAnsweringService(ChatClient.Builder chatClientBuilder) {
this.chatClient = chatClientBuilder.build();
}
@Override
public Answer askQuestion(Question question) {
var answerText = chatClient.prompt()
.user(question.question())
.call()
.content();
return new Answer(answerText);
}
}
重点看 .user(question.question()):业务输入在这里进入提示词,后面的 .call().content() 执行调用并取得文本。模型、base URL 和选项由配置提供,不必写进这段业务实现。
LangChain4j 的入口更短。容器直接注入可用的 ChatModel,方法体只需要 return new Answer(chatModel.chat(question.question()));。在这个例子里,开发者接触到的主要 API 就是 chat()。
切换到 Quarkus 后,这段 LangChain4j 实现的构造器和方法体完全相同,只把 Spring 的 @Service 换成 CDI 的 @ApplicationScoped。
这能证明该示例中的模型调用代码容易跨运行时复用。它没有证明整个应用迁移只需改一个注解,HTTP 接入、配置和测试启动等外围差异,原文没有完整展示。
共用 HTTP 测试,比比较代码长短更有说服力
三个实现都继承同一个抽象集成测试,只提供各自的 baseUri()。测试通过 REST Assured 访问运行中的应用,不直接调用 Java 服务对象,因此 Spring Boot 和 Quarkus 接受的是同一份外部行为检查。
下面的测试验证 /ask 能回答事实问题,同时检查 HTTP 状态和响应字段:
public abstract class AbstractQuestionAnsweringIT {
protected abstract URI baseUri();
@Test
@DisplayName("answers a factual question")
protected void answersAFactualQuestion() {
ask()
.body(new Question("What is the capital of France? Answer with the city name only."))
.when()
.post("/ask")
.then()
.statusCode(200)
.body("answer", not(blankOrNullString()))
.body("answer", containsStringIgnoringCase("paris"));
}
// 还包括:拒绝空白问题(400),拒绝格式错误的请求体(400)
private RequestSpecification ask() {
return given().baseUri(baseUri().toString()).contentType(ContentType.JSON);
}
}
注意最后一条断言 containsStringIgnoringCase("paris"):作者没有要求输出严格等于某个字符串,因为即便 temperature 为零,模型输出也可能变化。测试还包含空白问题返回 400、请求体格式错误返回 400,不过这里只展示了说明,没有展开实现。
作者称它为语义断言。更准确地看,这是用关键词近似检查回答内容,比完整字符串匹配宽松,但还不能理解答案是否正确。例如,包含「paris」的否定回答也可能通过。
模型运行在由 Testcontainers 启动的 Ollama 容器中,不需要 API key,也没有按次调用的 API 费用,便于在 CI 中重复执行。不过,原文没有给出具体模型、版本或镜像信息,因此读者还不能仅凭这些片段复刻相同条件。
我的判断
我认可作者把「同一应用、同一验收标准」放在比较中心。对已经使用 Spring Boot 的团队,这比另起一个脱离现有工程的演示项目更有参考价值;相比逐项核对功能表,它还能暴露真实的配置和接入差异。
依赖方面,原文给出的明确数字是:同在 Spring Boot 上,Spring AI 引入 106 个 jar,LangChain4j 引入 49 个 jar。作者将差异归因于 Spring AI 额外带入 JSON-schema 生成器、tokenizer 和 Reactor,而这个简单用例没有使用这些能力。
但 jar 数量不能直接换算为包体积、内存或请求性能。Quarkus 会把运行时拆成更多小组件,作者也明确提醒不能据此判断它更重。
启动结论更需要保留条件。作者说 Quarkus 模块最小、启动最快,但数据来自单台机器上的少量运行,取中位数;计时前 Ollama 已经启动,窗口内只包含应用启动。所给原文也缺少具体启动时间和生产 Java 行数,无法判断差距大小。
这些结果适合帮助缩小候选范围,还不足以替团队完成选型。
RAG、会话记忆、工具调用、Model Context Protocol、可观测性、安全和 agents 都被列为后续可比较的能力,并未在这次实验中验证。若这些才是项目难点,就应当把它们加入样例后再做决定。
能拿来做什么
- 给现有 Java 服务接入 AI 时,可以先定义自己的问答接口,把框架调用集中在一个实现类中,再比较接入所需的配置和依赖。
- 给前端提供
/ask接口时,可以共用一套 HTTP 验收用例,检查成功响应、空白输入和错误请求体,避免框架替换后接口行为悄悄变化。 - 测试模型回答时,可以避免对整段自然语言做精确匹配,同时检查关键词断言会不会放过错误回答,并将稳定的协议断言与回答内容检查分开设计。
- 如果项目需要 RAG 或工具调用,可以沿用作者的方法,给每个候选栈实现同一条真实业务链路,再用相同测试比较开发成本与运行表现。