news 2026/10/13 4:42:35

Spring AI ChatClient Advisor:内置与自定义实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring AI ChatClient Advisor:内置与自定义实践

这期继续聊 Spring AI 实践系列的第二篇,重点拆 ChatClient 里的 Advisor(建议器)。如果你已经用 ChatClient 调通过大模型接口,多半会遇到一个很现实的问题:需求一多,业务方法就开始膨胀,拼历史消息、做敏感词过滤、打日志、记审计、甚至限流,全部堆在同一个方法里。Advisor 就是 Spring AI 为这类"横切逻辑"设计的一层可插拔组件,挂在请求链路中间,相当于发往模型的请求出门前先过几道安检,回来时再走一遍复查。

这篇文章适合两类人。一类是刚刚用 ChatClient 跑通基础对话、准备真正工程化的朋友;另一类是已经在业务代码里堆过一堆 if/else、想重新组织代码结构的开发者。看完你至少能解决三个问题:内置的 Advisor 怎么配置、自定义 Advisor 怎么写、以及后面大概率会遇到哪些坑。

1. 先搞懂 Advisor 在调用链里的位置

1.1 从 ChatClient 到模型的一次请求,中间到底发生了什么

很多人第一次看 Spring AI 的文档,会被一堆概念吓住,比如 ChatClient、ChatModel、Advisor、Prompt、Message。其实剥开来看,一次完整请求的路径并不复杂。

当你写下这样一段调用代码:

String result = chatClient.prompt() .system("你是一个产品技术支持") .user("我的订单还没到,怎么办?") .call() .content();

框架内部大概会依次做这些事:

  1. 把系统提示词、用户消息、模板参数,统一组装成一个 Prompt 对象。
  2. 在真正发起模型 API 调用之前,进入 Advisor 链的 before 阶段。
  3. 由链上的最后一个环节发起对模型的远程调用,拿到原始响应。
  4. 响应内容再经过 Advisor 链的 after 阶段,逐层返回给 ChatClient。
  5. ChatClient 把最终文本交给你,也就是 content() 拿到的字符串。

Advisor 链本质上就是一个责任链。如果你配置了多个 Advisor,它们的执行顺序和 Web 容器里的过滤器很类似。用生活里的场景来理解,我觉得最贴切的是快递驿站:包裹到达驿站后,会先进门登记、再安检、再按地址分拣,最后才交到快递员手里。这一整套流程并不会改变包裹本身,但它能保证包裹安全、准确、可追踪地送出去。Advisor 就是在模型请求送出之前和之后,帮你完成这些"附加流程"的地方。

需要特别提一句:这条链路对业务代码是透明的。你写业务逻辑时不需要关心当前到底挂了几个 Advisor,只要 ChatClient 构建时配置好,后续每次调用都会自动走完这些环节。这个透明性,正是它适合做横切逻辑的根本原因。

1.2 不加 Advisor 时,业务代码是怎么一步步变成大杂烩的

如果不用 Advisor,这些"附加流程"会变成什么样?我见过很多实际项目,典型的演进过程是这样的。

第一版,你只是单纯调用:

public String chat(String userId, String question) { return chatClient.prompt().user(question).call().content(); }

看起来挺干净。但需求很快来了:用户希望模型记得上一轮聊了什么。于是你开始自己拼历史:

public String chat(String userId, String question) { List<Message> history = messageStore.findByUserId(userId); String promptWithHistory = buildPromptWithHistory(history, question); String answer = chatClient.prompt().user(promptWithHistory).call().content(); messageStore.save(userId, question, answer); return answer; }

再后来,产品要求对输入做敏感词过滤,要求每次调用记录日志用于审计,要求统计 token 消耗,要求对某些用户做灰度提示词。于是这个 chat 方法会越来越长,每一行都在处理"聊天以外的业务"。等到维护时,一个方法动辄几百行,想单独调整提示词策略,还得小心不要动到历史消息逻辑。

