LLM 共识门中的非回答并非分歧
当一个模型未能回复时,将其计为不一致会浪费您的重试预算,并将服务中断转变为最终判定。如何区分这两种状态。
双模型共识门是一个简单的想法:向两个独立的大语言模型 (LLM) 提出相同的问题,仅在它们意见一致时才执行操作,并将所有其他情况都交由人工处理。这个方法是有效的,我之前也写过文章,探讨为什么对于必须正确的决策,共识机制优于单一模型。这篇文章要讨论的是这种模式本身存在的一种故障模式。在一个对抓取到的候选术语进行分类的流水线中,我们的分歧率在几周内从 18% 攀升至 68%,并且越来越多标为“模型无法达成一致”的项目被永久地从自动化流程中剔除。这些项目中,有很大一部分根本就从未出现过分歧。只是其中一个模型根本没有回答,而代码将沉默计为异议。
共识门有四种结果,而不是两种
对于共识门,简单的思维模型是它有两种结果:模型们达成一致,或未达成一致。真实的状态空间更大,而额外的状态正是可靠性流失的地方:
- 两个模型都作答了,结论相同。可以安全地采取行动。
- 两个模型都作答了,结论不同。这是一次真正的分歧,值得重试或人工介入。
- 一个模型作答了,另一个没有。你只有一个意见,而不是两个。没有进行任何比较。
- 两个模型都未作答。此批次完全中断。
在编写代码时,状态 2 和状态 3 感觉很相似,因为在这两种情况下你都无法继续。但它们的含义却截然相反。分歧是关于数据的信号:该项目确实模棱两可,重复提问也不太可能得到一致的结果。未作答是关于基础设施的信号:超时、速率限制、内容过滤器、部分解析。项目本身可能非常简单。
大多数实现都会对状态 4 进行编码,因为完全中断是响亮而明显的。状态 3 是那个被悄悄地归入状态 2 的状态,而这种归并就是 bug 所在。
在代码中,无回答如何演变为分歧
这种坍缩通常发生在映射查找时。每个模型都会返回一批判定结果,你按项目对它们进行索引,然后进行比较:
gv, gok := geminiVerdicts[item]
ov, ook := openaiVerdicts[item]
if !gok || !ook {
return Judgment{Agreed: false} // missing answer, "not agreed"
}
if gv != ov {
return Judgment{Agreed: false} // real disagreement
}
两条路径都会生成相同的结构体。在下游,Agreed == false 会被记录为 drop-disagree,一个重试计数器会增加,并且在三次失败的尝试后,该项目会被标记为“耗尽”并从自动填充中移除。系统无法告诉你,即使在其日志中,一个耗尽的项目究竟是三次都模棱两可,还是三次都运气不佳。
有两个细节使情况比看起来更糟。首先,部分解析丢失的情况很常见。一个被要求判断一批 15 个项目的模型,有时会返回 14 个结果,并且任何地方都没有报错。丢失的项目会进入状态 3,但这并非其自身的过错。其次,单边故障在时间上是相关的。当一个提供商晚上状态不佳时,整批项目会同时进入状态 3,并且其中的每个项目都会消耗一次重试机会。
重试预算是真正的牺牲品
我们的关卡为每个项目提供三次尝试机会。三次真正的分歧是“模型对此无法达成一致,应由人工介入”的合理定义。重试预算是一个用于过滤模糊性的过滤器。
将无应答计入该预算,会悄然改变过滤器的筛选标准。一个经历了两个不稳定的夜晚和一次真正分歧的项目,其耗尽状态与一个模糊了三次的项目相同。更糟糕的是,持续的单方面服务中断会将基础设施故障直接转化为数据结论:在中断期间处理的每个项目都会向耗尽状态迈进一步,经过三个这样的夜晚,流水线就对数百个从未实际评估过的项目“得出”了某种结论。
当我们最终能够看到数据时,这正是数据所显示的。重试被标记为分歧的项目,大约有三分之一的时间会改变结果(在第一次“分歧”后重试的项目中,181 个后来被解决为一致拒绝,139 个被解决为一致接受,而 577 个仍处于分歧状态)。这种不稳定性中,有一部分是模型在处理边缘输入时真正的摇摆不定。但其中未知的一部分是状态 3 披着状态 2 的外衣,而在修复之前,日志格式使得这两者无法区分:代码存储的是一个固定的解释性字符串,而不是每个模型具体说了什么。
修复方案:记录非答案,但不计入统计
这项变更很小,包含三个部分。
首先,扩展 judgment 结构体,以便在比较之后保留真实值:
type Judgment struct {
Agreed bool
Oneside bool // at least one model gave no verdict
GeminiOut string // raw verdict, "" means no answer
OpenAIOut string
}
在任何提前返回之前,先填充每个模型对应的字段。在遇到缺失答案时,最自然的短路位置也正是该信息即将丢失的地方,因此顺序很重要:先填充,后返回。
其次,为无答案的情况提供其专属的记录判定,并将其排除在重试计数之外。我们之前的耗尽规则是,对于一个项目的日志行,max(attempt) >= 3。将无答案的行写入并设置 attempt = 0,可以使其对每次审计查询都可见,同时又不会导致耗尽。下一次真实的尝试仍然会通过 max(attempt) + 1 来计算其尝试次数,因此真实尝试的序列不会中断。
第三,对单边(one-sided)进行广义定义。只要整个批次已返回,那么当任一模型缺失(包括两个都缺失)时,Oneside 都应为 true。在一个批次中,如果两个模型都丢弃了同一个项目,这仍然是无法比较,而不是不一致。为两个模型都没有返回任何内容的情况保留完全中断路径,并在那里完全跳过日志记录,因为一次无效的运行完全不应留下任何逐项的痕迹。
毒丸与三段式升级防护
将无应答从耗尽计数中排除会产生新的风险。如果某个特定输入总是导致某个提供商静默(内容过滤器是典型原因),那么该项目现在会无限重试。它每晚都会重新进入处理队列,消耗两次 LLM 调用,且永远无法解决。队列中就出现了一个毒丸。
针对此修复的再修复方法是,将持续的单方面失败升级回计数的路径中,并设置防护措施,以确保在普通故障期间不会触发升级:
- 在滑动窗口内而不是在全部时间内统计无应答的行。我们使用了 14 天。一个在一年中每月只出现一次不稳定夜晚的项目并非毒丸,而生命周期内的总计数最终还是会将其升级。
- 在升级之前,检查批次。如果今晚批次中的大多数都是单方面失败,那么这是提供商的事故,而不是项目的属性。对整个批次抑制升级,并记录为普通的无应答。抑制机制会朝安全的方向倾斜:最坏的情况是多重试一晚。
- 当确实要升级时,保持记录的裁决与真正的分歧一致,以便所有下游消费者(耗尽查询、仪表板、消耗估算)的行为与之前完全相同,并在一个单独的原因列中放入一个固定的标记字符串。之后阅读该项目的人必须能看到“此项脱离自动化是因为某个提供商在其上持续失败”,因为对此的正确响应与对真正模棱两可情况的正确响应是不同的。
在三次尝试预算的基础上增加三次“击中”升级机制后,毒丸的最长生命周期被限制在六个晚上,并且在这个限制期内,不会对所发生的情况进行任何歪曲。
看到差异后的变化
可见的成效是立竿见影的:每夜的统计数据被拆分为“有分歧”和“单方面”,并且流水线第一次能够回答糟糕的一周究竟是模型质量问题还是基础设施问题。在此变更之前,那个问题不仅是难以回答,更是无法从数据中得到答案,因为日志行在本应存储证据的地方存储了一个常量字符串。记录每个模型的原始判定结果会占用两列,并消除了一整类的猜测。
还有一个微妙的记账规则值得说明:报告统计数据时应依据实际记录的内容,而非分类器看到的内容。一个最初作为单方面项目抵达但被上报到计数路径中的条目,应该归入今晚报告的“有分歧”一列,因为这是数据库现在关于它的说法。如果总计数和存储的行数据出现偏差,那么未来的每一次调试会话都将从核对它们开始。
自建门控的核对清单
- 在代码中明确枚举四种结果:一致 (agree)、不一致 (differ)、单方面 (one-sided)、服务中断 (outage)。如果其中两种结果共享一个结构体值,那么你已经将它们合并了。
- 在任何提前返回之前,填充每个模型的原始判定结果并将其存储。对于缺失的答案,使用 "" 是可以的;但使用一个固定的解释性字符串则不行。
- 将无应答排除在耗尽规则之外。在
max(attempt)规则下,一个attempt = 0的行是一种只记录而不计数的廉价方法。 - 将“在一个本应健康的批次中,两个模型都错过了某个项目”的情况视为单方面问题,而不是不一致问题,也不是服务中断问题。
- 限制“毒丸”效应:在滑动窗口内发生 N 次单方面失败后进行上报,当整个批次都失败时抑制上报,并用明确的原因标记已上报的行。
- 让统计与记录保持一致。统计数据应根据已写入的内容计算,而不是根据所执行的分支。
这些内容并非 LLM 所特有。任何包含不可靠投票者的投票系统都有同样的三种区别:异议 (dissent)、缺席 (absence) 和信号中断 (blackout)。LLM 管道只是让这一点很容易被忽略,因为超时和不一致都以同样的形式出现:一个你今晚无法完成的比较。