Spring AI 这两年在 Java 圈子里讨论度一直不低,但真正动手跑通一个能对话的 Demo,很多人卡在第一步——配置文件怎么写、依赖怎么引、ChatClient 到底怎么串起来。我前阵子在一个模拟项目里完整走了一遍这套流程,从零到能对话大概花了不到半小时,核心代码加起来不超过二十行。这篇文章就把整个过程拆开讲清楚,包括 yml 里每个参数为什么这么配、ChatClient 的四步链式调用每一步在干什么、以及我踩过的几个坑。不管你是刚接触 Spring AI 的新手,还是已经用过其他 AI 框架想快速迁移的老手,应该都能从里面找到能直接抄的部分。
1. 项目整体设计与思路拆解
1.1 为什么选 Spring AI 而不是自己封装 HTTP 调用
很多人第一反应是:调个对话接口而已,用 RestTemplate 或者 WebClient 直接发 POST 不就行了,为什么要引入一个框架?我一开始也是这么想的,但实际写下来发现,自己封装至少要处理这几件事:请求体的 JSON 结构拼装、不同模型厂商接口协议的差异、流式响应的解析、对话上下文的维护、异常重试和超时控制。这些活儿单看每一件都不难,但加在一起就是几百行胶水代码,而且每换一个模型供应商就得改一遍。
Spring AI 的价值在于它把这些东西抽象成了统一的接口。ChatClient 负责对话交互,ChatModel 负责底层模型通信,Advisor 负责拦截和增强,你切换模型供应商的时候,大部分业务代码不用动,改一下依赖和 yml 配置就行。这跟当年 Spring 对 JDBC 做的抽象是一个思路——不是说你不能自己写,而是框架帮你把重复劳动吃掉了。
注意:Spring AI 目前仍在快速迭代中,不同小版本之间 API 有调整的可能。建议在 pom 里锁定具体版本号,不要用范围版本,否则某天重新构建可能直接编译不过。
1.2 整体架构:三层结构各管什么
我把这个 Demo 的结构拆成三层来理解,这样后面看代码的时候不容易迷路。
第一层是配置层,也就是 application.yml。这一层负责告诉 Spring AI 用哪个模型供应商、API 地址在哪、用哪个模型、温度参数多少、超时时间多长。这一层是纯声明式的,不写 Java 代码。
第二层是模型层,由 Spring AI 的 Starter 自动装配出 ChatModel 实例。你引入不同的 starter,它就装配不同的实现。比如引入某个云厂商的 starter,它装配的就是对应厂商的 ChatModel。这一层你基本不用管,Spring 容器启动时自动完成。
第三层是交互层,也就是 ChatClient。这是你写业务代码时直接打交道的对象。它提供了一套链式 API,让你用 fluent 风格构建请求、发送消息、拿到响应。四步链式调用说的就是这一层。
三层之间的关系是:配置层驱动模型层,模型层注入交互层。你改配置,模型层跟着变,但交互层的代码写法基本不变。这就是抽象带来的好处。
1.3 四步链式调用的设计哲学
ChatClient 的链式调用我概括为四步:创建实例 → 设置系统提示 → 传入用户消息 → 调用获取响应。这个顺序不是随便定的,它对应了一次对话请求的完整生命周期。
创建实例是确定"用哪个模型、带哪些默认行为";设置系统提示是确定"AI 扮演什么角色、遵守什么规则";传入用户消息是确定"这次问什么";调用获取响应是"真正发起请求并拿到结果"。每一步都是可选的(除了创建实例和调用),你可以只创建实例然后直接 call,也可以叠加多个提示词。
这种设计的好处是可读性强。你一眼就能看出这次对话的系统提示是什么、用户问了什么,不用去翻请求体构造代码。而且链式调用天然支持复用——你可以先创建一个配置好系统提示的 ChatClient 实例,然后在多个地方复用它,每次只换用户消息。
2. 核心细节解析与实操要点
2.1 yml 配置里每个参数的含义
先看一份最小可用的配置。我以 OpenAI 兼容协议为例,因为大部分云厂商都提供兼容接口,改个 base-url 就能切换。
spring: ai: openai: api-key: ${AI_API_KEY} base-url: https://api.example.com chat: options: model: gpt-4o-mini temperature: 0.7 max-tokens: 2048逐行拆解一下。api-key是身份凭证,我强烈建议用环境变量注入而不是硬编码在 yml 里,原因不用多说,代码一旦提交到仓库,密钥就泄露了。base-url是接口地址,如果你用的是官方服务可以省略这一行,用兼容接口的云厂商就必须显式指定。
model指定具体用哪个模型。这里有个经验:开发调试阶段用便宜的小模型,比如 mini 系列,等提示词调好了再换大模型跑正式效果。我见过有人一上来就用最贵的模型调试,一天烧掉几十块,其实前期的调试根本不需要那么强的能力。
temperature控制输出的随机性,取值 0 到 2 之间。0 表示每次输出几乎一样,适合需要稳定结果的场景比如信息抽取;0.7 左右适合日常对话;1.5 以上会变得很有创意但容易跑偏。做客服类应用建议 0.3 到 0.5,做创意写作可以到 1.0 以上。
max-tokens限制单次响应的最大长度。这个参数很关键,设太小会导致回答被截断,设太大又浪费额度。一般对话场景 1024 到 2048 够用,长文生成场景需要 4096 以上。注意这个值不是越大越好,有些模型对输出长度有硬上限,超过会直接报错。
2.2 依赖引入的取舍
pom 里需要引两个东西:Spring AI 的 BOM 和具体的 starter。
<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>1.0.0</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> </dependency> </dependencies>用 BOM 的好处是统一管理版本,starter 不用写 version,避免版本冲突。这是 Spring 生态的标准做法,没什么好纠结的。
这里有个坑要提醒:Spring AI 的仓库地址可能不在 Maven 中央仓库里,如果拉不下来依赖,需要在 pom 里额外配置 milestone 或 snapshot 仓库。具体地址以官方文档为准,我这里不贴了,因为地址会变。判断方法很简单,如果mvn dependency:resolve报找不到 artifact,八成就是仓库没配。
2.3 ChatClient 的两种创建方式
ChatClient 有两种拿到实例的方式,我两种都用过,各有适用场景。
第一种是自动注入 Builder。Spring 启动时会自动装配一个ChatClient.Builder,你直接注入进来,然后.build()就得到实例。
@Service public class ChatService { private final ChatClient chatClient; public ChatService(ChatClient.Builder builder) { this.chatClient = builder.build(); } }第二种是手动创建。你自己 new 一个 Builder,传入 ChatModel。
ChatClient client = ChatClient.builder(chatModel).build();什么时候用哪种?如果你整个应用只用一个模型,用自动注入最省事。如果你需要同时对接多个模型(比如一个用于对话、一个用于摘要),那就手动创建多个实例,每个绑定不同的 ChatModel。我建议新手先用自动注入跑通,等有明确的多模型需求再改手动。
提示:自动注入的 Builder 是原型作用域的,每次注入都是新实例,所以你可以放心地在构造函数里 build,不用担心共享状态问题。
3. 实操过程与核心环节实现
3.1 第一步:创建 ChatClient 实例
这一步是整个链路的起点。我用自动注入的方式,代码最简洁。
@Service public class ChatService { private final ChatClient chatClient; public ChatService(ChatClient.Builder builder) { this.chatClient = builder .defaultSystem("你是一个专业的技术助手,回答简洁准确。") .build(); } }注意这里我在 build 之前调了defaultSystem。这是设置默认系统提示,意味着这个实例后续所有对话都会带上这个系统提示,不用每次重复设置。这是链式调用的第一个优化点——把不变的配置固化在实例上。
为什么把系统提示放在创建阶段而不是每次调用时设置?因为系统提示通常在一个服务里是固定的。比如客服服务永远是"你是客服",翻译服务永远是"你是翻译"。把它固化在实例上,调用代码就干净了。当然如果你需要动态切换系统提示,也可以在调用时覆盖,后面会讲。
3.2 第二步:设置系统提示(可选但推荐)
如果你没有在创建时设置 defaultSystem,或者需要针对某次调用临时改系统提示,可以在链式调用里加.system()。
String response = chatClient.prompt() .system("你是一个幽默的段子手,用轻松的语气回答。") .user("解释一下什么是依赖注入") .call() .content();.system()和创建时的defaultSystem是什么关系?后者是默认值,前者是本次调用的覆盖值。如果两者都设置了,.system()优先。这个优先级设计很合理,符合"就近原则"。
系统提示的写法有讲究。我总结了几条经验:第一,明确角色,不要只说"你是一个助手",要说"你是一个有十年经验的 Java 架构师";第二,明确输出格式,比如"用不超过三句话回答";第三,明确禁止事项,比如"不要编造不存在的 API"。这三条加上去,输出质量会有明显提升。
3.3 第三步:传入用户消息
.user()就是传入用户消息,这是链式调用里最核心的一步。
String response = chatClient.prompt() .user("Spring AI 的 ChatClient 怎么用?") .call() .content();.user()接受字符串,也接受其他类型。如果你要做多轮对话,需要传入消息列表,可以用.messages()方法。但大多数单轮场景,.user()就够了。
这里有个细节:.user()可以调用多次吗?可以,但效果是追加消息,不是覆盖。如果你连续调两次.user(),会变成两条用户消息。这个行为在构造多轮对话时有用,但单轮场景下容易误用。我建议单轮就用一次,多轮用.messages()显式传列表,语义更清晰。
3.4 第四步:调用并获取响应
.call()是真正发起请求的方法,.content()是从响应里提取文本内容。
String content = chatClient.prompt() .user("你好") .call() .content();.call()返回的是一个ChatResponse对象,里面除了文本内容,还有元数据比如 token 消耗、模型名称、结束原因等。如果你需要这些信息,就不要调.content(),直接拿ChatResponse。
ChatResponse response = chatClient.prompt() .user("你好") .call() .chatResponse(); String text = response.getResult().getOutput().getContent();什么时候需要完整响应?做用量统计、做成本核算、做调试的时候。日常业务代码里,.content()直接拿字符串就够了。
3.5 完整可运行的最小示例
把四步串起来,一个完整的 Controller 长这样:
@RestController @RequestMapping("/chat") public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient.Builder builder) { this.chatClient = builder .defaultSystem("你是一个简洁的技术助手。") .build(); } @GetMapping("/ask") public String ask(@RequestParam String question) { return chatClient.prompt() .user(question) .call() .content(); } }启动项目,访问/chat/ask?question=你好,就能看到模型返回的内容。整个核心代码不到二十行,这就是 Spring AI 抽象带来的效率。
3.6 流式响应怎么改
上面是同步阻塞的写法,用户要等模型全部生成完才能看到结果。如果响应内容长,等待体验很差。改成流式只需要把.call()换成.stream()。
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> stream(@RequestParam String question) { return chatClient.prompt() .user(question) .stream() .content(); }.stream()返回的是Flux<String>,配合 SSE 就能实现打字机效果。注意produces要设成text/event-stream,否则浏览器不会按流式处理。这个改动很小,但用户体验提升很大,我建议对话类应用默认就用流式。
4. 常见问题与排查技巧实录
4.1 启动报错找不到 ChatModel Bean
这是新手最常见的问题。报错信息大概是No qualifying bean of type 'ChatModel'。原因通常是三种:一是 starter 没引对,二是 yml 配置的层级写错了,三是 api-key 为空导致自动装配跳过。
排查顺序:先看 pom 里 starter 的 artifactId 对不对,再看 yml 里spring.ai下面的层级是不是和 starter 匹配,最后检查 api-key 环境变量有没有真的注入进去。我遇到过有人 yml 写对了但环境变量名拼错,结果 api-key 是空字符串,自动装配直接跳过,报的就是找不到 Bean。
4.2 调用返回 401 或 403
认证失败。先确认 api-key 有没有多余的空格或换行,从环境变量读的时候特别容易带进来。再确认 base-url 有没有写错,有些兼容接口的路径需要带/v1,有些不需要,这个要看具体厂商的文档。最后确认模型名称对不对,有些厂商的模型名和官方不一样。
4.3 响应被截断
如果回答说到一半就没了,八成是max-tokens设小了。把它调大试试。但要注意,调大之后如果还是截断,可能是模型本身的输出上限到了,这时候需要在提示词里要求模型分点回答或者精简表达。
4.4 中文乱码
这个在流式响应里偶尔遇到。检查两处:一是produces有没有设对,二是响应头的字符集。Spring 默认用 UTF-8,一般不会有问题,但如果前面有网关或者反向代理,可能会被改掉。排查方法是直接看原始响应字节,确认是编码问题还是内容问题。
4.5 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 找不到 ChatModel Bean | starter 未引入或配置层级错误 | 检查 pom 和 yml 层级 |
| 401/403 | api-key 无效或 base-url 错误 | 检查密钥和地址 |
| 响应截断 | max-tokens 过小 | 调大参数或优化提示词 |
| 中文乱码 | 字符集配置问题 | 检查 produces 和响应头 |
| 启动慢 | 首次拉取依赖或模型列表 | 检查网络和仓库配置 |
| 流式无输出 | produces 未设 event-stream | 检查注解配置 |
4.6 几个我踩过的坑
第一个坑是在循环里反复创建 ChatClient。我一开始图省事,每次请求都 new 一个 Builder 再 build,结果发现性能很差。后来改成在构造函数里创建一次,复用实例,性能立刻上来了。ChatClient 是线程安全的,可以放心复用。
第二个坑是系统提示写太长。我一开始把一大堆规则塞进 defaultSystem,结果每次请求都带着这一大段,token 消耗很高。后来把固定规则精简,动态规则放到调用时设置,成本降了不少。
第三个坑是忽略超时配置。默认超时可能很长,模型响应慢的时候请求会一直挂着。建议在 yml 里显式配置连接超时和读取超时,具体参数名看 starter 文档。我一般设连接 10 秒、读取 60 秒,大部分场景够用。
第四个坑是把 api-key 提交到了仓库。这个不用多说了,血的教训。现在我的做法是本地用.env文件,仓库里只放.env.example,CI 环境用密钥管理服务注入。
4.7 提示词调优的实操心得
系统提示的质量直接决定输出质量。我总结了一个三段式模板:角色定义 + 任务约束 + 输出格式。举个例子:
你是一个资深 Java 后端工程师。 回答技术问题时,优先给出可运行的代码示例,代码要带注释。 输出格式:先用一句话总结,再给代码,最后补充注意事项。这个模板比单纯写"你是一个助手"效果好很多。角色定义让模型知道用什么知识库,任务约束让它知道重点在哪,输出格式让结果结构化。我实测下来,同样的模型,加上这三段之后,回答的可用性提升很明显。
另外,温度参数和提示词要配合调。提示词约束越严格,温度可以适当调高一点增加灵活性;提示词比较宽松,温度就要调低避免跑偏。这两个参数不是独立的,要一起看。
5. 扩展方向与进阶思路
5.1 多轮对话怎么实现
单轮对话跑通之后,下一步自然是多轮。核心是把历史消息带上。Spring AI 提供了ChatMemory抽象,可以自动维护对话历史。
ChatClient client = builder .defaultSystem("你是一个技术助手。") .defaultAdvisors(new MessageChatMemoryAdvisor(new InMemoryChatMemory())) .build();加上这个 Advisor 之后,同一个会话 ID 的对话会自动带上历史。会话 ID 通过.advisors()传入。这个机制比自己手动维护消息列表省事很多,但要注意内存版的历史存在内存里,重启就没了,生产环境需要换成持久化实现。
5.2 结构化输出怎么拿
有时候你不想拿字符串,想直接拿对象。Spring AI 支持.entity()方法,传入目标类型,它会自动把模型输出解析成对象。
record Answer(String summary, List<String> points) {} Answer answer = chatClient.prompt() .user("总结一下 Spring AI 的三个优点") .call() .entity(Answer.class);这个功能很实用,但有个前提:模型输出必须是合法 JSON。所以提示词里要明确要求输出 JSON 格式,否则解析会失败。我一般会在系统提示里加一句"输出必须是合法 JSON,不要包含 markdown 代码块标记"。
5.3 后续可以怎么扩展
跑通基础对话之后,可以往几个方向扩展。一是接入 RAG,把私有文档向量化后作为上下文注入,让模型基于你的资料回答。二是加工具调用,让模型能调用外部 API 查天气、查数据库。三是做多模型路由,简单问题用小模型、复杂问题用大模型,控制成本。这几个方向 Spring AI 都有对应的抽象,但每个都值得单独写一篇,这里就不展开了。
我个人在实际操作中的体会是,Spring AI 最大的价值不是帮你省了多少代码,而是帮你建立了一套统一的思维模型。你理解了 ChatClient、ChatModel、Advisor 这几个概念之后,换任何模型供应商都是换配置的事,业务代码基本不动。这种可迁移性,比省下来的那几十行胶水代码值钱得多。