ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringAI入门到实战:Java开发者的大模型接入与工具调用指南

SpringAI入门到实战:Java开发者的大模型接入与工具调用指南

我最早产生接入 SpringAI 的想法,是在一个给内部系统加「智能问答助手」的项目里。当时团队第一反应是直接调大模型平台的 HTTP 接口:自己写 HTTP 客户端、自己拼 JSON 请求体、自己解析流式返回、自己管理上下文轮次,还要根据不同的模型服务商各写一套适配代码。一通折腾下来,代码倒是能跑,但只要一换模型平台,整个调用层就得推倒重来。后来换成 SpringAI 重新梳理了一遍,整个接入过程从「写胶水代码」变成了「写配置和业务方法」,这才是 Java 生态里接入大模型该有的姿势。

这篇文章我会从依赖接入、配置项、核心 API、结构化输出、工具调用这几个方面展开,最后单独讲几个我实际落地时踩过的坑。内容面向已经在用 Spring Boot、想给项目加 AI 能力的开发同学,也适合正在做技术选型调研的团队参考。看完之后你能自己搭出一个最小可用的 SpringAI 工程,并且知道后续往生产环境推的时候要提前防哪些问题。

1. 没有 SpringAI 之前,Java 项目接大模型到底有多别扭

1.1 传统 HTTP 调用方式的样板代码困境

很多 Java 开发者最开始接触大模型接口,走的路子都差不多:用 OkHttp 或者 HttpClient 拼一个 POST 请求,header 里带鉴权信息,body 是模型要求的消息数组,然后通过回调或execute()拿到响应,再手动从嵌套 JSON 里一层层取值。这个流程单看一次调用,工作量不算大,但一旦你的业务场景复杂起来,问题立刻暴露出来。

拿多轮对话举例。你要自己维护一个历史消息列表,每次请求前把历史压入 body,响应后再把 assistant 消息塞回去;如果中间插了工具调用,还要处理模型返回的 tool_calls 字段,再继续把工具结果发过去。这一套链路下来,光状态管理就够写几百行。而且不同平台之间的鉴权方式、路径前缀、参数命名、流式格式都可能不一样,今天接 A 平台,明天换 B 平台,适配层的改动量几乎是重写一遍。

1.2 SpringAI 的抽象方式:模型即 Spring Bean

SpringAI 的思路很直接:把大模型接入当成一个普通的 Spring Boot starter 来处理。框架替你封装了 HTTP 通信、鉴权、请求构建、响应解析、流式处理、工具调用等底层细节,你只需要面向ChatModel、ChatClient、Prompt、Message这几个核心抽象编程。

打个比方,这就像你平时用 MyBatis 操作数据库一样。JDBC 你也能写,但没人会在项目里直接去处理 Connection、PreparedStatement、ResultSet 的每一个细节。SpringAI 对模型做的,就是 MyBatis 对 JDBC 做的这件事:把样板逻辑收敛到框架内部,让业务代码只关心业务本身。

ChatModel是最底层的模型接口,负责执行一次对话调用,对应早期的RestTemplate;ChatClient是更上层的流式 API,支持链式调用和强大的 prompt 组装能力,这也是目前推荐的主用入口。再加上Prompt、SystemMessage、UserMessage、AssistantMessage这套消息模型,SpringAI 把大模型交互里最常见的几种场景都覆盖了。

1.3 这套框架适合什么场景,不适合什么场景

从我这几个月的实践来看,SpringAI 最擅长的是几类业务:

  • 聊天/客服助手:多轮对话、system prompt 约束、上下文维护都有现成 API。
  • 结构化数据提取:让模型返回 JSON,直接转成 Java 对象。
  • 工具调用/函数调用:通过@Tool注解把业务方法暴露给模型。
  • 知识库问答的起步阶段:配合向量库和EmbeddingModel做最基础的 RAG。

但如果你打算直接拿它做复杂的自主规划智能体,或者模型要连续执行几十步任务才能出结果,那 SpringAI 目前更适合当「底座」,而不是「全自动大脑」。它把模型调用这一层做得足够稳,但 Agent 的编排逻辑、记忆存储、任务拆解这些,还是得你自己写。认清这一点,能帮你少走不少弯路。

2. 依赖接入和环境配置:先把最简单的对话跑通

2.1 版本选择这件事,比你想的重要

SpringAI 的版本迭代非常快,不同版本之间的 API 变化也很大。如果你在搜索引擎里找教程,很容易看到旧版本写法,抄进去发现编译不过。我个人的建议是直接跟着当前稳定版走,不要因为看过老教程就死守旧版本。

