Spring AI入门实战:Java开发者如何快速集成大模型
很多Java开发者看到“AI应用”这四个字,第一反应是:这玩意儿不是Python的活儿吗?我写后端Java的,怎么去凑这个热闹?实话说,我一开始也是这么想的。但Spring AI出现之后,这个局面变了——你不需要去啃Python那套生态,不需要重学PyTorch、Transformers,也不用自己去拼HTTP请求去调大模型API,直接用你熟悉的Java、Spring Boot那一套,就能把大模型集成到业务系统里。
这篇文章就是给你准备的Spring AI入门实战。我会从工程搭建讲到Prompt模板、结构化输出、工具调用,再聊到RAG和Agent雏形,最后把生产环境容易踩的坑也给你捋一遍。适合那些准备在Java项目里引入AI能力、但还不太确定从哪下手的后端开发同学,也适合那些只听说过Spring AI、想看看它到底能干什么的初学者。
1. 为什么Java开发者需要Spring AI
1.1 AI集成在Java这边到底卡在哪了
过去几年,想要在Java项目里调用大模型,基本就是自己写一个HttpClient,往OpenAI、通义千问这些接口上怼JSON。听起来不难,但真做起来就全是零零碎碎的活:
- 每个模型厂商的报文格式不一样,OpenAI的、智谱的、阿里的,参数名和结构都有微妙差别;
- 每次调用都要自己拼Prompt,业务一变就得改代码;
- 模型返回的是一大段自然语言文本,想把它变成Java对象,还得自己写解析逻辑;
- 如果你想做多轮对话,就得自己维护历史消息列表,手动拼接上下文;
- 想做RAG或者Agent那套,工作量大到基本等于自己造轮子。
这些痛点,LangChain在Python那边很早就解决了,而Java这边的对应方案,直到Spring AI面世才算真正形成气候。它的定位就是:给Java生态一个大模型集成标准。
1.2 大模型访问的“统一抽象层”
Spring AI做的事情,放在Spring家族里其实特别顺理成章。Spring Data统一了数据库访问,Spring Cloud统一了微服务组件,Spring AI则是统一了大模型的访问方式。
你面向的是一套统一的ChatClient API,底层不管是接OpenAI、DeepSeek、通义千问,还是本地跑一个Ollama,都通过配置切换模型厂商。就好比你写SQL不用关心底层是MySQL还是PostgreSQL,写Spring AI代码也不用关心大模型服务商是谁。
我用过一个很直白的比喻:Spring AI就像大模型圈的JDBC。它定义了统一的规范,把各家模型的差异挡在接口外面,你的业务代码只跟抽象层打交道。要是哪天想换模型,改个配置就能搞定,不用把业务逻辑重写一遍。
1.3 它能帮你省掉哪些脏活
具体点说,Spring AI帮你省掉的并不是“调API”这一步,而是整个工程化链条:
- Prompt管理:支持类似Thymeleaf的模板语法,把提示词做成模板,动态传参;
- 输出控制:输出解析成Java Bean,让模型返回的内容可以直接被程序消费;
- 对话记忆:内置ChatMemory和MessageWindow,多轮对话上下文不用自己维护;
- 结构化输出:BeanOutputConverter能直接让模型按你的JSON Schema返回,然后转换成对象;
- RAG全套:文档读取、切分、向量化、检索、注入上下文,一条链路都给你接好了;
- 工具调用:用注解就能把Java方法暴露给模型,实现Agent的基础能力;
- 可观测性:和Micrometer Tracing集成,调用链路上能看到模型调用的耗时和Token消耗。
这些原本来回折腾的事,现在一个框架全包了,这就是我觉得Java开发者真的应该开始学Spring AI的原因。
2. 起手式:15分钟搭出你的第一个Spring AI应用
2.1 准备工作与依赖引入
先说版本。目前Spring AI 1.0.0 GA已经发布,BOM坐标是spring-ai-bom,可以直接用中央仓库的版本,不需要额外配置什么特殊仓库。Spring Boot建议用3.3.x以上。
工程本身是个普通的Spring Boot Web项目,关键依赖就一个:
<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.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> </dependency> </dependencies>注意,spring-ai-openai-spring-boot-starter这个starter兼容所有“OpenAI协议”的模型服务。国内很多模型厂家的API都兼容这个协议,所以你并不一定非得用OpenAI官方服务,换个配置就能接DeepSeek、通义千问这些。
2.2 配置文件里的门道
接着是application.yml,我习惯把模型相关的配置单独放一块:
spring: ai: openai: base-url: https://api.deepseek.com api-key: ${AI_API_KEY} chat: options: model: deepseek-chat temperature: 0.7几个关键点说一下:
- base-url一定要配。默认是OpenAI官方地址,如果你用的是第三方兼容服务,不换成自己的地址,请求直接404;
- api-key绝对不要硬编码。用环境变量或者配置中心管理,我在后面“生产环境避坑”那节会仔细讲;
- temperature是温度系数。值越低回答越保守、越稳定,适合提取信息之类的任务;值越高回答越发散、越有创造性,适合文案生成。一般聊天场景0.7就行,结构化提取建议调到0.2以下。
如果你不想注册一堆云厂商账号,还有个更省事的选择:本地装一个Ollama,然后引入spring-ai-ollama-spring-boot-starter,配置里把base-url指向http://localhost:11434,模型填qwen2.5之类的本地模型就行。好处是彻底免费、离线可用,适合日常学习调试。
2.3 呼吸之间:第一个对话接口
依赖和配置就绪后,写代码就很快了。Spring AI的核心入口是ChatClient,类似Web开发里的RestClient,是你跟大模型交互的主通道。
@RestController public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient.Builder builder) { this.chatClient = builder.build(); } @GetMapping("/chat") public String chat(@RequestParam String message) { return chatClient.prompt() .user(message) .call() .content(); } }启动项目后,访问http://localhost:8080/chat?message=你好,就能看到模型返回的文字。到这里,一个能跑起来的Java AI应用就诞生了。
你可能会好奇这个ChatClient到底是什么时候被创建出来的。Spring AI的自动配置会在应用启动时,根据spring.ai.openai下的配置,自动创建对应的ChatModelBean,再通过ChatClient.Builder帮我们组装好客户端。你只管在需要的地方注入ChatClient就行。
3. 把Prompt这块玩明白
3.1 System Prompt和User Prompt的区别
很多初学者不知道,给模型的输入其实可以拆成两层:系统提示词(System Prompt)和用户提示词(User Prompt)。
System Prompt是给模型立人设、定规则的,它对本次对话全局生效;User Prompt则是用户真正的输入。
举个例子,同样一个问题“什么是AQS?”,不同的System Prompt会带来完全不同的回答:
- 不设System Prompt:模型可能给出通用解释,也可能啰嗦一堆;
- 设置“你是资深Java面试官,回答要精炼、突出重点”:回答就会更结构化,像面试答案;
- 设置“你是初学者导师,尽量用生活化例子解释”:回答就会更通俗。
在实际业务里,System Prompt往往是你的业务规则,比如“你是客服助手,只回答商品相关问题,不知道就说不清楚”,这是产品逻辑的一部分,比用户输入本身更值得花时间去打磨。
3.2 用PromptTemplate做动态提示词
在Spring AI里写Prompt,我强烈建议用模板,而不是在代码里拼字符串。模板语法和Thymeleaf很像,用{变量名}做占位符。
String systemPrompt = """ 你是一位{role},请用{language}回答用户问题。 回答要求:简洁、准确、不超过{maxLength}字。 """; @GetMapping("/chat/template") public String chatWithTemplate(@RequestParam String question) { PromptTemplate template = new PromptTemplate(systemPrompt, Map.of("role", "Java面试导师", "language", "中文", "maxLength", 200)); Prompt prompt = template.create(); return chatClient.prompt(prompt).user(question).call().content(); }用模板有几点好处:提示词的调整不需要重新编译代码;模板可以放到配置中心统一管理;团队成员可以不懂代码但会改Prompt。
这里顺便提一个细节,chatClient.prompt(prompt).user(question)这种写法,实际上是同时发送两条消息:一条是最初构造好的System消息,一条是当前的User消息。如果你需要多轮上下文,还得靠ChatClient的Advisor机制,稍后我会讲到。
3.3 打字机效果:流式输出
业务里如果需要“打字机”效果,比如AI逐字回复,就不能用同步调用了,得用流式接口。Spring AI直接返回Flux,天然适配WebFlux。
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> streamChat(@RequestParam String message) { return chatClient.prompt() .user(message) .stream() .content(); }前端用EventSource或者fetch流式读取就行。这里的底层原理并不神秘——大模型的API本身支持SSE(Server-Sent Events),模型输出是分批推送的。Spring AI做的事情是把这些分批的Token用Flux包装起来,让你能以一种响应式的方式消费。
3.4 多轮对话:别把上下文丢给前端处理
如果每次请求都是独立的,模型是没有记忆的,上一轮的对话内容它完全不知道。要做聊天机器人,必须维护上下文。
Spring AI给了一个非常简洁的方案,使用ChatMemory和MessageWindowAdvisor。我一般这样配置:
@Bean ChatClient chatClient(ChatClient.Builder builder) { return builder .defaultAdvisors(new MessageWindowChatMemoryAdvisor( new InMemoryChatMemory(), 10, WindowType.LAST_N)) .build(); }这里的10表示保留最近10轮对话。模型API的上下文窗口是有限的,不能无限叠加历史消息,所以要按窗口大小滑动淘汰旧消息。
从成本角度看,每轮对话都会把历史消息重新发给模型一遍,Token消耗是成倍增长的。消息窗口越大,成本越高,响应越慢。所以一般聊天场景10轮左右比较合理,需要更长期记忆的场景,得上RAG或者外部存储的那种方案,而不是靠窗口无限拉长。
4. 让AI回答可以被程序消费:结构化输出
4.1 为什么需要结构化输出
聊天场景下,模型返回一大段自然语言没问题,反正人看得懂。但如果你要把模型接到业务流程里,比如从用户留言中提取工单信息、从合同里抓关键字段,那模型返回的就不能是“一段流畅的中文”,必须是规整的JSON。
一种土办法是告诉模型“请返回JSON”,然后自己用Jackson解析。听起来可行,但实际用起来满是坑:模型输出经常带多余的说明文字、Markdown代码块标记,偶尔还会因为和上下文拼接导致JSON格式断裂,解析直接失败。
Spring AI解决这个问题的方案是BeanOutputConverter。
4.2 用BeanOutputConverter直接得到Java对象
这个类的思路是:先根据你给的Java类的字段定义,动态生成一个JSON Schema,然后把这段Schema作为约束放在Prompt里让模型输出;拿到输出后,再自动用ObjectMapper把JSON反序列化成你的Java对象。
看代码更直观。假设我要从一段用户反馈里提取工单信息:
public record OrderExtraction( String orderId, String customerName, String complaintType, String description, int priority ) { } @GetMapping("/extract") public OrderExtraction extract(@RequestParam String feedback) { var converter = new BeanOutputConverter<>(OrderExtraction.class); String formatInstruction = converter.getFormat(); String message = """ 从用户反馈中提取工单信息,只返回JSON,不要额外输出。 反馈内容:%s 输出格式要求:%s """.formatted(feedback, formatInstruction); return chatClient.prompt() .user(message) .call() .entity(converter); }注意call().entity(converter)这一句,它会直接返回OrderExtraction对象,解析和反序列化Spring AI全部帮我们做了。输出什么字段、字段类型对不对,都从record定义推出来的。
4.3 结构化输出的两个翻车点
这里分享两个我踩过的坑。
第一个是模型不老实,偏要加说明文字。虽然Prompt里明确说了“只返回JSON”,但它还是可能给你来一句“好的,以下是提取结果:```json...”。解决办法是在System Prompt里强化约束,同时把temperature调到0,让输出确定性更高。
第二个是字段类型不匹配。比如我给priority定义为int,但模型输出了"high",反序列化直接报错。保险的做法是枚举类型都用String定义,然后在Java侧自己做个转换,别指望模型真的理解你的枚举值约束。模型是概率输出,不是类型安全系统的产物,这个认知要建立起来。
4.4 工具调用:让Java方法变成AI的“手”
如果说结构化输出是“让AI按规矩说话”,那工具调用(Function Calling)就是“让AI能干活”。
核心逻辑是这样的:你定义好一个Java方法,通过注解告诉模型这个方法能做什么、参数是什么;模型在回答时,如果判断需要调用这个方法,就会在响应里带一个特殊的“工具调用请求”;Spring AI拦截到这个请求,执行你的Java方法,把方法结果再喂回给模型;模型拿到结果后组织最终回答。
举个例子。做一个查询订单状态的工具:
public class OrderTools { @Tool(description = "根据订单号查询订单当前状态") public String getOrderStatus(String orderId) { // 这里实际去查数据库 return "订单 " + orderId + " 已发货,预计明天送达"; } }然后把工具注册到ChatClient:
@Bean ChatClient chatClient(ChatClient.Builder builder) { return builder .defaultTools(new OrderTools()) .build(); }这时你问它“帮我查一下订单20250121到哪了”,模型会自己决定调用getOrderStatus("20250121"),拿结果再回复你。
这个能力就是Agent的雏形。你会发现,AI不再只是个嘴替,它能跟你的业务系统交互了。后面的进阶玩法——控制设备、查数据库、发邮件——都是这个思路的延伸。
工具调用这块最需要注意的,是模型只会根据你的方法描述决定调不调用。如果你的@Tool注解描述写得含糊,模型就会在不需要的时候瞎调或者需要的时候不调。描述的注释建议写清楚“什么场景下调用”“参数含义是什么”,这部分和写API文档一样重要。
5. RAG实战:让AI拥有你的业务知识
5.1 模型不知道你公司的内部情况
大模型的知识来自训练数据,截止日期之前的世界它知道,但你们公司内部的制度、你们产品的使用手册、上个季度的经营数据,这些它一概不知。
有人想,那我直接把文档塞进Prompt里让AI读不就行了?短文档可以,但一份几十页的PDF,塞进去Token直接爆炸,还超出上下文窗口限制,而且每次请求都传一遍,既慢又贵。
RAG(检索增强生成)解决的正是这个问题。思路就两步:
- 先把文档切块、向量化,存进向量数据库;
- 用户提问时,先去向量库里检索最相关的几个片段,只把这几段拼进Prompt,再让模型基于检索到的内容回答。
简单说就是:把“查文档”这件事从“全都塞给AI”变成“先检索再回答”,既省钱又精准。
5.2 基于SimpleVectorStore的完整可跑示例
Spring AI把RAG链路串得很顺。我直接给一个最小可运行例子,模型接的还是DeepSeek那一套,向量存储用Spring AI自带的SimpleVectorStore,它是个内存级向量库,数据不持久化,重启就丢,但用来跑通逻辑足够了。
先准备一份文档放在src/main/resources/docs/company.txt,随便写几条公司的内部制度。然后:
@Bean VectorStore vectorStore(EmbeddingModel embeddingModel) { return new SimpleVectorStore(embeddingModel); }EmbeddingModel这个Bean也是Spring AI自动配置好的,默认用模型服务商的embedding接口。注意,DeepSeek目前没开放embedding接口,如果你接的是DeepSeek,得再配一个兼容的embedding模型,比如Ollama本地的nomic-embed-text,或者换通义千问的embedding模型。具体配置按模型商文档来。
在应用启动时把文档加载进去:
@Component public class DataInitializer implements ApplicationRunner { private final VectorStore vectorStore; public DataInitializer(VectorStore vectorStore) { this.vectorStore = vectorStore; } @Override public void run(ApplicationArguments args) { var reader = new PagePdfDocumentReader("classpath:/docs/company.pdf"); // 如果只是txt可以用 TextDocumentReader List<Document> documents = reader.get(); TextSplitter splitter = new TokenTextSplitter(200, 50); List<Document> chunks = splitter.apply(documents); vectorStore.add(chunks); System.out.println("已写入 " + chunks.size() + " 个文档片段"); } }这里TokenTextSplitter(200, 50)表示每个片段约200个Token,相邻片段重叠50个Token。这个参数很关键:切太小,语义不完整;切太大,检索精度下降;重叠的目的是不让一句话被拦腰截断。
查询接口就很有味道了,用QuestionAnswerAdvisor:
@GetMapping("/qa") public String qa(@RequestParam String question) { return chatClient.prompt() .user(question) .advisors(QuestionAnswerAdvisor.builder() .vectorStore(vectorStore) .build()) .call() .content(); }QuestionAnswerAdvisor干的事,是先把用户问题向量化,去向量库里检索相似片段,然后把检索结果包装成Prompt上下文,一并交给模型。整个过程对业务代码几乎无感,这是Spring AI在设计上非常舒服的一点。
5.3 向量检索的原理可以这样理解
向量检索说起来玄乎,本质上就是“找相似”。每个文档片段被embedding模型映射成一个高维向量,比如768维,语义相近的文本在向量空间里距离更近。检索时把用户问题也映射成向量,然后算它跟所有文档片段的夹角余弦,取最接近的前K个。
所以这里有个很实际的经验:向量检索检索的是“语义相似”而不是“关键词匹配”。你搜“公司年假怎么请”也能匹配到写“休假制度”的片段,这是传统SQL LIKE做不到的。
但向量检索不是万能的,它对数字和专有名词很迟钝。比如用户问“2024年Q3营收多少”,向量相似度可能把含有很多财务术语的文档排前面,但具体数值可能是错位的。这种场景得靠元数据过滤甚至SQL召回辅助,别把RAG当全知全能。
6. 生产环境必须盯住的四个坑
6.1 密钥管理与成本失控
把API Key直接写在application.yml里是很多入门项目踩过的坑,我自己也踩过。一旦代码传到公开仓库,别人就拿着你的Key疯狂调用,一天烧掉几千块钱是分分钟的事。
我建议至少做到这几条:
- Key从环境变量读取,配置文件里只写占位符;
- 有条件就放配置中心或密钥管理服务;
- 云厂商的控制台里设置月度消费限额,超了就报警;
- 日志、异常信息里不要打印完整的Key。
6.2 超时、限流与重试
大模型接口的响应速度很不稳定,快的时候几百毫秒,慢的时候几十秒也是有的。Spring AI里可以配置重试机制:
spring: ai: openai: chat: options: timeout: 30s但重试一定要设上限,而且要对“模型返回空”“模型返回解析不了的内容”这些情况做降级处理。比如AI服务挂了,是直接报错,还是返回一个预设的兜底文案?这个降级策略建议在接口设计阶段就想好,不要等到线上出问题再补救。
6.3 模型的“幻觉”跟真实数据混在一起
RAG链路最危险的事,是模型把检索到的真实内容和“脑补”的内容混在一起,用户根本分辨不出来。我实测在回答公司政策类问题时,模型偶尔会在正确片段后面加一句“根据公司规定,员工每月可报销500元通讯费”,而这条规定根本不存在。
对策有两个方向:一是Prompt里明确要求“只能基于提供的资料回答,资料中没有的内容明确说明‘未在资料中找到’”;二是后置校验,把回答拿去和检索片段做相似度比对,太低就说明模型没守住规矩。第二个方案实现起来稍微复杂,但效果拔群。
6.4 测试一定要花钱
最后说一个很多Java开发者会忽视的点:AI应用的测试是烧钱的,因为每次runTestCase都会真实调用模型API。我见过的工程事故里,测试环境一个月把季度预算烧光的情况真实发生过。
建议测试策略这样定:
- 单元测试只测Prompt模板生成和结果解析,不调用真实模型,用Mock;
- 集成测试只跑几条关键链路,并且对Token消耗做预估;
- 如果模型支持,尽量用便宜的小模型跑测试,比如gpt-4o-mini这种级别的,别拿最强模型刷测试;
- 加一个全局开关,测试环境彻底禁用外呼接口,只走录制回放。
这些事看起来琐碎,但真到了线上,每一件都可能决定你项目能不能平稳落地。
7. 个人体会:这个框架值不值得学
如果只让我说一句,那就是:Spring AI值得学,但别把它想得太神。它解决了Java开发者接入大模型的工程化问题,但不解决业务问题。模型选型、Prompt设计、RAG的文档切分策略、Agent的工具编排,这些才是AI应用的核心难点,框架只是把外围的通信和组装工作帮你省掉了。
我个人在实操中的建议是:先跑通最简单的ChatClient,体验一次完整链路;再逐步加上模板、流式、结构化输出;然后按照业务需求引入RAG和工具调用。不要一上来就搞一个特别完整的Agent架构,那样调试起来会让你怀疑人生。
另外一个小经验:在团队里推广Spring AI时,别直接甩官方文档,那份文档更新太快,很多示例API版本之间都对不上。你先写好两三个能跑的最小Demo,让同事直接跑起来看到效果,比任何文档都更有说服力。我当初就是靠一个流式聊天接口的演示,让前端和后端同事都开始主动学这个框架。
技术学得再多,最终都要落到能跑、能维护、能控制成本的产品上。希望这篇文章能帮你平稳迈出第一步,后面的事,跑起来再遇到再说。