1. 为什么我建议你现在就试 Spring AI
先说结论:Spring AI 这个项目,把大模型聊天从一个“需要自己拼 HTTP 请求、处理 JSON、维护会话状态的麻烦事”变成了一个“只要你会 Spring Boot,十分钟就能跑通”的常规后端功能。我最早做智能聊天 Demo 的时候,用的是传统的 RestTemplate 去调大模型接口,要自己处理请求体、各种返回格式、超时重试,多轮对话还得自己维护历史消息,那叫一个酸爽。后来看到 Spring AI 出来,第一反应是“又一个封装库?”,真正试过之后才明白,它真正有价值的不是省那几十行 HTTP 代码,而是把ChatClient、ChatModel、消息历史、Prompt Template、结构化输出、甚至函数调用这些能力,用一套统一模式全部收编了。
Spring Boot 3 集成 Spring AI,解决的核心问题很明确:让普通 Java 后端工程师在不太了解大模型 API 细节的前提下,也能快速构建一个可维护、可测试、可扩展的智能聊天服务。适合谁?适合已经会用 Spring Boot 做接口、但对大模型还是一脸懵的开发者,也适合那些想在企业内部快速做一个私有知识库问答机器人、客服助手的团队。如果你是刚入门 Spring Boot 的小白,我建议你先把 Spring Boot 的基本自动装配玩熟,再来看这个,体验会好很多。
这篇我会从工程搭建、基础对话、流式输出、多轮记忆、函数调用、接口接入这六块往下拆,每一块我都会讲清楚为什么要这么做,以及我实际踩过的坑。文章很长,但保证没有一个步骤是废话。
2. 工程搭建与依赖准备
2.1 版本选型的那些坑
用 Spring AI 最怕的就是版本不匹配。Spring AI 本身迭代速度极快,从 0.8.x 到 1.0.0,不同版本之间 API 变化很大,甚至有些类的包路径都换了。我试过照着网上 0.8 版本的教程写到 1.0 的项目里,结果ChatClient的创建方式完全变了,编译都过不去。
我的建议是直接用 Spring Boot 3.2 或 3.3 以上版本,Spring AI 用 1.0.0 系列,两者配合最稳。还有一点要注意,Spring AI 的 Maven 仓库并不全在 Maven Central 上,需要显式配置仓库。早期版本用的是spring-milestones,到 1.0 之后正式版可以走 Central,但你后面如果要用到一些 AI 模型的扩展包,还是把官方仓库加上比较保险。我一般会在pom.xml里同时配置maven-central和spring-milestones,避免某些依赖解析不到。
2.2 最小化 Maven 依赖
创建一个新的 Spring Boot 3 项目,然后在pom.xml里加上最核心的依赖:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.3.4</version> <relativeParent/> </parent> <properties> <java.version>17</java.version> <spring-ai.version>1.0.0</spring-ai.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> <version>${spring-ai.version}</version> </dependency> </dependencies>这一步不需要spring-ai-core单独引入,因为spring-ai-openai-spring-boot-starter已经把所有核心类都传递进来了。如果你是接其他厂商的模型,把openai换成对应的 starter 即可,后面的核心用法大同小异。
2.3 配置文件怎么写
Spring AI 的配置逻辑很简单:你只要在application.yml里填好模型提供方的基础地址和密钥,ChatModel相关的 Bean 就会自动装配好。我用的配置长这样:
spring: ai: openai: base-url: https://your-api-endpoint api-key: ${LLM_API_KEY} chat: options: model: gpt-4o-mini temperature: 0.7这里我强调几个容易被忽略的点。第一,base-url一定要填对,如果你用的是中转网关或者公司内网部署的模型服务,这个地址决定了你能不能连上。第二,api-key建议通过环境变量注入,别硬编码在配置里,否则代码一提交,密钥就泄露了。第三,temperature这个参数,做闲聊机器人可以调高一点(0.8 左右),做知识库问答我建议调低(0.2 到 0.3),否则模型容易“自由发挥”,回答不够严谨。
3. 第一轮对话实现
3.1 从 ChatModel 到 ChatClient
早期 Spring AI 官方主推的是ChatModel,你可以把它当成一个“大模型客户端”,直接调用call()方法发消息。但实际开发里,我更推荐用ChatClient,因为它在可读性和链式调用方面做得更好。你可以把ChatModel理解为底层 JDBC,ChatClient相当于更高层次的封装,用起来更顺手。
在 Spring Boot 里,你可以这样注入:
@Configuration public class ChatClientConfig { @Bean ChatClient chatClient(ChatClient.Builder builder) { return builder.build(); } }如果你用的是 1.0.0 之后的版本,ChatClient不再像旧版本那样用ChatClient.create(chatModel)创建,而是提供了一个Builder,Spring Boot 的自动配置会把它准备好,你只管注入并通过构建器生成实例。这是比较容易踩的版本坑,代码报错时先看下是不是用了过时的 API。
3.2 写一个最简单的聊天接口
接下来写一个 Controller,接收用户的消息,返回模型回复:
@RestController @RequestMapping("/api/chat") public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient chatClient) { this.chatClient = chatClient; } @PostMapping("/sync") public String chat(@RequestBody String message) { return chatClient.prompt() .user(message) .call() .content(); } }这个接口的精髓就三行:prompt()表示开启一轮提示词构建,user()表示用户消息,call()表示同步发起请求,content()则是把模型返回的内容转成字符串。到这里为止,你的第一个“智能聊天”已经跑通了。我用 postman 测过,延迟大约在两三秒,取决于模型服务端的响应速度。
3.3 同步调用与流式调用的取舍
上面代码是同步调用,适合哪些场景呢?适合调用方不着急、可以等待完整结果回来的场景。但如果你要做那种“打字机效果”的聊天窗口,用户看着文本一个字一个字蹦出来,那必须走流式。原因很简单:大模型生成一段话可能需要几秒到几十秒,如果让用户一直等空白页面,体验极差。
我个人的经验是:接口内部逻辑简单的,先用同步调用跑通;真正上生产、做前端交互,直接切流式。同步调用最大的问题是超时控制很麻烦,如果后端网关超时时间只有 10 秒,模型生成得慢一点,整个请求就被断掉了。流式响应则不同,它可以边生成边往客户端推,前端感受到的延迟会低很多。
4. 流式输出:让聊天有“打字机”效果
4.1 WebFlux 还是 MVC + SSE
你可能会问,实现流式输出是不是必须引入 WebFlux?分情况。如果你整个项目都已经用了 WebFlux,那直接用Flux<String>返回最自然。但如果你像我一样,项目主体是 Spring MVC,那完全可以用SseEmitter或者ResponseBodyEmitter来达到同样的效果。Spring AI 本身支持流式 Token 回调,你只需要把回调结果转成 SSE 格式推给前端即可。
我最常用的做法是直接在 Controller 返回Flux<String>,但项目里加一个spring-webflux依赖。会不会冲突?不会,Spring MVC 和 WebFlux 同时存在时,Spring 会优先处理@Controller的请求,你只要不混用编程模型就没问题。如果你想完全避开 WebFlux,可以用SseEmitter,样例我下面也会给。
4.2 用 ChatClient 实现流式接口
用ChatClient写流式特别直白:
@PostMapping(value = "/stream", produces = "text/event-stream") public Flux<String> streamChat(@RequestBody String message) { return chatClient.prompt() .user(message) .stream() .content(); }.stream()返回的是Flux<String>,每个元素是模型生成的一个文本片段。Spring 会把Flux自动转成 SSE 响应,前端用EventSource或者 fetch 流式读取就能实现打字机效果。
这里有个细节,produces = "text/event-stream"要写上,否则 Spring 返回的 Content-Type 可能是application/json,前端解析就乱了。另一个细节是,Flux 默认在事件线程上执行,如果你在里面传的是耗时操作,建议配置一下线程池,避免占用容器线程。
4.3 SSE 事件格式化
如果你直接用.content(),默认每个元素是纯文本片段,前端收到后直接往页面上拼就行。但如果是做聊天产品,往往需要把“角色”和“内容”一起传。Spring AI 提供了.content()返回带类型信息的方式,你可以这样拿:
flux = chatClient.prompt().user(message).stream().content();如果要获取更细的信息,比如角色、索引,可以用.chatResponse():
return chatClient.prompt() .user(message) .stream() .chatResponse() .map(response -> { String role = response.getResult().getOutput().getRole().name(); String content = response.getResult().getOutput().getContent(); return "data: {\"role\":\"" + role + "\",\"content\":\"" + content + "\"}\n\n"; });这样前端就能拿到结构化数据,方便渲染气泡和头像。提醒一下,SSE 格式要求事件之间用空行分隔,\n\n别漏。
5. 多轮对话与上下文记忆
5.1 为什么需要 Memory
大模型本身是没有“记忆”的,它处理的是你传入的上下文。你每调用一次prompt().user("你好"),模型只知道你说了“你好”,并不知道你之前问过什么。所以实现多轮对话,核心是把你和用户的聊天记录拼在每次请求里,一起发给模型。
拿一个实际场景举例,用户先问“帮我写一段 Java 读取文件的代码”,接着又说“能不能改成用 BufferedReader”,如果模型拿不到前文,“能不能改成”这句话就是一句不完整的话,它根本不知道你在说什么。所以,多轮记忆不是可选项,是聊天产品的必选项。
5.2 手动维护消息列表
Spring AI 里维护会话历史的方式很简单:你可以维护一个List<Message>,把每次的用户消息和助手回复都塞进去,下一次请求时把整个列表作为 Prompt 发出去。
List<Message> history = new ArrayList<>(); // 用户发消息后 history.add(new UserMessage("帮我写一段读取文件的代码")); // 调用模型 ChatResponse response = chatClient.prompt() .messages(history) .call() .chatResponse(); // 拿助手回复 String reply = response.getResult().getOutput().getContent(); history.add(new AssistantMessage(reply));这里有个内存泄漏隐患要注意:如果消息列表无限增长,Token 上限很快会爆。GPT 类模型对输入长度有严格限制,超出后不是被截断,就是直接报错。解决方案很简单,设置一个最大轮数,比如保留最近 20 条,超出就丢弃最早的。你自己维护要写这个逻辑有点烦,但至少逻辑清晰,适合学习阶段理解原理。
5.3 用 ChatMemory 组件管理上下文
如果你不想手写滑动窗口,Spring AI 提供了ChatMemory相关的抽象。举个例子,你可以基于InMemoryChatMemory保存每个会话的历史:
var chatMemory = new InMemoryChatMemory(); var advisor = new MessageChatMemoryAdvisor(chatMemory, "default", 20); String answer = chatClient.prompt() .user(message) .advisors(advisor) .call() .content();Advisor 是 Spring AI 里一个非常像 AOP 的概念,它会在真正调用模型之前,自动从ChatMemory里取出历史消息,拼接进当前 Prompt,并在响应结束后把新消息写回去。这一步做完,你就不用手动维护history了。但要注意,InMemoryChatMemory是存在内存中的,单机跑没问题,一旦多实例部署,不同请求打到不同实例上,用户的历史就会“丢失”。真要上生产,得换基于 Redis 或数据库的记忆实现,或者自行实现ChatMemory接口。
5.4 会话 ID 的设计思路
前面提到MessageChatMemoryAdvisor第二个参数是会话标识,这个要好好设计。最简单的是从前端传一个sessionId,每次请求都带上,后端用它来查历史。如果你的系统已经有用户体系,建议直接用userId + sessionId作为标识,避免跨用户串号。
我踩过一个坑,就是早期偷懒,直接用用户 ID 当会话标识,结果用户开了两个聊天窗口,A 窗口问的事情在 B 窗口也能看到,内容串得一塌糊涂。后面改成“每次进入页面创建一个 UUID 作为 sessionId”才解决。
6. 函数调用:让模型拥有“动手”能力
6.1 函数调用到底解决什么问题
智能聊天如果只是纯文本问答,价值有限。真正让 AI 变得有用的,是让它能调用你写的函数。比如用户说“帮我查一下明天北京的天气”,模型本身并不知道天气数据,但它可以在识别出意图后,触发一个getWeather函数,把你的业务系统返回的数据再组织成自然语言回复用户。这整个过程,就是函数调用。
你把它想象成模型的“手”和“眼睛”:模型负责思考和表达,你负责提供执行能力。Spring AI 对函数调用的支持做得相当顺滑,你只需要在@Bean里注册一个Function,然后通过 prompt 的.functions("getWeather")把它传给模型。
6.2 定义一个业务函数
先写一个普通的方法,参数和返回类型最好都设计成一个 Java Bean,因为 Spring AI 需要把函数的参数描述发给模型,让它自动生成匹配的 JSON 参数。
public record WeatherRequest(String city) {} public record WeatherResponse(String city, String condition, int temperature) {} public class WeatherFunction implements Function<WeatherRequest, WeatherResponse> { @Override public WeatherResponse apply(WeatherRequest request) { // 真实场景在这里调用天气服务 return new WeatherResponse(request.city(), "晴", 26); } }然后在配置类里注册:
@Bean public FunctionCallback weatherFunction() { return FunctionCallbackWrapper.builder(new WeatherFunction()) .withName("getCurrentWeather") .withDescription("根据城市名获取当前天气") .withInputType(WeatherRequest.class) .build(); }6.3 在 Prompt 中绑定函数
调用时这样写:
String answer = chatClient.prompt() .user("北京今天天气怎么样?") .functions("getCurrentWeather") .call() .content();模型收到这句话后,会决定是否调用getCurrentWeather,如果需要,Spring AI 会自动执行函数并把结果回传。最终模型会综合函数返回的数据,生成类似“北京今天晴,气温 26 摄氏度”的回答。
这里有一个很容易踩的坑:不是所有模型都支持函数调用,也不是所有模型支持效果都好。我用一些本地开源模型试过,经常出现“模型以为自己在调用函数,但参数生成错误”的情况。做生产项目之前,先拿你的模型跑一轮函数调用测试,别等上线才发现不支持。
7. 基于本地知识库的智能问答
7.1 从“胡编乱造”到“有据可循”
如果你只是拿模型做开放闲聊,那没问题,它想象力越丰富越好。但如果你想让机器人回答你的产品文档、公司制度、内部 FAQ,直接裸调模型的后果就是一本正经地胡说八道。比如用户问“咱们公司的年假政策是什么”,模型可能编一个完全不存在的规定,这在企业内部场景是绝对不能接受的。
解决思路就是 RAG(检索增强生成):先从资料库里检索出和用户问题最相关的片段,再把片段塞进 Prompt,让模型基于这些素材回答。这样做有两个好处:一是回答有事实依据,幻觉大幅减少;二是你不需要为了新知识重新训练模型,改文档就能更新知识库,成本低很多。
7.2 文本切块与向量化
Spring AI 提供了若干文档处理工具,默认流程是:读取文档 -> 切块 -> Embedding(向量化)-> 存入向量数据库。我一般在代码里用一个SimpleVectorStore先跑通,后续再换真正的高性能向量库,比如Pgvector或者Milvus。
关键的参数是切块的大小和重叠度。切太大,检索粒度太粗,问题可能被淹没在长文本里;切太小,上下文不够完整,模型理解不了周边信息。我常用的配置是每个块 500 个 Token,块与块之间重叠 50 个 Token,这样在上下文完整性和检索准确率之间比较平衡。
var tokenizer = new OpenAiTokenizer(); var document = new Document("Spring AI 支持函数调用,可以将外部工具集成到模型中……"); List<Document> chunks = new TokenTextSplitter(500, 100, tokenizer).apply(List.of(document)); // 向量化并存储 var vectorStore = new SimpleVectorStore(embeddingModel); vectorStore.add(chunks);7.3 问答接口怎么写
接下来实现一个带知识库的问答接口:
@PostMapping("/faq") public String faq(@RequestBody String question) { return chatClient.prompt() .user("请根据以下资料回答问题:\n" + searchFromVectorStore(question)) .call() .content(); }更规范的方式是走 Advisor,让系统自动拼接召回内容。不过原理都一样:把检索到的文本塞给模型,让模型基于证据回答。建议在 Prompt 里加上“如果资料中没有相关内容,请明确说不知道,不要编造”,这句话对抑制幻觉效果非常明显。
7.4 向量数据库选型建议
如果你的聊天服务只是个人 Demo、或者公司内部几十个用户的场景,SimpleVectorStore就够用。它默认是把向量数据放到本地文件里,不依赖额外组件,开发环境特别方便。如果你要做高并发生产服务,就不建议用了,检索性能和持久化都不理想。
我自己在公司的项目里用的是 Postgres 加pgvector扩展,原因很简单:系统本来就在用 Postgres,不需要引入一个新的中间件,运维成本低。数据量很大会优先考虑 Milvus 这类专门的向量库。选型的核心指标是:CRUD 的顺手程度、检索性能、团队是否会维护。
8. 常见问题与排查技巧实录
8.1 常见报错速查表
我把实际开发里遇到的高频问题整理成一个表格,遇到报错直接对照查:
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
启动时报ChatClient.Builder不存在 | 版本太旧或依赖缺失 | 升级 spring-ai 到 1.0.0,引入对应 starter |
| 调用时报 API 密钥无效 | 配置项没生效或环境变量为空 | 检查spring.ai.openai.api-key是否读取成功 |
| 响应中文乱码 | 模型返回编码或前端解析问题 | 确保请求头Accept-Charset与返回 Content-Type 正确 |
| 请求超时 | 模型处理时间过长或网络瓶颈 | 延长超时时间,或改用流式接口 |
| 函数调用没有触发 | 函数名和描述不清晰,或模型不支持 | 换更强的模型,或者把函数描述写得更详细 |
| 多轮对话历史串了 | 会话 ID 设计不当 | 每次会话用独立 ID,按用户隔离 |
| Token 超限报错 | 消息列表无清理机制 | 开启 Advisor 并把窗口大小调小 |
8.2 排查思路:先看 Spring AI 的自动配置
遇到任何奇怪问题,第一件事去看spring-ai-openai-spring-boot-starter的自动配置日志。启动的时候打印的 INFO 日志里,会显示SpringAI: LLM model is ...、SpringAI: embedding model is ...之类的信息,如果它没加载出来,大概率是配置没生效,从配置源头查起,别瞎改代码。
8.3 压测并发时的坑
还有一次我做并发测试,发现线程一多,响应时间就变得极慢。后来排查到原因:默认情况下RestClient使用的连接池有限,大量请求排队等待连接,导致吞吐量上不去。解决办法是给 Spring Boot 配置连接池参数,或者换成异步调用。这不算 Spring AI 本身的坑,但是集成后特别容易踩到,提醒大家注意。
9. 部署与后续扩展
9.1 打成可执行 Jar 的小细节
Spring AI 项目本身就是一个标准 Spring Boot 应用,部署方式没什么特殊的。但我在这里强调一个容易被忽略的问题:如果你的项目里用了SimpleVectorStore,它把向量数据以 JSON 文件形式存在本地磁盘,打包成 Docker 镜像时,记得把数据目录挂载成卷,否则容器重启后你的知识库就没了。
docker run -v /host/data:/app/data -p 8080:8080 your-image9.2 接口层面的安全与限流
智能聊天接口一旦暴露到公网,就会面临滥用风险,毕竟每次调用大模型都花真金白银。我建议至少加一层接口鉴权,比如要求 Header 里带 token,然后加上简单的限流,例如同一个 IP 每分钟最多请求 10 次。生产上还可以把每次调用的 Token 消耗记下来,做预算监控。
9.3 从聊天到 Agent
当你能把ChatClient、函数调用、记忆、知识库这几个能力组合起来,你已经不是在做一个“聊天机器人”了,而是在做一个能完成任务的初级 Agent。比如用户说“帮我订一个明天下午两点的会议室”,模型第一步查会议室空闲情况,第二步帮你预定,第三步发消息通知参会人,每一步都可以靠一个函数调用完成,而模型负责规划整个流程。后续你可以继续深入 Spring AI 的 Agent 生态,实现更复杂的任务编排。我个人在体验这些功能时最大的感受是:大模型本身很像一个聪明但缺乏执行力的实习生,而 Spring AI 给了你一套把实习生快速接入现有业务系统的规范流程。
最后再说一个小技巧:调试 Prompt 时,别动不动就重写整个 Prompt,把核心指令拆成system和user两部分,前者负责设定角色和行为边界,后者负责每次问答的具体内容。这样你既保留了自己定义角色的灵活性,也能在多轮对话里避免历史消息把系统指令“挤掉”。Spring AI 的prompt().system(...)就是做这件事的,多用它,你会少踩不少 Prompt 异常混乱的坑。