Spring AI Advisor 编排:记忆 + 工具 + RAG 三合一,一个接口全搞定

发布时间:2026/10/15 2:53:06
Spring AI Advisor 编排:记忆 + 工具 + RAG 三合一,一个接口全搞定
Spring AI Advisor 编排记忆 工具 RAG 三合一一个接口全搞定作者鱼宵 Spring AI 实战精通营 · 第 9 篇第 8 课那个问答助手答得挺准但它有两个毛病一是金鱼脑子——你上一句刚说我叫王小明下一句问我叫啥它一脸懵二是瘸腿——只会查知识库你问订单 1001 到哪了它答不上来因为它碰不到你的数据库。现实里的客服机器人哪能这样它得记得你是谁、会查你的订单、还照公司知识库答制度。这节课我们不写一堆 if-else 去拼而是用 Spring AI 的Advisor 责任链把这三样像搭积木一样串进同一个/chat接口。代码全在仓库lesson-09/里clone 下来跑四轮对话你会亲眼看到模型第二轮还叫得出你名字第三轮自己跑去查了订单号第四轮又照知识库答了年假——三合一在一个接口里同时生效。一、核心原理一条安检通道把能力全串起来一句话Advisor 是夹在用户请求和大模型之间的可插拔工序多个 Advisor 按顺序串成一条责任链业务代码只写.user(msg).call()智能全在链上。1. Advisor 就是 Servlet Filter 的大模型版它和你天天写的 Servlet Filter、Spring Cloud Gateway 过滤器是同一个设计模式——责任链请求从外到内依次过每道工序每道工序能看、能改请求也能看、能改响应响应再逆序原路返回。类比机场安检通道乘客你的请求进通道依次过 摄像头打日志→ 身份核验台记忆认出你→ 资料查验台RAG塞参考页→ 登机口模型回答。想加道工序就加想拆就拆。2. 链长什么样顺序谁定用户请求 │ 请求方向外 → 内 ▼ [① LogAdvisor ] order0最外层第一个看到请求、最后看到响应 ▼ [② MessageChatMemoryAdvisor] 按 sessionId 取出历史对话拼进请求答完再存回去 ▼ [③ QuestionAnswerAdvisor ] 检索知识库把命中片段拼进提示词 ▼ [④ Tool 工具层 ] 模型内部自己决定要不要查 OrderTools ▼ [⑤ ChatModel (DeepSeek) ] 真正生成回答响应沿 ⑤→④→③→②→① 逆序返回一个容易踩的坑顺序由每个 Advisor 的getOrder()数字决定小外层不是你defaultAdvisors(a,b,c)写的先后。排链错乱时先查 order。3. 多轮记忆每个 sessionId 一本病历本大模型本身无状态——每次请求它都是第一次见你。所谓记忆其实是框架每次请求时把这段对话的历史消息一起塞进上下文发给模型。类比病历本每个患者sessionId一本病历医生模型先翻病历再写新一页。MessageWindowChatMemory是窗口记忆只保留最近 N 页本课 20 条写满撕最早的——省 token 钱太久之前的事它就忘了。不同 sessionId 互不串台靠请求里传的ChatMemory.CONVERSATION_ID区分。4. 编排不写 if-else靠挂积木这就是Agent 骨架日志、记忆、检索、查单这些独立能力全部变成链上一环。你不写如果用户问年假就查库、如果问订单就查 DB这种分支——模型自己根据上下文决定走哪条。二、动手四轮连测见证三合一环境JDK 17 Maven 3.9DEEPSEEK_API_KEY。本课端口8101若被占改 8103见踩坑表。第 1 步检查环境。java-version# True[bool][Environment]::GetEnvironmentVariable(DEEPSEEK_API_KEY)# TrueGet-NetTCPConnection-LocalPort 8101-State Listen-ErrorAction SilentlyContinue# 无输出空闲第 2 步编译 启动。cd spring-ai-journey\lesson-09$env:JAVA_HOMEC:\Program Files\Java\jdk-17mvn clean install-DskipTests mvn spring-boot:run# Tomcat started on port 8101 即成功第 3 步同一个 sessionIdu1连续聊四轮。[Console]::OutputEncoding[System.Text.Encoding]::GetEncoding(936)$wNew-ObjectSystem.Net.WebClient$q1[uri]::EscapeDataString(你好我叫王小明今天第一天入职)$w.DownloadString(http://localhost:8101/chat?msg$q1sessionIdu1)$q2[uri]::EscapeDataString(我刚才说我叫什么名字)$w.DownloadString(http://localhost:8101/chat?msg$q2sessionIdu1)三、关键代码五道工序逐道拆第一段application.yml——和第 8 课同款配置。server:port:8101# 本课端口lesson-09 8101被占改 8103spring:ai:openai:base-url:${LLM_BASE_URL:https://api.deepseek.com}api-key:${DEEPSEEK_API_KEY}chat:options:model:${LLM_MODEL:deepseek-chat}max-tokens:500# 一次问答可能检索工具多轮组织回答取大点temperature:0.0# 事实问答照本宣科embedding:transformer:onnx:model-uri:file:./models/model.onnxtokenizer:uri:file:./models/tokenizer.jsoncache:enabled:false第二段LogAdvisor.java——自己写一道工序看懂责任链。packagecom.springai.lesson09;importorg.slf4j.Logger;importorg.slf4j.LoggerFactory;importorg.springframework.ai.chat.client.ChatClientRequest;importorg.springframework.ai.chat.client.ChatClientResponse;importorg.springframework.ai.chat.client.advisor.api.CallAdvisor;importorg.springframework.ai.chat.client.advisor.api.CallAdvisorChain;/** * 自定义日志 Advisor链上第一道最外层不改内容只打日志。 * adviseCall拿到请求→交给下一道工序 chain.nextCall(request)→再拿到响应返回。 * 和 Servlet Filter 一模一样。 */publicclassLogAdvisorimplementsCallAdvisor{privatestaticfinalLoggerlogLoggerFactory.getLogger(LogAdvisor.class);OverridepublicChatClientResponseadviseCall(ChatClientRequestrequest,CallAdvisorChainchain){// 请求阶段最外层第一个看到log.info( [① 日志Advisor] 请求进入{} ,request.prompt().getContents());// 接力棒调了它请求才继续往里走记忆→检索→模型ChatClientResponseresponsechain.nextCall(request);// 响应阶段模型答完从里往外最外层最后一个看到log.info( [① 日志Advisor] 响应返回{} ,response.chatResponse().getResult().getOutput().getText());returnresponse;}OverridepublicStringgetName(){returnlogAdvisor;}OverridepublicintgetOrder(){return0;}// 0最外层包住所有其他工序}注意两个 1.0.9 的真实 API拿请求内容用request.prompt().getContents()没有 getUserText拿响应用response.chatResponse()不是 response()。第三段AdvisorConfig.java——串起整条链本课核心。packagecom.springai.lesson09;importorg.springframework.ai.chat.client.ChatClient;importorg.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;importorg.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor;importorg.springframework.ai.chat.memory.ChatMemory;importorg.springframework.ai.chat.memory.InMemoryChatMemoryRepository;importorg.springframework.ai.chat.memory.MessageWindowChatMemory;importorg.springframework.ai.embedding.EmbeddingModel;importorg.springframework.ai.vectorstore.SimpleVectorStore;importorg.springframework.ai.vectorstore.VectorStore;importorg.springframework.context.annotation.Bean;importorg.springframework.context.annotation.Configuration;/** * 编排总装日志 记忆 RAG 三道 Advisor 串进一个 ChatClient。 */ConfigurationpublicclassAdvisorConfig{// 向量库和第 7/8 课同款内存 本地 ONNX 嵌入BeanpublicVectorStorevectorStore(EmbeddingModelembeddingModel){returnSimpleVectorStore.builder(embeddingModel).build();}// 多轮记忆窗口记忆每会话只留最近 20 条消息防提示词膨胀BeanpublicChatMemorychatMemory(){returnMessageWindowChatMemory.builder().chatMemoryRepository(newInMemoryChatMemoryRepository()).maxMessages(20).build();}BeanpublicChatClientcompositeChatClient(ChatClient.Builderbuilder,VectorStorevectorStore,ChatMemorychatMemory){// 工序②记忆顾问按会话ID取/存历史会话ID 在 Controller 里传MessageChatMemoryAdvisormemoryAdvisorMessageChatMemoryAdvisor.builder(chatMemory).build();// 工序③RAG 问答顾问和第 8 课同一个QuestionAnswerAdvisorqaAdvisorQuestionAnswerAdvisor.builder(vectorStore).build();StringsystemPrompt 你是星辰科技的智能客服助手同时具备三种能力 1. 依据知识库资料回答入职制度问题 2. 用提供的工具查询订单状态 3. 记住同一用户上一轮说过的话。 规则资料里没有就老实说知识库暂无相关信息建议联系人工客服 400-800-8888不要编造。 ;returnbuilder.defaultSystem(systemPrompt)// 三道工序一次挂好日志(①) → 记忆(②) → RAG(③).defaultAdvisors(newLogAdvisor(),memoryAdvisor,qaAdvisor).build();}}这里有个 1.0.9 的实物坐标要记没有InMemoryChatMemory这个类了网上旧教程还在写正确组合是MessageWindowChatMemory.builder().chatMemoryRepository(new InMemoryChatMemoryRepository()).maxMessages(20).build()。第四段OrderTools.java——Tool 查订单模型自己决定调不调。packagecom.springai.lesson09;importorg.slf4j.Logger;importorg.slf4j.LoggerFactory;importorg.springframework.ai.tool.annotation.Tool;importorg.springframework.ai.tool.annotation.ToolParam;importorg.springframework.stereotype.Component;importjava.util.Map;/** * 模拟订单系统。Tool 把方法登记成工具框架翻译成 JSON Schema 发给模型 * 模型自己决定要不要调、传什么参数。 */ComponentpublicclassOrderTools{privatestaticfinalLoggerlogLoggerFactory.getLogger(OrderTools.class);// 模拟订单库订单号 - 状态生产换真实 DBprivatestaticfinalMapString,StringORDER_DBMap.of(1001,已发货物流单号 SF1234567890预计明天送达,1002,已签收2026-09-30 由本人签收,1003,退款审核中预计 1~3 个工作日原路退回);Tool(description根据订单号查询订单当前状态。当用户提到订单号、发货、物流、签收、退款时使用本工具。)publicStringgetOrderStatus(ToolParam(description订单号例如 1001)StringorderId){// 这行一出现就是模型在链末端自主调了 Java 方法的现场证据log.info( [工具] 模型调用 getOrderStatus(orderId{}) ,orderId);returnORDER_DB.getOrDefault(orderId,未找到订单 orderId请确认订单号是否正确);}}第五段CompositeAssistantController.java——按轮传会话 ID 开工具。packagecom.springai.lesson09;importorg.springframework.ai.chat.client.ChatClient;importorg.springframework.ai.chat.memory.ChatMemory;importorg.springframework.web.bind.annotation.GetMapping;importorg.springframework.web.bind.annotation.RequestParam;importorg.springframework.web.bind.annotation.RestController;/** * GET /chat?msg你好我叫小王sessionIdu1 * 背后日志Advisor → 记忆Advisor → RAGAdvisor → 模型(自主决定要不要查单) */RestControllerpublicclassCompositeAssistantController{privatefinalChatClientcompositeChatClient;// AdvisorConfig 里的复合 ClientprivatefinalOrderToolsorderTools;// 工具 BeanpublicCompositeAssistantController(ChatClientcompositeChatClient,OrderToolsorderTools){this.compositeChatClientcompositeChatClient;this.orderToolsorderTools;}GetMapping(/chat)publicStringchat(RequestParam(msg)Stringmsg,RequestParam(valuesessionId,defaultValuedefault)StringsessionId){returncompositeChatClient.prompt().user(msg)// 关键一行告诉记忆 Advisor 这是哪场会话它据此取历史/存历史.advisors(a-a.param(ChatMemory.CONVERSATION_ID,sessionId))// 这一轮开放查订单工具模型自己判断用不用.tools(orderTools).call().content();}}注意.advisors(a - a.param(ChatMemory.CONVERSATION_ID, sessionId))这行——忘了它多轮记忆直接失效所有人共享一个叫 “default” 的病历本。第六段Lesson09Application.java——标准启动类。packagecom.springai.lesson09;importorg.springframework.boot.SpringApplication;importorg.springframework.boot.autoconfigure.SpringBootApplication;/** * 启动后自动建知识库再访问 GET /chat?msg...sessionIdu1 */SpringBootApplicationpublicclassLesson09Application{publicstaticvoidmain(String[]args){SpringApplication.run(Lesson09Application.class,args);}}建库器FaqDataLoader和第 8 课逐行一致Tika 读 faq.md → 切 4 块 → 本地嵌入入库这里不重复贴clone 下来就在 src 里。四、实测输出四轮连测三合一肉眼可见以下是 2026-10-05 本机真实运行DeepSeek8101 端口。启动日志和第 8 课一样加载 1 个文档 → 切 4 块 → 入库完成。第 1 轮msg你好我叫王小明今天第一天入职你好王小明欢迎加入星辰科技第一天入职辛苦啦 我是你的智能客服助手可以帮你解答入职相关问题考勤、年假、报销、社保…… 如果你有订单需要查询发货/物流/签收/退款把订单号告诉我即可。第 2 轮验证记忆msg我刚才说我叫什么名字你刚才说你叫王小明今天第一天入职。它没再听你报名字却答出了王小明——记忆 Advisor 把上一轮带进了提示词。第 3 轮验证工具msg帮我查一下订单1001现在什么状态王小明你的订单 1001 状态如下 - 状态已发货 - 物流单号SF1234567890 - 预计送达明天服务端日志同时打出[工具] 模型调用 getOrderStatus(orderId1001)——这串数字不是模型编的是它自己决定调了你的 Java 方法。注意它开口还叫王小明记忆和工具在同一条链上协同了。第 4 轮验证 RAGmsg入职年假一共有几天王小明关于年假天数 - 入职满 1 年享 5 天年假 - 之后每多 1 年增加 1 天 - 上限最多 15 天 请假需通过 OA 系统提交年假要提前 3 个工作日。数字和知识库年假条款逐字对应。控制台每轮还成对打出[① 日志Advisor] 请求进入…响应返回——责任链肉眼可见。看出门道了吗记忆、工具、RAG 三件事业务代码里一个 if-else 都没有全靠挂 Advisor 和传.tools()组合出来。五、挑战题改个参数看看会怎样⭐会话隔离先用sessionIdu1说我叫张三再用sessionIdu2说我叫李四然后分别问我叫什么——验证两个会话互不串记忆。答案就在CompositeAssistantController的sessionId参数。⭐⭐调窗口大小把AdvisorConfig里maxMessages(20)改成4连聊 5 轮后再问第一句说的是什么看模型是不是忘了——亲眼见证窗口记忆的边界。⭐⭐故意漏传会话号把 Controller 里.advisors(a - a.param(ChatMemory.CONVERSATION_ID, sessionId))这行注释掉再用 u1/u2 各报一次名字看是不是所有人共享了一个default病历本。六、生产环境进阶三个加分项1. 链上加安全过滤。日志、记忆、RAG 之外生产里常加一道 GuardAdvisor用户问题出现密码手机号就直接拦截。日志监控类 Advisor 放最外层order0能包住所有工序。2. 记忆换持久化。本课InMemoryChatMemoryRepository重启即丢。生产换成 JDBC / Redis 实现spring-ai-starter-model-chat-memory-repository-jdbc只换这一行 Bean业务代码不动——又一次面向接口编程的胜利。3. 窗口大小成本闸门。maxMessages不是随便填的每条历史都吃 token、都计费。窗口越大越记得住但越贵按业务算好轮数别让病历越堆越厚。七、面试回答模板面试官Spring AI 的 Advisor 是什么为什么说它像 Servlet Filter一句话夹在请求和模型之间的可插拔工序能改请求也能改响应和 Servlet Filter/Gateway 过滤器同构。展开实现 CallAdvisor在adviseCall里先处理请求、调chain.nextCall(request)接力、再处理响应请求外→内、响应内→外。指向本课 LogAdvisor / lesson-09追问多个 Advisor 怎么定顺序看每个 Advisor 的getOrder()数字小外层不是defaultAdvisors参数的书写顺序。日志监控类放 order0 最外层能包住所有工序。指向本课第二节链图追问多轮记忆怎么配为什么必须传会话 ID三件套ChatMemory 仓库 MessageChatMemoryAdvisor 拼历史进请求 CONVERSATION_ID 参数做会话隔离。忘传会话 ID 就所有人共享一份记忆串台窗口记忆 maxMessages 防提示词膨胀。指向本课第三节 AdvisorConfig Controller追问怎么把 RAG Tool 记忆编进同一个接口业务代码零分支RAG 和记忆做成 defaultAdvisors 挂在 ChatClient 上工具按轮.tools(orderTools)传入模型在自己的循环里决定检索还是调工具。这就是声明式编排。指向本课第三节 第四节实测八、总结表坑现象解法8101 被无关进程占用Port already in use别强杀改 yml 端口为 8103 再启动用了不存在的 InMemoryChatMemory旧教程代码编译不过MessageWindowChatMemory InMemoryChatMemoryRepository忘传 CONVERSATION_ID多轮记忆失效所有人共享 defaultController 加 .advisors(a-a.param(…))排链错乱顺序不符合预期看 getOrder()不看 defaultAdvisors 参数顺序1.0.9 访问器名getUserText/response() 找不到用 request.prompt().getContents()、response.chatResponse()JAVA_HOME 指向 JDK8mvn 跑在 Java 8构建前设 $env:JAVA_HOME九、关于这个系列本文是「Java 后端实战精通营」系列第 9 篇原则实战驱动、由浅到深、面试向每篇文章的结论都可以亲手验证。Spring AI 实战精通营10 课https://gitee.com/j67mk2/spring-ai-journey本文对应源码位置lesson-09/内含LogAdvisor自定义工序 AdvisorConfig三道责任链 OrderTools查单 CompositeAssistantController复合接口系列文章一览按发布顺序篇主题1Spring AI 初体验配好 yml 就能聊ChatClient 四步链式调用2Spring AI 提示词模板{变量} 参数化 few-shot一条提示词反复用3Spring AI 结构化输出entity() 把模型回答解析成 JavaBean别再手撕 JSON4Spring AI 工具调用Tool 让大模型自己查订单查库存5Spring AI 流式输出Flux SSE 打字机回答不再干等三秒6Spring AI 多模态给大模型一双眼睛图片它也能看懂7Spring AI 向量检索本地 ONNX 嵌入文本秒变坐标知识库零成本起步8Spring AI RAG 问答助手回答带引用AI 不再睁眼说瞎话9Spring AI Advisor 编排记忆 工具 RAG 三合一一个接口全搞定10Spring AI 企业智能客服RAG 工具 记忆 流式 兜底十课收官下一篇预告《Spring AI 企业智能客服RAG 工具 记忆 流式 兜底十课收官》——前面九课的零件这一课全部攒成一个完整项目浏览器打开一个聊天页回答一个字一个字蹦出来流式五件套整合还和 LangChain4j 版同一项目做了对照。跑完有任何报错把终端输出发评论区一起排查。你连聊四轮后它还记得你第一轮报的名字吗标签建议SpringAI、Advisor、多轮记忆摘要建议≤256 字一个客服机器人要记得你名字、会查你订单、还照公司知识库答制度——这些不用写 if-else靠 Spring AI 的 Advisor 责任链串起来就行。本文手写日志 Advisor把记忆、RAG、Tool 编进同一个 /chat 接口四轮实测见证三合一附会话隔离、窗口大小等 3 道挑战题和面试模板源码在 gitee lesson-09 可 clone 直接跑。