不是说手动拼历史不行,而是当这些逻辑越来越多时,它们彼此纠缠,没有清晰边界。Advisor 的作用,就是把"请求之前要做的事"和"请求之后要做的事"从业务方法里剥离出来,让人各司其职。业务方法只关心"用户说了什么、我要拿到什么答案",而历史、过滤、日志、审计都交给 Advisor 链去处理。

理解了这一点,再看后面的内置 Advisor 和自定义写法,思路会顺很多。

2. 内置 Advisor 怎么用:记忆与日志两个高频场景

先说结论:Spring AI 内置的 Advisor 里,实际项目中最常用的是两类——对话记忆类和日志观测类。它们解决的是两个每天都躲不开的问题:模型没有状态,以及调模型像黑盒。

2.1 让模型记住上下文:MessageChatMemoryAdvisor 的配置与窗口选择

模型接口本身是无状态的。所谓"记住",其实是在每次请求前把之前的对话历史塞进 prompt,请求结束后再把新消息写回某个存储。Spring AI 把这件事封装在 MessageChatMemoryAdvisor 里,你只需要告诉它:记忆存在哪里、每次取多少窗口。

先看一个最小配置:

ChatMemory chatMemory = InMemoryChatMemory.builder().build(); ChatClient chatClient = ChatClient.builder(chatModel) .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory, 20)) .build();

构建好后,业务调用时通过 advisor 参数指定会话 ID。这里有个细节:这个 ID 通常是当前用户的身份标识,也可以是前端传入的会话编号;只要保证同一用户、同一会话传同一个值,后续请求就能读到之前保存下来的历史内容。

String answer = chatClient.prompt() .user("我叫小明,我最喜欢的颜色是蓝色") .advisors(a -> a.param("conversationId", "user-001")) .call() .content();

如果你没有指定会话 ID,所有对话会共用一段默认记忆,这在真实的多人业务场景里基本不可接受,所以务必在业务入口处把当前用户或当前会话映射到唯一的 conversationId。

关于窗口大小,我的建议是别拍脑袋设一个很大的数字。窗口 20 指的是最多保留多少条历史消息,但每条历史消息里可能带着很长的内容,真正决定成本上限的是 token。如果业务里的单条消息本身就经常超过几百 token,建议优先按 token 上限去控制,或者配合摘要压缩来处理早期历史。设置窗口时还要考虑模型的上下文长度:比如模型支持 8K context,你历史就占了 6K,那新问题能用的空间就很紧张,复杂任务很容易触发截断或答非所问。

从成本角度,你也可以把窗口设小一点,只保留最近 5 到 10 轮。很多客服场景并不需要模型记住二十轮之前的细节,反而需要它专注于当前问题。宁可让模型在关键节点主动追问,也不要让它被一堆无用的历史背景干扰。这算是我在多轮对话项目里踩出来的经验。

2.2 用 SimpleLoggerAdvisor 看清楚每一次请求的"长相"

排查对话类问题时,第一件事永远是"看请求到底发了什么"。SimpleLoggerAdvisor 就是干这个的:它会在请求前后打印相关日志,把 prompt 元数据和最终的响应文本记录到日志系统。配置很简单:

ChatClient chatClient = ChatClient.builder(chatModel) .defaultAdvisors(new SimpleLoggerAdvisor()) .build();

默认配置下,一次调用会输出类似这些信息:请求的 userText 或 systemText、请求中的参数集合、返回的响应内容。这套东西在开发环境调试时非常好用,能帮你快速确认"消息到底有没有拼上历史""系统提示词有没有被覆盖""返回内容里为什么多了一段注释"。

但有一点要非常小心:如果你的业务涉及敏感信息,比如用户手机号、地址、内部文档内容,直接把默认的 SimpleLoggerAdvisor 开到生产环境,等于把这些内容全部写进日志。日志系统一旦被误读或长期保存,就成了新的数据泄漏点。我的处理方式是:生产环境要么不开,要么对日志输出做二次加工,把可能敏感的词替换成掩码再打印。

