我之所以把这篇文章命名为“摸索实践”,是因为Spirng AI这条路我确实是一步步踩出来的,不是看两眼文档就能直接写代码的那种顺滑体验。Spring AI是Spring官方在AI应用开发领域给出的一套抽象框架,目标是把大模型接入、结构化输出、向量检索这些事,从“自己拼HTTP请求”变成“像写Spring Data一样面向接口”。对已经在用Spring Boot的Java开发者来说,它是目前和技术栈融合最自然的AI集成方式,没有之一。
这篇文章更适合两类人:一类是Java背景扎实但没碰过AI应用的开发者,你可以照着操作把一个对话Demo跑起来;另一类是已经用Python调过模型接口、现在想把能力搬回业务系统的后端工程师,你关心的配置切换、结构化输出、RAG落地这些坑,我都会讲到。我的所有经验都来自实际项目中两个星期的反复折腾,踩坑记录和代码示例会一起给出来。
1. Spring AI到底解决什么问题
1.1 它和直接调模型SDK有什么本质差别
先说一个很直观的场景。假设你的业务代码里要接入一个聊天模型,最原始的做法是写一个服务类,里面拼HTTP请求、带鉴权头、处理超时重试、解析响应JSON。刚开始只有一个供应商的时候,这套代码勉强能用。可一旦你想从A厂商切换到B厂商,或者想在A和B之间做负载分流,你会发现业务代码里到处散落着厂商SDK的类型和方法,改动成本高得吓人。
Spring AI做的事情,和Spring Data对数据库的抽象非常像。你写JpaRepository的时候根本不用关心底层是MySQL还是PostgreSQL,同样,使用Spring AI时你面向的是ChatClient、ChatModel这类统一接口,供应商之间的差异被隔离在依赖和配置层面。框架内部帮你管理了请求构造、鉴权注入、响应解析、超时与重试策略,甚至把“让模型返回JSON”这件事做成了类型安全的映射。
这个设计还有一个容易被忽略的好处:测试变得好写了。以前你Mock一个HTTP客户端或者SDK对象,现在只需要Mock一个ChatModel接口,配合Mockito就能把业务逻辑完整跑一遍,完全不需要真实调用模型。
1.2 什么场景值得引入,什么场景反而没必要
不是所有AI需求都应该无脑上Spring AI。我根据实际项目经验画了一条明显的分界线:
| 适用场景 | 不建议用的场景 |
|---|---|
| 团队已经是Spring技术栈,希望AI调用能和现有业务代码统一管理 | 公司自研了高性能模型网关,已有完善的流式转发和限流机制 |
| 需要结构化输出,比如从模型返回中直接映射业务对象 | 只是做一次性的脚本调用,不值得引入框架 |
| 要搭建RAG流水线,希望文本切分、向量化、检索能被规范管理 | 对延迟极度敏感,要求毫秒级直连模型API |
| 需要快速切换不同模型供应商,不想改业务代码 | 已有成熟的Prompt工程平台,业务层完全不需要感知模型细节 |
我个人的判断标准很简单:如果你的复杂度在于“AI能力怎么被业务复用”,那就值得用;如果复杂度在于“底层链路怎么调度”,框架帮不了你太多,该自己写网关还是得自己写。
2. 从零搭建:把第一个Spring AI应用跑起来
2.1 环境准备和版本选择是第一个大坑
Spring AI到现在仍然没有发布正式稳定版,版本号一直处在里程碑(M)和快照(Snapshot)之间。我一开始图新鲜选了最新快照版本,结果发现不仅API和文档对不上,还间接牵连了Spring Boot等其他依赖的版本解析,折腾了一个晚上才把问题定位清楚。
这里给一个非常务实的版本策略:选择当前官方文档明确推荐的里程碑版本,不要追求最新。实际项目中我用的是Spring Boot 3.2及以上版本,配合JDK 17,这套组合在兼容性和稳定性上表现不错。生成工程时可以直接用Spring Initializr,在依赖搜索框里输入Spring AI相关选项;如果用的IDE版本比较老找不到,就手动在pom里加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> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-xxx-spring-boot-starter</artifactId> </dependency>BOM的作用是统一管理所有Spring AI子模块的版本,避免你自己去记每个artifact的版本号。如果你用的是Maven,记得检查仓库配置,有些里程碑版本不在默认中央仓库里,需要额外配置Spring的里程碑仓库,这个细节很多人会漏。
2.2 最小化对话Demo:代码比想象中简单
配置文件的写法是第一个容易出错的地方。在application.yml里,你需要配置模型提供商的接口地址、API Key和模型名称。有一个细节我印象特别深:接口地址base-url只填域名会导致404,必须包含到/v1这个路径前缀,因为大部分厂商的REST接口都挂在版本前缀下面。
spring: ai: model-provider: base-url: https://api.example.com/v1 api-key: ${AI_API_KEY} chat: options: model: example-chat-model temperature: 0.7这里我特意把配置前缀写成了model-provider,实际项目中前缀取决于你引入的starter的artifactId,通常就是供应商名称。API Key一定要走环境变量,直接写死在配置文件里是迟早要出事的坏习惯。
写好配置后,注入ChatModel就能发起第一次对话了:
@Service public class ChatService { private final ChatModel chatModel; public ChatService(ChatModel chatModel) { this.chatModel = chatModel; } public String ask(String question) { return chatModel.call(question); } }这段代码虽然能跑,但我建议你从第一行代码开始就用ChatClient而不是ChatModel裸调。因为后续你要用到的结构化输出、流式响应、Prompt模板,基本都是基于ChatClient展开的。早期版本里ChatClient的构造方式出现过大改版,我之前照着旧博客写ChatClient.builder(),结果在新版本里compile都过不去,后来才发现官方已经把构造方式统一到了自动配置的Bean上面。
2.3 配置管理里那些不起眼但致命的细节
除了API Key和base-url,还有几个连接参数我建议在一开始就配好,否则跑着跑着就会遇到玄学超时:
spring: ai: model-provider: connection-timeout: 60s read-timeout: 60s之前我遇到过对话稍长一点就报超时的问题,排查了很久才发现是默认读取超时太短,生成型模型响应本来就慢,短超时等于自己给自己埋雷。另外_model参数名不要想当然,不同供应商的模型名称规则差异很大,填错了通常会报model not found一类的错误,中文资料里满天飞的模型名不一定适配你用的供应商,以官方模型列表为准。
注意:API Key不要提交到Git仓库。我在实际项目里吃过亏,某次随手提交了一个带Key的配置文件,第二天就收到账单异常通知。现在我的习惯是本地建一个application-local.yml并加入.gitignore,里面放置真实配置,主配置文件只保留环境变量占位符。
3. 核心API的理解、调优和典型翻车现场
3.1 ChatClient:从同步调用到流式响应
当你把ChatModel升级为ChatClient后,代码会变得更有框架感。我最常用的是这两种调用方式:
@Service public class ChatService { private final ChatClient chatClient; public ChatService(ChatClient.Builder builder) { this.chatClient = builder.defaultSystem("你是一个乐于助人的中文助手").build(); } public String syncAsk(String question) { return chatClient.prompt() .user(question) .call() .content(); } public Flux<String> streamAsk(String question) { return chatClient.prompt() .user(question) .stream() .content(); } }同步调用适合后台任务、批处理这类不需要实时反馈的场景;流式调用则适合面向用户的对话接口,让内容像打字机一样逐字输出,大幅降低用户的等待焦虑。Flux本身是响应式编程里的概念,如果你没接触过,先理解成“一个异步流”就够了,前端接SSE时非常顺手。我在Controller里直接返回Flux配合Server-Sent Event,浏览器端只用EventSource就能收到消息。
3.2 结构化输出:从String里硬解析JSON是最蠢的写法
最早的摸索阶段,我让模型返回JSON字符串,然后在代码里用正则把被markdown围栏包住的内容抠出来,再用Jackson反序列化。这个思路在小样本下勉强能跑,一旦模型偶尔在JSON前后多输出一句解释性文字,整个解析就崩了。后来我才发现Spring AI提供了结构化输出的能力,相当于让模型按照指定格式输出,再由框架自动完成反序列化。
public record QueryResponse( String answer, List<String> references ) {} @Entity public class QaService { private final ChatClient chatClient; public QueryResponse askWithStructure(String question) { return chatClient.prompt() .user(question) .call() .entity(QueryResponse.class); } }这段代码看起来简单,但有几个细节值得单独拎出来说。第一,如果你定义的是普通class而不是record,一定要给它一个无参构造器,否则反序列化阶段大概率会抛异常。第二,类字段名尽量和你的中文需求描述一致,否则模型在语义推断时容易跑偏。第三,建议在类上加上@JsonIgnoreProperties(ignoreUnknown = true),因为模型有时候会额外多输出一些字段,没有这个注解可能直接映射失败。
我还踩过一个更隐蔽的坑:当返回内容比较复杂,比如嵌套泛型或者List里面套对象时,默认的类型解析可能出错。解决办法是把期望结构写清楚,并在Prompt里明确告诉模型“只输出JSON,不要输出任何解释文字”。如果框架版本支持,也可以在调用级别配置JSON Schema约束,这样模型侧会严格遵守输出格式,几乎不会出现格式漂移。
3.3 流式输出的SSE对接和中文乱码
上一节提到的流式响应,放到Web接口里还有个常见坑。你如果直接返回Flux ,前端收到的Content-Type可能不是text/event-stream,导致EventSource连接直接报错。正确的做法是需要按SSE协议包装数据事件。另外中文环境下,如果响应里出现了非法字符,很容易被浏览器截断,表现为“消息说一半就停了”。
我的处理方法是定义统一的响应包装,每次发送一个完整句子再换行,前端按事件完整接收。同时给流式接口配置合理的超时时间,避免模型端长时间不返回时连接一直挂着占用线程。
4. 进阶实践:把RAG跑通才算真正入门
4.1 不懂RAG就先用一句话理解它
RAG,Retrieval-Augmented Generation,检索增强生成。官方解释很绕,我的白话理解是:大模型有知识盲区,尤其是你公司内部的文档它根本没见过,如果你直接把问题丢给它,它只能瞎编。RAG的思路是先检索:把你的文档切碎、向量化、存入向量库,用户提问时先从库里找出相关片段,再把这些片段塞进Prompt,让模型基于这些材料作答。
Spring AI把这条流水线抽象成了几个清晰的角色:文档读取器负责解析源文件,文本切分器负责把长文切片,嵌入模型负责把文本变成向量,向量存储负责检索相似内容。对我这样习惯了分层架构的Java工程师来说,这套抽象用起来真的很顺。
4.2 一个最小可运行的RAG Demo
我建议新手不要在第一步就引入复杂的向量数据库,先用内存版跑通链路,后续再迁移。下面是一个能直接运行的最小实现思路:
@Configuration public class RagConfig { private final EmbeddingModel embeddingModel; public RagConfig(EmbeddingModel embeddingModel) { this.embeddingModel = embeddingModel; } @Bean public VectorStore vectorStore() { return new SimpleVectorStore(embeddingModel); } }然后从本地文档构建索引:
@Service public class RagService { private final VectorStore vectorStore; private final ChatClient chatClient; // 构造函数省略 public void buildIndex(String filePath) throws IOException { String content = Files.readString(Path.of(filePath)); TextSplitter splitter = new TokenTextSplitter(300, 50); List<Document> documents = splitter.split(new Document(content)); vectorStore.add(documents); } public String ask(String question) { List<Document> matches = vectorStore.similaritySearch(question); String context = matches.stream() .map(Document::getContent) .collect(Collectors.joining("\n")); String prompt = "请基于以下资料回答问题:\n" + context + "\n问题:" + question; return chatClient.prompt().user(prompt).call().content(); } }代码非常简单,但已经涵盖了RAG的核心链路:切分、向量化、存储、检索、增强、推理。我特别说一下chunkSize,也就是文本切分大小。太小了会导致上下文碎片化,模型看不到完整逻辑;太大了会导致检索出来的片段包含大量噪音。我实操下来,300到500个token是比较稳的区间,overlap重叠50左右,保证相邻片段之间不丢关键语句。
4.3 向量存储选型与资源控制的经验教训
内存版向量库适合验证逻辑,但项目一旦进入联调阶段,我建议立刻切换到独立服务型向量库,否则重启一次就要重新灌数据,而且内存占用会持续增长。嵌入模型的选择也很关键:如果你用的是远程API,检索延迟受限于网络,质量稳定;如果你选择本地嵌入模型,需要注意加载到内存后对JVM堆的冲击。
我在“本地嵌入+远程对话”这个组合上踩过一个大坑:嵌入模型加载完占了一部分堆内存,再叠加流式对话的并发压力,应用直接就OutOfMemoryError了。后来在启动参数里把最大堆内存调大,并限制了向量检索的返回条数,情况才稳定下来。另外别贪心把所有文档一次性切碎全部入库,先小批量验证检索效果,再逐步扩充,否则问题会被淹没在海量数据里,排查效率极低。
5. 常见问题与排查技巧实录
5.1 把日志级别调起来是第一步
遇到任何调用异常,我的第一反应不是改代码,而是把Spring AI的日志级别调到TRACE,看清完整的请求体和响应体。很多所谓“玄学错误”,只要看到真实响应文本,原因立刻浮出水面。比如模型返回的JSON被倒进了某个说明性字段、请求头里没带上API Key、base-url拼写多了一个斜杠,这些都能在日志里一目了然。调日志的方式很简单,在application.yml里把对应包的level设置成trace就行。排查完问题再调回INFO,避免生产环境日志爆炸。
5.2 高频报错速查表
我把这两个星期里遇到的高频问题做了个速查表,希望能帮你节省排查时间:
| 异常现象 | 可能原因 | 处理办法 |
|---|---|---|
| 401 Unauthorized | api-key没生效、base-url路径错误 | 检查环境变量是否注入,确认路径是否包含/v1 |
| 404 model not found | 模型名填错、当前账户无权限 | 到官方模型列表核对名称,换一个可用的模型 |
| 读取超时 | 默认read-timeout太短、模型响应慢 | 在配置里调大读取超时到60秒以上 |
| 反序列化失败 | 返回内容被模型加了多余文字、实体类无无参构造 | 启用结构化输出约束,补无参构造器,加ignoreUnknown |
| 内存溢出 | 本地嵌入模型占堆、向量库数据量过大 | 调大JVM堆内存,控制检索条数和文档切分批大小 |
| 依赖缺失ClassNotFound | 只引入了核心starter,缺对应功能模块 | 按需补充文档解析器、向量库等模块依赖 |
| 流式响应断帧 | 中文字符被截断、SSE数据格式不标准 | 按完整句子输出事件,确认Content-Type和换行格式 |
5.3 我收藏的几个调试习惯
有几个调试习惯,我后来每次写AI功能都会带上。第一,把系统提示词和用户消息拼好后先打印出来,人工读一遍再发给模型,很多“效果不好”的问题其实出在Prompt本身。第二,调试阶段把温度调到0,排除随机性干扰,先确认逻辑通不通,最后再调高温度追求多样性。第三,做文本切分实验时,不要用完整的大文件,挑几段代表性内容试,观察检索命中质量。第四,流式响应录完日志后一定要确认“完成”事件有被消费,否则连接会被框架挂起,前端永远等不到结束信号。
提示:遇到网上流传的Spring AI代码片段,先检查它对应的版本再试。这个框架目前API变动还比较频繁,很多旧文章里的写法在新版本已经废弃了。
6. 一点个人经验总结
最后说点很实在的。Spring AI是一个解决“接入”痛点的框架,它不会让你的模型变得更聪明,也不能替你写出更好的Prompt。它的价值在于把模型供应商切换、结构化输出、RAG链路这些重复劳动变成标准化的配置和接口,让你能把精力放回业务本身。
根据我自己的体会,如果你现在准备入坑,不要一上来就追向量数据库和高阶流式编排。先把最小的对话Demo跑通,体会Spring Boot如何把大模型封装成一个看起来平平无奇的Bean;接着尝试结构化输出,让模型返回能被业务代码直接使用的对象;然后再加上RAG,把文档问答这个几乎每个企业都会遇到的场景走一遍。每走一步都确认它能稳定运行,再往下一步走。这个顺序看起来慢,但恰恰是踩坑最少、产出最快的路径。
我印象最深的一点是:框架替你解决了百分之八十的复杂度,剩下百分之二十仍然要靠你理解AI本身的工作原理。当你掌握了这套组合之后,Spring AI给你的不止是一个能调用聊天模型的工程,而是一条把AI能力平滑嵌入Java生态的成熟路径。