在Spring AI中保护大语言模型输出安全
[LOADING...]
1. 概述
当我们通过自己的 API 暴露大型语言模型时,第一反应往往是过滤用户发送的提示词。Spring AI 提供了 SafeGuardAdvisor 来完成这项工作。它会在提示词到达模型之前,阻止任何包含禁用词的提示词。
但这只覆盖了问题的一半。一个看起来完全无害的提示词,仍然可能产生我们不愿返回的响应。 模型会基于自身知识作答。用户甚至不必说出我们试图隐藏的那个东西。
在本教程中,我们将通过一个自定义 advisor 来补上这个缺口,它会在 ChatClient 将模型响应返回之前对其进行检查。我们会先设置输入防护,找出其中的漏洞,然后在其上叠加一个输出防护。
2. 设置项目
我们只需要一个 chat starter 依赖。这里添加 Google GenAI 的 starter:
最新版本的 spring-ai-starter-model-google-genai 可在 Maven 仓库中获取。Advisor 与模型无关,因此任何其他聊天 starter 在这里也同样适用。
接下来,我们将 starter 指向我们的 Gemini API key,并选择一个模型:
在整个教程中,我们将构建一个 Java 编码助手,并遵循一条规则:绝不能推荐遗留日期 API 而不用 java.time。
3. 阻止敏感提示词
SafeGuardAdvisor 是 Spring AI 内置的、用于处理不期望输入的手段。它会扫描提示词中的敏感词列表。一旦找到,就会短路 Advisor 链。 模型完全不会被调用,调用方会收到一个预设响应。
让我们将其注册为 ChatClient 上的默认 Advisor,并搭配一个设定助手角色的系统提示词:
该 Advisor 使用简单的子字符串检查将每个词与提示词进行比对,因此匹配区分大小写且要求精确。它还会扫描整个提示词,包括系统消息。这里有个陷阱:如果我们的系统提示词中写明了 SimpleDateFormat,那么该防护会拒绝每一个请求。
现在,任何提及遗留 API 的问题都会在到达模型之前被拦截:
模型完全不会被调用,这使其成为我们能设置的成本最低的防护措施。
4. 为什么仅靠输入过滤还不够
SafeGuardAdvisor 只能看到提示词。开发者可以在不输入类名的情况下询问某个类。询问“我们在 Java 7 中是如何解析日期字符串的?”不会匹配我们列表中的任何内容,因此会到达模型。由于 java.time 在 Java 8 之前并不存在,模型只有一个诚实的答案:
在 Java 7 中,日期字符串是通过
java.text.SimpleDateFormat类解析的:需要定义格式模式,将字符串转换为java.util.Date对象。
防护没有触发,但遗留 API 还是回来了。 输入过滤只匹配用户输入的内容,而模型没有义务局限在这套词汇内。要捕获这种情况,我们必须检查返回的内容。
5. 创建输出 Advisor
Spring AI 没有内置用于过滤响应内容的 Advisor,因此我们将自行编写一个。它采用与 SafeGuardAdvisor 相同的三项设置,我们会分两步构建:先是类及其 Builder,然后是过滤逻辑。
5.1. 骨架与 Builder
用于检查响应的 Advisor 只需要实现 CallAdvisor 接口,Spring AI 将其用于非流式调用。我们的类保存 Builder 配置的三个值,并通过 Advisor 契约暴露其中两个:
builder 与 SafeGuardAdvisor 上的 builder 类似,因此两个 Advisor 在调用处的写法一致:
sensitiveWords() 的可变参数重载是 SafeGuardAdvisor 未提供的。默认顺序值得进一步审视。Spring AI 使用一个内部的 ChatModelCallAdvisor 终止调用链,其 order 恰好是 Ordered.LOWEST_PRECEDENCE。如果某个 Advisor 也使用这个值,它可能被排在终端 Advisor 之后。 终端 Advisor 不会再继续委托。我们的 Advisor 就会静默地永远不执行。
默认设为 Ordered.LOWEST_PRECEDENCE - 1 会使我们排在所有实际执行的 Advisor 的最后。Spring AI 自带的 StructuredOutputValidationAdvisor 也留出了同样的余量,默认值为 Ordered.LOWEST_PRECEDENCE - 2000。
5.2. 实现 adviseCall()
与输入防护不同,输出 Advisor 一开始会先放行调用。 我们先调用调用链的其余部分,然后才检查返回的内容:
如果响应中没有 ChatResponse 或 generation,就没有可供检查的内容,因此我们直接将其返回。否则,我们会遍历每个 Generation 并映射,然后将过滤后的列表与原始列表比较。Generation 实现了 equals(),因此这是值比较而非同一性检查;当没有任何变化时,我们返回原始响应。
替换时会更换消息,但保留 generation 的元数据,因此结束原因得以保留:
6. 注册 Advisor
注册输出防护就是将其加入同一个 defaultAdvisors() 调用:
这两个防护有意监视不同的词列表。 输出防护监视的是 DateFormat 而不是 SimpleDateFormat,这样也能捕获父类,并增加了 Calendar 和 Timestamp,也就是模型会顺手使用的相邻遗留类型。将这些加入输入防护会适得其反:开发者询问 Calendar 并不是问题,推荐它的答案才是问题。
我们没有设置 order:SafeGuardAdvisor 默认是 0 并先运行,而我们的默认值会让输出防护最后运行。
7. 测试防护机制
该 Advisor 是一个普通类,因此我们无需模型即可测试它。先从测试夹具开始:
现在用 Mockito 对调用链进行打桩,并向其传入一个泄露内容的 generation:
干净的答案会走提前返回,因此我们拿回的是完全相同的实例:
端到端测试中,那个曾绕过输入防护的问题现在返回时已被过滤:
8. 局限性
子字符串匹配在两个方面都很粗糙。 它区分大小写,因此一个大小写偏差就会漏过。它也没有词边界的概念。正因如此,我们的列表写明 java.util.Date,而不是单独的 Date。较短的词也会拒绝基于 LocalDate 的优秀答案。改写则可以直接绕过它。
它也是一个仅适用于调用模式的防护。OutputSafeGuardAdvisor 实现了 CallAdvisor,但没有实现 StreamAdvisor,因此流式响应会绕过它。过滤流确实更难,因为敏感词可能跨越两个数据块,而较早的数据块已经到达客户端。
如果需要更强的保证,内容审核模型比词列表更合适。
9. 结论
在本文中,我们构建了一个自定义 Spring AI Advisor,用于检查模型响应,并在其中包含我们不愿返回的内容时进行替换。将其与 SafeGuardAdvisor 配合使用可以覆盖两个方向:一个防护拒绝提示词,另一个防护拒绝答案。单独使用任何一个都不完整,但二者结合可以用很少的代码补上明显的缺口。
一如既往,完整源代码可在 GitHub 上获取。