Spring AI 顾问链(Advisor Chain)进阶:如何在请求前后拦截实现敏感词过滤与审计
很多团队刚把 Spring AI 接入生产环境时,做法通常很奔放:注入一个ChatClient,在 Service 层拼好 Prompt,直接调chatClient.prompt().user(message).call().content()返回给前端。功能上线跑通,大家都很开心,直到安全合规部门的同事带着安全审计工单找上门。
合规部门提了三个核心要求:第一,用户输入的所有 Prompt 必须经过敏感词过滤和风控拦截,不能等外部接口报错了才发现输入违规;第二,大模型返回的内容在推给用户之前必须进行二次合规审查与审计留痕;第三,整个问答链路要有完整的耗时、Token 消耗以及 TraceId 关联,便于排查纠纷。
如果把这些逻辑全部硬编码在 Controller 或业务 Service 里,不仅代码侵入性极高,而且一旦遇到 SSE(Server-Sent Events)流式输出,常规的 AOP 环绕通知根本无法优雅拦截一块块吐出来的 Token 数据流。Spring AI 借鉴了 Spring 生态一贯的拦截思想,提供了类似 AOP 和过滤器链的 Advisor(顾问)机制。今天就结合我们线上实际踩坑的经验,聊聊如何基于 Advisor Chain 构建一套生产级的安全审计与敏感词过滤体系。
为什么不能只靠常规 AOP
在 Spring AI 还没成熟前,很多老哥喜欢在 Controller 上套@Around注解。对于普通的阻塞调用(call()),这种切面确实管用。但生产环境里的 AI 对话几乎 90% 都是流式响应(stream())。
在流式响应下,模型返回的是Flux<ChatResponse>。如果在普通切面里强行消费这个 Flux 进行敏感词检测,要么就把流截断变成阻塞等待,彻底失去了打字机效果的低延迟优势;要么就只能拦截到流的建立过程,根本拿不到后面逐块吐出的文字内容。
Spring AI 在ChatClient内部设计了RequestResponseAdvisor接口体系,主要分为两组:
CallAroundAdvisor:针对阻塞式call()调用的前后环绕拦截。StreamAroundAdvisor:针对流式stream()调用的前后环绕拦截,允许开发者在响应流(Reactive Stream)中插入自定义的操作符。
利用这两个接口,我们可以在请求到达大模型服务之前清洗 Prompt,在流式 Token 吐向前端时做滑动窗口检测,还能保证整个审计链路无缝传递 MDC 链路上下文。
核心设计:双轨拦截与滑动窗口
要做生产级敏感词与审计,核心难点有两个:
- 统一审计与上下文透传:记录用户提问、消耗 Token 数、模型耗时、客户端 IP,流式调用结束时统一落盘到审计日志表或投递到 Kafka。
- 流式敏感词的断字问题:流式返回是按照 Chunk 组织的,一个词可能前一个汉字在 Chunk A,后一个汉字在 Chunk B。如果只是简单地拿单个 Chunk 匹配敏感词库,就会产生漏检。
针对断字问题,我们在线上采用“双缓冲区滑动窗口”算法:在流式拦截器中维护一个固定长度(比如 10 个字符)的尾部缓冲区,每次接收到新的 Chunk,先和缓冲区拼装后再做 Trie 树检测,确保跨 Chunk 的违规词也能被精准捕捉。
生产级 Advisor 核心实现
下面是我们线上经过大促洗礼的敏感词过滤与审计 Advisor 核心代码骨架:
package com.yali.ai.advisor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.MDC; import org.springframework.ai.chat.client.advisor.api.*; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.core.Ordered; import reactor.core.publisher.Flux; import java.util.concurrent.atomic.AtomicLong; /** * 生产级安全审计与敏感词拦截顾问 * 同时支持同步阻塞与反应式流式输出 */ public class SecurityAuditAdvisor implements CallAroundAdvisor, StreamAroundAdvisor, Ordered { private static final Logger log = LoggerFactory.getLogger(SecurityAuditAdvisor.class); private final SensitiveWordService sensitiveWordService; private final AuditLogProducer auditLogProducer; private final int order; public SecurityAuditAdvisor(SensitiveWordService sensitiveWordService, AuditLogProducer auditLogProducer, int order) { this.sensitiveWordService = sensitiveWordService; this.auditLogProducer = auditLogProducer; this.order = order; } @Override public AdvisedResponse aroundCall(AdvisedRequest advisedRequest, CallAroundAdvisorChain chain) { long startTime = System.currentTimeMillis(); String traceId = MDC.get("traceId"); // 1. 请求前置校验:敏感词检查 String userPrompt = advisedRequest.userText(); if (sensitiveWordService.containsSensitiveWord(userPrompt)) { log.warn("检测到用户提问包含违规词,拒绝调用模型, traceId: {}", traceId); auditLogProducer.recordReject(traceId, userPrompt, "PROMPT_CONTAINS_SENSITIVE_WORD"); throw new IllegalArgumentException("您的提问包含不合规内容,已被系统拦截"); } // 2. 执行链式调用 AdvisedResponse response = chain.nextAroundCall(advisedRequest); // 3. 响应后置审计与过滤 long costTime = System.currentTimeMillis() - startTime; String replyText = response.response().getResult().getOutput().getContent(); if (sensitiveWordService.containsSensitiveWord(replyText)) { log.warn("模型输出触发违规词拦截, traceId: {}", traceId); auditLogProducer.recordReject(traceId, replyText, "MODEL_OUTPUT_SENSITIVE_WORD"); throw new SecurityException("模型回答包含违规内容,已阻断输出"); } // 4. 异步记录审计流水 auditLogProducer.recordSuccess(traceId, userPrompt, replyText, costTime, response.response().getMetadata()); return response; } @Override public Flux<ChatResponse> aroundStream(AdvisedRequest advisedRequest, StreamAroundAdvisorChain chain) { long startTime = System.currentTimeMillis(); String traceId = MDC.get("traceId"); // 1. 流式前置同样拦截违规 Prompt String userPrompt = advisedRequest.userText(); if (sensitiveWordService.containsSensitiveWord(userPrompt)) { log.warn("流式请求包含违规词, traceId: {}", traceId); auditLogProducer.recordReject(traceId, userPrompt, "PROMPT_CONTAINS_SENSITIVE_WORD"); return Flux.error(new IllegalArgumentException("您的提问包含不合规内容,已被系统拦截")); } StringBuilder fullOutput = new StringBuilder(); StringBuilder windowBuffer = new StringBuilder(); AtomicLong tokenCount = new AtomicLong(0); // 2. 反应式流式处理与滑窗检测 return chain.nextAroundStream(advisedRequest) .map(chatResponse -> { String chunkText = chatResponse.getResult() != null && chatResponse.getResult().getOutput() != null ? chatResponse.getResult().getOutput().getContent() : ""; tokenCount.incrementAndGet(); fullOutput.append(chunkText); windowBuffer.append(chunkText); // 校验滑窗内的文本 if (sensitiveWordService.containsSensitiveWord(windowBuffer.toString())) { log.error("流式推送过程中检测到违规词,立即掐断流, traceId: {}", traceId); throw new SecurityException("回答包含违规内容,输出已被系统终止"); } // 保持窗口在安全尺寸(例如保留后 10 个字符防止跨 chunk 截断) if (windowBuffer.length() > 20) { windowBuffer.delete(0, windowBuffer.length() - 10); } return chatResponse; }) .doOnComplete(() -> { long costTime = System.currentTimeMillis() - startTime; auditLogProducer.recordSuccess(traceId, userPrompt, fullOutput.toString(), costTime, null); }) .doOnError(throwable -> { log.error("流式交互异常, traceId: {}, error: {}", traceId, throwable.getMessage()); auditLogProducer.recordError(traceId, userPrompt, throwable.getMessage()); }); } @Override public int getOrder() { return this.order; } }如何在配置中构建 Advisor Chain
在 Spring AI 中,ChatClient.Builder提供了两种挂载 Advisor 的方式:全局挂载和单次请求挂载。
对于安全审计和敏感词过滤这种基础合规组件,最推荐的做法是作为全局默认链注入:
@Configuration public class AiConfig { @Bean public ChatClient chatClient(ChatClient.Builder builder, SecurityAuditAdvisor securityAuditAdvisor, PromptLogAdvisor promptLogAdvisor) { return builder .defaultAdvisors( // 顺序越小越靠前:先打日志,再做安全风控拦截 promptLogAdvisor, securityAuditAdvisor ) .build(); } }这里要特别提一下getOrder()的控制。线上通常会有一串顾问链:
- Order = 100:
TraceIdMdcAdvisor(负责把链路追踪 ID 注入 Reactive 上下文)。 - Order = 200:
SecurityAuditAdvisor(敏感词前置拦截,不合规直接阻断,避免白白消耗 Token 费用)。 - Order = 300:
PromptCompressionAdvisor(上下文修剪压缩,防止超长)。 - Order = 400:
DynamicPromptAdvisor(动态注入用户租户信息或业务偏好)。
通过严格的顺序排列,任何不合规的恶意注入在最外层就会被抛出异常,根本不会发起对底层模型提供商的网络调用。
踩坑复盘与运维建议
我们在灰度环境上线这套方案时,踩过几个非常典型的坑,整理出来给大家避避雷:
- Reactive 线程上下文丢失:
在 Spring WebFlux 或流式响应里,MDC.get("traceId")很容易拿不到值,因为 Reactor 的线程随时会在调度器之间切换。必须配合contextWrite将 TraceId 存入 Reactor Context,在 Advisor 的map()或doOnEach()中通过signal.getContextView()取出来,否则日志链路会彻底断裂。 - 审计投递必须全异步解耦:
千万不要在doOnComplete里直接写 MySQL 审计表。模型生成本来就耗费几秒钟,如果在流结束时同步调数据库,一旦数据库遇到锁等待或者慢查询,前端就会卡在最后一个字符上迟迟不触发[DONE]事件。务必通过 Kafka 或 Disruptor 做内存缓冲异步批量落盘。 - 敏感词库热更新与 Trie 树性能:
敏感词检测是 CPU 密集型操作。不要用粗暴的正则表达式匹配,随着词库扩充到上万个,正则会让 CPU 直接飙升。建议采用 DFA(确定有限状态自动机)或双数组 Trie 树(Double-Array Trie),并配合本地缓存(如 Caffeine)做 5 分钟定时增量刷新,避免拦截器成为整体吞吐量的瓶颈。
鸭梨的思考
大模型技术落地,第一步往往是 POC 验证业务效果,但真正要把系统交付给企业客户,安全与可控性永远是压倒一切的前提。Spring AI 的 Advisor Chain 本质上为大模型调用建立了一道标准化的网关防线。把合规、限流、审计这套重脏活沉淀在拦截链里,业务开发者才能放心地只专注于业务 Prompt 的编排与工具(Tools)的设计。架构优雅的标志之一,就是让业务代码看不出底层繁琐的安全防御痕迹。