另外,如果你是异步流式调用,日志里能看到的常常是拼凑起来的片段,或者什么都不显示。这种时候不要怀疑 Advisor 没生效,先确认你用的调用方式和版本里该 Advisor 是否支持流式输出。实在不行,就在业务层自己打关键节点的日志。

2.3 内置 Advisor 速览:还有哪些值得关注

除了记忆和日志,Spring AI 生态里还有不少 Advisor 或类似机制的组件。不过因为版本演进较快,不同版本的类名和功能覆盖范围经常变化,我这里只挑值得关注的方向,具体名称请以你当前依赖版本为准。

Advisor 类型核心作用适合场景
对话记忆类请求前注入历史消息,请求后保存新消息多轮客服、AI 助手、连续问答
日志观测类打印请求和响应,方便调试开发环境联调、线上问题排查
业务自定义类脱敏、审计、限流、提示词改写内容安全、合规、灰度策略

表格里最后一行"业务自定义类"虽然不算内置,但它是比较推荐的扩展方向。框架只负责提供脚手架,真正的业务逻辑应该放在自己的 Advisor 里。比如很多团队会自己写一个审计 Advisor,在 around 阶段记录每个用户的问题和模型答案,方便后期做质检;或者写一个提示词改写 Advisor,把用户口语化的问题改写成更规范的检索式表达。这些内容我放到第三部分详细展开。

3. 手写一个自定义 Advisor:给模型请求加"安检"

在动手之前,先明确一个前提:不同版本的 Spring AI 中 Advisor 接口方法签名有差异,本文示例以实践通用写法为主,实际开发请对照你引入的版本 API。核心思路是一致的。

3.1 写一个 AroundAdvisor:脱敏与 traceId 注入的实现骨架

Advisor 接口主要提供了 before、after、around 三个阶段。你可以只实现某个阶段,也可以实现 around 来完全控制整个链路。从灵活度来看,around 是最强的,因为它在 before 和 after 之外,还掌握了"是否继续调用下一个 Advisor 甚至模型"的决定权。

下面是一个比较通用的骨架,场景是:调用模型之前,对用户文本做脱敏,并往里注入一个业务 traceId,方便后续日志串联整个调用过程。

public class SensitiveWordAndTraceAdvisor implements AroundAdvisor { private final int order; public SensitiveWordAndTraceAdvisor(int order) { this.order = order; } @Override public AdvisedRequest before(AdvisedRequest request) { String userText = request.userText() == null ? "" : request.userText(); String masked = maskSensitiveInfo(userText); return AdvisedRequest.from(request) .withUserText(masked) .withSystemText(buildSystemTextWithTrace(request)) .build(); } @Override public AdvisedResponse around(AdvisedRequest request, Callable<AdvisedResponse> chain) throws Exception { AdvisedRequest prepared = before(request); AdvisedResponse response = chain.call(prepared); after(prepared, response); return response; } @Override public void after(AdvisedRequest request, AdvisedResponse response) { // 后置处理:对响应文本做合规检查、统计耗时等 } @Override public int getOrder() { return order; } private String maskSensitiveInfo(String text) { // 根据业务规则替换手机号、身份证等敏感信息 // 示例从简,实际可以引入正则或专用脱敏组件 return text.replaceAll("1[3-9]\\d{9}", "***手机号***"); } private String buildSystemTextWithTrace(AdvisedRequest request) { String trace = TraceContext.get(); return "当前请求追踪ID:" + trace + "\n" + (request.systemText() == null ? "" : request.systemText()); } }

这个骨架覆盖了三个关键问题:一是在 before 阶段对用户输入做脱敏,这是大多数安全和合规需求最常落地的位置;二是在系统提示词中注入请求跟踪信息,方便后续在大量日志中按 traceId 串联;三是通过 around 方法把后续链路的调用包在中间,确保前后置逻辑都在同一次请求生命周期里执行。

有一点要提醒:我在 around 里手动调用了 before 和 after,这在代码组织上是可行的,但对不熟悉这套机制的人来说容易搞混。更稳妥的做法是只重写 around,把 before 和 after 留空,在 around 里按自己的顺序调用那些逻辑。但无论你怎么组织,核心思路都是一样的:在链式调用中让当前 Advisor 包裹下一个环节的调用。

3.2 多个 Advisor 的顺序控制:先安检还是先记录

当系统里有多个 Advisor 时,顺序就变得非常重要。我见过一个典型事故:一个团队加了两个 Advisor,一个是日志 Advisor,一个是脱敏 Advisor。日志 Advisor 的 order 比脱敏小,结果它先打印的是包含手机号的原始内容,脱敏逻辑在后面才执行。本意是保证日志安全,却在第一步就泄漏了数据。

责任链的顺序规则并不难记:数值越小的 order 越靠前执行。before 阶段按 order 从小到大执行,after 阶段则反过来,从大到小返回。这在概念上和过滤器链是等价的。你可以通过构造函数传入 order,也可以注册成 Spring Bean 后通过实现 Ordered 接口来控制。

我给你的建议是三个:

