新人笔记---Spring AI的Advisor以及其底层机制讲解(涉及源码),包含一些遇见的Spring AI的Advisor缺陷问题的解决方案

这篇主要是博主想了解一下Spring AI的advisor底层机制,所记下的一篇笔记,博主扒源码扒了一整天,算是从大体上梳理了整个advisor的机制,具体细节问题可能会有错误和疏忽。同时附上了一些项目中advisor的涉及遇见的一些问题和解决方法,以及项目中advisor的一些比较好的设计思路和方案,觉的有帮助或者喜欢的还请点个赞⦁⩊⦁

一:解释一下advisor机制

一句话解释:Spring AI 的 Advisor 机制,本质上就是一个专门针对"AI对话(请求与响应)"的拦截器。 它可以在你把问题发给大模型之前 ,或者大模型把答案返回给你之后,对数据进行拦截、修改、增强或记录。

1. 核心概念:洋葱模型

Advisor 的执行过程就像一个洋葱。请求从外向内层层穿透,直到核心(调用大模型),然后响应从内向外层层返回。

  • 请求阶段 (Request) :按顺序执行(A -> B -> C -> 模型)。
  • 响应阶段 (Response) :按相反顺序执行(模型 -> C -> B -> A)。

这意味着,你可以在请求发给 AI 之前 修改提示词(比如注入历史记录),也可以在 AI 返回结果 之后 处理数据(比如格式化输出或检查敏感词)。这里可以看见advisor机制就类似于拦截器,在我们发送LLM请求之前做一些自定义的处理(比如做RAG检索,记录观测日志),并在LLM返回响应后,通过他的回调方法来进行一些AI响应结果的自定义处理,然后返回给用户

二:具体深入了解一下advisor内部

1.介绍一下继承体系

这是advisor的继承体系,可以看见我们的advisor接口是整个体系的核心,有两个子接口:CallAdvisorStreamAdvisor,他们的实现类都差不多

我们查看advisor接口的内部,发现他继承自order接口,并且内部提供了一个变量DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER,这个博主也不太理解,下面引入一下AI的解释

一、DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER

java 复制代码
int DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER = Ordered.HIGHEST_PRECEDENCE + 1000;

这是定义 Chat Memory Advisor 的默认执行优先级顺序

部分 含义
Ordered.HIGHEST_PRECEDENCE Spring 框架中定义的最高优先级值(通常是 Integer.MIN_VALUE,即 -2147483648
+ 1000 在最高优先级基础上往后挪1000位,也就是**优先级降低1000档

为什么要这样设计?

java 复制代码
优先级数值越小,执行越靠前(越先执行)

Ordered.HIGHEST_PRECEDENCE (-2147483648)
    ↓
Spring AI 内部 Advisors(框架自己的拦截器)
    ↓
DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER (-2147482648)  ← 这里
    ↓
用户自定义 Advisors(你写的拦截器,可以插在这里)
    ↓
...
Ordered.LOWEST_PRECEDENCE (2147483647)

设计意图

"It leaves room (1000 slots) for the user to plug in their own advisors with higher priority"

翻译:留出1000个空位,让用户可以插入优先级更高的自定义 Advisor

也就是说:

  • Spring AI 自己的内部 Advisor 优先级最高(最先执行)
  • Chat Memory Advisor 次之
  • 用户自定义的 Advisor 可以插在 Chat Memory 之前(数值更小,优先级更高),处理更紧急的逻辑

使用方法

方式1:你不干预(默认情况)

Spring AI 自动把 ChatMemoryAdvisor 放在 +1000 的位置,你什么都不用做。

java 复制代码
// 默认配置,ChatMemoryAdvisor 自动生效
@Bean
public ChatClient chatClient(ChatClient.Builder builder) {
    return builder
        .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory))
        .build();
}

方式2:你想让自定义 Advisor 在 ChatMemory 之前执行

场景:你想在加载历史记忆之前,先做一些预处理(比如敏感词过滤、请求改写)。

java 复制代码
@Component
public class MyPreProcessAdvisor implements Advisor {
    
    // 优先级比 ChatMemory 高(数值更小,先执行)
    @Override
    public int getOrder() {
        // 比 ChatMemory 的 +1000 更小,所以先执行
        return Ordered.HIGHEST_PRECEDENCE + 500;  // 插在前500的位置
    }
    
    @Override
    public String getName() {
        return "MyPreProcessAdvisor";
    }
    
    @Override
    public AdvisedRequest advise(AdvisedRequest request, Map<String, Object> context) {
        // 在 ChatMemory 加载历史之前,先处理请求
        System.out.println("我先执行!在记忆加载之前");
        return request; // 继续传给下一个 Advisor
    }
}

执行顺序

java 复制代码
1. MyPreProcessAdvisor (+500)  ← 你先执行
2. ChatMemoryAdvisor (+1000)   ← 然后加载记忆
3. 其他默认 Advisors
4. 最终调用 LLM

可以看见我们实现的自定义的顾问,它内部都会提供getOrder(),setOrder()发给方法,我们可以手动通过控制返回值大小,来控制advisor的执行顺序(数值小的先执行)

可以看见order接口内部提供了两个不同的precedence阈值与一个getOrder方法,getOrder() 方法核心作用只有一个:为对象定义一个 "优先级 / 顺序值",让 Spring 能自动按这个值排序,决定它们的执行顺序

2.介绍一下CallAdvisor与StreamAdvisor

