后端

重用验证器会反转您管道中失败的含义

同一个故障关闭检查,在生成数据时是安全的跳过,在审计数据时却是破坏性的删除。下面介绍如何安全地移动它。

本文由 AI 模型从英文原文翻译而来,措辞可能与原文有出入。 阅读英文原文

一个在生产环境中运行了数月的验证器是最诱人复用的代码。它经过测试、经过调优,并且已经了解你的数据形态。因此,当你需要审计在验证器存在之前写入的记录时,将其指向旧的行看起来像一行代码就能搞定的工作:同样的函数,新的输入集。这种直觉在逻辑上是正确的,但在后果上是错误的。一个返回“无效”的检查,在生成路径上意味着“不要写入此内容”,而在审计路径上则意味着“删除已存在的内容”,而代码无法区分这二者。本文是关于它所产生的特定故障,以及修复该故障的三向判断。

隐藏在共享检查器中的不对称性

考虑一个生成翻译术语并在存储前逐一验证的流水线。验证器分三个阶段运行:精确匹配快捷方式、低成本的嵌入距离测试,以及针对模糊的中间范围,使用一个语言模型来回答“是”或“否”。任何在任一阶段失败的内容都不会被写入。调用方会继续执行并在第二天重试。

现在,我们来看看该验证器内部的错误处理:

for _, r := range gray {
    ok, err := e.verifyGray(ctx, r.ko, r.backKo)
    if err != nil {
        r.verdict = DropBacktranslate  // treat an API error as a failed check
        continue
    }
    if ok {
        r.verdict = VerdictPass
    } else {
        r.verdict = DropBacktranslate
    }
}

在生成路径上,这是正确甚至优雅的。超时、速率限制、格式错误的响应:所有这些情况都可以归结为“我们没有确认这个,所以我们不会写入它。”这是故障关闭(Fail-closed)模式。假阴性的成本只是跳过一行数据,并在第二天重试一次。

将同一个函数指向已存在的、已确认的记录,成本就会反转。现在,一次 API 超时就会将一个正确的、经人工批准的行标记为待删除。验证器没有改变。但影响范围(blast radius)变了,因为调用者将一个“不写入”的决定变成了一个“要销毁”的决定。

陷阱在于,这种不对称性在调用点是看不出来的。审计代码读起来和生成代码一样无害:

verdicts, err := engine.VerifyPairs(ctx, pairs)
for _, v := range verdicts {
    if !v.Pass {
        rejectRecord(ctx, v.ID)  // looks symmetric, is not
    }
}

返回值无法告诉你的信息

更深层次的问题在于,一个布尔类型的通过标志将两种不同的情况压缩成了一种。“模型检查了这对数据并判定它们不匹配”和“我们从未得到答案”这两种情况,最终都表现为 Pass: false。在生成路径上,这种压缩是无害的,因为两种结果都会导向同样的安全操作。在审计路径上,你迫切需要将它们区分开来,而类型签名却不允许你这样做。

解决方法不是让验证器更智能,而是不再要求验证器给出裁决,转而要求它提供证据。大多数验证器返回的已经不止是布尔值了:比如距离、置信度、中间产物。你应该将你的破坏性决策改为基于这些信息来制定。

在我们的案例中,返回类型在标志之外还携带了一个距离值:

type PairVerdict struct {
    Pass   bool
    BackKo string   // the round-trip translation, empty if we never got one
    Dist   float64  // embedding distance, 0 when the shortcut matched
}

该距离是测量出来的,而不是判断出来的。API 错误无法伪造出较大的距离,因为失败的调用根本不会产生距离值。因此,距离阈值是对数据的断言,而布尔值则是对过程的断言。

三向判断

审计需要三种结果,而不是两种,而第三种才是关键所在:

  • Delete:存在不匹配的确凿证据。在我们的流水线中,这指的是距离值高于上限带,远超合法同义词所在的区域。
  • Keep:验证器明确通过了该记录,包括语言模型审查了模糊对并予以批准的情况。
  • Hold:所有其他情况。检查无法完成,往返结果为空,或者模型在模糊带内给出了否定回答。记录下发现,保持数据不变,并将其放入人工处理队列。

