1. 为什么我劝Java后端尽早把Spring AI摸一遍
先把结论撂在这儿:如果你是一个写了三五年Spring Boot的Java后端,最近又在被各种"大模型应用""RAG知识库""Agent"的需求追着跑,那Spring AI这条线你绕不过去。我大概是从去年底开始系统性地把Spring AI从0.8一路跟到1.0 GA,中间踩的坑、推翻的方案、重写的代码,加起来够写一本小册子。这篇就把我这一路的东西摊开讲,从它到底解决什么问题,到Advisor、Tool Calling、RAG这几块怎么落地,尽量讲透。
Spring AI本质上是什么?一句话:它是Spring生态里那套"依赖注入+自动配置+约定优于配置"的哲学,被原封不动搬到了大模型应用开发上。你以前写@Service注入JdbcTemplate,现在写@Service注入ChatClient;你以前用application.yml配数据源,现在用同样的方式配模型参数。对Java后端来说,学习曲线几乎是平的——这是它最大的价值,也是它跟LangChain4j、跟Python那套LangChain最本质的区别。
它能做什么?聊天对话、流式输出、函数调用(Tool Calling)、向量检索增强(RAG)、多轮记忆、结构化输出、可观测性,这些主流能力它都有。适合谁看?我建议是三类人:一是手上已经有Spring Boot项目、想低成本接入AI能力的后端;二是被要求做企业级RAG知识库、但不想引入Python技术栈的团队;三是想理解"AI应用工程化"到底长什么样的开发者。如果你只是想调个API玩一玩,那其实用不用Spring AI都行,但一旦涉及多模型切换、会话管理、工具编排,它的优势就出来了。
下面我按"整体设计思路→核心机制拆解→实操落地→踩坑排查"这个顺序展开,中间会穿插大量我实际项目里的代码和参数,能抄的直接抄。
2. Spring AI的整体设计思路与选型考量
2.1 它到底抽象了哪几层
理解Spring AI,关键是理解它的分层抽象。我把它拆成四层来看,从下往上:
最底层是Model层,对应ChatModel、EmbeddingModel、ImageModel这些接口。这一层屏蔽了OpenAI、智谱、通义、Ollama等不同厂商的差异,你换模型基本只改配置不改代码。这里有个设计细节值得说:Spring AI没有搞一个"万能Model"接口,而是按能力拆分,因为聊天模型和嵌入模型的输入输出结构完全不同,硬合并只会让类型系统一团糟。
往上一层是Client层,核心是ChatClient。这是你日常打交道最多的东西,它提供了流式(stream())、同步(call())、结构化输出(entity())三种调用范式。ChatClient是线程安全的,可以做成单例Bean全局注入,这点跟RestTemplate是一个思路。
再往上是Advisor层,这是Spring AI最有意思的设计。Advisor本质是一个拦截器链,请求进模型之前、响应出模型之后,都能插一脚。记忆管理、RAG检索、日志、内容审核,全都是Advisor。这个设计直接借鉴了Spring MVC的HandlerInterceptor和AOP的思想,Java后端一看就懂。
最顶层是应用编排层,也就是Tool Calling、Agent、Workflow这些。Spring AI本身不提供完整的Agent框架(这块Spring AI Alibaba补了一些),但通过Tool Calling + Advisor的组合,你能拼出大部分Agent场景。
2.2 为什么是Advisor而不是AOP
有人会问,既然都是拦截,为什么不直接用Spring AOP?我一开始也这么想,后来发现不行。原因有三:
第一,Advisor需要访问和修改对话上下文。AOP的切点通常只拿到方法参数,而Advisor需要拿到完整的ChatClientRequest(包含消息列表、模型选项、工具定义),还要能改写它。用AOP你得把上下文塞进ThreadLocal,非常别扭。
第二,Advisor有明确的顺序语义。比如记忆Advisor必须在RAG Advisor之前执行(先加载历史再检索),这个顺序通过getOrder()控制,跟Spring的Ordered接口一脉相承。AOP的@Order虽然也能排序,但语义没这么清晰。
第三,Advisor是可组合的链。你可以动态往ChatClient上挂不同的Advisor组合,不同业务用不同链。AOP的切面是静态织入的,做不到这种运行时灵活组合。
所以Advisor不是重复造轮子,而是针对"对话式AI"这个特定场景重新设计的拦截机制。理解这一点,你后面用起来就不会觉得别扭。
2.3 版本选型:1.0 GA还是继续观望
我实测下来的建议是:新项目直接上1.0.x GA。0.8到1.0之间API变动确实大(ChatClient的构建方式、Advisor的接口签名都改过),但1.0之后基本稳定了。如果你还在0.8,迁移成本主要在Advisor和Tool Calling这两块,其他还好。
配套的Spring Boot版本,我推荐3.3以上,最好3.4+。因为Spring AI 1.0依赖Spring Framework 6.2,而6.2的一些特性(比如对虚拟线程更好的支持)在3.4里才完整。JDK的话,17是底线,21更好——虚拟线程在处理大量并发流式请求时收益明显,我后面会讲。
至于Spring AI Alibaba,它是阿里在Spring AI基础上做的增强,主要补了通义系列模型的深度适配、NL2SQL、以及一些Agent编排能力。如果你用阿里云百炼平台,直接上它省事;如果模型来源比较杂,纯Spring AI + 各家官方SDK也行。
3. 核心机制拆解:Advisor、Tool Calling、RAG到底怎么跑
3.1 Advisor链的执行时序
这块我必须讲细,因为太多人在这里翻车。一个请求从ChatClient发出到拿到响应,Advisor链的执行顺序是这样的:
请求阶段(before方向)按getOrder()从小到大执行,响应阶段(after方向)按从大到小回卷。这跟Servlet Filter的链式调用一模一样。假设你挂了三个Advisor:日志(order=0)、记忆(order=100)、RAG(order=200),那么:
- 日志Advisor记录原始请求
- 记忆Advisor从存储加载历史消息,插入到消息列表
- RAGAdvisor根据用户问题检索知识库,把检索结果作为上下文拼进Prompt
- 请求发给模型
- RAGAdvisor拿到响应,可能做后处理
- 记忆Advisor把本轮对话存回存储
- 日志Advisor记录响应和耗时
注意:记忆Advisor的order一定要小于RAG Advisor,否则会出现"历史消息里没有本轮问题,但RAG却检索了本轮问题"的诡异现象。我踩过这个坑,排查了半天。
3.2 Tool Calling的完整生命周期
Tool Calling(也叫Function Calling)是让模型"调用你的Java方法"的机制。它的流程比很多人想的复杂:
第一步,你在构建ChatClient时通过.tools(new MyTools())注册工具。Spring AI会扫描这个对象上所有@Tool注解的方法,把方法名、描述、参数schema提取出来。
第二步,请求发给模型时,这些工具定义会作为tools字段一起传过去。模型看到工具列表后,如果判断需要调用,它不会直接返回答案,而是返回一个tool_calls结构,里面包含要调用的工具名和参数。
第三步,Spring AI拦截到这个响应,在本地反射调用你的Java方法,拿到返回值。
第四步,把方法返回值作为一条tool角色的消息追加到对话里,再次发给模型。模型这次基于工具结果生成最终答案。
关键点在于:这是一个多轮往返过程,不是一次调用。所以你的工具方法要尽量快,否则整个链路延迟会叠加。我一般要求工具方法执行时间控制在200ms以内,超过的要么加缓存,要么改成异步。
3.3 RAG的两条技术路线
RAG(检索增强生成)在Spring AI里有两套实现思路,很多人搞混:
路线一:Advisor式RAG(QuestionAnswerAdvisor)。这是Spring AI内置的,你只需要配一个VectorStore,挂上QuestionAnswerAdvisor,它自动帮你做"向量化问题→检索→拼Prompt"。优点是开箱即用,缺点是检索策略固定,不好定制。
路线二:手动式RAG。你自己注入VectorStore,手动调similaritySearch(),拿到文档后自己拼Prompt。优点是灵活,能做多路召回、重排序、混合检索;缺点是要写的代码多。
我的建议是:原型阶段用路线一快速验证,生产环境用路线二。因为生产环境的检索质量要求高,内置的相似度检索经常不够用,你需要加关键词过滤、元数据过滤、重排序这些。
4. 从零搭建:一个可运行的Spring AI工程
4.1 依赖与配置
先上Maven依赖。这里有个坑:Spring AI的BOM要单独引入,不然版本对不齐。
<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-starter-model-openai</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-vector-store-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-advisors-vector-store</artifactId> </dependency> </dependencies>如果你用智谱AI,把spring-ai-starter-model-openai换成spring-ai-starter-model-zhipuai,配置项前缀从spring.ai.openai改成spring.ai.zhipuai。智谱的模型名比如glm-4-plus,嵌入模型用embedding-3,这些在配置里写清楚就行。
application.yml大概长这样:
spring: ai: openai: api-key: ${AI_API_KEY} base-url: https://api.openai.com chat: options: model: gpt-4o-mini temperature: 0.7 embedding: options: model: text-embedding-3-small data: redis: host: localhost port: 6379提示:
api-key千万别硬编码在yml里,用环境变量或者配置中心。我见过有人把key提交到Git,第二天就被刷爆了额度。
4.2 第一个ChatClient
配置类里把ChatClient做成Bean:
@Configuration public class AiConfig { @Bean public ChatClient chatClient(ChatClient.Builder builder, ChatMemory chatMemory, VectorStore vectorStore) { return builder .defaultSystem("你是一个专业的技术助手,回答要简洁准确。") .defaultAdvisors( MessageChatMemoryAdvisor.builder(chatMemory).build(), QuestionAnswerAdvisor.builder(vectorStore).build() ) .build(); } @Bean public ChatMemory chatMemory() { return MessageWindowChatMemory.builder() .maxMessages(20) .build(); } }这里MessageWindowChatMemory是内存版记忆,只保留最近20条消息。生产环境要换成基于Redis或数据库的实现,否则重启就丢。
4.3 流式接口怎么写
流式输出是AI应用的标配,Spring AI用Flux返回:
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> stream(@RequestParam String message, @RequestParam String conversationId) { return chatClient.prompt() .user(message) .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId)) .stream() .content(); }注意produces = TEXT_EVENT_STREAM_VALUE,这是SSE的标准MIME类型。前端用EventSource接收就行。conversationId通过Advisor的param传进去,记忆Advisor靠它区分不同会话。
实操心得:流式接口一定要配超时和背压。我遇到过模型响应慢、客户端又不断重连,导致连接数暴涨的情况。在WebFlux里加个
.timeout(Duration.ofSeconds(60))能挡掉大部分问题。
5. Tool Calling实战:让模型调用你的业务方法
5.1 定义一个工具类
假设我们要让模型能查订单状态,先写工具:
@Component public class OrderTools { private final OrderService orderService; public OrderTools(OrderService orderService) { this.orderService = orderService; } @Tool(description = "根据订单号查询订单状态,返回订单的当前状态和预计送达时间") public OrderStatus queryOrder(@ToolParam(description = "订单号,格式为ORD开头加12位数字") String orderNo) { return orderService.getStatus(orderNo); } }@Tool的description非常关键,模型就是靠它判断该不该调用这个工具。描述要写清楚"这个工具做什么、参数是什么格式、返回什么"。我见过有人写"查询订单",结果模型经常不调用,改成上面那种详细描述后命中率大幅提升。
5.2 注册与调用
在构建ChatClient时注册:
ChatClient client = builder .defaultTools(orderTools) .build(); String answer = client.prompt() .user("帮我查一下订单ORD202401011234的状态") .call() .content();模型会自动识别出需要调用queryOrder,提取订单号,执行方法,然后基于结果回答。
5.3 工具设计的几个原则
我总结了四条,都是血泪教训:
第一,工具粒度要适中。太细(比如"查订单金额""查订单时间"分开)会导致模型频繁多次调用,延迟高;太粗(一个工具干所有事)会导致参数复杂、模型理解困难。一般一个工具对应一个明确的业务动作。
第二,参数类型要简单。尽量用String、Integer、Boolean这些基础类型,避免嵌套对象。模型对复杂schema的解析准确率会下降。
第三,工具方法要幂等。因为模型可能因为超时重试而重复调用,如果你的工具是"扣款"这种非幂等操作,一定要加幂等键。
第四,返回值要精简。工具返回的内容会作为消息塞回给模型,如果返回一大坨JSON,既浪费token又干扰模型判断。只返回必要字段。
6. RAG落地:从文档入库到检索增强
6.1 文档切分与向量化
RAG第一步是把你的知识文档切块、向量化、存进向量库。Spring AI提供了TokenTextSplitter:
@Bean public VectorStore vectorStore(EmbeddingModel embeddingModel, RedisConnectionFactory factory) { return RedisVectorStore.builder(factory, embeddingModel) .indexName("tech-docs") .initializeSchema(true) .build(); } public void ingest(List<Document> documents) { TokenTextSplitter splitter = new TokenTextSplitter(500, 100, 5, 10000, true); List<Document> chunks = splitter.apply(documents); vectorStore.add(chunks); }TokenTextSplitter的参数含义:chunkSize=500(每块500 token)、minChunkSizeChars=100、minChunkLengthToEmbed=5、maxNumChunks=10000、keepSeparator=true。切块大小是个玄学,500到1000 token之间比较通用。太小会丢上下文,太大会稀释语义。
注意:切块时最好保留一定的重叠(overlap),Spring AI的splitter默认会做,但重叠比例要自己调。我一般设10%到20%。
6.2 检索策略的优化
内置的QuestionAnswerAdvisor用的是纯向量相似度检索,实际用下来召回率一般。我做了几个优化:
混合检索:向量检索 + 关键词检索(BM25),两路结果合并去重。Spring AI本身不直接支持BM25,但你可以用Elasticsearch或Redis的全文检索能力自己实现。
元数据过滤:给每个Document打上source、category、timestamp等元数据,检索时用filterExpression过滤。比如只检索某个产品线的文档:
SearchRequest request = SearchRequest.builder() .query(question) .topK(5) .filterExpression("category == 'product-a'") .build();重排序:检索出topK(比如20条)后,用一个重排序模型(如Cohere Rerank或本地的小模型)重新打分,取top5。这一步对最终质量提升非常明显,我实测能提升20%以上的相关性。
6.3 手动RAG的完整实现
生产环境我一般不用内置Advisor,而是手动控制:
public String ragChat(String question, String conversationId) { // 1. 检索 List<Document> docs = vectorStore.similaritySearch( SearchRequest.builder().query(question).topK(5).build() ); // 2. 拼上下文 String context = docs.stream() .map(Document::getText) .collect(Collectors.joining("\n\n---\n\n")); // 3. 构造Prompt String prompt = """ 基于以下参考资料回答问题。如果资料中没有相关信息,请明确说明"资料中未提及",不要编造。 参考资料: %s 问题:%s """.formatted(context, question); // 4. 调用模型 return chatClient.prompt() .user(prompt) .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId)) .call() .content(); }这个Prompt模板里的"不要编造"很关键。不加这句,模型经常在检索不到内容时硬编,这在企业场景里是致命的。
7. 常见问题与排查技巧实录
7.1 问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
启动报No qualifying bean of type ChatModel | 依赖没引对或配置缺失 | 检查starter依赖和spring.ai.*.api-key |
| 流式输出中文乱码 | 编码未指定 | 响应头加charset=UTF-8 |
| Tool Calling不触发 | 工具描述不清或参数schema有问题 | 打印请求日志看tools字段 |
| RAG检索结果不相关 | 切块太大或嵌入模型不匹配 | 调小chunkSize,换嵌入模型 |
| 记忆丢失 | 用了内存版且服务重启 | 换Redis或JDBC实现 |
| 响应超时 | 模型慢或工具方法阻塞 | 加超时,工具方法异步化 |
| 并发下会话串了 | conversationId没传或传错 | 检查Advisor param传递 |
7.2 几个我踩过的深坑
坑一:Advisor顺序导致记忆污染。前面提过,记忆Advisor的order必须小于RAG。我一开始没注意,结果历史对话里混进了检索内容,模型回答开始胡言乱语。
坑二:向量库维度不匹配。换嵌入模型时忘了重建索引,导致检索报维度错误。嵌入模型的维度是固定的(比如text-embedding-3-small是1536维),换模型必须重新入库。
坑三:Tool Calling的循环调用。模型有时候会陷入"调用工具→结果不满意→再调用"的死循环。解决办法是设置最大迭代次数,Spring AI的ToolCallingManager可以配置,或者你在工具描述里明确"此工具只调用一次"。
坑四:流式接口的异常处理。Flux里的异常如果不处理,客户端会收到一个断开的连接,看不到任何错误信息。要用.onErrorResume()兜底,返回一个友好的错误消息。
7.3 性能优化的几个点
虚拟线程:JDK 21下开启spring.threads.virtual.enabled=true,能显著提升并发流式请求的吞吐。我压测下来,同样硬件下QPS能提升2到3倍。
连接池:模型调用是HTTP请求,底层HTTP客户端的连接池要调好。默认值往往偏小,高并发下会成为瓶颈。
缓存:相同问题的回答可以缓存。用Caffeine做本地缓存,key是问题+会话上下文的hash。注意RAG场景下缓存要谨慎,因为检索结果可能变化。
异步工具:耗时的工具方法用@Async或CompletableFuture,避免阻塞模型调用线程。
8. 关于Agent和后续扩展的一些想法
Spring AI本身不是完整的Agent框架,但通过Tool Calling + Advisor + 多轮循环,你能拼出一个基础的ReAct Agent。核心逻辑是:让模型在每轮决定"是调用工具还是给出最终答案",如果是调用工具就执行后继续循环,直到模型给出最终答案或达到最大轮数。
Spring AI Alibaba在这块做了增强,提供了更完整的Agent编排能力,还有NL2SQL这种垂直场景的封装。如果你的场景是"自然语言查数据库",直接用它省很多事。
至于Agentic RAG(让Agent自主决定检索策略),目前Spring AI还没有开箱支持,需要自己实现。思路是把"检索"也做成一个Tool,让模型自己决定什么时候检索、检索什么。这个方向很有意思,但工程复杂度不低,建议先把基础RAG跑稳再考虑。
我个人的体会是,Spring AI最大的价值不在于它功能多全,而在于它让Java后端能用自己熟悉的方式进入AI应用开发。你不需要学Python,不需要理解LangChain那套抽象,用Spring Boot的思维就能把大部分场景做出来。当然它也有短板,比如Agent编排能力弱、生态还在完善,但对于企业级应用来说,稳定、可维护、和现有技术栈无缝集成,这些比花哨的功能重要得多。
最后分享一个小技巧:调试Spring AI时,把日志级别调到DEBUG,org.springframework.ai包下的日志会打印完整的请求和响应,包括发给模型的Prompt和工具调用详情。这个比任何调试工具都好用,尤其是排查RAG和Tool Calling问题时。