(1)介绍一下CallAdvisor与StreamAdvisor
1. CallAdvisor(同步拦截器)

它是处理"一次性买卖"的。

  • 核心方法adviseCall(ChatClientRequest request, CallAdvisorChain chain)

  • 工作流程

    1. 接收请求。
    2. 调用 chain.nextCall(request)(这一步会阻塞,直到 AI 返回完整结果)。
    3. 拿到完整的 ChatClientResponse
    4. 你可以修改这个完整的响应,或者直接返回。
  • 适用场景

    • 普通的问答接口。
    • 需要获取完整 Token 消耗统计。
    • 需要将 AI 的回复转换为 Java 对象(JSON 转 Object)。
2. StreamAdvisor(流式拦截器)

它是处理"涓涓细流"的。

  • 核心方法adviseStream(ChatClientRequest request, StreamAdvisorChain chain)

  • 工作流程

    1. 接收请求。
    2. 调用 chain.nextStream(request)
    3. 注意 :这里拿到的不是一个结果,而是一个 Flux<ChatClientResponse> (这是一个数据流管道,里面源源不断地流出文字片段)。
    4. 你需要对这个 Flux 进行操作(比如使用 Reactor 操作符:map, filter, doOnNext)。
    5. 最后返回修改后的 Flux
  • 适用场景

    • 聊天窗口,需要用户看到实时的生成过程。
    • 需要实时过滤敏感词(一旦检测到敏感词,立马切断流)。
java 复制代码
┌─────────────────────────────────────────────────────────┐
│                      用户发送消息                         │
└─────────────────────────────────────────────────────────┘
                           ↓
┌─────────────────────────────────────────────────────────┐
│  判断调用方式:call() 还是 stream()?                      │
│  • chatClient.prompt().call()    → 走 callAdvisor       │
│  • chatClient.prompt().stream()  → 走 streamAdvisor     │
└─────────────────────────────────────────────────────────┘
                           ↓
        ┌─────────────────┴─────────────────┐
        ↓                                   ↓
┌───────────────┐                   ┌───────────────┐
│  callAdvisor  │                   │ streamAdvisor │
│   (同步路径)   │                   │   (流式路径)   │
├───────────────┤                   ├───────────────┤
│ 1. 前处理      │                   │ 1. 前处理      │
│    加载记忆    │                   │    加载记忆    │
│ 2. 调用下一个  │                   │ 2. 调用下一个  │
│    Advisor    │                   │    Advisor    │
│ 3. 调用 LLM   │                   │ 3. 调用 LLM   │
│ 4. 后处理      │                   │ 4. 流式返回    │
│    保存记忆    │                   │    逐字显示    │
│ 5. 返回完整响应│                   │ 5. 流结束后    │
│               │                   │    保存记忆    │
└───────────────┘                   └───────────────┘

下面重点解释一下stream流式响应

具体可以看chatClient内部构造,调用stream()方法会返回一个StreamResponseSpec接口类型的对象

这里看stream()的重写方法,本质是返回一个DefaultStreamResponSpec对象,并将设置好的请求参数,包括顾问连都传递进去

该类是StreamResponseSpec的实现类,接受stream()的方法并且封装在内部变量中

最终我们调用的chatReponse方法本质还是调用该类的重写后的chatResponse方法,底层还是doGetObservableFluxChatResponse方法,和之前的chatClient源码篇都差不多,最后返回一个流式对象Flux<ChatResponse>

具体的调用图片

(2)CallAdvisor的方法

他提供了一个接口方法adviseCall

参数 类型 含义
chatClientRequest ChatClientRequest 用户的请求对象(包含 Prompt、配置等)
callAdvisorChain CallAdvisorChain Advisor 调用链,用于传给下一个 Advisor
返回值 ChatClientResponse AI 的完整响应对象

adviseCall = 对「同步阻塞式」AI 调用进行拦截处理的方法

adviseCall streamAdvisor
调用方式 同步(阻塞等待完整响应) 异步流式(实时接收片段)
返回值 ChatClientResponse(单个完整对象) Flux<ChatClientResponse>(数据流)
适用场景 短回复、不需要实时显示 长回复、需要打字机效果
用户感知 等全部生成完才显示 逐字实时显示

adviseCall(总指挥)

  • 定义:这是 CallAdvisor 接口中定义的唯一必须实现的方法。
  • 职责:它负责完全控制请求的处理流程。它决定了什么时候修改请求、什么时候调用下一个节点(最终调用 AI)、什么时候修改响应。
  • 底层逻辑:如果你直接实现 CallAdvisor 接口,你必须手写 adviseCall,并在里面手动调用 chain.nextCall(request)。

它定义了我们内部的顾问核心逻辑的处理,顾问链中节点的传递等等,Spring AI 提供了一个名为 BaseAdvisor 的接口,它实现了 CallAdvisor,并帮你写好了 adviseCall 的默认逻辑。
StreamAdvisor也是同理,这里就不再解释了

3.介绍一下BaseAdvisor,以及具体的顾问链的底层,如何调用等等

该类具体的结构图

java 复制代码
// BaseAdvisor 的默认实现
public interface BaseAdvisor extends CallAdvisor {

