跳到主要内容

护栏也是模型:那个没人放进仪表盘的隐藏依赖

· 阅读需 13 分钟
Tian Pan
Software Engineer

这是一个正在演变成一种典型的故障复盘(postmortem)模式。主模型整晚运行良好。延迟平稳,Token 吞吐量正常,服务商状态页显示正常。然而,整整 40 分钟内,每一个用户请求都失败了——因为位于模型前端的安全分类器(safety classifier)超时了,中间件将该超时封装为一个通用异常,而异常处理程序返回了拒绝响应。你的模型并没有宕机。是你的“门禁”宕机了,而这扇门被一名从未将其视为决策选项的工程师配置成了“故障即关闭”(fail closed)。

令人不安的事实是,大多数团队在生产环境中运行着第二个机器学习系统,却不愿承认。内容审核分类器、越狱检测器、PII 清洗器、主题过滤器——每一个都是模型,拥有各自的延迟分布、错误率、在漂移中悄然腐烂的训练数据假设,以及各自的故障模式。但因为它被称为“护栏”,它就被当作配置文件来对待:设置一次,从不监控,在仪表盘上缺席,也不在值班手册中。你绝不会在没有 SLO 的情况下发布主模型。但大多数团队发布护栏时,甚至连健康检查都没有。

这篇文章的主旨是:像对待被保护对象一样严肃地对待这扇“门”——为它的延迟制定预算,有目的地选择它的故障模式,像关注错误预算一样关注它的误报率,并处理真正棘手的情况——流式传输——在这种情况下,决定用户体验的是护栏的架构,而非模型本身的架构。

门禁就在你的关键路径上,且具有延迟分布

每个同步护栏都会在请求路径上出现两次:一次在进入时(提示词筛选),一次在退出时(响应筛选)。这意味着你的用户感知到的是它的 p99,而不是 p50——而且防护模型具有真实的尾部延迟,因为它们是真实存在的模型。

根据你部署的内容,这些数字会跨越两个数量级。在专用基础设施上运行的轻量级越狱检测器每次输入检查大约增加 15–40 ms——这对于端到端预算低于 200 ms 的语音流水线来说也是可以接受的。专用的审核分类器通常在 90 ms 内返回标签。

但像 8B 参数的防护模型这种基于 LLM 的安全手段是完整的自回归推理:它和你的主模型一样,也存在冷启动、批处理队列和尾部延迟。这就是为什么 Meta 会同时发布 Llama Guard 的 8B“质量选型”和 1B“延迟选型”——这两个规格的存在就是一种承认:门禁的延迟预算是一个一等公民级别的工程约束。

直接得出的三个实践建议:

  • 给护栏一个明确的延迟预算,像处理任何其他跳转(hop)一样,从面向用户的 SLO 中扣除它。如果你的产品承诺 2 秒首字延迟,而你的输出检查在 p99 时需要 400 ms,那么你的模型实际上只有 1.6 秒的时间。如果没有人进行这项减法,你的 SLO 就是虚构的。
  • 并行运行独立检查,而不是链式运行。 由“提示词注入检查 → PII 检查 → 主题过滤器”组成的顺序流水线会叠加三个尾部延迟。并行执行意味着你只需要支付最大值,而不是总和。那些串行链接护栏的团队经常会发现,主导 p95 的是这条链条,而不是模型本身。
  • 开启护栏进行压测。 护栏的容量通常是事后才考虑的——通常只是主模型容量的一小部分——因此在流量激增时,分类器会首先达到饱和。在高负载下排队的门禁要么成为你的瓶颈,要么更糟,由于超时而进入某种你从未预设过的故障模式。这引出了真正的问题。

故障即开启或故障即关闭:那个被意外做下的决定

当护栏无法访问时——无论是超时、OOM、配额耗尽,还是防护模型本身的部署错误——以下两种情况必居其一。要么流量不受检查地通过(故障即开启),要么流量停止(故障即关闭)。每个部署的系统都选择了其中之一。几乎没有团队能告诉你他们选了哪一个,因为这个选择通常是异常处理代码的一个涌现属性:一个记录日志并继续执行的 try/except 就是“故障即开启”;而一个将来自分类器的任何非 200 响应转换为拒绝的中间件就是“故障即关闭”。