现在接入 Spring 生态的新框架,版本管理有一个相对固定的思路:项目用 Maven 的话,在dependencyManagement里先引入 SpringAI 的 BOM,再引入对应的 starter,避免直接写各种子模块版本号。这样升级的时候只改 BOM 版本就行。Java 版本方面,Spring Boot 3.x 需要 JDK 17 或更高,SpringAI 也一样,所以老项目要接入前,先确认基础环境已经升级。

2.2 pom.xml 里的最小依赖长什么样

以我最近搭的一个内部 Demo 为例,父工程里的依赖管理大概是这样的:

<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>1.0.0-M6</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>

然后在具体模块里引入模型客户端 starter。这里要注意:SpringAI 支持多家模型平台,starter 的 artifactId 会随平台不同而变化。我为了演示通用接入流程,使用了占位名称,实际项目里请根据你选择的平台,参考官方文档找到对应的 starter 模块名。

<dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-<platform></artifactId> </dependency> </dependencies>

<platform>替换成你实际使用的模型平台模块名即可。之所以推荐这种写法,是因为 SpringAI 把不同平台的差异全部收敛在 starter 内部,业务代码层面你接触到的ChatClient、ChatModel等类型是统一的。这意味着你将来换平台,改依赖和配置就能切走,业务代码不用大改。

2.3 application.yml 里的核心配置项

SpringAI 的自动配置会读取spring.ai前缀下的属性。配一个模型连接,最核心的就是三样:接口地址、密钥、模型名称。

spring: ai: <platform>: base-url: ${AI_BASE_URL} api-key: ${AI_API_KEY} chat: options: model: ${AI_MODEL_NAME} temperature: 0.7

我一直用环境变量的方式注入这些敏感配置,而不是直接写死在 yml 里。原因很朴素:代码仓库会流转到不同开发者手里,api-key 一旦进了 Git 历史,后患无穷。base-url这里也多说一句,不同平台的接入地址差异很大,有的平台要求走它自有的端点,有的平台提供兼容主流协议的端点,用后者的话你甚至不需要改代码,只改base-url和api-key就能切模型。

2.4 写一个最朴素的 Controller 验证链路

依赖和配置就绪之后,写一个最基础的接口来验证整条链路是否通。SpringAI 的自动配置会把ChatClient、ChatModel这些 Bean 注册到容器里,你直接在 Controller 里注入就行。

@RestController @RequestMapping("/ai") public class AIController { private final ChatClient chatClient; public AIController(ChatClient.Builder chatClientBuilder) { this.chatClient = chatClientBuilder.build(); } @GetMapping("/chat") public String chat(@RequestParam(defaultValue = "介绍一下你自己") String message) { String response = chatClient.prompt(message) .call() .content(); System.out.println(response); return response; } }

跑起来之后,浏览器访问/ai/chat,看到模型正常回复,接入就算完成了。很多第一次用的人在这里会愣一下:怎么连组建请求的代码都不用写?确实不用写。chatClient.prompt(message)内部已经按照消息协议帮你把UserMessage组装好,call().content()负责发请求并取出文本结果。这里有个小细节要注意:ChatClient是通过ChatClient.Builder构建出来的,不要自己去new,否则拿不到自动配置里的模型参数。

3. ChatClient 的消息体系:从单轮到多轮,再到流式

3.1 调用参数对输出质量的影响

在继续写业务之前,我想先聊几个直接影响输出效果的参数。这些参数配置在spring.ai.<platform>.chat.options下,也可以在每次请求时通过ChatOptions临时覆盖。我用一张表来总结它们的用途:

参数作用我的实践建议
temperature控制随机性,数值越高回答越发散客服/知识库问答这类要精确的场景,调到 0.2-0.4;文案创意场景可以到 0.8 左右
max-tokens限制本次回复的最大 token 数不要设太小,否则长文本会被截断;但设得太大又会增加成本和延迟
top-p核采样,控制候选词集合范围一般保持默认就可以,和 temperature 二选一调整即可
model指定模型标识符开发环境用轻量模型,生产环境用强模型,用配置切换
timeout请求超时时间流式场景尤其注意,模型生成慢,超时阈值要给足

我在项目里最常用的一套组合是:精确型任务temperature=0.2、max-tokens=1024;创意型任务temperature=0.8、max-tokens=2048。这些参数直接决定了回答的「性格」,建议团队内部形成一套参数约定,避免每个人凭感觉乱调。