    // 这是核心方法(总指挥)
    @Override
    default ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) {
        // 1. 调用 before 方法(前置处理)
        // 你可以在这里修改 request
        ChatClientRequest processedRequest = this.before(request, chain);

        // 2. 调用链中的下一个 Advisor 或 AI 模型
        // 这一步才是真正的"调用 AI"
        ChatClientResponse response = chain.nextCall(processedRequest);

        // 3. 调用 after 方法(后置处理)
        // 你可以在这里修改 response
        return this.after(response, chain);
    }

    // 这是留给你的扩展点(具体工人)
    default ChatClientRequest before(ChatClientRequest request, AdvisorChain chain) {
        return request; // 默认什么都不做
    }

    default ChatClientResponse after(ChatClientResponse response, AdvisorChain chain) {
        return response; // 默认什么都不做
    }

baseAdvisor就像是一个半成品的advisor,他是一个接口,继承自callAdvisor/streamAdvisor,提供了before/after方法,让我们自己实现顾问增强逻辑,同时重写了advisorCall发方法,封装好了顾问跳转方法,不需要我们手动实现,以及顾问调用before/after的触发时机等等,我们后续的自定义顾问就用到了他,基本我们需要实现自己的顾问,就要实现该接口

他这里的输入参数有ChatRequest顾问链CallAdvisorChain,看到这一点,博主突然想到了,他这与chatClient内部的call方法有关系啊

我们的call内部就是构建了一个advisor顾问链BaseAdvisorChain,并且传递了参数chatRequest和顾问链条等全部所需参数

同时该BaseAdvisorChain本身就继承了CallAdvisorChain和StreamAdvisorChain,正好符合我们顾问的两种形式,是不是猛然间都联系起来了,后续他就会去调用doGetObservableChatClientResponse,我们之前笔记有讲过

简单来说:doGetObservableChatClientResponse 是启动流水线的"总开关",而 adviseCall 是流水线上具体的"加工工位"。

以下是详细的代码级关系解析:

核心连接点:nextCall

这两段代码通过 CallAdvisorChain.nextCall() 方法紧密连接在一起。

  1. 第一段代码 (doGet...)

    • 它是整个流程的入口
    • 它负责设置监控环境(Observation)。
    • 关键动作:它调用了 this.advisorChain.nextCall(chatClientRequest)
    • 含义:"准备好环境,然后启动第一个 Advisor。"
  2. 第二段代码 (adviseCall)

    • 它是 BaseAdvisor 的默认实现,代表任意一个中间 Advisor 的行为。
    • 关键动作:它内部也调用了 callAdvisorChain.nextCall(processedChatClientRequest)
    • 含义:"我处理完我的逻辑(before),然后交给下一个 Advisor,处理完后我再收尾(after)。"

我们顺着这个思路再继续狠狠扒,看看底层到底发生了什么

BaseAdvisorChain底有唯一一个实现类DefaultAroundAdvisorChain,所以我们传递的顾问链本质就是传的这个实现类

他这里内部的顾问链本质就是一个栈Deque

这是他核心的方法nextCall,也是我们之前一直说的中重要的方法,他就是控制顾问节点到底怎么传递的 它的作用是取出下一个顾问(Advisor)并执行它nextCall = 从链中"弹出"下一个 Advisor,并执行它的 adviseCall 方法

核心点来了

第8行:弹出下一个 Advisor

java 复制代码
var advisor = this.callAdvisors.pop();
操作 含义
.pop() 从栈顶弹出一个 Advisor(移除并返回

关键理解

  • Advisors 是按 getOrder() 排序后压入栈的
  • pop() 取出当前应该执行的那个
  • 取出后链中就少了一个,下次 nextCall 会取下一个
java 复制代码
初始栈(按 order 从小到大排序):
[Logging(100), Memory(200), Safety(300)]

第1次 pop() → Logging(100)  栈变成 [Memory(200), Safety(300)]
第2次 pop() → Memory(200)   栈变成 [Safety(300)]
第3次 pop() → Safety(300)   栈变成 []

第10-14行:构建可观测性上下文

java 复制代码
var observationContext = AdvisorObservationContext.builder()
    .advisorName(advisor.getName())           // Advisor 名称
    .chatClientRequest(chatClientRequest)     // 当前请求
    .order(advisor.getOrder())                // 优先级顺序
    .build();

作用 :为 Micrometer Observation(监控/追踪)准备数据。

字段 用途
advisorName 日志/监控中显示 "正在执行 MemoryAdvisor"
chatClientRequest 记录这个 Advisor 处理时的请求状态
order 记录执行顺序,方便排查问题

他底层就是通过将顾问执行链以栈形式构建,启动执行链从栈顶pop()顾问,执行,不断pop弹出顾问,然后执行该顾问的增强逻辑,再弹出,再执行,直到栈为空,此时顾问链执行完成

4.介绍一下其他的顾问实现类

这几个顾问实现类就简单讲解一下,不需要过多关注

1. ChatModelCallAdvisor ------ LLM 调用代理

作用链的终点,真正调用大模型生成响应。

java 复制代码
public class ChatModelCallAdvisor extends BaseAdvisor {
    
    private final ChatModel chatModel;
    
    @Override
    public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) {
        // 不调用 chain.next()!直接调 LLM
        return this.chatModel.generate(request.getPrompt());
    }
}

特点

  • 它是 最后一个执行的 Advisor
  • 内部不调用 chain.next()
  • 它的 getOrder() 应该是 LOWEST_PRECEDENCE(最后执行)

执行位置

java 复制代码
LoggingAdvisor (100) → MemoryAdvisor (200) → SafetyAdvisor (300) → ChatModelCallAdvisor (MAX)
                                                                          ↓
                                                                    调用 LLM.generate()

