news 2026/10/13 15:48:56

第10章:Spring AI 2.0 Advisor 初识——日志与护栏

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
第10章:Spring AI 2.0 Advisor 初识——日志与护栏

上章思考题答案

  1. 会换掉。MessageWindowChatMemory.process发现新来的系统消息与记忆里的不同,就去掉此前的系统消息,只留这一次的。应保留当前请求传入的那一版店规,不要把每一版都堆在历史里。
  2. 用户原话在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.call

call 次数不变

call 次数加一

本章只挂日志和护栏这两个顾问。多个顾问谁先谁后、命中后是否还写入记忆,不在这一节展开。


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.java
  • spring-ai-client-chat/.../advisor/SimpleLoggerAdvisor.java
  • spring-ai-client-chat/.../advisor/SafeGuardAdvisor.java
  • spring-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 优点与缺点

对比项控制器里写 ifSafeGuardAdvisor 加日志顾问
覆盖面容易漏接口挂在客户端上的调用都经过
是否出网看你有没有提前 return短路则 call 次数为 0
文案各写各的统一 failureResponse
日志散落打印SimpleLoggerAdvisor
链的复杂度低本章只放两个,顺序仍要固定

优点:

  1. 退款、超时、库存共用一个客户端时,护栏不用复制。
  2. 拒答可以用计数证明没有调用模型。
  3. 中文拒答由应用给出,不依赖模型自觉。
  4. 日志顾问把请求和响应收在一处。
  5. 测试不需要密钥。

缺点:

  1. getContents()匹配整段提示词,店规里写出敏感词会误伤。
  2. 默认英文拒答不适合中文 App。
  3. 相同order的先后不稳定。
  4. 日志默认打印完整响应,可能带上不该留存的用户原文。
  5. 本章没有处理「换一种说法问成本」的绕过。词表不是分类器。

4.2 适用场景

适用:

  1. 明确禁止的短语,例如内部成本价。
  2. 需要证明某些问题没有打到 DeepSeek。
  3. 多个接口共用同一个ChatClient。
  4. 本地测试不想申请密钥。
  5. 要给正常调用留下统一的请求响应日志。

不适用:

  1. 需要理解同义改写、隐晦套话的安全策略。词表挡不住「进货价大概多少」。那类判断要另做,不要把词表说成完整安全方案。
  2. 需要十个顾问按业务阶段编排。那是第 16 章的链,不是本章的两个包裹。

4.3 注意事项

  • 词表中的字面量不要写入系统提示词。
  • 日志级别打开后,确认输出里没有DEEPSEEK_API_KEY。密钥应只在客户端配置里,不要放进消息。
  • 拒答测试使用新的计数模型,避免累计计数。
  • order显式写成 0 和 1,不要都用默认 0。

4.4 常见踩坑

  1. 所有问题都被拒答。根因是店规写了「禁止回答内部成本价」,护栏在系统消息里命中了词。处理:店规改成「只回答退款规则」,词只留在词表。
  2. 账单里仍有敏感问题的 token。根因是控制器先调用了模型,再根据回答打码。处理:护栏必须在nextCall之前返回。用计数测试卡住这条回归。
  3. 日志文件里出现用户手机号和完整地址。根因是默认响应打印把整段消息写入了永久日志。处理:生产换一个只打印会话编号、模型名和Usage的函数,完整正文进有权限的审计库。SimpleLoggerAdvisor的构造器接受请求和响应两个函数,本章测试用默认函数即可。

4.5 思考题

  1. 把护栏的order改成 -1、日志保持 0。拒答时计数仍是 0 吗?日志顾问的adviseCall还会执行吗?
  2. 用户不问「内部成本价」,而问「这单货物的进货价」。词表会放行。放行之后,模型调用次数应该是多少?这类改写要放在本章的词表里继续堆词,还是留到后面的策略?

答案在第 11 章开头。

延伸阅读与资源

10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用
后端工程师转型AI第一课-Ollama 与私有化大模型实战
大型语言模型(LLM) vLLM 高性能推理落地实战
Agent开发之LlamaIndex 实战修炼与源码进阶
大语言模型Transformers 实战修炼与源码剖析

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/13 15:47:53

DR5白金插件在PS2021中的精准安装与人像质感修图工作流

简介:这是一款专为Photoshop 2021(WIN系统)用户打造的DR5白金高级磨皮插件套装,面向人像修图师、电商美工及中高级PS使用者,解决精细化肤质调整、高效批量磨皮与自然质感保留等核心痛点。资源共1566个文件,…

作者头像 李华
网站建设 2026/10/13 15:46:51

蒋立源编译原理第三章习题与答案:从正规式到DFA的高效复习法

简介:这是蒋立源《编译原理(第三版)》第三章习题与答案的PDF文档,适合正在学习词法分析、文法与自动机理论的高校学生、考研复习者及自学者使用。资源共1个PDF文件,压缩包约2.97MB,内容按习题序号编排&…

作者头像 李华
网站建设 2026/10/13 15:46:21

SAP分类、批次、序列号管理:构建完整物料追溯链的实操指南

简介:PDF课件面向SAP实施顾问、供应链与库存管理人员,系统讲解分类管理、序列号管理和批次管理三大库存精细化控制模块,以解决多评估类型、分割评估下的库存消耗顺序与单品追溯难题。分类管理部分介绍事务代码CL01/CL02/CL03维护分类、CT04/T…

作者头像 李华
网站建设 2026/10/13 15:46:15

Agent能扛多大的事儿?从工程化视角拆解承载力的四个底座

最近被问得最多的一句话,就是标题这句:“你的Agent,能扛多大的事儿?”问的人有做运营的,有写代码的,也有刚接触AI的团队负责人。他们眼里的Agent,不是某个模型接口,而是那个能自己查…

作者头像 李华
网站建设 2026/10/13 15:46:10

QGIS在线图源全攻略:XYZ、WMTS与坐标系避坑指南

干 GIS 的应该都有同感:打开 QGIS 之后第一件事就是找一张能用的底图,没有底图,矢量点线面就像往黑板上丢了一堆散装数字,谁也别想定位。在线图源恰恰是解决这个问题的捷径,QGIS 里加一个稳定图源,几秒钟就…

作者头像 李华
网站建设 2026/10/13 15:45:57

大模型三层架构实战:输入层、模型层与输出层的工程化拆解

1. 大模型三层架构到底在拆什么1.1 从一个常见困惑说起这两年AI圈的新东西多到让人眼花缭乱。今天出一个能读图的模型,明天出一个能操作电脑的智能体,后天又冒出一个能实时语音对话的助手。很多人看着这些新名词,第一反应是“又来了一个新模型…

作者头像 李华