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

0 阅读5分钟

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              ] order=0,最外层:第一个看到请求、最后看到响应
     ▼
 [② 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.9+,DEEPSEEK_API_KEY。本课端口 8101(若被占改 8103,见踩坑表)。

第 1 步:检查环境。

java -version                              # True
[bool][Environment]::GetEnvironmentVariable('DEEPSEEK_API_KEY')        # True
Get-NetTCPConnection -LocalPort 8101 -State Listen -ErrorAction SilentlyContinue   # 无输出=空闲

第 2 步:编译 + 启动。

cd spring-ai-journey\lesson-09
$env:JAVA_HOME="C:\Program Files\Java\jdk-17"
mvn clean install -DskipTests
mvn spring-boot:run                  # Tomcat started on port 8101 即成功

第 3 步:同一个 sessionId=u1,连续聊四轮。

[Console]::OutputEncoding=[System.Text.Encoding]::GetEncoding(936)
$w = New-Object System.Net.WebClient
$q1=[uri]::EscapeDataString('你好,我叫王小明,今天第一天入职')
$w.DownloadString("http://localhost:8101/chat?msg=$q1&sessionId=u1")
$q2=[uri]::EscapeDataString('我刚才说我叫什么名字?')
$w.DownloadString("http://localhost:8101/chat?msg=$q2&sessionId=u1")

三、关键代码:五道工序,逐道拆

第一段:application.yml——和第 8 课同款配置。

server:
  port: 8101                          # 本课端口:lesson-09 = 8101(被占改 8103)
spring:
  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.onnx
        tokenizer:
          uri: file:./models/tokenizer.json
        cache:
          enabled: false

第二段:LogAdvisor.java——自己写一道工序,看懂责任链。

package com.springai.lesson09;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.chat.client.advisor.api.CallAdvisor;
import org.springframework.ai.chat.client.advisor.api.CallAdvisorChain;

/**
 * 自定义日志 Advisor:链上第一道(最外层),不改内容只打日志。
 * adviseCall:拿到请求→交给下一道工序 chain.nextCall(request)→再拿到响应返回。
 * 和 Servlet Filter 一模一样。
 */
public class LogAdvisor implements CallAdvisor {

    private static final Logger log = LoggerFactory.getLogger(LogAdvisor.class);

    @Override
    public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) {
        // 请求阶段:最外层第一个看到
        log.info("======== [① 日志Advisor] 请求进入:{} ========", request.prompt().getContents());
        // 接力棒:调了它请求才继续往里走(记忆→检索→模型)
        ChatClientResponse response = chain.nextCall(request);
        // 响应阶段:模型答完从里往外,最外层最后一个看到
        log.info("======== [① 日志Advisor] 响应返回:{} ========",
                response.chatResponse().getResult().getOutput().getText());
        return response;
    }

    @Override public String getName() { return "logAdvisor"; }

    @Override public int getOrder() { return 0; }   // 0=最外层,包住所有其他工序
}

注意两个 1.0.9 的真实 API:拿请求内容用 request.prompt().getContents()(没有 getUserText),拿响应用 response.chatResponse()(不是 response())。

第三段:AdvisorConfig.java——串起整条链(本课核心)。

package com.springai.lesson09;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.vectorstore.SimpleVectorStore;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * 编排总装:日志 + 记忆 + RAG 三道 Advisor 串进一个 ChatClient。
 */
@Configuration
public class AdvisorConfig {

    // 向量库:和第 7/8 课同款(内存 + 本地 ONNX 嵌入)
    @Bean
    public VectorStore vectorStore(EmbeddingModel embeddingModel) {
        return SimpleVectorStore.builder(embeddingModel).build();
    }

    // 多轮记忆:窗口记忆,每会话只留最近 20 条消息(防提示词膨胀)
    @Bean
    public ChatMemory chatMemory() {
        return MessageWindowChatMemory.builder()
                .chatMemoryRepository(new InMemoryChatMemoryRepository())
                .maxMessages(20)
                .build();
    }

