错误答案看起来和正确答案一模一样

你把 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 调用的成本是一次的三倍。这是表面的算术。真正的算术取决于你如何处理结果。

如果你在每次请求上都跑三个模型并取多数输出,你就是在烧钱。大多数团队不应该这么做。

更聪明的模式是分层:

  1. Fast path: 一个便宜的模型处理请求。
  2. Audit path: 抽取一部分请求,或者所有超过置信度阈值的请求,异步发给第二个模型。
  3. 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、两个模型、一行日志开始。分歧会准确地告诉你该往哪里看。