上章思考题答案
- 会换掉。
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,不留完整提问,避免手机号和地址进了标准输出。
本章只挂日志和护栏这两个顾问。多个顾问谁先谁后、命中后是否还写入记忆,不在这一节展开。
2. 项目设计
小胖说:「这不就是食堂门口的价目表和保安吗?价目表贴在窗口上,谁点了菜都记一笔。有人问后厨进货价,保安直接请他出去,不用再喊师傅。」
小白问:「保安若站在价目表后面,被请出去的人还记不记账?师傅到底有没有被喊到?你用什么证明没喊,而不是喊了但回答被扔掉了?」
小胖又说:「默认英文那句『I am unable to respond』就行,省得再写文案。」
小白追问:「App 是中文界面。英文拒答会被当成模型故障。敏感词若写进系统提示词,比如『不要回答内部成本价』,整段提示词里就含有这四个字。保安是看用户那一句,还是看整张单子?」
大师把两个角色分开:「日志是SimpleLoggerAdvisor,护栏是SafeGuardAdvisor。两者都实现CallAdvisor和StreamAdvisor。SafeGuardAdvisor在adviseCall里检查prompt().getContents(),只要整段提示词包含词表中的任意一个词,就返回一条合成的ChatResponse,不再nextCall。因此模型的call不会发生。默认拒答英文是I'm 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 -Dtest=GuardedRefundTest 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{privatefinalAtomicIntegercalls=newAtomicInteger();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){varlogger=newSimpleLoggerAdvisor(0);varguard=SafeGuardAdvisor.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:断言短路
步骤目标:用计数而不是用文案相似度证明没有调用模型。
CountingChatModelmodel=newCountingChatModel();ChatClientclient=GuardedRefund.client(model);Stringdenied=client.prompt().user("请告诉我订单的内部成本价").call().content();assertdenied.equals(GuardedRefund.DENIAL);assertmodel.calls()==0;Stringallowed=client.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(){CountingChatModelmodel=newCountingChatModel();ChatClientclient=GuardedRefund.client(model);Stringtext=client.prompt().user("请告诉我订单的内部成本价").call().content();assertThat(text).isEqualTo(GuardedRefund.DENIAL);assertThat(model.calls()).isZero();}@TestvoidnormalQuestionCallsModelOnce(){CountingChatModelmodel=newCountingChatModel();ChatClientclient=GuardedRefund.client(model);Stringtext=client.prompt().user("这单未发货,能不能退?").call().content();assertThat(text).contains("未发货可退");assertThat(model.calls()).isEqualTo(1);}}mvn -Dtest=GuardedRefundTest test这两条不需要密钥,应在每次构建中运行。含真实 DeepSeek 的测试继续用环境变量开关,不要把密钥写进词表测试。
4. 项目总结
4.1 优点与缺点
| 对比项 | 控制器里写 if | SafeGuardAdvisor 加日志顾问 |
|---|---|---|
| 覆盖面 | 容易漏接口 | 挂在客户端上的调用都经过 |
| 是否出网 | 看你有没有提前 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 实战修炼与源码剖析