资讯动态

Spring AI 顾问链(Advisor Chain)进阶:如何在请求前后拦截实现敏感词过滤与审计

发布时间:2026/10/5 5:21:55 来源:尧图企业网站定制
Spring AI 顾问链Advisor Chain进阶如何在请求前后拦截实现敏感词过滤与审计很多团队刚把 Spring AI 接入生产环境时做法通常很奔放注入一个ChatClient在 Service 层拼好 Prompt直接调chatClient.prompt().user(message).call().content()返回给前端。功能上线跑通大家都很开心直到安全合规部门的同事带着安全审计工单找上门。合规部门提了三个核心要求第一用户输入的所有 Prompt 必须经过敏感词过滤和风控拦截不能等外部接口报错了才发现输入违规第二大模型返回的内容在推给用户之前必须进行二次合规审查与审计留痕第三整个问答链路要有完整的耗时、Token 消耗以及 TraceId 关联便于排查纠纷。如果把这些逻辑全部硬编码在 Controller 或业务 Service 里不仅代码侵入性极高而且一旦遇到 SSEServer-Sent Events流式输出常规的 AOP 环绕通知根本无法优雅拦截一块块吐出来的 Token 数据流。Spring AI 借鉴了 Spring 生态一贯的拦截思想提供了类似 AOP 和过滤器链的 Advisor顾问机制。今天就结合我们线上实际踩坑的经验聊聊如何基于 Advisor Chain 构建一套生产级的安全审计与敏感词过滤体系。为什么不能只靠常规 AOP在 Spring AI 还没成熟前很多老哥喜欢在 Controller 上套Around注解。对于普通的阻塞调用call()这种切面确实管用。但生产环境里的 AI 对话几乎 90% 都是流式响应stream()。在流式响应下模型返回的是FluxChatResponse。如果在普通切面里强行消费这个 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 FluxChatResponse 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 100TraceIdMdcAdvisor负责把链路追踪 ID 注入 Reactive 上下文。Order 200SecurityAuditAdvisor敏感词前置拦截不合规直接阻断避免白白消耗 Token 费用。Order 300PromptCompressionAdvisor上下文修剪压缩防止超长。Order 400DynamicPromptAdvisor动态注入用户租户信息或业务偏好。通过严格的顺序排列任何不合规的恶意注入在最外层就会被抛出异常根本不会发起对底层模型提供商的网络调用。踩坑复盘与运维建议我们在灰度环境上线这套方案时踩过几个非常典型的坑整理出来给大家避避雷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的设计。架构优雅的标志之一就是让业务代码看不出底层繁琐的安全防御痕迹。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