  • 基础框架类 Advisor(比如日志)order 设在中高位,业务处理类 Advisor(比如脱敏、鉴权)order 设在中低位,让业务处理先于日志记录。
  • 每个自定义 Advisor 要有稳定的 order 值,不要临时写一个魔法数。
  • 在 ChatClient 构建时,把 Advisor 列表按业务逻辑的依赖关系整理好,不要只依赖框架排序。

理解了顺序,谁来保证不出错?答案是测试。写一次集成测试,用一个 mock 模型服务,把带敏感词的文本走一遍,然后去断言日志里没有敏感词。这种测试看起来笨,但它能挡住大部分顺序回归问题。

3.3 数据流转细节:AdvisedRequest 里能改什么、不能改什么

AdvisedRequest 是整个 Advisor 机制里最重要的数据结构。它封装了一次模型请求在各个 Advisor 之间传递的信息。虽然没有必要把每个字段都背下来,但你至少要清楚几个常用入口:

  • userText:用户输入文本,适合做输入侧脱敏、改写、关键词替换。
  • systemText:系统提示词,适合注入策略、上下文、额外约束。
  • messages:请求里的完整消息列表,如果要直接操作消息数组,可以在这里处理。
  • params:提示词模板的参数集合,适合动态注入变量。

很多人在写自定义 Advisor 时会犯同一个错误:直接修改原始请求里的字段。从接口设计的角度,这是坏味道。正确的做法是像前面代码演示的那样,用AdvisedRequest.from(request)复制出一份新请求,然后修改这份新请求再传给链条。这样做的好处是:每个 Advisor 都只改自己关心的一小部分,后续 Advisor 拿到的是经过前面处理的完整快照,调试时也更容易追踪。

另外,不要在 before 阶段轻易丢弃消息。比如你想过滤掉某条历史消息,如果处理不当,后续想恢复就很麻烦。更好的方式是构造新的消息列表,把需要保留的消息传下去。一句话:Advisor 的职责是"加工"请求,不是"销毁"请求。

3.4 进阶:在 around 里做单次重试和超时控制

最后一个进阶技巧,也是我在生产环境里真正用过的:在 around 阶段增加单次重试。大模型接口和普通接口不太一样,偶尔会有网络抖动、服务端限流、返回格式异常。与其在业务层写复杂重试,不如在 Advisor 里统一处理。

@Override public AdvisedResponse around(AdvisedRequest request, Callable<AdvisedResponse> chain) throws Exception { try { return chain.call(request); } catch (Exception ex) { if (allowRetry(request)) { return chain.call(request); } throw ex; } }

注意几个细节:

