Spring AI1.1.0 实战周记|从 ChatClient 到手写 RAG,全套可运行代码 + 踩坑速查
AI 转型系列・第 1 周技术复盘
Java 后端转型 AI 应用开发第一周总结。10 个实验接口、22 个踩坑记录、通过
javap校验 7 个 Jar 包 API 签名。
本文不讲故事,全部为干货:可直接复制运行核心代码、Spring AI1.1.0 真实 API 签名(全部反编译本地 Jar 验证,不是复制网上过期文档)、真实实测数据。如果你正在跟进 Spring AI 1.1.0,本篇可以当做随身速查手册。
技术栈与 Maven 依赖(直接复制可用)
环境:Spring Boot 3.5.3 + Spring AI 1.1.0 + JDK 21 + 本地 Ollama
生成模型:qwen3.8:27b‑mlx;嵌入模型:bge‑m3
全部本地运行,零大模型 API 调用费用
<properties><java.version>21</java.version><spring-ai.version>1.1.0</spring-ai.version></properties><!-- BOM统一管理版本,子依赖无需指定version --><dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-bom</artifactId><version>${spring-ai.version}</version><type>pom</type><scope>import</scope></dependency><dependencies><!-- Ollama 模型核心starter:对话+嵌入 --><dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-starter-model-ollama</artifactId></dependency><!-- JDBC持久化会话记忆(H2数据库存储对话历史) --><dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId></dependency><!-- ⚠️重点!向量存储是独立模块,不会被starter自动传递引入,必须手动添加 --><dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-vector-store</artifactId></dependency><!-- RAG问答Advisor组件(QuestionAnswerAdvisor) --><dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-advisors-vector-store</artifactId></dependency><!-- 额外需要:spring‑web、spring‑jdbc、h2数据库,自行引入 --></dependencies>🚨第一个高频坑:
Spring AI1.0 版本之后 Ollama 的 starter 已经改名为spring‑ai‑starter‑model‑ollama;向量库、RAG 问答 Advisor 被拆分为独立 Artifact。
不要完全照搬网络旧教程,拿不准就去 Maven Central 核对组件是否存在。
application.yml 配置
spring:ai:ollama:chat:options:model:qwen3.8:27b-mlx# 模型名称必须和 ollama list 输出完全一致embedding:options:model:bge-m3# 对话模型、嵌入模型是两套独立配置,使用不同模型1. ChatClient:同步调用 + SSE 流式输出
// 同步调用,一次性获取完整回答 String resp = chatClient.prompt().user(q).call().content();SSE 流式接口,实现打字机逐字输出效果
importreactor.core.publisher.Flux;importorg.springframework.http.ResponseEntity;importorg.springframework.web.bind.annotation.GetMapping;importorg.springframework.web.bind.annotation.RequestParam;importorg.springframework.web.bind.annotation.RestController;@RestControllerpublicclassChatController{privatefinalChatClientchatClient;publicChatController(ChatClientchatClient){this.chatClient=chatClient;}@GetMapping(value="/chat/stream",produces="text/event-stream;charset=UTF-8")publicResponseEntity<Flux<String>>chatStream(@RequestParamStringq){returnResponseEntity.ok(chatClient.prompt().user(q).stream().content());}}import jakarta.servlet.*; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponseWrapper; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; @Component // ⚠️千万不能漏!不加该注解Filter不会注册,静默失效,无任何报错 public class SseCharsetFilter extends OncePerRequestFilter { private static String fixContentType(String value) { if (value != null && value.startsWith("text/event-stream") && !value.toLowerCase().contains("charset")) { return value + ";charset=UTF-8"; } return value; } @Override protected void doFilterInternal(HttpServletRequest req, HttpServletResponse resp, FilterChain chain) throws ServletException, IOException { chain.doFilter(req, new HttpServletResponseWrapper(resp) { @Override public void setContentType(String type) { super.setContentType(fixContentType(type)); } @Override public void setHeader(String name, String value) { if ("Content-Type".equalsIgnoreCase(name)) { value = fixContentType(value); } super.setHeader(name, value); } @Override public void addHeader(String name, String value) { if ("Content-Type".equalsIgnoreCase(name)) { value = fixContentType(value); } super.addHeader(name, value); } }); } }2. 会话记忆:Spring AI1.1.0 真实 API(排坑)
⛔网上大量旧教程、AI 生成代码会出现
chatMemoryRetrieveSize、ChatMemory.RETRIEVE_SIZE常量,1.1.0 版本已经完全删除,API 属于编造,直接编译报错。
正确配置:
importorg.springframework.ai.chat.client.ChatClient;importorg.springframework.ai.chat.memory.ChatMemory;importorg.springframework.ai.chat.memory.MessageWindowChatMemory;importorg.springframework.ai.chat.memory.MessageChatMemoryAdvisor;importorg.springframework.ai.chat.memory.repository.ChatMemoryRepository;importorg.springframework.ai.model.chat.ChatModel;importorg.springframework.context.annotation.Bean;importorg.springframework.context.annotation.Configuration;@ConfigurationpublicclassChatClientConfig{/** * 会话记忆Bean,唯一可以设置消息窗口大小的位置 * 默认最大保存20条消息;SystemMessage人设消息永远不会被窗口截断清理 */@BeanpublicChatMemorychatMemory(ChatMemoryRepositoryrepository){returnMessageWindowChatMemory.builder().chatMemoryRepository(repository).maxMessages(30).build();}@BeanpublicChatClientchatClient(ChatModelchatModel,ChatMemorychatMemory){returnChatClient.builder(chatModel).defaultSystem("你是一位严谨的技术助手,回答保持简洁。").defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build()).build();}}运行时指定会话 ID,实现多会话隔离
CONVERSATION_ID属于字符串契约,拼写错误不会抛异常,只会记忆静默失效!
不要在代码手动ChatClient.create(chatModel)裸创建对象,会绕过全部 Advisor 记忆能力。
// cid:会话唯一id,不同用户/对话使用不同cid实现会话隔离Stringresp=chatClient.prompt().user(q).advisors(a->a.param(ChatMemory.CONVERSATION_ID,cid)).call().content();记忆四层链路:ChatModel(无状态大模型) ← ChatClient ← MessageChatMemoryAdvisor(拦截存取历史) ← MessageWindowChatMemory(消息窗口截断) ← ChatMemoryRepository(持久化存储)
消息截断发生在消息存入存储的瞬间,系统人设 SystemMessage 不会被清理。
3. 结构化输出 entity () 三种写法
// 使用Record做数据契约,无样板代码publicrecordProductInfo(Stringname,Stringcategory,List<String>sellingPoints){}// ① 返回单个对象:.call()之后调用,老Milestone版本写在prompt链中间,GA正式版API位置变更ProductInfoinfo=chatClient.prompt().user(u->u.text("抽取商品信息:{desc}").param("desc",q)).call().entity(ProductInfo.class);// ② 返回顶层集合List<T>:对抗Java泛型擦除,{}匿名子类不能省略!List<ProductInfo>list=chatClient.prompt().user(u->u.text("批量抽取商品信息:{desc}").param("desc",q)).call().entity(newParameterizedTypeReference<List<ProductInfo>>(){});💡底层原理:
BeanOutputConverter自动生成 JSON Schema 约束追加到 Prompt,拿到返回做清洗 + Jackson 反序列化。
⚠️重要认知:只能提升输出合规概率,不是 100% 保证。生产环境必须做:字段判空、解析异常捕获降级、高敏感业务增加重试。
调试:打印框架自动生成的格式指令
BeanOutputConverter<ProductInfo>converter=newBeanOutputConverter<>(ProductInfo.class);System.out.println(converter.getFormat());4. Prompt 模板花括号安全律(RAG 高危坑)
chatClient.prompt()// system提示词没有内置param参数能力,只能字符串替换.system("你是{domain}领域的专家".replace("{domain}",domain)).user(u->u.text("分析这段商品描述:\n{desc}").param("desc",q))// ✅ 用户输入、检索文档,永远使用param传入.call().content();三种运行时行为(重点 RAG 开发)
表格
| 场景 | 现象 |
|---|---|
| 模板占位符数量多于 param 传入参数 | 直接抛出运行时异常 |
| param 传入参数,模板没有对应占位符 | 静默忽略,不会报错(隐蔽大坑) |
| 直接把用户 / 检索文档字符串拼接进 text () | 文本内部{xxx}会被识别为占位符,直接报错 |
📌铁律:用户输入、RAG 检索出来的文档内容,一律使用
.param()传参,禁止直接字符串拼接进模板。
类比数据库:相当于 SQL 的预编译#{},防止 Prompt 注入。
5. Embedding 嵌入 + 手写余弦相似度
// 文本向量化float[]vector=embeddingModel.embed("你好");// 自检:配置声明维度 和 返回向量长度必须相等intdeclareDim=embeddingModel.dimensions();// 批量向量化,返回 List<float[]>,下标和输入文本一一对应List<float[]>batchVectors=embeddingModel.embed(List.of("文本A","文本B"));手写余弦相似度,和 Spring AI 内置EmbeddingMath.cosineSimilarity逻辑完全一致(反编译实锤,异常逻辑、计算公式全部相同)
publicclassSimilarityUtil{/** * 余弦相似度 [-1 ~ 1],越接近1语义越相似 */publicstaticdoublecosine(float[]a,float[]b){if(a.length!=b.length){thrownewIllegalArgumentException("向量维度不一致!RAG铁律:入库、检索必须使用同一个Embedding模型");}doubledot=0,normA=0,normB=0;for(inti=0;i<a.length;i++){dot+=a[i]*b[i];normA+=a[i]*a[i];normB+=b[i]*b[i];}returndot/(Math.sqrt(normA)*Math.sqrt(normB));}}实测踩坑数据(nomic‑embed‑text 中文短板)
同义句:0.705;无关中文句子高达 0.996;短词退款 vs 垫款相似度 1.0,向量几乎完全一致。
不同语义文本全部挤在 0.99 + 窄区间,排序直接失效。
✅嵌入模型选型铁律:
嵌入模型语言必须匹配业务语料;不要迷信排行榜分数,拿自己业务文档实测 topK 检索命中率;更换嵌入模型,整个向量库必须全部重新向量化(维度就是模型的户口,不可混用)。
6. VectorStore 向量存储 & 手拼最小 RAG
项目启动自动加载知识库(CommandLineRunner)
importorg.springframework.ai.document.Document;importorg.springframework.ai.reader.TextReader;importorg.springframework.ai.splitter.TokenTextSplitter;importorg.springframework.ai.vectorstore.VectorStore;importorg.springframework.core.io.ClassPathResource;importorg.springframework.boot.CommandLineRunner;importorg.springframework.context.annotation.Bean;importorg.springframework.context.annotation.Configuration;importjava.util.List;@ConfigurationpublicclassRagLoadConfig{@BeanpublicCommandLineRunnerloadKnowledge(VectorStorevectorStore){returnargs->{// 1.读取文档List<Document>docs=newTextReader(newClassPathResource("knowledge/notes.txt")).get();// 2.文本切块,切块大小直接影响检索质量List<Document>chunks=newTokenTextSplitter().apply(docs);// 3.存入向量库,内部自动调用EmbeddingModel完成向量化vectorStore.add(chunks);};}}裸检索接口(RAG 排错第一现场!!)
⚠️排查 RAG 故障优先调用检索接口,不要直接看问答输出;老版本 API
getContent()已经删除,使用getText()获取文档内容
// 只做向量检索,不调用大模型,用来排查检索是否命中正确片段List<Document>hits=vectorStore.similaritySearch(SearchRequest.builder().query(q).topK(3).build());List<String>textList=hits.stream().map(Document::getText).toList();完全手写 RAG 链路(不使用官方 Advisor)
List<Document>hits=vectorStore.similaritySearch(SearchRequest.builder().query(q).topK(3).build());// 使用分割符拼接检索片段Stringcontext=hits.stream().map(Document::getText).collect(Collectors.joining("\n---\n"));// 组装prompt交给大模型Stringanswer=chatClient.prompt().system("仅根据参考材料回答问题;参考材料没有相关信息就直接说不知道,禁止编造。").user(u->u.text("{question}\n\n参考材料:\n{context}").param("question",q).param("context",context)).call().content();官方封装 QuestionAnswerAdvisor 对照组写法
searchRequest 不要设置 query,组件会自动拿用户输入的消息当做检索 query
Stringanswer=chatClient.prompt().advisors(QuestionAnswerAdvisor.builder(vectorStore).searchRequest(SearchRequest.builder().topK(3).build()).build()).user(q).call().content();SimpleVectorStore 内存向量库特性:重启服务数据全部丢失,项目启动通过 CommandLineRunner 重新灌入;切换 Milvus/Qdrant 等专业向量数据库,只是把「内存逐条余弦计算」替换为 HNSW 索引加速,上层业务逻辑不变。
🚨RAG 最阴险坑点:检索完全脱靶,但程序不会抛出任何异常,HTTP 返回 200,只是返回错误片段。排查口诀:
/ask结果不对,先打/search看原始检索结果!
7. 本周 API 速查表(全部 javap 反编译验证,避坑对照表)
表格
| 功能 | Spring AI1.1.0 真实写法 ✅ | 过期 / AI 编造旧写法 ❌ |
|---|---|---|
| 结构化 entity | call().entity(Class)/entity(PTR) | entity 写在 prompt 链中间(Milestone 旧版) |
| 设置记忆窗口大小 | MessageWindowChatMemory.builder().maxMessages(n) | chatMemoryRetrieveSize()(AI 编造,不存在) |
| 构建 SimpleVectorStore | SimpleVectorStore.builder(embeddingModel).build() | new SimpleVectorStore(em)(构造器 protected,不能直接 new) |
| 构建检索请求 | SearchRequest.builder().query(q).topK(3).build() | new SearchRequest(q).withTopK() |
| 获取文档正文 | document.getText() | document.getContent()(方法已删除) |
| RAG 问答 Advisor | QuestionAnswerAdvisor.builder(vs).searchRequest(...).build() | new QuestionAnswerAdvisor(vectorStore) |
| 记忆 Advisor 包路径 | org.springframework.ai.chat.client.advisor | org.springframework.ai.chat.memory(1.0 已经迁移) |
8. 高频踩坑速查表(10 个最高频问题)
表格
| 现象 | 排查 & 解决方案 |
|---|---|
| AI 生成的代码编译报错,API 找不到 | 使用javap/ 解压 jarunzip‑l反编译本地 Jar 核对真实签名,不要迷信 AI 输出 |
| Ollama 报 404 模型找不到 | ollama list核对本地模型名称,yml 必须和输出完全一致 |
| Spring 服务端口变成 4397 | 环境变量 SERVER_PORT 优先级高于 yml 配置;launchctl unsetenv SERVER_PORT清除环境变量 |
curl 传中文 / 带{}参数报 400 | 使用curl‑G --data‑urlencode做 URL 编码,RFC3986 不允许裸传特殊字符 |
| SSE 流式中文乱码 | 先用xxd确认原始字节;乱码样式改变 = 后端字节没问题,浏览器解码问题 |
| Filter / CommandLineRunner 完全不执行 | 检查是否加@Component/@Bean;没有注册到 Spring 容器等于不存在 |
| 会话记忆完全不生效 | 必须注入容器管理的 ChatClient Bean;禁止代码内手动ChatClient.create()裸创建 |
| RAG 问答结果不对,但没有报错 | 调用裸检索接口/search验证;优先排查嵌入模型是否适配业务语言 |
| param 传了参数模板不生效 | 占位符名称与 param key 必须完全一致,字符串契约,不会编译报错 |
| 合并代码引入莫名其妙 bug | 坚持每日 git commit,留存变更,避免残留半截旧代码 |
本文是「Java 后端 AI 转型实录」系列周总结第 1 篇。项目后续开源在 GitHub(链接见主页)。下周预告:Tool Calling 工具调用。