    @Bean
    public ChatClient compositeChatClient(ChatClient.Builder builder,
                                          VectorStore vectorStore,
                                          ChatMemory chatMemory) {
        // 工序②:记忆顾问(按会话ID取/存历史,会话ID 在 Controller 里传)
        MessageChatMemoryAdvisor memoryAdvisor = MessageChatMemoryAdvisor.builder(chatMemory).build();
        // 工序③:RAG 问答顾问(和第 8 课同一个)
        QuestionAnswerAdvisor qaAdvisor = QuestionAnswerAdvisor.builder(vectorStore).build();

        String systemPrompt = """
                你是"星辰科技"的智能客服助手,同时具备三种能力:
                1. 依据知识库资料回答入职制度问题;
                2. 用提供的工具查询订单状态;
                3. 记住同一用户上一轮说过的话。
                规则:资料里没有就老实说"知识库暂无相关信息,建议联系人工客服 400-800-8888",不要编造。
                """;

        return builder.defaultSystem(systemPrompt)
                // 三道工序一次挂好:日志(①) → 记忆(②) → RAG(③)
                .defaultAdvisors(new LogAdvisor(), memoryAdvisor, qaAdvisor)
                .build();
    }
}

这里有个 1.0.9 的实物坐标要记:没有 InMemoryChatMemory 这个类了(网上旧教程还在写),正确组合是 MessageWindowChatMemory.builder().chatMemoryRepository(new InMemoryChatMemoryRepository()).maxMessages(20).build()。

第四段:OrderTools.java——@Tool 查订单(模型自己决定调不调)。

package com.springai.lesson09;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;

import java.util.Map;

/**
 * 模拟订单系统。@Tool 把方法登记成工具,框架翻译成 JSON Schema 发给模型,
 * 模型自己决定要不要调、传什么参数。
 */
@Component
public class OrderTools {

    private static final Logger log = LoggerFactory.getLogger(OrderTools.class);

    // 模拟订单库:订单号 -> 状态(生产换真实 DB)
    private static final Map<String, String> ORDER_DB = Map.of(
            "1001", "已发货,物流单号 SF1234567890,预计明天送达",
            "1002", "已签收,2026-09-30 由本人签收",
            "1003", "退款审核中,预计 1~3 个工作日原路退回"
    );

    @Tool(description = "根据订单号查询订单当前状态。当用户提到订单号、发货、物流、签收、退款时使用本工具。")
    public String getOrderStatus(@ToolParam(description = "订单号,例如 1001") String orderId) {
        // 这行一出现,就是"模型在链末端自主调了 Java 方法"的现场证据
        log.info("===== [工具] 模型调用 getOrderStatus(orderId={}) =====", orderId);
        return ORDER_DB.getOrDefault(orderId, "未找到订单 " + orderId + ",请确认订单号是否正确");
    }
}

第五段:CompositeAssistantController.java——按轮传会话 ID + 开工具。

package com.springai.lesson09;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

/**
 * GET /chat?msg=你好我叫小王&sessionId=u1
 * 背后:日志Advisor → 记忆Advisor → RAGAdvisor → 模型(自主决定要不要查单)
 */
@RestController
public class CompositeAssistantController {

    private final ChatClient compositeChatClient;   // AdvisorConfig 里的复合 Client
    private final OrderTools orderTools;             // 工具 Bean

    public CompositeAssistantController(ChatClient compositeChatClient, OrderTools orderTools) {
        this.compositeChatClient = compositeChatClient;
        this.orderTools = orderTools;
    }