3.2 用 SystemMessage 约束场景:一个 SQL 生成小助手

只做一个「你说一句我回一句」的聊天接口没什么意思,真实业务里最常用到的是 System Prompt。SystemMessage 用于设定模型的身份和行为规则,这也是所有对话任务的基座。

我做过一个内部小工具:让大模型根据表结构生成 SQL 查询语句。业务人员不需要懂数据库,只要用自然语言描述需求,系统返回可执行的 SQL。这个功能的 System Prompt 很能说明问题:

@GetMapping("/sql") public String generateSql(@RequestParam String requirement) { String systemPrompt = """ 你是一名数据库专家。用户会给你一个业务需求,请生成对应的 SQL 查询语句。 约束: 1. 只能返回 SQL 和简短说明,不要返回多余内容。 2. 优先使用索引字段。 3. 如果需求不明确,先回复需要补充的条件。 现有表结构: create table order_info ( id bigint primary key, user_id bigint, goods_name varchar(128), order_amount decimal(10,2), create_time datetime ); """; return chatClient.prompt() .system(systemPrompt) .user(requirement) .call() .content(); }

看到没有,system(...)和user(...)是分开设置的,SpringAI 会把这些消息拼装成正确的消息序列发送给模型。这条链路里 System Prompt 是整个回答质量的锚点,大部分「模型回答不靠谱」的问题,根源都是 System Prompt 约束不够清晰,而不是模型本身不行。

3.3 多轮对话:上下文怎么维护

聊天类业务的核心难点是上下文维护。SpringAI 的底层消息模型里,SystemMessage、UserMessage、AssistantMessage都实现了Message接口,多轮对话本质上就是往消息列表里追加内容。

