错误答案看起来和正确答案一模一样
你把 prompt 发给 GPT-4o。它返回一个 JSON blob,置信度 0.97。你把同样的 prompt 发给 Claude 3.5 Sonnet。它返回了另一个不同的 JSON blob,置信度也是 0.97。两个模型听起来都无比确定。但它们各自以不同的方式错了。
这不是假设。如果你运行任何非平凡的 LLM 工作负载超过几千个请求,你就会遇到两个能力相当的模型在事实、分类或结构化输出上产生分歧的情况。分歧本身就是一个信号。大多数团队却无视了它。
N-version programming 这个老想法——运行多个实现并对结果投票——曾因成本过高而被大多数软件弃用。但对于 LLM 来说,这些”实现”不过是不同的 API 调用。成本是真实存在的,但交付错误答案的成本往往更糟。
面向 LLM 的 N-Version Programming 长什么样
经典版本来自安全关键系统。三个独立编写的程序处理同样的输入。一个投票器比较输出。如果两个一致、一个不一致,多数胜出,少数被标记待查。
对于 LLM,你不需要三个工程团队。你需要三把 API key。独立性较弱,模型共享训练数据,但分歧仍然是可测量且有用的。两个在重叠语料上训练的模型仍然可能幻觉出不同的事实、以不同方式解读歧义,或在相反的边缘案例上失败。
问题不是它们会不会分歧。它们一定会。问题是分歧出现时你怎么办。
一个真正有效的简单分歧检测器
这里有一个具体的 Python 系统。它把同样的 prompt 发给多个模型,提取结构化输出,并用领域感知规则而非朴素的字符串相等性来比较它们。
import asyncio
from dataclasses import dataclass
from typing import Callable, List, Any
import json
@dataclass(frozen=True)
class VariantResult:
model: str
output: dict
raw: str
latency_ms: float
class DisagreementResolver:
def __init__(
self,
comparators: List[Callable[[Any, Any], bool]],
tiebreaker: Callable[[List[VariantResult]], VariantResult],
):
# Each comparator returns True if two outputs are "the same"
self.comparators = comparators
# Tiebreaker picks a winner when no clear majority exists
self.tiebreaker = tiebreaker
def cluster(self, results: List[VariantResult]) -> List[List[VariantResult]]:
"""Group results into equivalence classes."""
clusters: List[List[VariantResult]] = []
for r in results:
placed = False
for cluster in clusters:
# A result joins a cluster if it matches the cluster's first member
if all(c(r.output, cluster[0].output) for c in self.comparators):
cluster.append(r)
placed = True
break
if not placed:
clusters.append([r])
return clusters
def resolve(self, results: List[VariantResult]) -> tuple[VariantResult, List[VariantResult]]:
"""Returns (winner, dissenters)."""
clusters = self.cluster(results)
clusters.sort(key=len, reverse=True)
if len(clusters) == 1:
# Unanimous agreement. Pick the fastest as winner.
winner = min(clusters[0], key=lambda r: r.latency_ms)
return winner, []
if len(clusters[0]) > len(clusters[1]):
# Clear majority.
winner = min(clusters[0], key=lambda r: r.latency_ms)
dissenters = [r for c in clusters[1:] for r in c]
return winner, dissenters
# No clear majority. Invoke tiebreaker.
winner = self.tiebreaker([c[0] for c in clusters])
dissenters = [r for c in clusters for r in c if r.model != winner.model]
return winner, dissenters
关键设计选择在于 comparator。字符串相等性对 LLM 输出毫无用处。两个模型可能分别返回 {"category": "refund"} 和 {"category": "Refund"},它们在实质内容上没有任何分歧。
def category_comparator(a: dict, b: dict) -> bool:
return a.get("category", "").lower() == b.get("category", "").lower()
def amount_comparator(a: dict, b: dict) -> bool:
# Floats from different models can differ slightly
return abs(a.get("amount", 0) - b.get("amount", 0)) < 0.01
def strict_json_comparator(a: dict, b: dict) -> bool:
return json.dumps(a, sort_keys=True) == json.dumps(b, sort_keys=True)
# Use them in order of domain importance
resolver = DisagreementResolver(
comparators=[category_comparator, amount_comparator],
tiebreaker=lambda candidates: min(candidates, key=lambda r: r.latency_ms),
)
这到底在哪里抓到 Bug
分歧检测不是为了发现明显的错误。明显的错误早就被更简单的手段拦截了。
它是为了捕捉对任何单一模型来说都看似正确的微妙错误。以下是我们最常见的三种模式:
自信的幻觉分歧。 一个模型凭空捏造了一个产品 SKU。另一个模型捏造了另一个不同的 SKU。两份输出都通过了 schema 校验。分歧是唯一提示某些内容被伪造的信号。
边界条件分裂。 一个模型把一笔 $999.99 的交易归类为 “high value”。另一个模型把它归类为 “standard”。prompt 中的阈值是模糊的。分歧告诉你,prompt 的规范不足。
时间漂移。 一个模型以 ISO 8601 格式返回日期。另一个模型返回了同一日期的不同时区版本,因为 prompt 说了 “today”,而两个模型对推理时的时钟有不同的假设。
在每种情况下,两份输出都没有明显损坏。分歧本身就是 bug 报告。
成本模型出奇地可接受
三次 API 调用的成本是一次的三倍。这是表面的算术。真正的算术取决于你如何处理结果。
如果你在每次请求上都跑三个模型并取多数输出,你就是在烧钱。大多数团队不应该这么做。
更聪明的模式是分层:
- Fast path: 一个便宜的模型处理请求。
- Audit path: 抽取一部分请求,或者所有超过置信度阈值的请求,异步发给第二个模型。
- Escalation path: 当 audit path 检测到分歧时,第三个模型来打破平局。分歧被记录,供 prompt engineering 审查。
这意味着你只在真正需要审查的那部分请求上支付三倍成本。对于许多工作负载来说,这部分不到流量的 5%。
async def tiered_resolve(prompt: str, primary_model: str, audit_model: str) -> dict:
primary = await call_model(primary_model, prompt)
# Async audit: do not block the fast path
audit_task = asyncio.create_task(call_model(audit_model, prompt))
try:
audit = await asyncio.wait_for(audit_task, timeout=2.0)
except asyncio.TimeoutError:
# Audit lagged. Ship the primary result.
return primary.output
if outputs_agree(primary.output, audit.output):
return primary.output
# Disagreement detected. Escalate.
tiebreaker = await call_model("gpt-4o", prompt)
log_disagreement(primary, audit, tiebreaker)
return tiebreaker.output
log_disagreement 调用是长期价值所在。每一次分歧都是一个数据点,告诉你 prompt 哪里模糊、schema 哪里约束不足、或者训练示例哪里互相矛盾。
这解决不了什么
用 LLM 做 N-version programming 有真实的局限,假装没有会让它变得危险。
共享失败模式。 如果两个模型都因为 prompt 里有个 typo 而失败,它们会以同样的方式失败。模型不像三个手写程序那样独立。它们共享架构、训练目标,以及互联网上的大量内容。
系统性偏见。 如果你的 prompt 隐式编码了某种偏见,多个模型可能会一致地复现它。分歧检测只能捕捉分歧,捕捉不了共同的错误。
规模成本。 即使只有 5% 的 audit 率,如果你的月请求量达到数十亿次,成本也会变得昂贵。这种经济账适用于高风险的决策,不适用于每一次自动补全。
Escalation path 的延迟。 当两个模型分歧、你需要第三个模型时,你已经给关键路径增加了两次往返延迟。对于实时场景,你可能需要先发主模型的结果、再异步解决分歧,接受一个短暂的潜在错误窗口。
如何在不过度工程的情况下开始
你不需要在第一天就搭建完整的投票框架。从最高风险的 prompt 上做 pairwise comparison 开始。
选一个结构化输出调用,这里的一个错误答案真的会损失金钱或信任。在后台任务里加上第二个模型调用。用领域专属的 comparator 比较输出。记录分歧。每周审查日志。
两周后,你就会知道你的 prompt 是扎实还是灾难。大多数 prompt 都比作者想象的更脆弱。分歧日志是一次诚实的体检。
有了数据之后,你可以决定是搭建 resolver、优化 prompt,还是接受这个任务对全自动处理来说太模糊。
真正的胜利在于反馈闭环
LLM n-version programming 的目标不是构建一个完美的投票器。目标是打通生产环境中的分歧与 prompt 改进之间的闭环。
每次两个模型分歧,你就找到了一个规范不完整的地方。修复规范,而不只是修复输出。随着时间推移,分歧率下降。当它降到足够低时,你可以缩减 audit 样本,或者降级 tiebreaker 模型。
系统越用越便宜。这与大多数安全机制相反——后者通常是规模越大越贵。
从一个 prompt、两个模型、一行日志开始。分歧会准确地告诉你该往哪里看。