    @GetMapping("/chat")
    public String chat(@RequestParam("msg") String msg,
                       @RequestParam(value = "sessionId", defaultValue = "default") String sessionId) {
        return compositeChatClient.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——标准启动类。

package com.springai.lesson09;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * 启动后自动建知识库,再访问 GET /chat?msg=...&sessionId=u1
 */
@SpringBootApplication
public class Lesson09Application {
    public static void main(String[] args) {
        SpringApplication.run(Lesson09Application.class, args);
    }
}

建库器 FaqDataLoader 和第 8 课逐行一致(Tika 读 faq.md → 切 4 块 → 本地嵌入入库),这里不重复贴,clone 下来就在 src 里。

四、实测输出:四轮连测,三合一肉眼可见

以下是 2026-10-05 本机真实运行(DeepSeek,8101 端口)。启动日志和第 8 课一样:加载 1 个文档 → 切 4 块 → 入库完成。

第 1 轮:msg=你好,我叫王小明,今天第一天入职

你好,王小明!欢迎加入星辰科技,第一天入职辛苦啦~🎉
我是你的智能客服助手,可以帮你解答入职相关问题:考勤、年假、报销、社保……
如果你有订单需要查询(发货/物流/签收/退款),把订单号告诉我即可。

第 2 轮(验证记忆):msg=我刚才说我叫什么名字?

你刚才说你叫王小明,今天第一天入职。😊

它没再听你报名字,却答出了"王小明"——记忆 Advisor 把上一轮带进了提示词。

第 3 轮(验证工具):msg=帮我查一下订单1001现在什么状态

王小明,你的订单 1001 状态如下:
- 状态:已发货
- 物流单号:SF1234567890
- 预计送达:明天

服务端日志同时打出 [工具] 模型调用 getOrderStatus(orderId=1001)——这串数字不是模型编的,是它自己决定调了你的 Java 方法。注意它开口还叫"王小明",记忆和工具在同一条链上协同了。

第 4 轮(验证 RAG):msg=入职年假一共有几天?

王小明,关于年假天数:
- 入职满 1 年:享 5 天年假
- 之后每多 1 年:增加 1 天
- 上限:最多 15 天
请假需通过 OA 系统提交,年假要提前 3 个工作日。

数字和知识库年假条款逐字对应。控制台每轮还成对打出 [① 日志Advisor] 请求进入…响应返回——责任链肉眼可见。

看出门道了吗?记忆、工具、RAG 三件事,业务代码里一个 if-else 都没有,全靠挂 Advisor 和传 .tools() 组合出来。

五、挑战题:改个参数,看看会怎样

  1. ⭐ 会话隔离:先用 sessionId=u1 说"我叫张三",再用 sessionId=u2 说"我叫李四",然后分别问"我叫什么"——验证两个会话互不串记忆。答案就在 CompositeAssistantController 的 sessionId 参数。
  2. ⭐⭐ 调窗口大小:把 AdvisorConfig 里 maxMessages(20) 改成 4,连聊 5 轮后再问第一句说的是什么,看模型是不是"忘了"——亲眼见证窗口记忆的边界。
  3. ⭐⭐ 故意漏传会话号:把 Controller 里 .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId)) 这行注释掉,再用 u1/u2 各报一次名字,看是不是所有人共享了一个"default"病历本。

六、生产环境进阶:三个加分项

1. 链上加安全过滤。 日志、记忆、RAG 之外,生产里常加一道 GuardAdvisor:用户问题出现"密码""手机号"就直接拦截。日志监控类 Advisor 放最外层(order=0),能包住所有工序。

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 参数的书写顺序。日志监控类放 order=0 最外层,能包住所有工序。(指向本课第二节链图)

追问:多轮记忆怎么配?为什么必须传会话 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 课):gitee.com/j67mk2/spri…

  • 本文对应源码位置:lesson-09/(内含 LogAdvisor 自定义工序 + AdvisorConfig 三道责任链 + OrderTools 查单 + CompositeAssistantController 复合接口)

系列文章一览(按发布顺序):

篇主题
1Spring AI 初体验:配好 yml 就能聊,ChatClient 四步链式调用
2Spring AI 提示词模板:{变量} 参数化 + few-shot,一条提示词反复用
3Spring AI 结构化输出:entity() 把模型回答解析成 JavaBean,别再手撕 JSON
4Spring 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 直接跑。