两种默认设置在特定环境下都是合理的,而在错误的语境下则是危险的:

  • 故障即开启 (Fail-open) 保证了可用性,但悄无声息地移除了你的安全层。对于一个带有输出过滤器的编程助手(主要用于捕获意外泄露的密钥)来说,故障即开启 10 分钟可能是可以接受的,甚至是正确的决策。但对于用户群包含未成年人的消费类聊天产品,或强制执行监管边界的医疗保健部署,停机期间的故障即开启会导致事故披露。
  • 故障即关闭 (Fail-closed) 保证了安全性,但会将每次护栏的小故障转化为完整的产品停机。你的可用性现在是两个系统可用性的乘积——而第二个系统是那个没人进行过容量规划、没人进行过灰度发布、也没人会为此收到告警的系统。

“故障即关闭”还拥有大多数威胁模型都会忽略的攻击面:如果“被拦截”是故障模式,那么“导致拦截”就是一种攻击。在 ACM CCS 的 LAMPS 研讨会上发表的研究精确地证明了这一点——通过在共享提示词模板或被投毒的客户端配置中注入大约 30 个字符的对抗性序列,可以触发 Llama Guard 3 在超过 97% 的包含这些序列的用户请求中产生误报。防护装置本身成为了拒绝服务(DoS)的向量。没有越狱,没有有害内容,只有一扇被设定为“说不”的门,以及一个学会了如何根据指令让它“说不”的攻击者。

实际的解决方案不是在全局范围内选择一种模式,而是针对每个路由做出明确的决定,并写明利害关系:

  • 根据“漏检拦截”的成本与“误报停机”的成本,对每个受保护的界面进行分类。与支付相关的 Agent 动作和头脑风暴聊天不应采用相同的故障模式。
  • 考虑一条降级的中间路径:在护栏失效时,回退到更便宜的检查(正则表达式级的筛选、针对重复提示词的缓存判定、更小的本地分类器),而不是二进制的全有或全无。
  • 无论你选择什么,都要使其具备可观测性。“因超时跳过护栏”应该是一个被计数的、可告警的事件——而不是你在事后分析中才发现的一行调试日志。

误报漂移:在你未关注的方向上,防护门正在失效

监控护栏的团队几乎总是只监控一个方向:漏报(False Negatives)。是否有有害内容流出了?这是存在头条风险的方向,因此它获得了红队演练和事故复盘的关注。而另一个方向——合法请求被拦截(误报)——则在悄无声息地恶化,因为被拦截的用户很少会提交反馈。他们会重新组织语言、重试,或者直接离开。

基础率(Base rates)理应受到更多重视。在公开基准测试中,广泛部署的审核系统的误报率从远低于 1% 到超过 25% 不等,具体取决于数据集和类别——同一个分类器在一种流量分布上近乎完美,但在另一种分布上可能会过度拦截四分之一的良性内容。而你的生产环境数据分布与任何基准测试都不匹配。

而且这种分布并不是静止不变的。产品发布会改变用户的提问方式,新的流行语会改变 Token 分布,一个病毒式的用例可能会让你淹没在防护模型在训练中几乎从未见过的某个主题中。分类器漂移是一个正常的运维事实——SRE 文献将漂移监控视为一种可靠性实践,配备了仪表板、阈值和运维手册(runbooks),而不是将其当作一个数据科学研究项目。护栏也理应受到同样的对待,但有一点不同:审核具有对抗性,因此分布会发生偏移,是因为攻击者会在两个方向上探测防护门失效的地方。

还有一个更隐蔽的漂移来源常让团队措手不及:护栏在你不知情的情况下进行了更新。托管审核端点和托管安全层会根据供应商的时间表而非你的时间表发布新版本。防护门的版本更新可能会在你的代码或流量零变化的情况下,一夜之间改变你的拦截率——如果你没有将拦截率作为一等指标进行追踪,你可能会在事故调查中把时间花在检查提示词模板上。

