干活的人都知道,大模型对话接入业务系统,最难的不是调通API,而是怎么把对话管起来。现在Spring AI给出的答案是Advisors,说白了就是对话流水线上的拦截器和增强器。我自己在项目里把它应用到日志审计、敏感词过滤、上下文瘦身、Prompt增强这些场景之后,基本上没再为对话链路写过重复代码。
这篇文章就直接聊聊我在Spring AI Advisors上的实战拆解,包括它和拦截器、责任链模式的关系,三种Advisor的具体写法,怎么排序怎么调优,以及在业务系统中的几个典型用法。如果你是刚接触Spring AI的读者,可以先把它理解成Servlet里的Filter,或者MyBatis里的Interceptor:核心思想都是一样的,在请求进出某个边界时插入额外的处理逻辑。
1. 整体设计与思路拆解
1.1 为什么对话链路需要拦截和增强
先还原一个真实场景。假设你正在做一个企业内部知识库问答系统,用户在前端输入“帮我查一下上个月的销售数据”,这个请求会经历什么?
- 记录日志:谁在什么时候问了什么,这个对话要不要留存。
- 多轮上下文:用户可能之前已经问了“那这个月呢”,如果没带上轮对话,模型根本不知道“这个月”指什么。
- 企业内部知识准备:裸的问模型,模型只会给你一个泛泛的回答,必须把相关的产品文档、制度条例、历史问答检索出来塞进Prompt。
- 敏感信息控制:用户问的某些问题涉及客户隐私,或者包含攻击性词汇,必须在交给模型前拦截掉。
- 响应后处理:模型返回的结果可能要过滤掉某些格式问题,或者追加引用来源。
这一堆逻辑如果全写在业务代码里,每个调用大模型的地方都得复制一遍,维护一次就崩一次。Advisors解决的就是这个问题:把对话请求和响应抽象成一个可插拔的链路,让各个环节可以独立开发、独立注册、灵活排序。
Spring AI里的Advisor模型就是围绕这个诉求设计的。它在ChatClient发起调用之前、之后以及调用前后(Around类型)分别提供了切入点,开发者可以像装饰器一样往里挂逻辑,并且通过Order控制执行顺序。这个设计参考了经典的责任链模式,也吸收了Spring AOP的切面思想,但比AOP更聚焦对话场景。
1.2 Advisor和拦截器、过滤器、AOP的联系
很多人在第一次看到Advisor时会产生一个困惑:这不就是拦截器吗?确实,从编程范式上看,它们共享同一套思想。我们熟悉的Java Web Filter、Spring MVC Interceptor、MyBatis Plugin,本质上都是在主流程上插入横切逻辑,把通用关注点从业务代码里剥离出来。
Spring AI Advisors的定位更精准:它专门包装LLM调用过程。一个Advisor接收包含Prompt、对话历史、模型参数等信息的ChatClientRequest,有机会修改这些信息,然后传递给下一个处理器,最后也能看到ChatClientResponse并进行加工。
我个人的理解是,你可以把它当成Prompt工程的运行时框架。Prompt工程是在设计阶段决定“怎么问模型”,Advisor则是在运行阶段动态决定“这次到底怎么问模型”。这两者结合起来,才能应对复杂的业务对话场景。
1.3 为什么选择Spring AI的Advisor而不是自己封装
有些人可能会说,我自己写一个工具类,每次调用大模型前自己拼Prompt,不也能实现同样的效果吗?短期看确实能跑,但问题出现在复杂度和可维护性上。
举个例子。当你需要处理10类功能场景,每类场景有自己的一套增强逻辑,并且这些逻辑还存在交叉共享(比如所有场景都要记录审计日志,部分场景需要额外注入知识库内容),自己封装的做法很快就会变成一串难以维护的if-else。而Advisors的链式组合方案,可以在配置中心或者一个配置类里,通过声明式的方式组合出不同的处理链路。
另一个好处是Spring AI本身已经内置了一批有用的Advisors,比如:
- MessageChatMemoryAdvisor:对话记忆管理。
- SimpleLoggerAdvisor:请求响应日志。
- SafeGuardAdvisor:简单的输入输出防护。
- QuestionAnswerAdvisor:给Prompt注入检索增强内容,相当于RAG的桥接。
这意味着你不需要从零开始,熟悉内置组件就能覆盖大部分常见需求。真正需要自定义的,往往是那些贴合业务规则的专属逻辑,比如敏感词列表、知识库检索器、业务参数注入等。
2. 核心细节解析与实操要点
2.1 核心API逐个拆解
要玩转Advisors,先得把两个核心接口看懂。
ChatClientRequest封装了一次完整的对话请求,内部主要包含:
- Messages:当前这轮用户消息和系统提示词。
- Memory:对话记忆,可能在请求过程中被读取和修改。
- Params:模型的参数集合,包括模型名、温度、token上限、工具定义等。
- Context:一个键值对集合,用于在Advisor之间传递业务数据。
ChatClientResponse则封装了模型的返回结果,包含响应文本和生成时的元信息。
围绕这两个核心对象,Advisor接口提供了三个默认方法:
- before:在调用模型之前执行,只接收ChatClientRequest,能拿到修改后的request。
- after:在模型调用结束后执行,接收request和response,能对响应做后处理。
- around:完全接管调用链,是三种类型中最灵活的。它可以修改请求、调用下一个处理器(即调用点ProceedingJoinPoint)、修改响应。
值得留意的是,Advisor接口本身也继承了一个OrderProvider接口,所以每个Advisor都自带排序字段。这个Order直接决定了多个Advisor在同一链路上的执行顺序。
2.2 三种类型的选择逻辑
什么时候用before,什么时候用around,很多刚入门的读者会纠结。我根据实际项目经验,给出一个判断标准:
- 如果你只是想改附加上下文、补一条日志、注入一段Prompt,用before就够了。它的语义很清晰:调用前干点事,不需要关心模型到底怎么被调用的。
- 如果你想对响应做裁剪、增加引用、去敏感词,用after。
- 如果你想控制整个链条,比如实现超时熔断、做请求重试、根据输入内容决定是否放行,甚至想完全绕过模型调用,就必须用around。
在同一个自定义Advisor中同时实现多个方法也是允许的。比如一个审计Advisor,可以在before里记录请求参数,在after里记录响应内容,在around里把整体耗时也算出来。
2.3 排序机制和调用顺序
Order的概念听起来简单,但实际用的时候很容易踩坑。它的规则和Spring的@Order注解一致:数值越小优先级越高。
在拦截和增强的场景里,执行顺序是一种嵌套模型,类似洋葱圈。一个Order为1的Advisor在最外层,它先执行,然后调用Order为2的Advisor,再调用Order为3的......直到最后一个Advisor真正触发模型调用。响应返回时则是反向的:最内层的Advisor先拿到结果,最外层的最后拿到结果。
理解这个嵌套模型很重要,因为前后顺序直接影响你的增强效果。举例来说:
- 敏感词过滤应该在最外层,因为它需要看到最完整的输入,也要保证输出在最后被清洗。
- 记忆管理应该靠内层,因为它必须紧邻模型调用,确保Prompt已经融合了历史消息。
- 日志记录最好放在最外面,这样它能记录到完整的请求和最终返回。
如果你把一个顺序理解成了“平级管道”,那多个Advisor交织时,很容易出现行为不符合预期的情况。
2.4 上下文数据如何在Advisor之间传递
很多时候,一个Advisor处理完的数据要传给下一个Advisor使用。比如你从认证系统拿到了用户的部门信息,希望在Prompt注入阶段把这个信息拼进去。
在Spring AI里,这个诉求通过ChatClientRequest中的Context字段解决。Context的本质是一个Map,你可以往里放任何对象。而且为了操作方便,Advisor接口还提供了一个便捷方法:getRequestContext()和updateRequestContext()。
实际工作中,我经常在一个前置Advisor里做如下操作:
- 从SecurityContext取用户身份。
- 查询用户所属部门、角色、权限等级。
- 把这一堆信息塞进Context。
下游的Prompt增强Advisor再去Context拿这些业务属性,动态拼进系统提示词。这种设计的好处是解耦,上游不知道下游怎么用数据,下游也不关心数据是哪来的。
3. 实操过程与核心环节实现
3.1 工程准备:Maven依赖和基础配置
开始动手之前,先把依赖配好。我这里使用的是Spring Boot 3.x和Spring AI 1.0.0-M系列版本,你可以根据自己的项目情况微调版本号。
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> <version>1.0.0-M6</version> </dependency>然后在application.yml里配置模型的基本信息。我这里用OpenAI兼容的接口做演示,实际用通义千问、DeepSeek或者本地模型也一样,关键的Advisor机制不区分底层模型。
spring: ai: openai: api-key: ${OPENAI_API_KEY} base-url: ${OPENAI_BASE_URL} chat: options: model: gpt-4o-mini temperature: 0.73.2 从ChatClient到Advisors:完整链路演示
ChatClient是Spring AI面向开发者的门面。从ChatClient发起一次请求,到底发生了什么?我画一条逻辑链路在脑子里过一遍:
- 开发者构造ChatClientRequest,包含用户消息Content。
- ChatClient拿到这个请求后,按Order顺序执行所有Advisors。
- 每个Advisor都有机会修改请求内容。
- 最后一个Advisor调用内部的ChatModel,真正发给大模型。
- 模型返回后,响应再按逆序经过所有Advisors。
- ChatClient把最终响应交给调用方。
代码层面的装配如下。我通常会用Builder模式创建带Advisors的ChatClient。
@Configuration public class ChatClientConfig { private final ChatClient.Builder builder; public ChatClientConfig(ChatClient.Builder builder) { this.builder = builder; } @Bean public ChatClient chatClient() { return builder .defaultAdvisors( new MessageChatMemoryAdvisor(chatMemory), new SimpleLoggerAdvisor(), new MySensitiveWordAdvisor(), new MyKnowledgeInjectAdvisor() ) .defaultUser("你是企业内部的智能助手,请用专业、简洁的语言回答问题。") .build(); } }3.3 自定义一个Before类型的请求日志与id注入Advisor
先写一个最基础的。这个Advisor的作用是:
- 为每次请求生成唯一的traceId。
- 记录请求进来的原始消息。
- 把traceId放到上下文中,下游可以用它做链路追踪。
public class TraceIdAdvisor implements Advisor { private static final Logger log = LoggerFactory.getLogger(TraceIdAdvisor.class); @Override public ChatClientRequest before(ChatClientRequest request) { String traceId = UUID.randomUUID().toString().substring(0, 8); log.info("[{}] 接收到用户请求: {}", traceId, request.messages().stream() .map(message -> message.getContent()) .collect(Collectors.joining(" | "))); return ChatClientRequest.builderFrom(request) .context("traceId", traceId) .build(); } }这个方法里有两个动作值得注意。第一,builderFrom是从现有请求复制出一个新请求,这样可以保证不可变性。第二,context方法返回一个全新的Context对象,原来的请求不会被污染。
在实际项目中,我会在这个Advisor里继续扩展,比如把用户ID也塞进去,这样后面打日志的时候统一用traceId加userId,排查问题就很直观了。
3.4 实战关键:做一个Around类型的敏感信息拦截Advisor
这是我在企业项目中写过最多的类型。拦截的目的不是简单拒绝请求,而是根据规则决定是否放行、是否替换内容、是否脱敏。这里我提供一个相对完整的实现,包含了关键词检测、正则替换、内容脱敏三类能力。
public class SensitiveGuardAdvisor implements Advisor { private static final List<String> BLOCKED_WORDS = List.of( "违法交易", "隐私数据", "非法获取" ); private static final List<Pattern> SENSITIVE_PATTERNS = List.of( Pattern.compile("1[3-9]\\d{9}"), Pattern.compile("\\d{17}[0-9Xx]") ); @Override public ChatClientResponse around(ProceedingJoinPoint pjp) { ChatClientRequest request = (ChatClientRequest) pjp.getArgs()[0]; String userInput = request.messages().stream() .filter(message -> message.getMessageType() == MessageType.USER) .map(Message::getContent) .collect(Collectors.joining("\n")); // 命中绝对禁止的词,直接返回提示,不调用模型 for (String blockedWord : BLOCKED_WORDS) { if (userInput.contains(blockedWord)) { return ChatClientResponse.builder() .withResponse("抱歉,这个问题不在我可以协助的范围内。") .build(); } } // 对手机号身份证等敏感信息做脱敏 String maskedInput = userInput; for (Pattern pattern : SENSITIVE_PATTERNS) { maskedInput = pattern.matcher(maskedInput).replaceAll("***"); } if (!maskedInput.equals(userInput)) { ChatClientRequest maskedRequest = ChatClientRequest.builderFrom(request) .messages(maskMessages(request.messages(), maskedInput)) .build(); // 可以记录一条审计日志 Object result = pjp.proceed(new Object[]{maskedRequest}); return (ChatClientResponse) result; } return (ChatClientResponse) pjp.proceed(); } }这个实现里藏着几个容易犯错的细节,我展开说:
- pjp.getArgs()拿到的参数数组,第一个元素就是ChatClientRequest。必须小心处理这个数组,如果你传一个新的请求对象,必须保证数组元素数量和类型一致。
- 直接不调用模型时,需要构造一个ChatClientResponse返回。但要注意,这样返回的响应不会经过后续的after类型Advisor。如果你希望这个拒绝响应也记录日志,最好把拦截逻辑放在最外层。
- 脱敏的逻辑要放在调用模型之前,否则模型已经把数据读走了。
说到脱敏,我实际项目中还遇到过一个更麻烦的情况:用户输入本身没带敏感信息,但模型为了举例自己编了个手机号。这里就需要再在after方法里做一次响应内容的正则替换,把模型输出中的手机号统一替换掉。
3.5 利用SimpleLoggerAdvisor排查对话内容
Spring AI内置的SimpleLoggerAdvisor非常实用,尤其是在调试阶段。它可以在请求发出前打印完整的Prompt,在响应返回后打印完整的生成结果。对于排查“为什么模型答非所问”和“是不是Prompt拼接错了”这两个问题,它比任何断点都直观。
启用的方法就是在ChatClient构建时注册进去,或者单独给某个方法调用时临时追加Advisor。
ChatClient chatClient = ChatClient.builder(chatModel) .defaultAdvisors(new SimpleLoggerAdvisor()) .build();默认的日志输出可能稍显啰嗦,你可以重写它的日志内容。我司常用的做法是:只记录系统提示词的前200字和用户消息全文,响应只记录前500字。这样日志文件不会爆炸,信息量又足够排查问题。
3.6 给指定调用临时添加Advisor
除了全局默认的Advisors,Spring AI还允许你在一次具体的调用中临时增加Advisor,这在实际业务里非常常见。
典型场景是这样的:你的系统有普通问答和代码生成两个功能。普通问答不需要注入代码规范,但代码生成必须要把公司内部的编码规范文档作为增强内容放进去。这时候不能把代码规范的Advisor加到全局,否则每次普通问答都在无谓拼接大量文档。
做法是在调用时追加:
String response = chatClient.prompt() .user("请帮我写一个Spring Boot的定时任务") .advisors(new CodingStandardAdvisor("编码规范_v3.md")) .call() .content();这里我传了一个带参数的Advisor对象,构造时就把知识库路径传入。和全局Adivsor不同,这个Advisor只对本次调用生效,用完即焚。
3.7 对话记忆Advisor的使用和参数说明
多轮对话是另一个强需求场景。Spring AI的MessageChatMemoryAdvisor提供了简单的对话记忆能力。它维护一个WindowChatMemory,默认保留最近20条消息,超过的会被丢弃。
ChatMemory chatMemory = WindowChatMemory.builder() .maxMessages(20) .build(); ChatClient chatClient = ChatClient.builder(chatModel) .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory)) .build();这个Advisor的设置有几个参数值得注意:
- maxMessages控制的是消息条数,不是token数。如果模型上下文窗口较小,建议把这个值调低一些。
- 默认情况下,它会把最近的历史消息都作为上下文发送给模型。对于超长对话,你可以重写ChatMemory的实现,做按token截断,或者引入向量检索,按相关性筛选历史消息。
- 如果你的对话是分用户的,需要在调用时通过Advisor参数指定conversationId,否则所有用户会共享同一份记忆。
String response = chatClient.prompt() .user("刚才的问题再说一遍") .advisors(advisorParam -> advisorParam .param(MessageChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID, "user-123")) .call() .content();这套机制在实际生产里很够用,但如果你面临的是海量并发用户,建议还是自己实现一个基于Redis的ChatMemory。原理都一样:把记忆的存储从内存挪到分布式存储里。
4. 实际项目中的增强场景与落地经验
4.1 Prompt增强:动态注入业务上下文
先记住一个概念:增强的核心不是把Prompt变长,而是把Prompt变准。
我做过一个客服工单系统的大模型助手。用户问“为什么我的工单还没处理”,如果模型只看这句话,它能给出的回答只能是“工单状态由管理员决定”这类废话。真正的做法是,通过Advisor在请求进入前,把当前用户的工单状态、最近操作记录、责任人信息查出来,拼进Prompt。
实现思路如下:
public class TicketContextAdvisor implements Advisor { private final TicketService ticketService; @Override public ChatClientRequest before(ChatClientRequest request) { String traceId = (String) request.getContext().get("traceId"); Long userId = (Long) request.getContext().get("userId"); List<Ticket> recentTickets = ticketService.listRecentByUserId(userId); String dynamicContext = recentTickets.stream() .map(t -> String.format("工单号%s,状态%s,最近更新%s,处理人%s", t.getTicketNo(), t.getStatus(), t.getUpdatedAt(), t.getAssignee())) .collect(Collectors.joining("\n")); String enhancedSystemPrompt = """ 你是工单处理助手。以下是当前用户最近的工单信息,请基于这些真实数据回答问题。 %s """.formatted(dynamicContext); return ChatClientRequest.builderFrom(request) .messages(new SystemMessage(enhancedSystemPrompt)) .build(); } }这里有两个实际经验。第一,动态信息的时效性很重要。如果工单状态在Advisor查询后发生了变化,模型给出的是过期信息,用户投诉率很高。所以查询动作尽量靠近模型调用,或者至少设置一个非常短的缓存,比如5秒。第二,不要把整个工单正文全塞进Prompt。体量大的内容建议先做摘要,只保留关键字段,否则token消耗会让你月底抬头看账单。
4.2 检索增强(RAG)接入:QuestionAnswerAdvisor的使用
在Spring AI生态里,RAG和Advisor的配合是天然的设计。QuestionAnswerAdvisor是官方提供的开箱即用实现,它的作用是:把用户的问题作为查询条件,去向量数据库里检索相关文档片段,把片段注入Prompt。
代码写起来非常简单,前提是你已经配置好了向量存储和嵌入模型。
@Bean public Advisor questionAnswerAdvisor(VectorStore vectorStore) { return new QuestionAnswerAdvisor(vectorStore); }问题在于默认的检索策略未必完全贴合业务。比如你检索出来的内容可能不止一段,也可能检索出来的内容相关性不够。我通常的做法是自定义一个Advisor,在调用检索之前先对用户输入做意图判断,再决定是否走检索,以及检索的topK和similarityThreshold。
实际项目中,把RAG检索放进Advisor,意味着所有经过ChatClient的请求都可以自动附带检索增强能力。这比在业务代码里到处插入检索逻辑要干净得多。
4.3 安全增强:输入过滤、输出校验与审计日志
安全方面,生产环境必须考虑三个环节:
- 输入侧:拒绝恶意请求,脱敏个人敏感信息。
- 输出侧:过滤模型生成的违规内容,防止模型幻觉输出公司机密。
- 审计侧:完整记录原始输入、脱敏后的输入、模型输出、最终输出。
我推荐的做法是写一个CompositeAdvisor,或者利用Order把三个方向拆成多个独立的Advisor。下面是一个简化版的输出校验实现。
public class OutputGuardAdvisor implements Advisor { private static final Pattern COMPANY_CONFIDENTIAL = Pattern.compile("(薪资|年终奖|组织调整|未公开财报)"); @Override public ChatClientResponse after(ChatClientResponse response) { String content = response.getResult().getOutputText(); if (COMPANY_CONFIDENTIAL.matcher(content).find()) { String cleaned = "基于安全和合规要求,这部分信息无法提供。"; return ChatClientResponse.builderFrom(response) .withResponse(cleaned) .build(); } return response; } }这个模式在金融行业特别常见,企业对于模型输出有强合规要求,宁可回答得保守一点,也不能让模型报出敏感数据。
还有一个容易忽视的细节:审计日志一定要记录“输入前”和“输出后”两个状态。如果只记录脱敏前的原始输入,一旦发生隐私泄露事件,追溯链路就是断的。我现在就在审计Advisor里同时存两类数据:数据库存脱敏后的日志用于常规查看,加密存储原始数据用于合规审计。
4.4 多模型路由与A/B测试:Advisor控制选择哪个模型
还有一类比较高级的用法,是用Advisor实现模型路由。比如,普通问题走便宜快速的模型,复杂问题走更强的模型,这个决策就可以放在Advisor的before方法里。
public class ModelRoutingAdvisor implements Advisor { @Override public ChatClientRequest before(ChatClientRequest request) { String userInput = extractUserContent(request); // 简单策略:超过50个字,或者包含"为什么""如何实现"等词,走强模型 ChatOptions targetOptions; if (userInput.length() > 50 || userInput.contains("如何")) { targetOptions = OpenAIProxyChatOptions.builder() .withModel("gpt-4o") .withTemperature(0.3) .build(); } else { targetOptions = OpenAIProxyChatOptions.builder() .withModel("gpt-4o-mini") .withTemperature(0.7) .build(); } return ChatClientRequest.builderFrom(request) .options(targetOptions) .build(); } }我在生产中把策略做得更复杂一些:结合用户等级、问题类型、当前系统负载三个维度来路由。高峰期自动把非关键问题降级到便宜模型,极大节省了成本。这个逻辑放在Advisor里非常自然,因为它拿得到请求的所有数据和上下文。
4.5 性能增强:缓存与超时控制
大模型调用的延迟通常在小几秒到几十秒不等,尤其在企业内网环境,稳定性还不能完全保证。我建议在Advisor层做两件事:缓存和超时。
缓存适合那些答案相对稳定的问题,比如“请假流程是什么”“报销标准是多少”,这类咨询类问题的答案往往高度重复。缓存实现可以复用Spring Cache的抽象,在around方法里先查缓存,命中则直接返回。
超时控制则是用around方法包一层Future或者使用Spring的@Timed。
public class TimeoutAdvisor implements Advisor { private final Duration timeout = Duration.ofSeconds(30); @Override public ChatClientResponse around(ProceedingJoinPoint pjp) throws Throwable { ExecutorService executor = Executors.newSingleThreadExecutor(); Future<Object> future = executor.submit(() -> { try { return pjp.proceed(); } catch (Throwable e) { throw new RuntimeException(e); } }); try { return (ChatClientResponse) future.get(timeout.toSeconds(), TimeUnit.SECONDS); } catch (TimeoutException e) { return ChatClientResponse.builder() .withResponse("抱歉,服务响应超时,请稍后重试。") .build(); } finally { executor.shutdownNow(); } } }注意这个实现里有个坑:pjp.proceed是阻塞调用,如果模型端迟迟不返回,直接卡在future.get上,线程池会一直占用。所以我用的线程数必须克制,或者用带超时线程池的调度框架。真实生产我一般用Resilience4j的TimeLimiter来做,比手写线程池稳得多。
5. 常见问题与排查技巧实录
5.1 多个Advisor的执行顺序不符合预期
这是群里被问得最多的一个问题。症状是:明明感觉日志Advisor应该先执行,结果却是后执行。原因几乎都是Order数值设置不对,或者忘记实现Ordered接口。
排查方法是先打印每个Advisor的order值。
@PostConstruct public void printOrders() { List<Advisor> advisors = chatClient.getAdvisors(); for (Advisor advisor : advisors) { System.out.println(advisor.getClass().getSimpleName() + " order=" + advisor.getOrder()); } }如果发现有两个相同Order的情况,实际执行顺序是不确定的,必须避免。我自己的规范是:如果超过5个Advisor,就在常量类里统一定义顺序,不能在每个地方随便填一个数字。
5.2 在Advisor里修改Prompt后模型没有生效
这个问题也比较典型。你明明在before方法里给request的messages放了新的SystemMessage,结果模型输出还是老样子。
排查思路是检查你到底改对了对象没有。ChatClientRequest是贯穿全链路的值对象,如果你直接创建了一个新的Request但后续没有把它传递下去,修改就丢掉了。正确做法是使用builderFrom复制原请求,并在复制的同时替换messages。
另外,简单粗暴地添加一个SystemMessage并不等同于覆盖旧的SystemMessage。有些模型对多个SystemMessage的处理逻辑是拼接,有些是只取最后一个,这些差异会导致行为不可预期。稳妥做法是过滤掉原有的SystemMessage,只保留你新加的那条。
List<Message> newMessages = request.messages().stream() .filter(m -> m.getMessageType() != MessageType.SYSTEM) .collect(Collectors.toCollection(ArrayList::new)); newMessages.add(new SystemMessage(myEnhancedPrompt)); return ChatClientRequest.builderFrom(request) .messages(newMessages) .build();5.3 对话记忆错乱:不同用户串了上下文
这个问题一旦在生产环境出现,就是事故级故障。用户A问了“我的订单怎么还没到”,用户B紧接着问“那我的呢”,结果模型把A的订单信息回答给了B。
排查后基本都指向同一个原因:没有设置conversationId。MessageChatMemoryAdvisor默认用一个全局的conversationId,所有请求共享同一份记忆。如果你没有在每次请求时显式传入用户级ID,串记忆就是必然的。
修正方法:
String response = chatClient.prompt() .user(userInput) .advisors(advisorParam -> advisorParam .param(MessageChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID, String.valueOf(userId))) .call() .content();我在项目里还做了一个兜底:如果发现conversationId为空,直接抛异常并拒绝调用。宁可系统报错,也不能让用户之间的数据互相污染。这个兜底策略上线后,私下里救了不少次场。
5.4 Advisor内部抛异常会导致链路中断
默认情况下,Advisor里任何一个环节抛出异常,整条调用链都会中断。这在某些场景是好事(比如敏感词拦截),但在另外一些场景就是坏事(比如日志记录服务挂了,结果正常问答也全不可用了)。
我建议把非核心的增强逻辑包上try-catch,并且在catch里设置降级开关。
@Override public ChatClientResponse around(ProceedingJoinPoint pjp) throws Throwable { try { // 增强逻辑 doEnhance(request); } catch (Exception e) { log.warn("增强组件异常,降级跳过", e); } return (ChatClientResponse) pjp.proceed(); }尤其是在多处使用同一个第三方检索服务时,如果检索服务抖动,整个问答不可用,用户感知极差。降级跳过增强,虽然回答质量会有下降,但至少系统还能用。
5.5 日志记录不全或重复
日志Advisor如果在多个ChatClient实例里都注册了,或者同一个Advisor既在全局默认又在单次调用时追加了,就会导致同样的请求被打印多次。
解决方案是给日志Advisor加一个幂等标识,在Context里检查当前请求是否已经打印过。比如第一次打印时往Context塞一个标志,第二次进来发现标志存在,直接跳过。
启动日志默认打印在控制台可能没问题,生产环境我建议配置成异步日志,并且对Advisor日志单独建一个logger category。这样不同链路的日志可以分别汇总和检索,不会全搅在一起。
5.6 性能问题:Advisor链路太长导致延迟叠加
每加一个Advisor,就意味着多一次环绕包装。如果每个Advisor都访问一次数据库或者远程服务,那延迟就不是模型耗时,而是所有增强组件的耗时总和。
我测试过一个项目,不加Advisor的模型调用是3秒,加了6个Advisor后变成7秒。逐个排查发现有两个Advisor分别做了远程调用,累计耗时将近3秒。
性能优化建议:
- 检索、查询等耗时操作,尽量并行执行。可以在Advisor里用CompletableFuture把多个数据源并行查询再聚合。
- 对可缓存的结果加缓存,尤其是知识库检索和权限校验。
- 那些只是透传数据的轻量Advisor,可以考虑合并成一个。不是Advisor拆得越细越好。
5.7 常见问题速查表
我把上面遇到的问题整理成一张速查表,方便后面排查时快速定位。
| 症状 | 可能原因 | 解决方向 |
|---|---|---|
| 多个Advisor执行顺序不对 | Order相同或未实现Ordered接口 | 统一定义顺序常量,检查是否实现OrderProvider |
| 改了Prompt但没生效 | 新request对象没在链路上传递 | 返回builderFrom构建的新对象 |
| 多个SystemMessage冲突 | 原有SystemMessage没被清理 | 过滤后替换,避免覆盖语义不明确 |
| 用户之间对话串了 | 缺少conversationId | 每次调用显式传用户级ID,空的直接报错 |
| 增强组件报错连累主流程 | 异常冒泡中断链路 | 非核心增强逻辑加try-catch降级 |
| 日志重复打印 | Advisor在多个位置重复注册 | 用Context标志做幂等控制 |
| 链路太慢 | 多个Advisor串行远程调用 | 并行化、加缓存、合并轻量Advisor |
6. 关于Advisor扩展性的进一步思考
6.1 从API调用到流式输出的增强差异
前面讲到的都是基于阻塞调用的实现。实际生产中,流式输出(Streaming)越来越普及,用户看到逐字生成的效果,体验比等待完整响应要舒服得多。
但流式输出给Advisor带来了新挑战:ChatClientResponse在流式场景下不是一个完整的对象,而是一个Flux流。你没法在after方法里拿到完整的文本后再做替换,因为你拿到的是一串流。
Spring AI对这块的处理是:Advisor可以针对流式调用使用around方法,拦截到对方的Flux,然后对流做map操作,逐块进行内容增强或清理。
我试过在流式输出上做敏感词实时打码,思路是维护一个滑动的文本缓冲区,把每个chunk追加进去,再检测敏感词,最后把脱敏后的chunk按原节奏输出。这样做有一些复杂度,但效果非常自然,用户感知不到实时脱敏的存在。如果你对流式输出有强需求,建议提前规划好Adapter,别等业务上线了再重构。
6.2 结合Spring AI新的Auto-Configuration机制
1.0.0-M系列版本的Spring AI引入了Auto-Configuration机制,允许通过配置文件声明式地注册Advisor,而不是纯写Java代码。我试过用spring.ai.chat.client.advisor配置项注册内置的LoggerAdvisor和MemoryAdvisor,效果不错。
这个机制的优点在于:不同的部署环境可以通过Profile使用不同的Advisor组合。比如本地开发环境启用详细的日志Advisor,生产环境启用安全脱敏和审计Advisor,代码完全不用动。
但它的局限也很明显:自定义Advisor如果有复杂的构造依赖,比如需要注入Mapper或者第三方客户端,还是得通过@Bean方式注册。两者可以混用,关键看你项目里的实际需求。
6.3 常见误区提醒
最后说几个我看过很多人踩的坑:
- 误区一:Advisor越多越好。我的建议是保持克制,每个Advisor必须有明确职责,否则排查问题时你会怀疑人生。
- 误区二:before里只能加Prompt。实际上它还能改模型参数(temperature、maxTokens)、改上下文(Context)、甚至改消息类型。
- 误区三:after只改文本。它还能改元数据,比如给响应追加token用量、成本估算等,这对计费系统很有用。
- 误区四:around里修改了request但直接调用了pjp.proceed(),此时如果你不把新request传进去,你的修改就无效。这个细节我在刚开始几天内至少看三个人犯过同样错误。
最后聊一点我个人的实践感受。Spring AI Advisors给我的最大启发是,它把对话系统的可观测性、安全性和上下文管理从业务代码中彻底剥离了出来。过去我们做LLM应用,各种增强逻辑散落在业务Service里,代码改起来战战兢兢。现在通过Advisor的流水线设计,新增一个增强点基本就是新增一个类的事,完全不影响原有业务。当你把Advisor的排序、上下文传递、异常降级这套机制理顺之后,后续接再多模型、扩展再多场景,也只是往流水线上加组件而已,一点都不慌。