@Component public class ChatService { private final ChatModel chatModel; public ChatService(ChatModel chatModel) { this.chatModel = chatModel; } public String chatWithHistory(String userId, String userInput) { // 实际项目请用 Redis 等外部存储,这里用内存容器演示 List<Message> history = HistoryStore.get(userId); history.add(new UserMessage(userInput)); ChatResponse response = chatModel.call(new Prompt(history)); String assistantText = response.getResult().getOutput().getText(); history.add(new AssistantMessage(assistantText)); HistoryStore.save(userId, history); return assistantText; } }

这里最重要的一个经验:每次请求都必须把完整历史消息列表发给模型。模型本身没有记忆,它只能根据你提供的全部消息来猜测上下文。所以消息越多,token 成本越高,请求越慢。实战里要在性能和成本之间做平衡,一般做法是只保留最近 N 轮对话,或者把过长的历史做摘要压缩。

还有个小坑:在多轮对话中,如果某次模型回复失败或者中断,你最好把这条失败的 assistant 消息也从历史里清掉,否则下次请求时模型会看到一个「半截话」,容易影响回答逻辑。

3.4 流式输出:Flux 才是聊天场景的正确姿势

前面讲的都是同步调用,也就是等模型把完整内容生成完,一次性返回。这种方式在生成较长的回答时体验很差,用户会盯着页面上一个转圈好几秒。真实世界的 AI 应用几乎都是流式输出:一个字一个字往外蹦。

SpringAI 对流式的支持做得比较优雅,核心 API 返回的是 Reactive Streams 的Flux<String>。如果你的接口在 Spring WebFlux 环境下,可以直接把Flux作为响应体返回;如果是 Spring MVC 环境,也能借助 SseEmitter 把数据推给前端。

@GetMapping(value = "/stream", produces = "text/event-stream") public Flux<String> streamChat(@RequestParam String message) { return chatClient.prompt(message) .stream() .content(); }

stream()之后拿到的Flux<String>,通过produces = "text/event-stream"声明为 SSE 接口后,浏览器 EventSource 或前端 fetch API 就能实时消费。这一步出来之后,产品体验会有一个质的提升。多提一句:流式接口的线程模型和同步接口完全不同,同步调用会一直占着一个 Tomcat 工作线程,而流式返回是异步处理的,不会长时间占用 Web 容器线程,对高并发场景友好得多。

4. 结构化输出:让模型直接返回可用的 Java 对象

4.1 从「解析 JSON 的痛」到 BeanOutputConverter

让大模型输出 JSON 是一个很常见的需求:从简历里提取候选人信息、从聊天记录里抽取工单内容、判断用户情绪并给出分类标签等等。但之前自己处理这个过程非常烦:模型偶尔会在 JSON 前后加一段解释文字,你的JSONObject.parseObject直接崩;有时候字段名和预期的对不上,又要写复杂的类型适配。

SpringAI 提供了一个工具类BeanOutputConverter,可以把模型的输出约束并转换成任意 POJO/record 类型。它的实现思路是:把你的目标类型结构写进 prompt,让模型只返回合规 JSON,然后在拿到响应文本后,用框架自带的转换逻辑反序列化成 Java 对象。

4.2 一个完整的结构化输出例子

我用一个课程推荐场景来演示。假设有一个 Agent 需要根据用户的学习需求返回推荐课程列表,课程对象包含名称、时长和学习路线关键词:

public record Course( String courseName, Integer durationDays, List<String> skillPoints ) {}

接口代码如下:

@GetMapping("/course/recommend") public Course recommendCourse(@RequestParam String goal) { BeanOutputConverter<Course> converter = new BeanOutputConverter<>(Course.class); String response = chatClient.prompt() .system(converter.getSystemInstruction()) .user("我想学 SpringAI,目标:" + goal) .call() .content(); // 把模型返回的文本转成 Course 对象 return converter.convert(response); }

converter.getSystemInstruction()会自动生成一段「请严格按照 JSON 格式返回」的约束说明,然后把 Java 类型字段信息告诉模型。converter.convert(response)则负责把返回的 JSON 文本解析成Course实例。整个过程没有手写任何JSONObject代码。

4.3 结构化输出不是银弹,该校验还得校验

我在实际用了一段时间之后,发现结构化输出有几个隐藏问题需要自己留意:

  • 模型偶尔还是会返回带说明文字的文本,比如先写一句「好的,这是您需要的课程信息:」,然后才是 JSON。遇到这种情况,converter.convert()会失败。需要在转换前对文本做一次预处理,或者捕获异常后重试一次。
  • 模型的 JSON 字段类型可能与声明不一致,比如你的 record 里是Integer,它给你返回字符串"5"。框架会尽力转换,但复杂的嵌套类型还是建议手动校验。
  • 结构化输出会多消耗一些 token,因为它要把类型结构写进 System Prompt,这在 token 计费模式下是有成本的。

这些限制说明了同一件事:把 AI 的输出当成普通程序输入来对待。转换前校验、转换后兜底、失败时重试,这三个习惯养成之后,结构化输出在工程上才算是真正稳了。

5. 工具调用:让模型学会调用你的 Java 方法

5.1 为什么需要 Tool Calling

模型本身是「离线大脑」,它知道很多通用知识,但它不知道我们系统里的实时状态:订单号对应的物流进度、某个商品当前的库存、用户最近的消费记录、数据库里某张表的结构……这些都藏在你的业务系统里。

要做到「问模型一个问题,它自己去查系统再回答」,靠常规聊天接口做不到。Tool Calling 就是为了解决这个问题而生的:模型在回答过程中,可以决定调用一个你注册好的函数,拿到函数返回值之后,再组织成最终回答。

5.2 用 @Tool 注解暴露方法

SpringAI 注册业务方法的方式非常简单,在方法上打@Tool注解即可:

@Component public class OrderTools { @Tool(description = "根据订单号查询订单当前状态,订单号以A开头") public String getOrderStatus(String orderId) { // 这里模拟查询后端订单服务的逻辑,实际项目可注入 Service 调用 if ("A1001".equals(orderId)) { return "已发货,预计后天到达"; } if ("A1002".equals(orderId)) { return "正在打包,暂未出库"; } return "未找到该订单"; } }

关键就在这个description上。别小看这段描述,它决定了模型在什么时机、用什么参数来调用这个方法。描述写得越具体,参数含义越明确,模型的调用准确率越高。如果你写的是「查询订单状态」,模型很可能在你只提供用户ID的时候也尝试调用,然后传入一个错误参数。

有了工具类之后,在ChatClient构建时把它关联进去:

@RestController @RequestMapping("/ai") public class ChatController { @GetMapping("/tool") public String chatWithTool(@RequestParam String message) { var chatClient = ChatClient.builder(chatModel) .defaultTools(new OrderTools()) .build(); return chatClient.prompt(message) .call() .content(); } }

请求进来后,模型先判断当前问题是否需要工具,如果需要,SpringAI 会自动完成「模型返回工具调用请求 → 框架反射执行你的 Java 方法 → 把方法结果回传给模型 → 模型基于结果生成最终回答」的闭环。整个过程对业务方法来说,就是一个普通的方法调用;但对使用者来说,模型「学会」了查询你系统的业务状态。

5.3 工具调用链路的理解与排查

工具调用是 SpringAI 里最推荐优先掌握的扩展能力,因为它的收益非常直接。但调试的时候会有一个常见的理解偏差:你以为程序会先执行工具方法,再让模型看结果,实际上模型的决策顺序是「先判断调用什么工具、传什么参数,再执行,然后再继续生成」。如果你的工具方法返回内容格式很复杂,比如一大段 JSON,模型可能理解不了,所以工具方法的返回值应该尽量是简短自然语言的报告,而不是原始数据结构。

排查工具调用问题时,我建议把每次请求的完整日志打开。SpringAI 在响应对象里会保留工具调用的中间信息,看日志能确认模型到底选了哪个工具、传了什么参数,多数「工具没生效」的案例,最后都查到这里。

6. 实战落地才会遇到的几类坑

6.1 依赖拉取与网络受限环境

国内团队接 SpringAI 时,第一个坎往往不是代码,而是依赖下不下来。Spring 的里程碑版本仓库比较特殊,如果你的构建服务器网络受限,会卡在坐标解析阶段。实际项目里我一般两个方案并行:在 Maven 配置里加仓库镜像;或者在本地准备好依赖后,导出到公司内部仓库再统一使用。这个问题看着小,却容易在团队协作时挡住所有人,最好提前处理。

6.2 流式接口和线程模型带来的隐患

用流式接口后,很多人以为只要把 Controller 返回类型改成Flux就完事了。其实这里面有一个容易被忽略的隐患:如果业务代码在Flux生成过程中调用了阻塞式 IO(比如同步查数据库、同步调外部 API),会阻塞底层的事件循环线程。我排查过一个线上偶发卡顿的问题,最后发现是工具方法里用了一个阻塞式 HTTP 客户端,把整个流式链路拖住了。

经验有两条:流式链路里的工具方法尽量用异步写法;没有把握的话,先给回调链路上配置单独的调度线程池。压测跑过之后,再考虑优化到极致。

6.3 限流、重试与超时:模型接口不像数据库接口

数据库出问题,通常很快就能感知;模型接口出问题,症状要隐蔽得多。限流时它不一定会报错,而是长时间没有返回;超时设短了,生成一份长报告会被频繁打断;重试机制写不好,短时间高频重试反而触发更严格的限流。

我现在的做法是给模型调用统一包一层带重试和熔断的服务。重试策略用指数退避,初次等待 500 毫秒,倍率 2,最多 3 次。超时时间根据场景区分:纯聊天设置 30 秒,生成长文档设置 60 到 90 秒。这些参数写死在代码里不如放在配置中心,方便线上动态调整。

@Bean public ChatClient chatClient(ChatModel chatModel) { return ChatClient.builder(chatModel) .defaultOptions(ChatOptions.builder() .temperature(0.7) .build()) .build(); }

6.4 日志安全:API Key 和用户敏感信息

最后一个坑,更多是意识和习惯问题。Spring 的自动配置在启动阶段会解析并读取配置项,如果日志级别调到 DEBUG,某些框架会打印出完整的配置绑定信息,API Key 可能就这么泄露到日志里。我的处理方式是:配置文件里的密钥一律使用环境变量占位,并确保日志框架中标记的敏感字段不会被输出;在业务日志里也只记录模型返回的文本摘要,不带完整 Prompt,很多企业内部数据不适合作为提示词发送出去。

这一步做不做,上线后面对审计时的压力完全不同。宁可现在多花几分钟设计脱敏策略,不要在出事之后补。

最后聊点实在的

接入 SpringAI 这几个月,我最深的感受是:它没有试图让你「忘记」大模型的复杂性,而是把复杂性挡在了合理的边界之外。Java 团队原本要花两周时间写的模型接入层,现在一个周末就能跑通,剩下的大部分精力可以用在真正的业务逻辑和产品体验上。如果你正在选型,我的建议是先别急着上各种重型 AI 框架,把 SpringAI 的基础链路、结构化输出、工具调用这三板斧吃透,已经能覆盖大多数真实业务场景。最后留一个小技巧:把模型平台相关的配置全部收敛到配置中心,通过不同环境profile切换模型名称,开发环境用轻量模型控制成本,生产环境用能力更强的模型保证质量,这一套组合在多个项目里都跑得很稳。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进