錯誤答案看起來跟正確答案一模一樣
你把一個 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 呼叫的成本是一次的三倍。這是表面的計算。真正的計算取決於你怎麼運用這些結果。
如果你對每個請求都跑三個模型並採用多數輸出,那你是在燒錢。大多數團隊不該這樣做。
更聰明的模式是分層的:
- 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 裡有錯字而失敗,兩個會以同樣方式失敗。模型不像三個手寫的程式那樣獨立。它們共享架構、訓練目標,以及網路上大量的資料。
系統性偏誤。 如果你的 prompt 隱含地編碼了某種偏誤,多個模型可能一致地複製它。分歧偵測只能抓到分歧,無法抓到共同錯誤。
規模化的成本。 即使只有 5% 的 audit 比率,如果你的量是每月數十億次請求,也會變得昂貴。這套經濟學適用於高風險決策,不適用於每個自動補全。
Escalation path 的延遲。 當兩個模型分歧而你需要第三個時,你已經在關鍵路徑上增加了兩次來回。對於即時使用情境,你可能需要非同步地 ship primary result 並非同步解決分歧,接受一段短暫的潛在錯誤窗口。
如何在不過度設計的情況下開始
你不需要在第一天就建立完整的投票框架。從最高風險 prompt 的兩兩比較開始。
挑選一個結構化輸出呼叫,錯誤答案確實會造成金錢或信任損失。在背景任務中加入第二個模型呼叫。用一個具備領域意識的 comparator 來比對輸出。記錄分歧。每週檢視一次日誌。
兩週後,你就會知道你的 prompt 是穩固的還是災難。大多數 prompt 比作者想像的更脆弱。分歧日誌是一個誠實度檢查。
有了資料之後,你可以決定要建 resolver、調整 prompt,還是接受這項任務對全自動處理來說太過模糊。
真正的勝利是回饋迴路
LLM N-version programming 的目標不是建立一個完美的投票器。目標是閉合「生產環境分歧」與「prompt 改善」之間的迴路。
每次兩個模型分歧,你就找到了規格不完整的地方。修正規格,而不只是修正輸出。隨著時間過去,分歧率會下降。當它降到夠低,你可以縮減 audit 抽樣或降級 tiebreaker 模型。
這套系統越變越好就越便宜。這跟大多數安全機制相反——後者通常隨著規模增加而越來越貴。
從一個 prompt、兩個模型、一筆 log 開始。分歧會精確地告訴你該往哪裡看。