这就解释了之前 nextCall 中空栈的问题------不是空栈调 LLM,而是 ChatModelCallAdvisor 作为最后一个节点直接调 LLM


2. SafeGuardAdvisor ------ 安全防护顾问

作用:过滤敏感内容,防止 AI 生成有害信息。

java 复制代码
public class SafeGuardAdvisor extends BaseAdvisor {
    
    @Override
    protected ChatClientRequest before(ChatClientRequest request) {
        // BEFORE: 检查用户输入是否含敏感词
        String content = request.getPrompt().getContents();
        if (containsSensitiveWords(content)) {
            throw new IllegalArgumentException("输入包含敏感内容");
        }
        return request;
    }
    
    @Override
    protected ChatClientResponse after(ChatClientResponse response) {
        // AFTER: 检查 AI 输出是否含敏感内容
        String output = response.getResult().getOutput().getContent();
        if (containsSensitiveWords(output)) {
            // 替换或拦截
            return replaceSensitiveContent(response);
        }
        return response;
    }
}

使用场景

  • 内容审核
  • 敏感词过滤
  • 合规检查

3. SimpleLoggerAdvisor ------ 简单日志记录

作用:记录请求和响应,方便调试和监控。

java 复制代码
public class SimpleLoggerAdvisor extends BaseAdvisor {
    
    private static final Logger logger = LoggerFactory.getLogger(SimpleLoggerAdvisor.class);
    
    @Override
    protected ChatClientRequest before(ChatClientRequest request) {
        logger.info("【Request】Prompt: {}", request.getPrompt().getContents());
        logger.info("【Request】Model: {}", request.getModel());
        logger.info("【Request】Temperature: {}", request.getTemperature());
        return request;
    }
    
    @Override
    protected ChatClientResponse after(ChatClientResponse response) {
        logger.info("【Response】Content: {}", response.getResult().getOutput().getContent());
        logger.info("【Response】Tokens: {}", response.getResult().getMetadata().getUsage());
        return response;
    }
}

输出示例

java 复制代码
[SimpleLoggerAdvisor] 【Request】Prompt: 你好,请介绍一下Spring AI
[SimpleLoggerAdvisor] 【Request】Model: gpt-4
[SimpleLoggerAdvisor] 【Request】Temperature: 0.7
...
[SimpleLoggerAdvisor] 【Response】Content: Spring AI 是一个用于简化...
[SimpleLoggerAdvisor] 【Response】Tokens: prompt=15, completion=128, total=143
怎么添加
方式1:全局默认(推荐)
java 复制代码
@Bean
public ChatClient chatClient(ChatClient.Builder builder, ChatModel chatModel) {
    return builder
        .defaultAdvisors(
            new SimpleLoggerAdvisor(100),           // 手动加日志
            new SafeGuardAdvisor(200),               // 手动加安全
            new MessageChatMemoryAdvisor(chatMemory) // 手动加记忆
        )
        .build();
}
方式2:单次调用添加
java 复制代码
chatClient.prompt("你好")
    .advisors(
        new SimpleLoggerAdvisor(100),  // 这次调用才加
        new SafeGuardAdvisor(200)
    )
    .call();

5.怎么手动实现自定义顾问

我们继承BaseAdvisor接口即可,然后重写他内部的方法

三:博主自己的疑问点

(1)

这里博主有个问题是:我们chatClient说到底就是通过执行顾问链的方式,来层层传递数据,执行增强后的逻辑,最后返回AI的响应结果ChatResponse,但是博主当前项目只是搭建了一个顾问,顾问里也没涉及AI调用内容,那么我们为什么执行完顾问链就获取到了AI执行结果了,哪一步触发了AI调用
这里就引入了另一个顾问ChatModelAdvisor

他也是callAdvisor/StreamAdvisor接口的实现类,有没有发现他已经重写好了adviseCall方法,内部你可以看见他就是自动获取到我们的参数信息,然后底层调用chatModel.call()方法,去实际调用AI的执行,并获取到响应结果chatClientResponse

  • 你负责:配置"花里胡哨"的顾问功能(记忆、RAG、日志、鉴权)。
  • 框架负责 :自动在顾问链条的最后一位 安插 ChatModelCallAdvisor,确保请求最终能发出去,并且能拿到 AI 的回复。

这一切都是SPring AI框架给你做好的了,这也太贴心了

(2)

我们的顾问链本质就是栈,那么我们设置的顾问的order属性哪里用到了

核心关系

java 复制代码
getOrder() 返回值 → 排序 → 压入栈 → pop() 弹出顺序 → 实际执行顺序

表格

getOrder() 数值特点 在栈中的位置 执行时机
越小(如 100) 优先级高 栈底(后压入) 先执行(先 pop)
越大(如 300) 优先级低 栈顶(先压入) 后执行(后 pop)

这一点博主没有继续扒源码,扒不动了,但是博主推测,Spring AI应该是通过我们的order值,去手动控制我们栈的顺序,让他符合数值越小越先执行的规律

四:ChatMemoryAdvisor

一、ChatMemoryAdvisor 是什么

官方内置 Advisor ,专门处理对话历史记忆

核心功能

功能 说明
加载历史 ChatMemory 中读取之前的对话
拼接 Prompt 把历史消息 + 当前消息合并成完整 Prompt
保存对话 把本轮对话(用户输入 + AI 回复)存回 ChatMemory

代码示意

java 复制代码
public class MessageChatMemoryAdvisor extends BaseAdvisor {
    
    private final ChatMemory chatMemory;
    private final String conversationId;  // 区分不同用户的对话
    