“保留”桶是确保复用安全的关键。它将“我不确定”从一个破坏性操作转变为一个工作项。它还将你的审计工作从清理任务转变为分类任务,而这本就应该是审计的应有之义。

这个设计中潜藏着一个微妙的顺序错误,我们曾遇到过,因此值得详细说明。我们的距离字段在两种完全不同的情况下会为零:当完全匹配的快捷方式触发时(完美通过),以及当往返结果为空时(完全无法评估)。一个仅基于距离编写的双分支规则会将空的往返结果归档为通过,写入一条通过记录,从而通过幂等性过滤器将该行从未来的所有审计中排除。该行将在从未被验证过一次的情况下被永久标记为已验证。在接触距离值之前检查往返产物是否为空,只需一行代码就能堵上这个漏洞。

一个验证器,两个调用点,三种结果 共享 验证器 生成 审计 失败:跳过行 成本 = 一次重试 失败:删除行 成本 = 数据丢失 删除 保留 暂存 // 相同代码,反向爆炸半径

空运行的数据

在进行任何实际操作之前,我们使用三向规则构建了审核程序,并以空运行模式对 300 条旧记录进行了审核。距离直方图是其中有趣的部分。每条记录都低于上限阈值。没有任何记录符合删除条件。

更能说明问题的是中间区间。一个意为“dermal filler injection”的术语,其四种语言的译文都回环翻译为“hyaluronic acid injection”,最终距离为 0.36。这些都是正确的翻译。回环翻译描述的是物质而非操作,这正是语言模型会标记为“不相同”的那种语义漂移。在双向规则下,那四行记录本会在四种语言中被删除,而原因仅仅是它们是正确的。

仅此一项观察就证明了整个设计的合理性。它还带来了一个我们没想到的发现:我们的零删除结果并不证明数据是干净的。它在一定程度上是总体过滤器造成的人为结果。审核仅检查了父条目处于“已确认”状态的记录,而我们最著名的错位示例——一个术语的翻译漂移成了一段医生传记的文字——恰好位于一个“未确认”的父条目下。审核程序不可能发现它。验证器的优劣取决于你提供给它的数据行。

将验证器移至破坏性路径的规则

  • **询问每条路径上的失败成本。**如果一条路径的代价是重试,而另一条的代价是数据被删除,那么你就不是在复用验证器,而是在赋予它一项新工作。
  • **通过度量而非裁决来引导破坏性操作。**距离、计数和差异不会因基础设施故障而被伪造,但布尔值可以。
  • **在添加删除操作前,先添加一个“保留”桶。**如果你的审计没有第三种结果,它就会将不确定性表现为破坏。
  • **检查产物,而不仅仅是分数。**一个空的中间结果其数值常常与一个完美结果相同。要显式地测试这种情况。
  • **警惕幂等性陷阱。**如果你为避免重复处理而记录“已检查”,那么一次错误的通过将是永久性的。该行会被标记为已验证,并且再也不会被复查。
  • **先对真实数据切片进行一次空运行,并观察其分布。**零删除的结果本身就是一种信息:要么是数据本身是干净的,要么是你的阈值设置有误,要么是你的总体过滤器排除了你正要寻找的那些行。
  • **在覆盖原始数据前先将其保存。**我们的做法是将其存入日志表中一个现有的未使用列。回滚的保险成本通常比看起来要低。

基本模式

这并非真的与验证器有关。它关乎这样一个事实:函数的安全属性并非函数本身所固有的。它们存在于函数与其调用者这一对关系中。“故障关闭”是“当此检查无法完成时会发生什么”这一情形的属性,而会发生什么则完全由返回语句另一侧的代码决定。

下一次,当你将成熟的验证逻辑应用于新的数据集时,要问的问题不是该逻辑是否仍然适用。而是“拒绝”的含义是否仍然适用。如果说“拒绝”过去意味着“等待”,而现在它意味着“销毁”,那么你就需要第三个词。