Spring AI 1.1.0 周学习回忆录:从 ChatClient 到手拼 RAG 的全部代码与坑
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 调用费用propertiesjava.version21/java.versionspring-ai.version1.1.0/spring-ai.version/properties!-- BOM统一管理版本子依赖无需指定version --dependencygroupIdorg.springframework.ai/groupIdartifactIdspring-ai-bom/artifactIdversion${spring-ai.version}/versiontypepom/typescopeimport/scope/dependencydependencies!-- Ollama 模型核心starter对话嵌入 --dependencygroupIdorg.springframework.ai/groupIdartifactIdspring-ai-starter-model-ollama/artifactId/dependency!-- JDBC持久化会话记忆H2数据库存储对话历史 --dependencygroupIdorg.springframework.ai/groupIdartifactIdspring-ai-starter-model-chat-memory-repository-jdbc/artifactId/dependency!-- ⚠️重点向量存储是独立模块不会被starter自动传递引入必须手动添加 --dependencygroupIdorg.springframework.ai/groupIdartifactIdspring-ai-vector-store/artifactId/dependency!-- RAG问答Advisor组件QuestionAnswerAdvisor --dependencygroupIdorg.springframework.ai/groupIdartifactIdspring-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.chatClientchatClient;}GetMapping(value/chat/stream,producestext/event-stream;charsetUTF-8)publicResponseEntityFluxStringchatStream(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 ;charsetUTF-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实现会话隔离StringrespchatClient.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,ListStringsellingPoints){}// ① 返回单个对象.call()之后调用老Milestone版本写在prompt链中间GA正式版API位置变更ProductInfoinfochatClient.prompt().user(u-u.text(抽取商品信息{desc}).param(desc,q)).call().entity(ProductInfo.class);// ② 返回顶层集合ListT对抗Java泛型擦除{}匿名子类不能省略ListProductInfolistchatClient.prompt().user(u-u.text(批量抽取商品信息{desc}).param(desc,q)).call().entity(newParameterizedTypeReferenceListProductInfo(){});底层原理BeanOutputConverter自动生成 JSON Schema 约束追加到 Prompt拿到返回做清洗 Jackson 反序列化。⚠️重要认知只能提升输出合规概率不是 100% 保证。生产环境必须做字段判空、解析异常捕获降级、高敏感业务增加重试。调试打印框架自动生成的格式指令BeanOutputConverterProductInfoconverternewBeanOutputConverter(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[]vectorembeddingModel.embed(你好);// 自检配置声明维度 和 返回向量长度必须相等intdeclareDimembeddingModel.dimensions();// 批量向量化返回 Listfloat[]下标和输入文本一一对应Listfloat[]batchVectorsembeddingModel.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模型);}doubledot0,normA0,normB0;for(inti0;ia.length;i){dota[i]*b[i];normAa[i]*a[i];normBb[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项目启动自动加载知识库CommandLineRunnerimportorg.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.读取文档ListDocumentdocsnewTextReader(newClassPathResource(knowledge/notes.txt)).get();// 2.文本切块切块大小直接影响检索质量ListDocumentchunksnewTokenTextSplitter().apply(docs);// 3.存入向量库内部自动调用EmbeddingModel完成向量化vectorStore.add(chunks);};}}裸检索接口RAG 排错第一现场⚠️排查 RAG 故障优先调用检索接口不要直接看问答输出老版本 APIgetContent()已经删除使用getText()获取文档内容// 只做向量检索不调用大模型用来排查检索是否命中正确片段ListDocumenthitsvectorStore.similaritySearch(SearchRequest.builder().query(q).topK(3).build());ListStringtextListhits.stream().map(Document::getText).toList();完全手写 RAG 链路不使用官方 AdvisorListDocumenthitsvectorStore.similaritySearch(SearchRequest.builder().query(q).topK(3).build());// 使用分割符拼接检索片段Stringcontexthits.stream().map(Document::getText).collect(Collectors.joining(\n---\n));// 组装prompt交给大模型StringanswerchatClient.prompt().system(仅根据参考材料回答问题参考材料没有相关信息就直接说不知道禁止编造。).user(u-u.text({question}\n\n参考材料\n{context}).param(question,q).param(context,context)).call().content();官方封装 QuestionAnswerAdvisor 对照组写法searchRequest 不要设置 query组件会自动拿用户输入的消息当做检索 queryStringanswerchatClient.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 编造旧写法 ❌结构化 entitycall().entity(Class)/entity(PTR)entity 写在 prompt 链中间Milestone 旧版设置记忆窗口大小MessageWindowChatMemory.builder().maxMessages(n)chatMemoryRetrieveSize()AI 编造不存在构建 SimpleVectorStoreSimpleVectorStore.builder(embeddingModel).build()new SimpleVectorStore(em)构造器 protected不能直接 new构建检索请求SearchRequest.builder().query(q).topK(3).build()new SearchRequest(q).withTopK()获取文档正文document.getText()document.getContent()方法已删除RAG 问答 AdvisorQuestionAnswerAdvisor.builder(vs).searchRequest(...).build()new QuestionAnswerAdvisor(vectorStore)记忆 Advisor 包路径org.springframework.ai.chat.client.advisororg.springframework.ai.chat.memory1.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 工具调用。