    @Override
    protected ChatClientRequest before(ChatClientRequest request) {
        // 1. 加载历史(Before)
        List<Message> history = chatMemory.get(conversationId, 10); // 最近10条
        
        // 2. 拼接到当前请求
        List<Message> messages = new ArrayList<>();
        messages.addAll(history);           // 历史
        messages.add(request.getUserMessage()); // 当前
        
        return request.withPrompt(new Prompt(messages));
    }
    
    @Override
    protected ChatClientResponse after(ChatClientResponse response) {
        // 3. 保存本轮对话(After)
        chatMemory.add(conversationId, request.getUserMessage());
        chatMemory.add(conversationId, response.getAssistantMessage());
        return response;
    }
}

二、与自定义 Advisor 的对比

维度 ChatMemoryAdvisor(内置) 自定义 Advisor
来源 Spring AI 官方提供 你自己写
功能 专门处理对话记忆 任意功能(日志、安全、限流...)
实现方式 继承 BaseAdvisor,重写 before/after 同样继承 BaseAdvisor,重写 before/after
是否需要 ChatMemory Bean ✅ 需要 看需求,不需要
复杂度 中等(要管理历史消息) 简单到复杂都可以

三、和自定义顾问的本质区别:没有本质区别!

ChatMemoryAdvisor 就是一个"官方写的自定义 Advisor"

它的结构和你的自定义 Advisor 完全一样

java 复制代码
// 官方写的
public class MessageChatMemoryAdvisor extends BaseAdvisor {
    @Override
    protected ChatClientRequest before(ChatClientRequest request) { ... }
    @Override
    protected ChatClientResponse after(ChatClientResponse response) { ... }
}

// 你写的
public class MyLoggerAdvisor extends BaseAdvisor {
    @Override
    protected ChatClientRequest before(ChatClientRequest request) { ... }
    @Override
    protected ChatClientResponse after(ChatClientResponse response) { ... }
}

唯一区别:官方已经帮你写好了,你不用重复造轮子。


四、ChatMemoryAdvisor 的特殊之处

1. 它依赖 ChatMemory 接口

java 复制代码
public interface ChatMemory {
    void add(String conversationId, Message message);
    List<Message> get(String conversationId, int lastN);
    void clear(String conversationId);
}
实现类 存储位置 特点
InMemoryChatMemory JVM 内存 简单、重启丢失
CassandraChatMemory Cassandra 分布式持久化
JdbcChatMemory 关系型数据库 已有数据库基础设施
Neo4jChatMemory Neo4j 图数据库 图结构分析

2. 它需要 conversationId 区分不同对话

java 复制代码
// 每次调用要指定对话ID
chatClient.prompt()
    .advisors(a -> a.param("conversation_id", "user_123"))  // 区分用户
    .call();

五:具体内部构造

先看一下构造

这里可以看见我们上面介绍的BaseAdvisor内部还有一个接口,即BaseChatMemoryAdvisor,

java 复制代码
default String getConversationId(Map<String, Object> context, String defaultConversationId)
参数 类型 含义
context Map<String, Object> 上下文数据,可能包含 conversation_id
defaultConversationId String 默认对话ID,当 context 中没有时用这个

返回值 :最终使用的 conversationId(String)


逐行拆解

java 复制代码
Assert.notNull(context, "context cannot be null");

作用 :如果 context 是 null,抛 IllegalArgumentException


java 复制代码
Assert.noNullElements(context.keySet().toArray(), "context cannot contain null keys");

作用 :遍历 context 的所有 key,如果有 null key,抛异常

防御性编程,防止 Map 中有 null 作为 key(虽然 HashMap 允许 null key,但这里不允许)

java 复制代码
Assert.hasText(defaultConversationId, "defaultConversationId cannot be null or empty");

作用defaultConversationId 必须有实际内容(不能是 null、""、" ")

java 复制代码
return context.containsKey(ChatMemory.CONVERSATION_ID)
    ? context.get(ChatMemory.CONVERSATION_ID).toString()   // 有就用 context 里的
    : defaultConversationId;                                // 没有就用默认值

整体逻辑流程图

java 复制代码
传入参数:
    context = {"conversation_id": "user_123", ...}
    defaultConversationId = "default_session"

检查:
    context.containsKey("conversation_id") ?
        ├── true  → 返回 "user_123"(context里的)
        └── false → 返回 "default_session"(默认值)

使用场景:多用户对话(需要区分用户)

java 复制代码
// 用户A的请求
Map<String, Object> context = new HashMap<>();
context.put(ChatMemory.CONVERSATION_ID, "user_A_001");

String convId = advisor.getConversationId(context, "default");
// 结果: "user_A_001"  ← 用 context 里的,A和B的记忆隔离

// 用户B的请求
context.put(ChatMemory.CONVERSATION_ID, "user_B_002");
String convId2 = advisor.getConversationId(context, "default");
// 结果: "user_B_002"

作用:不同用户的对话历史互不干扰

这里总结一下,这个Map集合很重要,我们不同的顾问都可以享有这个共享的MAP,每个顾问可以从MAP集合中获取到自己当前所需要的数据,比如我们上面的例子,他就是通过在Map中存放当前记忆顾问所需要的conversation_id,原因是不同用户的记忆是必须要隔离开的,通过将这个标识存入共享Map中,传递给顾问链,记忆顾问就能自动检索当前Map中是否有conversation_id,取出该值,从而找出该表示下用户的记忆。关于这个Map后面会讲解

