錯誤答案看起來跟正確答案一模一樣

你把一個 prompt 送給 GPT-4o。它回傳了一個 JSON blob,信心值 0.97。你把同一個 prompt 送給 Claude 3.5 Sonnet。它回傳了另一個 JSON blob,信心值也是 0.97。兩個模型聽起來都篤定不移。兩個都在不同層面上錯了。

這不是假設情境。只要你運行任何非瑣碎的 LLM 工作負載超過幾千次請求,就會遇到兩個能力相當的模型在事實、分類或結構化輸出上意見分歧的情況。分歧本身就是一個訊號。大多數團隊忽略它。

N-version programming 這個老概念——同時運行多個實作並對結果投票——過去被認為對大多數軟體來說太昂貴。有了 LLM,「實作」只不過是不同的 API 呼叫。成本是真實的,但上線錯誤答案的代價往往更慘重。

N-version programming 在 LLM 領域是什麼樣子

經典版本來自安全關鍵系統。三個獨立撰寫的程式處理同一個輸入。一個投票器比對輸出。如果兩個同意、一個不同意,多數勝出,少數被標記待檢查。

有了 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 的交易分類為「高價值」。另一個把它分類為「標準」。prompt 中的閾值是模糊的。分歧告訴你這個 prompt 規範不足。

時間漂移。 一個模型以 ISO 8601 格式回傳日期。另一個回傳同一個日期但使用了不同時區,因為 prompt 寫了「今天」,而兩個模型對推論時的時鐘假設不同。

在每個案例中,兩個輸出都沒有明顯損壞。分歧本身就是 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 裡有錯字而失敗,兩個會以同樣方式失敗。模型不像三個手寫的程式那樣獨立。它們共享架構、訓練目標,以及網路上大量的資料。

系統性偏誤。 如果你的 prompt 隱含地編碼了某種偏誤,多個模型可能一致地複製它。分歧偵測只能抓到分歧,無法抓到共同錯誤。

規模化的成本。 即使只有 5% 的 audit 比率,如果你的量是每月數十億次請求,也會變得昂貴。這套經濟學適用於高風險決策,不適用於每個自動補全。

Escalation path 的延遲。 當兩個模型分歧而你需要第三個時,你已經在關鍵路徑上增加了兩次來回。對於即時使用情境,你可能需要非同步地 ship primary result 並非同步解決分歧,接受一段短暫的潛在錯誤窗口。

如何在不過度設計的情況下開始

你不需要在第一天就建立完整的投票框架。從最高風險 prompt 的兩兩比較開始。

挑選一個結構化輸出呼叫,錯誤答案確實會造成金錢或信任損失。在背景任務中加入第二個模型呼叫。用一個具備領域意識的 comparator 來比對輸出。記錄分歧。每週檢視一次日誌。

兩週後,你就會知道你的 prompt 是穩固的還是災難。大多數 prompt 比作者想像的更脆弱。分歧日誌是一個誠實度檢查。

有了資料之後,你可以決定要建 resolver、調整 prompt,還是接受這項任務對全自動處理來說太過模糊。

真正的勝利是回饋迴路

LLM N-version programming 的目標不是建立一個完美的投票器。目標是閉合「生產環境分歧」與「prompt 改善」之間的迴路。

每次兩個模型分歧,你就找到了規格不完整的地方。修正規格,而不只是修正輸出。隨著時間過去,分歧率會下降。當它降到夠低,你可以縮減 audit 抽樣或降級 tiebreaker 模型。

這套系統越變越好就越便宜。這跟大多數安全機制相反——後者通常隨著規模增加而越來越貴。

從一個 prompt、兩個模型、一筆 log 開始。分歧會精確地告訴你該往哪裡看。