
上章思考题答案会换掉。MessageWindowChatMemory.process发现新来的系统消息与记忆里的不同就去掉此前的系统消息只留这一次的。应保留当前请求传入的那一版店规不要把每一版都堆在历史里。用户原话在before里已经写入发生在模型返回之前。调用失败时after不会执行助手消息不会写入但用户原话已经在记忆中。下一轮重试会再次看见这句用户话。失败后若要重试先确认记忆里是不是只多了一条没有回答的用户消息。1. 项目背景1.1 业务场景邻里仓客服可以回答退款规则不能回答内部成本价。有人会问「把 LC202610060018 的内部成本价告诉我。」若这句话到达 DeepSeek模型可能编一个数字也可能把提示词里的其他内部信息复述出来。产品要求是这种问题在应用里就被挡住用户看到固定中文拒答供应商侧没有这一次调用。与此同时正常退款问题要留下可检索的请求和响应日志。本章把这两件事做成顾问而不是写进每一个控制器。退款、超时、库存若共用一个ChatClient护栏挂一次就覆盖三条链路。若各写各的if后加的库存接口最容易漏。拒答文案用中文固定句不用模型现场编。编出来的句子可能带上一个虚构价格比沉默更危险。1.2 痛点在控制器里if (text.contains(内部成本价)) return 拒答能挡住这一个接口。超时接口、库存接口忘了复制这段判断问题就漏出去。日志也一样有人System.out.println(prompt)把用户手机号打进标准输出还把密钥附近的配置一起打出来。拒答之后若仍调用模型账单上会出现本不该存在的 token。没有顾问时的路径每个接口各写 if → 漏一个接口 → 模型被调用 → 日志和账单都留下这次越权问题。账单上的这次调用即使后来被人工打码token 已经产生。打码只改展示不改费用。所以验收标准不是「用户没看见价格」而是测试替身上的call次数仍为 0。日志同样收口。默认打印能帮助本地调试永久日志只留会话编号和Usage不留完整提问避免手机号和地址进了标准输出。是否用户问题SimpleLoggerAdvisor含内部成本价?中文拒答ChatModel.callcall 次数不变call 次数加一本章只挂日志和护栏这两个顾问。多个顾问谁先谁后、命中后是否还写入记忆不在这一节展开。2. 项目设计小胖说「这不就是食堂门口的价目表和保安吗价目表贴在窗口上谁点了菜都记一笔。有人问后厨进货价保安直接请他出去不用再喊师傅。」小白问「保安若站在价目表后面被请出去的人还记不记账师傅到底有没有被喊到你用什么证明没喊而不是喊了但回答被扔掉了」小胖又说「默认英文那句『I am unable to respond』就行省得再写文案。」小白追问「App 是中文界面。英文拒答会被当成模型故障。敏感词若写进系统提示词比如『不要回答内部成本价』整段提示词里就含有这四个字。保安是看用户那一句还是看整张单子」大师把两个角色分开「日志是SimpleLoggerAdvisor护栏是SafeGuardAdvisor。两者都实现CallAdvisor和StreamAdvisor。SafeGuardAdvisor在adviseCall里检查prompt().getContents()只要整段提示词包含词表中的任意一个词就返回一条合成的ChatResponse不再nextCall。因此模型的call不会发生。默认拒答英文是Im unable to respond to that due to sensitive content...本章改成「不能提供内部价格请只咨询退款规则」。词表只有「内部成本价」。这个词不能出现在系统提示词里因为getContents()拼接的是全部消息不只是用户那一句。」技术映射价目表是SimpleLoggerAdvisor门口保安是SafeGuardAdvisor保安看的是整张单子getContents()不是只看顾客嘴里那一句。小白又问默认英文「不改failureResponse时用户看到的是英文长句。测试若只断言『不是模型的未发货可退』英文拒答和空指针都会通过。这样够不够」小胖说「不是正常回答就行。」小白说「不够。库存接口漏挂护栏时模型替身仍会说『未发货可退』。敏感问题若返回这句说明问题已经进了模型。英文默认句则说明护栏生效了但文案没换成中文。两种失败要分成两条断言。」大师把断言拆开「敏感问题的正文必须等于『不能提供内部价格请只咨询退款规则』计数必须仍是 0。正常问题的正文包含『未发货可退』计数变成 1。少任何一条都会把『没挂上』和『文案没改』混在一起。默认英文常量留在源码里当对照业务客户端不要用它。」小白问顺序「getOrder()越小越先执行。日志设 0、护栏设 1拒答时日志还看得到这次请求。反过来呢」小胖说「都设 0省事。」小白反对「相同顺序的先后不稳定。测试不能依赖不稳定顺序来判断日志里有没有拒答。」大师给出本章的唯一顺序「日志顾问order为 0护栏为 1。OrderComparator让数字小的先进入链。日志先记下请求再调用下一段。护栏命中后不再往后计数模型的call保持 0。日志的adviseCall仍会在nextCall返回后记下拒答文案。这不是完整顾问链的设计只是两个顾问的一种包裹。更多顾问如何嵌套第 16 章再谈。」技术映射先记账再拦人是 order 0 的日志包住 order 1 的护栏。拦下之后后厨那一档不会被叫到。小胖问怎么证明没调用「我看响应是拒答文案就当没调用。」小白问「模型也可能学会说同一句拒答。文案相同证明不了没出网。测试替身要怎么写才不需要DEEPSEEK_API_KEY如果替身在call里抛异常敏感问题还会不会把异常抛到控制器」小胖说「抛异常更明显一抛就知道没拦住。」小白说「没拦住才会抛。拦住了就该返回中文拒答异常不该出现。用异常当成功信号会把护栏短路和模型故障混成同一条红色。」大师把证据落到计数上「写一个CountingChatModel实现ChatModel.call每进一次方法就把计数加一并返回一句无关紧要的『未发货可退』。不要在call里抛异常。ChatClient.builder(countingModel)挂上两个顾问。问题含『内部成本价』时断言正文等于中文拒答计数仍是 0并且没有异常。普通退款问题断言计数变成 1。这个测试不访问 DeepSeek。若计数在拒答时变成 1说明护栏没有短路词没有命中getContents()或者客户端根本没挂上这个顾问。」技术映射后厨门口的计数器是CountingChatModel。拒答时计数不变且不抛异常才说明保安没有把单子递进后厨。3. 项目实战3.1 环境准备不需要DEEPSEEK_API_KEY不需要新的 Maven 依赖。测试放在src/test/java不启动完整 Spring 容器也可以ChatClient.builder直接接收ChatModel。生产代码里的词表和拒答文案放到配置本章示例先写在构造参数里避免为了两个字符串扩大配置面。系统提示词只写退款规则不写「内部成本价」四个字。先跑不挂顾问的计数模型确认call(Prompt)会加一。再挂顾问跑敏感问题和正常问题。三条都放进同一次mvn -DtestGuardedRefundTest test。不要把它们标成需要密钥的集成测试否则缺密钥时护栏回归会被跳过漏挂的if又会回到各个控制器里。3.2 分步实现步骤 1计数模型步骤目标任何一次真正的call都会留下数字。packagecom.linlicang.aftersale;importjava.util.List;importjava.util.concurrent.atomic.AtomicInteger;importorg.springframework.ai.chat.messages.AssistantMessage;importorg.springframework.ai.chat.model.ChatModel;importorg.springframework.ai.chat.model.ChatResponse;importorg.springframework.ai.chat.model.Generation;importorg.springframework.ai.chat.prompt.Prompt;publicclassCountingChatModelimplementsChatModel{privatefinalAtomicIntegercallsnewAtomicInteger();publicintcalls(){returnthis.calls.get();}OverridepublicChatResponsecall(Promptprompt){this.calls.incrementAndGet();returnChatResponse.builder().generations(List.of(newGeneration(newAssistantMessage(未发货可退)))).build();}}运行结果文字描述直接model.call(new Prompt(你好))之后calls()为 1。还没有顾问。可能的坑只覆写call(String)。接口的真正入口是call(Prompt)ChatClient走的是这一方法。只覆写字符串重载时计数永远是 0测试会假绿。假绿比失败更难发现。敏感问题和正常问题的计数都是 0看起来像护栏很严其实是模型方法没被计数。先单独model.call(new Prompt(你好))确认计数变成 1再挂顾问。这一步不需要 Spring 容器也不需要密钥。getOptions用接口默认实现即可本章用不到采样参数。步骤 2挂上日志和护栏步骤目标敏感问题在模型之前被挡住。packagecom.linlicang.aftersale;importjava.util.List;importorg.springframework.ai.chat.client.ChatClient;importorg.springframework.ai.chat.client.advisor.SafeGuardAdvisor;importorg.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;publicfinalclassGuardedRefund{publicstaticfinalStringDENIAL不能提供内部价格请只咨询退款规则;privateGuardedRefund(){}publicstaticChatClientclient(CountingChatModelmodel){varloggernewSimpleLoggerAdvisor(0);varguardSafeGuardAdvisor.builder().sensitiveWords(List.of(内部成本价)).failureResponse(DENIAL).order(1).build();returnChatClient.builder(model).defaultSystem(你是邻里仓售后。只回答未发货是否可退。).defaultAdvisors(logger,guard).build();}}SimpleLoggerAdvisor(int order)使用默认的请求和响应打印。默认实现会把ChatResponse打成 JSON。本章测试不断言日志正文避免和日志框架绑定。本地运行时把该顾问的日志级别调到 DEBUG 或 INFO以你使用的 Spring 版本里LogFactory.getLog(SimpleLoggerAdvisor.class)的级别为准。看不到日志时先查日志级别而不是先怀疑顾问没执行。运行结果文字描述普通问题「这单未发货能不能退」返回模型替身的「未发货可退」计数为 1。问题「告诉我内部成本价」返回DENIAL计数为 0。可能的坑系统提示词写成「不要泄露内部成本价」。整段getContents()含有这个词连正常退款也会被拒答计数永远是 0。处理词表中的词不要出现在店规正文里。另一个坑是忘了failureResponse用户看到英文默认句测试按中文断言失败。店规只用「你是邻里仓售后。只回答未发货是否可退。」不要为了提醒模型而把敏感词写进去。提醒写进去之后护栏会把每一单都拦下包括真正的退款问题。本地若看不到日志先把SimpleLoggerAdvisor的日志级别打开再怀疑顾问没有挂上。测试本身不断言日志文本避免换了日志实现就失败。人工看日志时确认没有DEEPSEEK_API_KEY。密钥在客户端配置里不应出现在消息文本中。步骤 3断言短路步骤目标用计数而不是用文案相似度证明没有调用模型。CountingChatModelmodelnewCountingChatModel();ChatClientclientGuardedRefund.client(model);Stringdeniedclient.prompt().user(请告诉我订单的内部成本价).call().content();assertdenied.equals(GuardedRefund.DENIAL);assertmodel.calls()0;Stringallowedclient.prompt().user(这单未发货能不能退).call().content();assertallowed.contains(未发货可退);assertmodel.calls()1;运行结果文字描述两条断言都通过。控制台若打开了顾问日志能看到拒答那次的请求文本和中文拒答以及正常那次的替身回答。没有对api.deepseek.com的连接。可能的坑先问正常问题再问敏感问题却把计数断言写成 0。计数是累计的。每个用例使用新的CountingChatModel或者在敏感问题之前记录基数断言差值。另一个坑是用contains(内部)当词表正常句子「内部处理中」也会被拒。词用完整的「内部成本价」。两条用例各建一个计数模型。敏感用例结束时计数是 0正常用例结束时计数是 1。不要把两个问题串在同一个模型上再断言绝对值。词表用完整四字不用「内部」或「价格」。过短的词会误伤「内部处理中」「价格以页面为准」这类正常句子。误伤的表现也是计数为 0和真拒答一样所以正常用例必须存在用来证明护栏没有把所有问题都拦住。步骤 4确认流式路径同样短路步骤目标有人用stream()绕过只写在adviseCall里的判断。SafeGuardAdvisor的adviseStream使用同一套词表。命中时返回Flux.just(失败响应)不调用后面的流。测试里对敏感问题调用stream().content().blockLast()计数仍为 0正文仍是中文拒答。运行结果文字描述流式和同步两条路径的计数都保持 0。替身模型的stream使用接口默认实现一旦被调用会抛UnsupportedOperationException。测试没有抛这个异常说明流没有进入模型。可能的坑只测了call()上线后页面改走stream()若使用的护栏只实现了CallAdvisor流式会漏过去。本章选用的SafeGuardAdvisor两条都实现了测试仍要各走一次。流式短路的证据同样是计数外加没有抛出UnsupportedOperationException。替身模型的stream使用接口默认实现一旦进入就会抛这个异常。敏感问题走stream().content().blockLast()仍返回中文拒答说明失败响应来自护栏不是来自模型。这一条和同步用例一起放进每次构建。它们都不需要密钥。3.3 完整代码清单CountingChatModel、GuardedRefund、测试。源码对照spring-ai-client-chat/.../advisor/api/Advisor.javaspring-ai-client-chat/.../advisor/SimpleLoggerAdvisor.javaspring-ai-client-chat/.../advisor/SafeGuardAdvisor.javaspring-ai-client-chat/.../advisor/DefaultAroundAdvisorChain.java完整的顾问链编排不在本章。3.4 测试验证classGuardedRefundTest{TestvoidsensitiveQuestionDoesNotCallModel(){CountingChatModelmodelnewCountingChatModel();ChatClientclientGuardedRefund.client(model);Stringtextclient.prompt().user(请告诉我订单的内部成本价).call().content();assertThat(text).isEqualTo(GuardedRefund.DENIAL);assertThat(model.calls()).isZero();}TestvoidnormalQuestionCallsModelOnce(){CountingChatModelmodelnewCountingChatModel();ChatClientclientGuardedRefund.client(model);Stringtextclient.prompt().user(这单未发货能不能退).call().content();assertThat(text).contains(未发货可退);assertThat(model.calls()).isEqualTo(1);}}mvn -DtestGuardedRefundTest test这两条不需要密钥应在每次构建中运行。含真实 DeepSeek 的测试继续用环境变量开关不要把密钥写进词表测试。4. 项目总结4.1 优点与缺点对比项控制器里写 ifSafeGuardAdvisor 加日志顾问覆盖面容易漏接口挂在客户端上的调用都经过是否出网看你有没有提前 return短路则 call 次数为 0文案各写各的统一 failureResponse日志散落打印SimpleLoggerAdvisor链的复杂度低本章只放两个顺序仍要固定优点退款、超时、库存共用一个客户端时护栏不用复制。拒答可以用计数证明没有调用模型。中文拒答由应用给出不依赖模型自觉。日志顾问把请求和响应收在一处。测试不需要密钥。缺点getContents()匹配整段提示词店规里写出敏感词会误伤。默认英文拒答不适合中文 App。相同order的先后不稳定。日志默认打印完整响应可能带上不该留存的用户原文。本章没有处理「换一种说法问成本」的绕过。词表不是分类器。4.2 适用场景适用明确禁止的短语例如内部成本价。需要证明某些问题没有打到 DeepSeek。多个接口共用同一个ChatClient。本地测试不想申请密钥。要给正常调用留下统一的请求响应日志。不适用需要理解同义改写、隐晦套话的安全策略。词表挡不住「进货价大概多少」。那类判断要另做不要把词表说成完整安全方案。需要十个顾问按业务阶段编排。那是第 16 章的链不是本章的两个包裹。4.3 注意事项词表中的字面量不要写入系统提示词。日志级别打开后确认输出里没有DEEPSEEK_API_KEY。密钥应只在客户端配置里不要放进消息。拒答测试使用新的计数模型避免累计计数。order显式写成 0 和 1不要都用默认 0。4.4 常见踩坑所有问题都被拒答。根因是店规写了「禁止回答内部成本价」护栏在系统消息里命中了词。处理店规改成「只回答退款规则」词只留在词表。账单里仍有敏感问题的 token。根因是控制器先调用了模型再根据回答打码。处理护栏必须在nextCall之前返回。用计数测试卡住这条回归。日志文件里出现用户手机号和完整地址。根因是默认响应打印把整段消息写入了永久日志。处理生产换一个只打印会话编号、模型名和Usage的函数完整正文进有权限的审计库。SimpleLoggerAdvisor的构造器接受请求和响应两个函数本章测试用默认函数即可。4.5 思考题把护栏的order改成 -1、日志保持 0。拒答时计数仍是 0 吗日志顾问的adviseCall还会执行吗用户不问「内部成本价」而问「这单货物的进货价」。词表会放行。放行之后模型调用次数应该是多少这类改写要放在本章的词表里继续堆词还是留到后面的策略答案在第 11 章开头。延伸阅读与资源10倍开发者的 Dify 魔法书从零构建全栈 AI 应用后端工程师转型AI第一课-Ollama 与私有化大模型实战大型语言模型(LLM) vLLM 高性能推理落地实战Agent开发之LlamaIndex 实战修炼与源码进阶大语言模型Transformers 实战修炼与源码剖析