下面我们简单讲解一下BaseChatMemoryAdvisor的实现类

这一块博主扒不动了,太累了,直接展示AI解释吧,各位大佬有兴趣可以自己去手动翻阅一下源码

一、核心区别:存储和拼接的粒度不同

表格

维度 MessageChatMemoryAdvisor PromptChatMemoryAdvisor
存储单位 Message 对象(结构化) Prompt 对象(整体模板)
记忆内容 单条消息(用户/AI各一条) 整个 Prompt(包含系统提示+历史+当前)
灵活性 高(可精确控制每条消息) 低(整体替换)
适用场景 通用对话、多轮聊天 复杂模板、固定格式的 Prompt

二、MessageChatMemoryAdvisor ------ 基于消息的记忆
工作原理
java 复制代码
public class MessageChatMemoryAdvisor extends BaseChatMemoryAdvisor {
    
    @Override
    protected ChatClientRequest before(ChatClientRequest request) {
        // 1. 从内存读取历史消息(List<Message>)
        List<Message> history = chatMemory.get(conversationId, lastN);
        
        // 2. 构建新消息列表:系统提示 + 历史 + 当前消息
        List<Message> messages = new ArrayList<>();
        messages.add(new SystemMessage("你是一个助手"));  // 系统提示
        messages.addAll(history);                          // 历史对话
        messages.add(request.getUserMessage());            // 当前用户消息
        
        // 3. 用消息列表创建 Prompt
        return request.withPrompt(new Prompt(messages));
    }
    
    @Override
    protected ChatClientResponse after(ChatClientResponse response) {
        // 保存本轮对话(两条消息)
        chatMemory.add(conversationId, response.getUserMessage());      // 用户消息
        chatMemory.add(conversationId, response.getAssistantMessage()); // AI回复
        return response;
    }
}
存储结构
java 复制代码
ChatMemory 中存储:
├── [0] UserMessage: "你好"
├── [1] AssistantMessage: "你好!有什么可以帮你的?"
├── [2] UserMessage: "我叫张三"
├── [3] AssistantMessage: "你好张三!"
└── ...
特点
  • 结构化:每条消息有明确角色(User/Assistant/System)
  • 可精确控制:可以只取最近 N 条,或过滤特定类型
  • 通用性强:适用于大多数对话场景

三、PromptChatMemoryAdvisor ------ 基于 Prompt 的记忆
工作原理
java 复制代码
public class PromptChatMemoryAdvisor extends BaseChatMemoryAdvisor {
    
    @Override
    protected ChatClientRequest before(ChatClientRequest request) {
        // 1. 从内存读取历史 Prompt(整个 Prompt 对象)
        List<Prompt> historyPrompts = chatMemory.get(conversationId, lastN);
        
        // 2. 构建新 Prompt:历史 Prompt + 当前 Prompt
        // 或者直接把历史 Prompt 的 messages 提取出来拼接
        Prompt currentPrompt = request.getPrompt();
        
        // 合并所有 messages
        List<Message> allMessages = new ArrayList<>();
        for (Prompt histPrompt : historyPrompts) {
            allMessages.addAll(histPrompt.getInstructions());  // 提取历史消息
        }
        allMessages.addAll(currentPrompt.getInstructions());    // 当前消息
        
        return request.withPrompt(new Prompt(allMessages));
    }
    
    @Override
    protected ChatClientResponse after(ChatClientResponse response) {
        // 保存整个 Prompt(包含系统提示+历史+当前)
        Prompt fullPrompt = buildFullPrompt(response);
        chatMemory.add(conversationId, fullPrompt);  // 存的是 Prompt 对象!
        return response;
    }
}
存储结构
java 复制代码
ChatMemory 中存储:
├── [0] Prompt: {system="你是助手", messages=[User:"你好", Assistant:"你好!"]}
├── [1] Prompt: {system="你是助手", messages=[User:"我叫张三", Assistant:"你好张三!"]}
└── ...
特点
  • 整体存储:保留完整的 Prompt 模板(含系统提示、参数等)
  • 适合复杂场景:如需要保留每次调用的完整上下文(包括温度、模型参数等)
  • 占用空间大:存储的是整个 Prompt 对象,不是单条消息

四、对比总结
场景 推荐选择
普通多轮对话 MessageChatMemoryAdvisor
需要精确控制单条消息 MessageChatMemoryAdvisor
复杂 Prompt 模板(含系统提示、变量) PromptChatMemoryAdvisor
需要保留每次调用的完整参数 PromptChatMemoryAdvisor
内存敏感(存储空间小) MessageChatMemoryAdvisor

五、使用方式
java 复制代码
@Bean
public ChatClient chatClient(ChatClient.Builder builder, ChatMemory chatMemory) {
    return builder
        .defaultAdvisors(
            // 方式1:基于消息(常用)
            new MessageChatMemoryAdvisor(chatMemory),
            
            // 方式2:基于 Prompt(特殊场景)
            // new PromptChatMemoryAdvisor(chatMemory)
        )
        .build();
}

六、快速记忆
java 复制代码
MessageChatMemoryAdvisor = "记聊天记录"(一条一条记)
PromptChatMemoryAdvisor  = "记整篇作文"(一整篇一整篇记)

大多数情况用 MessageChatMemoryAdvisor 就够了,
只有需要保留完整 Prompt 模板时才用 PromptChatMemoryAdvisor。

我们的这些顾问都是已经被Spring AI封装好了,可以直接使用,下面博主展示一下我们项目里怎么用

