最近用 Spring AI 给内部工具加了一个自然语言查询入口,说实话,从加依赖到能聊天,花的时间比我预想中少太多。核心就两件事:yml 里把模型服务商的 key、base-url、模型名称配好,然后注入 ChatClient.Builder,用四步链式调用把提示词发出去、把结果接回来。整个过程没有手写 HTTP 请求、没有自己处理鉴权,也没有拼 JSON,Spring Boot 的自动装配直接帮你把客户端准备好了。这个项目的目标读者是那些想在业务里接大模型能力,但又不想引入太重框架的 Java 后端同学,也适合刚入门的 Spring 开发者抄一版作业。
1. 项目思路:Spring AI 到底解决了什么问题
1.1 如果不使用 Spring AI,你需要做哪些手工活
每个用过大模型 API 的人都知道,直接调 HTTP 接口并不复杂,复杂的是把它嵌进 Spring 生态里。你至少要处理四件事:
- 把模型厂商的鉴权信息、模型名、接口地址写进配置,再手动封装一个 RestTemplate 或 WebClient 客户端;
- 定义请求体和响应体对应的 Java 类,每次调用都要做一次 JSON 序列化和反序列化;
- 处理鉴权头、HTTP 状态码、重试、超时、异常包装,否则生产环境一个网络抖动就能把服务打挂;
- 如果要做多轮对话,还得自己在业务代码里拼接历史消息,长期维护成本不低。
这也是我看中 Spring AI 的直接原因。它不是要代替大模型厂商的 SDK,而是把“连接模型”这件事做成了 Spring 风格的自动配置。你只要把依赖引进去、把 yml 配好,容器启动后就会自动出现一个ChatClient.Builder。这个 Builder 背后已经帮你搞定了 HTTP 客户端、鉴权头、模型映射、异常处理、响应转换。业务代码里不需要关心模型厂商协议长什么样。
1.2 选型时的关键考量
选型那几天我其实认真纠结过。自己封装 RestTemplate 看起来可控,但代价是后续每个接入方都要重复造轮子。接一套自带监控、自带流式输出、自带结构化输出的框架,长期性价比更高。Spring AI 目前的最佳位置不是取代 LangChain 这类完整 Agent 框架,而是作为一个轻量的 Java 原生接入层。
- 如果你的项目是纯 Spring Boot 技术栈,用 Spring AI 的成本最低,Bean 管理、配置体系、异常体系都是现成的;
- 如果你需要的不只是文本聊天,还有图像生成、向量存储、结构化输出、Prompt Template、函数调用,Spring AI 把这些统一抽象成了同一套 API;
- 如果你希望测试代码可控,它还提供了伪聊天客户端等测试工具,不过这个超出了这次体验的范围。
我最后选择先跑通一个最简链路:yml 配置 -> ChatClient -> 链式调用 -> 返回结果。不追求一步到位,先把基础链路吃透,后面替换模型、加多轮、加流式,都只是在同一套 API 上加参数。
1.3 从配置到结果返回的完整链路
从概念上看,完整调用链路可以想象成一条流水线:
application.yml中的配置项由 Spring Boot 绑定到自动配置类;- 自动配置类创建 HttpClient 相关 Bean,再生成
ChatClient.Builder; - 业务代码通过构造器注入
ChatClient.Builder,调用build()得到一个ChatClient实例; - 调用
chatClient.prompt()开始组装请求,接着用.system(...)、.user(...)设置消息内容; .call()发起同步调用,内部完成鉴权头拼接、JSON 序列化、HTTP 发送、响应解析;.content()或.entity(...)从响应中取出文本或实体对象,交回业务代码。
也就是说,真正需要你手写的,只有“配置”和“链式调用”这两层。理解了这个链路,后面踩到任何问题都能顺着配置、自动装配、网络请求、响应解析这几个环节快速定位。
2. 准备阶段:依赖、yml 配置与自动装配
2.1 引入依赖的坑
Spring AI 的 starter 不像 Spring Web 那样一个依赖就完事,它通常需要结合 BOM 统一管理版本。我在pom.xml中做了两件事。第一步,在dependencyManagement里导入 BOM,避免自己和团队后续维护一堆版本号:
<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>你的兼容版本</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>第二步,引入服务商对应的 starter。这里的“xxx”需要替换成你实际使用的模型服务商前缀:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-xxx-spring-boot-starter</artifactId> </dependency>这个写法能保证你依赖的各个子模块版本一致。我第一次没引 BOM,直接写了一个版本号,结果编译没问题,运行时报附件和 core 模块版本不一致。后面换回 BOM 管理,这类问题就再没出现过。
2.2 yml 配置文件的核心字段
“配好 yml 就能聊”这句话不夸张,但配置项要想清楚。我最简配置长这样:
spring: ai: xxx: api-key: ${AI_API_KEY} base-url: ${AI_BASE_URL} chat: options: model: ${AI_MODEL:default-chat-model} temperature: 0.7 max-tokens: 1024这里的xxx必须是你引入的那个 starter 对应的前缀,每个服务商前缀不同。api-key、base-url、model 是三个绝对不能省的字段。api-key就是调用模型服务的密钥,base-url是 API 地址,model是模型名称。temperature和max-tokens不写也能跑,但建议第一次就设置好,因为它们直接影响模型输出的随机性和长度上限。
我用${AI_API_KEY}这种占位符而不是直接写死密钥,是为了避免把敏感信息提交到代码仓库。本地调试时可以在 IDEA 的运行配置里加环境变量,也可以在.env文件里维护。线上环境则放到配置中心或 CI/CD 变量中,安全性和可维护性都更好。
2.3 自动装配是怎么把 Bean 创建出来的
初学者最容易困惑的是:我没写 Bean,为什么能直接注入ChatClient.Builder?答案在 Spring Boot 的自动装配里。starter 被引入后,classpath 上出现了自动配置类。Spring Boot 启动时会读取META-INF下的配置信息,发现ChatClientAutoConfiguration一类的自动配置类。里面会用条件注解判断类路径中是否存在ChatClient和模型服务商相关的类,只要条件满足,就自动注册ChatClient.Builder和对应的模型工厂。
这个设计思路和 Spring Boot 的数据库自动配置完全一样。好处是业务代码里几乎零配置,坏处是一旦报错,新人很难理解“Bean 到底从哪里来”。遇到这种情况,我总结过一个简单排查法:启动日志里搜索ChatClient关键字,看自动配置条件是否满足;如果发现配置类没生效,先检查 starter 版本和 Spring Boot 版本是否匹配,再看 yml 前缀是否写对。
2.4 多环境配置怎么做
实际项目中不可能只有一个环境。开发环境、测试环境、生产环境的模型名称和 base-url 大概率不一样。我用 Spring Profile 拆了三份配置:
application.yml:公共配置,比如默认模型名称;application-dev.yml:开发环境的 key 和 base-url;application-prod.yml:生产环境的 key 和 base-url。
启动时通过SPRING_PROFILES_ACTIVE=prod指定环境,Spring Boot 会自动覆盖同名配置项。这样做的好处是,业务代码完全不用改,部署时只需切换环境变量。如果你有配置中心,也可以把spring.ai下的配置统一托管,效果一样。
3. ChatClient 四步链式调用:从零写一个能聊天的服务
3.1 入口:拿到 ChatClient.Builder 并 build
在 Spring Boot 项目里,你很少需要new ChatClient。正常写法是构造器注入ChatClient.Builder,然后调用build()得到一个 ChatClient 实例:
@Service public class ChatAssistant { private final ChatClient chatClient; public ChatAssistant(ChatClient.Builder builder) { this.chatClient = builder .defaultSystem("你是一个经验丰富的Java技术助手,回答力求准确、简洁。") .build(); } }build()这一步会把默认参数固化进客户端。如果你希望所有请求默认都带一套系统提示词,就在build()之前用defaultSystem(...)设置。这样后续每一次链式调用都会自动带上这段默认系统提示词,不需要重复传。当然,链式调用里也可以临时覆盖。
3.2 四步链路的第一步:system 系统提示词
严格来说,一次完整的链式调用从chatClient.prompt()开始,但prompt()更像是打开一个 Prompt 构建器,真正的四步链式方法是system、user、call、content。第一步是设置系统提示词:
String result = chatClient.prompt() .system("你是一个答疑助手。请直接回答,不要输出额外解释。") .user(userMessage) .call() .content();system(...)里的内容相当于给模型一个角色设定和行为准则。它不参与通常意义上的“用户问题”,但会深刻影响回答风格。我常用它来约束模型:禁止编造数据、遇到不确定的问题明确说不知道、返回 JSON 时要严格遵守字段格式。这些约束写在 system 里比写在 user 里更稳定,因为系统提示词在每次请求中优先级更高,也不容易被用户输入干扰。
实际使用时,system 提示词不要写得太长。模型对上下文的窗口有限,提示词太长会挤占真正的用户消息空间。我自己的习惯是 200 字以内,把最关键的约束写清楚就够了。
3.3 第二步:user 用户消息
user(...)这一步用来传递用户输入。它的参数可以是一个简单的字符串,也可以是一个带模板和参数的 Prompt 对象。
最简单的用法是直接传入文本:
.user("Spring AI 和普通 HTTP 调用有什么区别?请用三句话回答。")多轮对话场景下,你需要自己维护历史消息列表,然后把历史记录和当前问题一起拼进 user 消息里。注意不要无限增长,否则很快会超出模型的 token 上限。一般我会滑动截取最近 5 到 10 条消息,控制 context 体积。
如果你想在请求里追加额外上下文,比如传入一个业务对象,可以在user(...)的另一个重载里传入对象和相关 PromptTemplate。但初体验阶段,直接传字符串已经够用。
3.4 第三步:call 同步调用
call()是链式调用的核心执行步骤。调用它之后,ChatClient 会执行以下动作:
- 把 system 和 user 消息包装成请求体;
- 拼接鉴权头;
- 通过内置 HTTP 客户端把请求发给 yml 里配置的 base-url;
- 接收响应并将模型返回的文本包进 ChatResponse。
它是同步操作,所以当前线程会阻塞直到拿到响应或抛出异常。对于平时接口耗时要求低、交互频率低的内部工具,同步调用完全够用。如果要做实时打字机效果,可以把call()换成stream(),返回的是一个Flux<String>流式结果。不过那是进阶玩法,这次先不展开。
3.5 第三步和第四步之间:content 与结构化返回
很多人会把四步理解成“取文本”和“取对象”两种结束方式。其实call()返回的对象上有多个方法,其中最常用的是content()和entity(Class)。
content()直接返回 String,适合普通问答、摘要生成等场景。entity()则会让模型输出按指定结构返回,并自动完成 JSON 到 Java 对象的反序列化。例如:
public record CodeReviewResult(String summary, Boolean hasBug, String suggestion) {} CodeReviewResult result = chatClient.prompt() .system("你是代码评审专家。请以JSON格式返回,字段为summary、hasBug、suggestion。") .user(codeSnippet) .call() .entity(CodeReviewResult.class);使用entity(...)时,系统提示词必须明确告诉模型输出 JSON,并列出字段名,否则模型返回的文本很难被正确反序列化。这个功能非常适合做信息抽取、格式化输出、接口对接,能省去大量手工字符串解析。
3.6 完整四步链式调用示例
把上面的片段拼起来,就是一个可以放进 Controller 的最简聊天接口:
@RestController public class ChatController { private final ChatAssistant chatAssistant; public ChatController(ChatAssistant chatAssistant) { this.chatAssistant = chatAssistant; } @GetMapping("/chat") public String chat(@RequestParam(defaultValue = "你好,介绍一下你自己") String message) { return chatAssistant.ask(message); } } @Service public class ChatAssistant { private final ChatClient chatClient; public ChatAssistant(ChatClient.Builder builder) { this.chatClient = builder .defaultSystem("你是一个技术助手,回答尽量简洁、准确。") .build(); } public String ask(String userMessage) { return chatClient.prompt() .system("如果用户打招呼,请简单回应;如果用户问技术问题,请先给结论再补充细节。") .user(userMessage) .call() .content(); } }启动应用后,访问http://localhost:8080/chat?message=你好,就能收到模型回复。这个示例虽然短,但已经完整覆盖了“配好 yml 就能聊”的全部环节。
4. 配置和调用中的常见问题与排查实录
4.1 401 鉴权失败
最常见的问题就是 HTTP 401。我遇到这个报错通常会按顺序排查:
- 环境变量
AI_API_KEY是否真的存在,IDEA 里配置的环境变量需要重启才生效; - yml 中的占位符是否写对了变量名;
- API key 是否带有多余空格,复制的时候很容易混入换行符;
- 如果报告无法读取 appender,一般是自动配置拿到的配置是空值。
如果本地选了application-dev.yml,还要确认 Profile 是否被加载。很多时候配置放到了默认application.yml,但实际运行的是prodProfile,配置项被覆盖成了空。
4.2 404 接口地址不对
base-url 写错会直接报 404。不同模型服务的接口路径习惯不太一样,有的要求带/v1,有的不带。我刚开始把/v1/chat/completions和/v1搞混过,导致请求打到了不存在的位置。
排查思路很简单:先查看 yml 里的 base-url,再对照模型服务商文档给出的请求地址。注意尾部的斜杠,多一个/少一个/都可能造成路径拼接错误。Spring AI 内部会拼接默认接口路径,所以尽量不要在 base-url 里写过于具体的路径,留给程序自己拼接通常更稳定。
4.3 超时和连接池问题
生产环境下,模型服务响应偶尔会慢。如果默认超时时间太短,请求频繁超时,业务方会以为接口挂了。Spring AI 支持在 yml 中配置连接和读取超时时间,不同服务商前缀下字段略有差异,但基本都有类似connect-timeout、read-timeout的场景,需要你看一眼当前版本提供哪些属性。
我踩过一个坑:内网通过代理访问模型服务,连接能建立,但读取响应超时。排查了很久才发现是网络代理和长连接复用互相干扰。后面把连接池的 maxIdle 调小,并让 HTTP 客户端忽略某些代理头,才恢复正常。
4.4 中文乱码与返回内容截断
中文乱码现在比较少见,因为现代 HTTP 客户端默认按 UTF-8 处理。如果出现乱码,优先检查服务端口和数据库连接串是否指定了 UTF-8 编码。文本截断则更常是因为模型上下文或输出长度限制,可以通过调大max-tokens解决。
但如果你的业务本身需要处理长文档,就不能只依赖参数调大。我建议在调用前对文本做分片,把长文本拆成适合模型窗口的段落,分段生成摘要或结果,最后再合并。这个过程可以借助 Spring AI 的 PromptTemplate,但核心仍然是“先分片,再处理”。
4.5 版本兼容性排查
Spring AI 迭代速度比较快,版本差异有时很大。项目里同时用了 Spring Boot 3.2 和某个 Spring AI 版本,启动时报方法找不到,是因为某个 API 在升级后改了签名。
遇到这类问题,我建议先看官方 release note,再搜索项目里是否用了ChatClient的旧方法。尤其是.user()的返回值类型、CallResponseSpec是否存在,不同版本可能有细微差别。为了保证长期稳定,尽量用 BOM 管理版本,并固定 Spring Boot 大版本,不要混着升。
5. 实操心得与几处值得注意的细节
5.1 密钥管理别偷懒
初体验阶段直接用固定 key 写 yml 可以,但上了团队项目,密钥管理必须认真对待。最好统一走环境变量或配置中心,禁止把真实 key 提交到 Git。我见过同事不小心把 key 推到私有仓库后,几小时就被外部扫描工具抓走,只能紧急轮换密钥。这种事情一次就能让人长记性。
5.2 把 ChatClient 封装成你的业务服务
直接在各处注入ChatClient虽然方便,但会让业务代码散落提示词、参数和异常处理逻辑。我更建议在 Service 层封装一层ChatAssistant,把 system 提示词、调用参数、结果类型统一收口。后续要加日志埋点、限流、缓存、多模型切换,都只改一个类。这个习惯对任何团队都值。
5.3 流式输出与结果解析是下一步优化方向
四步链式调用适合快速验证。真正面向用户的产品,通常需要流式输出和结构化输出配合使用。你可以把call()换成stream(),让打字机式回复实时展示;也可以像前面说的entity(...)那样,把模型输出直接映射成业务对象。初体验结束后,这两条路都值得深入,但核心链路和配置思路不会变。
5.4 最后分享一个调试技巧
如果你在本地想看到模型请求到底发到了哪里、返回了什么,可以打开 Spring AI 的 Debug 日志,观察请求和响应细节。我经常在配置里临时加:
logging: level: org.springframework.ai: DEBUG跑一条请求,日志里能看到自动配置加载了哪些类、HTTP 请求最终打到了哪个地址、响应里包含哪些字段。排查了一个小时的问题,可能看完日志十秒就定位了。看完记得把日志级别调回 INFO,否则生产环境日志量会非常大。
从我的实际体验看,Spring AI 最大的价值不是省掉几行 HTTP 代码,而是把“连接大模型”变成了 Spring 项目里一件理所当然的事。配好 yml 之后,剩下的只是你如何设计提示词和封装业务逻辑。希望这篇初体验记录能帮正在尝试的你少走几步弯路。