生产环境护栏的最低监控要求如下:

  • 按类别、按场景、随时间变化的拦截率 —— 对 两个 方向的变动都发出告警。拦截率下降可能意味着向漏报方向漂移;拦截率上升则可能意味着防护模型更新后开始拒绝你的客户。
  • 对拦截流量进行人工抽样审核,而不仅仅是审核通过的流量。如果没有人去查看防护门拒绝了什么,那么你的误报率在设计上就是不可测量的。
  • 针对护栏变更的影子模式(Shadow-mode)评估。在实时流量上让新版护栏与旧版并行运行,对比判定结果,并在切换之前查看差异集——这正是你已经在主模型上应用的金丝雀发布原则。
  • 已知良性困难案例的带标签回归集 —— 包括医疗问题、安全教育提示词、处于边界的虚构类创作请求——在每次护栏变更时进行回放,就像针对数据库模式迁移(schema migration)运行集成测试一样。

流式传输使防护门变成了一个架构问题

以上所有内容都假设你可以在用户看到完整响应之前对其进行检查。流式传输(Streaming)打破了这一假设,这也是护栏设计不再仅仅是清单检查,而变成真正的权衡权衡之处。

最幼稚的选择是缓冲整个响应,运行输出检查,然后再释放——这会悄无声息地将你的流式产品变成非流式产品,并浪费了你在其他地方精心设计的首个 Token 响应时间(TTFT)。另一个幼稚的选择是不加检查地进行流式传输,事后再进行审核,这意味着用户已经阅读了你试图拦截的内容;对用户已经看到的内容进行追溯性删除,不过是自欺欺人。

可行的折中方案是增量检查,它带有两个可以直接编码这种权衡的可调参数。分块流式验证(Chunked streaming validation)在触发检查前会积累一个新 Token 窗口(块大小/chunk size),并携带上一个窗口的部分 Token 作为语义上下文(上下文大小/context size)。更大的块能给检查器提供足够的上下文来捕捉跨句子的违规行为,但也会增加生成和交付之间的延迟。更小的块流式传输更快,但有风险漏掉跨块边界拼凑出来的违规行为。

最近关于句子级流式护栏的研究表明,通过使用异步缓冲,开销可以被压低到令人印象深刻的程度——在各种 Token 速率下仅为几十毫秒。但基本形态没有改变:在流式传输中,在判定结果产生之前,部分输出已经到达了用户端。你的设计必须决定多少暴露是可以接受的,以及“句中停止”看起来应该是怎样的。

来自运行生产环境团队的两点运维笔记:首先,取消操作的交互设计(UX)是护栏的一部分:在中途切断流式响应需要一个经过设计的用户端行为(例如一个干净的撤回消息,而不是一段冻结的残缺段落)。其次,块边界规避(chunk-boundary evasion)是一个真实的攻击模式——如果你的检查器只看 128 个 Token 的窗口,对手的目标就会变成将攻击载荷拆分到缝隙两端,这就是为什么块之间的上下文携带(context carryover)不是可选插件,而是必选项。

将“门禁”放在仪表盘上

这里的核心逻辑是组织层面的,而非技术层面的。防护栏之所以最终处于无人监控的状态,是因为它的定义方式:它以合规要求的形式出现,作为中间件实现,并在心理上被归类为配置。但这种定义方式在实际应用中完全站不住脚 —— 它实际上是关键路径上的第二个模型,其容量会饱和,判定会漂移,版本会在你不知情的情况下发生变化,而且它的故障模式还是某人不经意间选定的。

解决方法是将这个“门禁”纳入主模型已有的每一项工程实践中。建立具有明确延迟预算的 SLO。进行包含它的容量规划和压力测试。针对每个界面做出深思熟虑的“故障放行/故障阻断”决策,并将其记录在案,同时对绕过路径进行计数和告警。监控双向的拦截率仪表盘。为每一次防护栏变更进行影子评估(Shadow evaluation),并准备一套针对良性困难案例的回归测试集。在 On-call 操作指南中增加一行,说明当分类器 —— 而不是模型本身 —— 宕机时该怎么做。

这些都不是什么新鲜事;这与你的团队在下游环节已经应用的可靠性规范完全一致。唯一的新步骤是承认你称之为防护栏的东西也是一个模型 —— 而正如你团队中的每个人都清楚的那样,当无人看管时,模型会以各种意想不到的方式出故障。

References:Let's stay in touch and Follow me for more thoughts and updates