五:顾问的Map容器

(1)顾问容器讲解

还是举我们项目中的例子,我们为顾问设置了多个param值

最终advisor的param存入当前图片该类的advisorParams参数中(不再赘述存如过程,前面有笔记)

这里的核心是toChatCLientRequest方法,他就是将我们的DefaultChatClientRequestSpec中的所有参数封装成ChatClientRequest对象,下面来看一下具体内部是啥样的

看代码最后面,DefaultChatClientUtils将变量advisorParams封装为一个context变量中,并返回一个chatClient

java 复制代码
/**
 * Represents a request processed by a {@link ChatClient} that ultimately is used to build
 * a {@link Prompt} to be sent to an AI model.
 *
 * @param prompt The prompt to be sent to the AI model
 * @param context The contextual data through the execution chain
 * @author Thomas Vitale
 * @since 1.0.0
 */
public record ChatClientRequest(Prompt prompt, Map<String, Object> context) {

    public ChatClientRequest {
       Assert.notNull(prompt, "prompt cannot be null");
       Assert.notNull(context, "context cannot be null");
       Assert.noNullElements(context.keySet(), "context keys cannot be null");
    }

    public ChatClientRequest copy() {
       return new ChatClientRequest(this.prompt.copy(), new HashMap<>(this.context));
    }

    public Builder mutate() {
       return new Builder().prompt(this.prompt.copy()).context(new HashMap<>(this.context));
    }

    public static Builder builder() {
       return new Builder();
    }

    public static final class Builder {

       private Prompt prompt;

       private Map<String, Object> context = new HashMap<>();