  • 重试最多一次,不要无限循环。模型调用成本高,无限重试容易造成成本失控。
  • 重试之前要判断异常类型。超时可以重试,但认证失败、参数错误这种就不要试了,试了也没用。
  • 如果请求里已经带有幂等 ID,或者当前是流式调用,务必确认重试语义不会破坏业务。流式响应已经发出一部分时,重试会导致用户看到两段重复回复,这种情况下直接放弃重试更安全。

这些内容在官方文档里写得不细,属于实践中的经验。如果用重试场景,建议先想清楚业务接受的最长等待时间和最多额外成本,再决定要不要加这一层。

4. 踩坑实录与排查技巧

4.1 记忆不生效或上下文爆炸?先查这三个地方

多轮记忆相关的问题是最常见的。总结起来,"记忆不生效"基本跑不出下面三种原因。

第一个原因是 conversationId 对不上。业务层传的会话 ID,和 MessageChatMemoryAdvisor 里用来存取记忆的 ID 不是同一个。尤其是 ID 来自网关、前端、数据库不同字段时,很容易出现大小写或前后空格不一致。排查方式是在日志里把每次请求的 conversationId 打出来,对照两次请求是否完全一致。

第二个原因是 ChatMemory 实例不唯一。如果你在每次构建 ChatClient 时都 new 一个内存记忆对象,那不同的 ChatClient 之间各玩各的,历史当然互相看不到。正确做法是把 ChatMemory 注册成 Spring 容器里的单例 Bean,让所有会话共用同一个存储。

第三个原因是窗口设置不合理。窗口太大,历史消息会迅速占满上下文长度,模型开始忽略用户当前问题,或者直接报上下文超限;窗口太小,历史太短,连续对话显得像失忆。一个相对稳妥的起步值是 10 到 20 轮,然后再根据 token 消耗和生产反馈去调。

还有一种上下文爆炸的情况:你把长文档或大段资料塞进系统提示词,同时又把历史消息全部带上。这两部分合起来轻松超过模型上限。建议把静态资料和动态历史分开管理,静态部分尽量精简,动态历史设置上限。

4.2 Advisor 顺序颠倒导致的问题

顺序导致的 bug 往往很隐蔽,因为代码单独看都对。再分享一个真实遇到的场景:团队里有人把日志 Advisor 放在最前面,目的是记录最原始的请求,方便还原用户问题。结果这个请求还没有做脱敏,日志直接把用户的完整地址和手机号写进了日志中心,最后在安全审计时才发现。

排查这类问题,光看代码逻辑可能不够。更快的方式是临时把所有 Advisor 的 order 和类名打印出来,确认它们在构建时的实际排序。如果发现日志的 order 小于业务处理的 order,就调整数值,让业务处理先执行。

这里也引申出一个设计原则:日志永远不要记录用户原始输入,至少要记录"经过脱敏后的输入"。如果你需要一个完整的原始输入用于问题复现,也请把它放在专门的安全存储里,而不是普通日志。

4.3 不要在 before 阶段做太重的活

很多人在 Advisor 里写很重的逻辑,比如查数据库、调别的微服务、甚至做复杂的 AI 分类,结果直观表现就是:对话响应明显变慢。原因很简单,每个模型请求都会经过这个 Advisor,一次同步请求可能还行,一旦并发上来,阻塞就会被放大。

我的建议是:

  • 权限校验、风控决策,请使用本地缓存或短超时,不要每次都远程实时调用。
  • 复杂的内容审核,建议使用异步队列处理,而不是在 before 阶段同步阻塞。
  • 如果一定要在链路里访问外部服务,请务必设置超时时间,并做好降级:外部服务故障时直接放行,而不是让整个聊天功能挂掉。

还有一点容易被忽略:当使用流式调用时,Advisor 的执行上下文未必和业务线程完全一致。线程池、虚拟线程、异步回调,可能让 ThreadLocal 里的 traceId 在不同阶段不连续。这时候不要强行依赖 ThreadLocal 传导,考虑把 traceId 放在请求参数里显式传递。

4.4 常见问题速查表

为了方便事后翻阅,把上面提到的典型问题整理成表格:

症状可能原因快速排查/解决
多轮记忆不生效conversationId 不一致打印请求参数,统一 ID
不同客户端互不共享记忆ChatMemory 被重复 new注册成单例 Bean
历史消息太长触发上下文超限窗口设置过大调小窗口或按 token 限制
日志记录了敏感原始信息日志 Advisor 在业务处理之前调整 order,先脱敏后记录
响应明显变慢before 阶段有外部同步调用加缓存、降级、异步化
流式调用日志缺失流式输出与 advisor 不兼容在业务层记录关键节点
自定义字段传入后丢失构造新请求时只复制了部分字段使用与请求一致的构造方法

这张表不需要背,但遇到问题的时候,从里面找到对应行,能省不少排查时间。

写到这里,关于 ChatClient Advisor 的核心用法和常见问题都讲得差不多了。我在实际项目里最大的体会是:Advisor 真正值钱的地方,不是帮你少写几行代码,而是逼着你去思考"一次模型对话到底包含哪些环节"。记忆、过滤、观测、鉴权,这些横切关注点一旦被拆成清晰的管线,后续加需求会从容很多。

如果你刚接触这套机制,我建议不要一上来就写复杂的自定义 Advisor。先用内置的记忆和日志把全链路跑通,搞清楚请求进出的顺序,再根据真实的报警和脏数据去写自己的处理逻辑。最后再分享一个小技巧:每个自定义 Advisor 都加上稳定的 order 和可读的类名,日志里带着名字排查,你会感谢之前那个认真的自己。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/13 4:42:04

自研BLE命令行扫描工具BSCAN:破解蓝牙调试低效难题

做嵌入式这几年&#xff0c;调试蓝牙设备一直是我觉得最磨人的环节之一。手头维护的模组多了以后&#xff0c;每次都得掏出不同厂家的调试软件&#xff0c;来回切换配置界面&#xff0c;日志格式还不统一&#xff0c;想批量过滤一下设备都费劲。后来我干脆自己写了一个命令行扫…

作者头像 李华
网站建设 2026/10/13 4:41:53

2026数据库风险监测选型指南:技术演进与落地复盘

如果你还停留在“数据库审计买一台盒子&#xff0c;镜像流量&#xff0c;事后翻日志”的认知阶段&#xff0c;那在2026年做数据库风险监测产品选型时大概率会踩坑。过去两年我深度参与了多个数据中心的风险监测产品评估、POC测试和落地运营&#xff0c;最大的感受是&#xff1a…

作者头像 李华
网站建设 2026/10/13 4:41:28

图色脚本从零到实战:原理、调参与避坑全攻略

图色脚本这个搜索词&#xff0c;我在后台看了很久了。几乎每周都会有人通过不同渠道问我同样的几个问题&#xff1a;图色脚本到底怎么学&#xff1f;学之前要会编程吗&#xff1f;为什么我照着别人的代码抄下来跑不通&#xff1f;趁着最近手上几个自动化项目收尾&#xff0c;我…

作者头像 李华
网站建设 2026/10/13 4:40:51

虚拟桌面发放流程详解:从模板封装到故障排查

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 4:40:48

MiniMax M3 上线 NVIDIA Vera Rubin:推理适配与部署实操指南

1. 这次更新到底意味着什么1.1 从标题拆出来的三个关键信息“MiniMax M3 上线 NVIDIA Vera Rubin”这个标题&#xff0c;信息密度其实很高。我第一眼看到的时候&#xff0c;脑子里自动拆成了三块&#xff1a;MiniMax M3是一个模型版本&#xff0c;NVIDIA Vera Rubin是一个硬件平…

作者头像 李华
网站建设 2026/10/13 4:40:47

Vue动画实战:CSS关键帧与类名机制配合实现弹窗与列表动效

Vue动画这块&#xff0c;很多同学一上来就盯着transition组件和六个类名看&#xff0c;结果动手做弹窗、列表错峰入场的时候&#xff0c;还是会被各种跳变、闪烁、时序问题折腾到怀疑人生。12-01这篇笔记&#xff0c;我单独把 CSS 关键帧&#xff08;keyframes&#xff09;拎出…

作者头像 李华