       private Builder() {
       }

这段是chatCLient的部分源码,可知它内部有变量Prompt和context(Map类型),通过看开发师的注释,可知prompt = 最终发给 AI 的内容context = 执行过程中内部用的临时数据(不发给 AI)可以确定我们给AI设置的advisor的param参数全部被放进这个context中了,传递给整个顾问链使用

后续我们在使用自定义顾问时,只需要通过获取到chatClientRequest对象,通过context()方法就可以获取到共享容器Map了,可以获取到内部的数据

(2)项目中的使用技巧

项目背景:我们是一个多智能体项目,具体其中一个架构是client1->client2->client3,client1负责分析,client2负责根据分析结果执行,client3负责监督执行情况,我们是都给他们配备了顾问,同时自定义了一个RAGAnswerAdvisor顾问,该顾问会在调用AI前执行我们的顾问,会进行RAG检索,bm25评分等等一堆逻辑

可以看见我们是将RAG检索结果documentContext,拼装到chatReuestprompt变量中,当作提示词返回给AI

问题1

我们每个client的顾问都是单独设置的,走的都是RAGAnwserAdvisor自定义顾问,这就会引发一个问题,就是MAP容器不互通,client1执行完后,client的顾问就结束了,Map容器不会再传到client2.这就会引发一个问题,我们client2的顾问调用时还会触发顾问的完整的RAG检索流程(因为走到都是同一个顾问),过于影响性能,因为用户问题是一样的,检索结果不会有太大区别,反而每次触发检索很影响性能

解决办法:

在第一次进行RAG检索时,外界传入一个共享容器的引用

我们设置了一个对象,专门用来管理多个client间的共享数据,这里注意我们设定了一个MAP集合,用于存放我们的RAG检索结果

我们client1调用advisor时,调用getDataObjects方法,获取到一个空的hashMap,传递给client1的顾问的共享容器,并设置键qa_execution_context

client1执行顾问,先获取到我们的RAG检索结果并规范化操作,注意我们这里调用了一个writeCacheRetriecalResult方法

我们将RAG检索结果保存在这个我们传递的HashMap中,而不再存入顾问的共享容器中(因为client1执行完后,client1的顾问链就会结束,共享容器也就不存在了)

因为外界的dynamicContext变量持有我们上面的hashMap的引用,上一步client1存入RAG检索结果,我们可以通过这个引用获取到具体的值。我们在设置client2的顾问时,通过调用dynamicContext.getDataObjects传递这个引用给client2的顾问,这样client2的顾问在执行的before方法时,通过取出client2的共享容器context,在通过qa_execution_context取出具体的结果。如果有值,则直接复用该RAG检索结果,如果没有值,再重新进行RAG检索,并将结果再次存入这个hashMap中

java 复制代码
┌─────────────────────────────────────────────────────────────┐
│ Step1AnalyzerNode 执行                                      │
│                                                             │
│  dynamicContext.getDataObjects()                            │
│    ├─ 返回 HashMap 实例(容器 A)                            │
│    └─ 传递给 advisor: .param("qa_execution_context", 容器 A) │
└─────────────────────┬───────────────────────────────────────┘
                      │
                      ▼
┌─────────────────────────────────────────────────────────────┐
│ RagAnswerAdvisor.before()                                   │
│                                                             │
│  executionContext = context.get("qa_execution_context")     │
│    ├─ executionContext 引用 容器 A                           │
│    └─ writeCachedRetrievalResult(executionContext, ...)     │
│         └─ executionContext.put("qa_retrieval_cache", ...)  │
│              👆 直接修改容器 A                               │
└─────────────────────┬───────────────────────────────────────┘
                      │
                      │ 容器 A 现在包含 RAG 检索结果
                      │
                      ▼
┌─────────────────────────────────────────────────────────────┐
│ Step2PrecisionExecutorNode 执行                             │
│                                                             │
│  dynamicContext.getDataObjects()                            │
│    ├─ 返回同一个 HashMap 实例(容器 A)                      │
│    └─ 容器 A 中已经包含 Step1 写入的数据                     │
│         └─ .param("qa_execution_context", 容器 A)           │
└─────────────────────┬───────────────────────────────────────┘
                      │
                      ▼
┌─────────────────────────────────────────────────────────────┐
│ RagAnswerAdvisor.before() (Step2)                           │
│                                                             │
│  readCachedRetrievalResult(executionContext, cacheKey)      │
│    ├─ executionContext 引用 容器 A                           │
│    └─ 从容器 A 中读取 "qa_retrieval_cache"                   │
│         └─ 命中缓存!跳过 RAG 检索                           │
└─────────────────────────────────────────────────────────────┘
  • 通过 Java 的 引用传递 机制
  • Step1 调用时: dynamicContext.getDataObjects() → HashMap 实例 A → 传给 advisor → advisor 修改实例 A
  • Step2 调用时: dynamicContext.getDataObjects() → 同一个 HashMap 实例 A → 读取已修改的数据
  • 为什么能共享?
  • 因为 dynamicContext.getDataObjects() 始终返回 同一个 HashMap 实例
  • Java 中对象是引用传递,advisor 内部的修改会直接反映到原始对象中
问题2

如果我们有的client本身需要跳过顾问的一些方法,比如client1需要RAG检索,client3不需要,怎么控制实现这种差异化的顾问,虽然用的是同一个顾问,但是执行的内容不同

解决

博主举个例子就明白了

我们在执行顾问时传递一个标识,比如我们图上的"qa_rag_enabled":false,那么我们在顾问执行RAG检索逻辑时,先从共享容器context中读取"qa_rag_enabled"这个键,如果值为fakse,那么就说明我们需要跳过RAG检索,直接进行下一步逻辑

六·:Spring AI的Advisor的一些缺陷

这是博主偶然之间发现的,也是那种本来程序好好的,突然改动了一点,程序突然就运行不了了,但是通过不断debug调试+问AI,终于解决了


我们是自定义的顾问出问题了,抛出空指针异常,这里博主就不迈关子了,直接解释哪里有问题

博主在操作顾问的共享容器context时,设置了一个keyqa_knowledge_tag,但是值为NULL ,这一点就出问题了

我们顾问的before(...) 执行成功了,返回了一个 ChatClientRequest 。然后 callAdvisorChain.nextCall() 把这个请求传给下一个 Advisor( ChatModelCallAdvisor ),而 ChatModelCallAdvisor 在处理时,其内部调用了 Map.copyOf(context) ,这里的 context 为 null。为NULL的原因就是因为我们的那个key的值为NULL

这就涉及到了Spring AI的一个涉及缺陷,这里附上github的具体问题链接https://github.com/spring-projects/spring-ai/issues/4952

问题的根因是: ChatClientRequest 的 context Map 中如果 某个 value 为 null ,那么在 ChatModelCallAdvisor 中调用 Map.copyOf() 时会抛出 NullPointerException 。因为 Map.copyOf() 不允许 null 值。ChatModelCallAdvisor.adviseCall() 内部会调用 Map.copyOf(context) ,而 Java 的 Map.copyOf() 方法 不允许 Map 中存在 null 值 ,否则会抛出 NullPointerException

从堆栈可以看到(博主出现的错误):

java 复制代码
at java.base/java.util.Map.copyOf(Map.java:1747)
at org.springframework.ai.chat.client.advisor.ChatModelCallAdvisor.
adviseCall(ChatModelCallAdvisor.java:57)

解决方案

做防御性编程,如果设置给context容器的字符串为NULL,那么就替换为空字符串``

java 复制代码
// 修复前
advisedUserParams.put("qa_retrieved_documents", documents);
advisedUserParams.put("qa_knowledge_tag", knowledgeTag);
advisedUserParams.put("qa_query", queryText);

// 修复后
advisedUserParams.put("qa_retrieved_documents", documents != null ? documents : Collections.emptyList());
advisedUserParams.put("qa_knowledge_tag", knowledgeTag != null ? knowledgeTag : "");
advisedUserParams.put("qa_query", queryText != null ? queryText : "");  
相关推荐
薪火铺子1 小时前
Redis 缓存三大问题与解决方案
redis·spring·缓存
sali-tec1 小时前
C# 基于OpenCv的视觉工作流-章66-直线夹角
图像处理·人工智能·opencv·算法·计算机视觉
不背八股的AI选手1 小时前
《别再“喂prompt赌运气”了:我的AI开发工程化管理实践》
人工智能
AC赳赳老秦1 小时前
接口测试自动化:用 OpenClaw 对接 Postman,实现批量回归测试、测试报告自动生成与推送
java·人工智能·python·算法·elasticsearch·deepseek·openclaw
两年半的个人练习生^_^1 小时前
Java日志框架和使用、日志记录规范
java·开发语言·开发规范
DO_Community1 小时前
DigitalOcean VPC 网络故障排查 Runbook 实战指南
人工智能·aigc·claude·deepseek
PILIPALAPENG1 小时前
第4周 Day 1:智能体记忆系统——给 Agent 一个"大脑"
前端·人工智能·python
是你的小橘呀1 小时前
coze工作流打造 来喽!!
人工智能
再玩一会儿看代码1 小时前
如何理解神经网络中的权重参数?从一张图看懂模型参数量计算
人工智能·经验分享·python·深度学习·神经网络·机器学习