間違った回答は、正しい回答と見分けがつかない

GPT-4oにプロンプトを投げる。confidence 0.97のJSON blobが返ってくる。同じプロンプトをClaude 3.5 Sonnetに投げる。異なるJSON blobが返ってくるが、こちらもconfidenceは0.97。両方のモデルが確信に満ちているように聞こえる。両方とも、それぞれ違う形で間違っている。

これは仮想の話ではない。非自明なLLMワークロードを数千リクエスト以上回せば、2つの高性能モデルが事実、分類、あるいは構造化出力で不一致を起こすケースに必ず遭遇する。不一致自体がシグナルだ。ほとんどのチームはこれを無視している。

N-version programming、複数の実装を並行して動かし結果を投票で決めるという古いアイデアは、ほとんどのソフトウェアにとってコストが高すぎると考えられていた。LLMでは、その「実装」は単なる異なるAPIコールに過ぎない。コストは実在するが、間違った回答をそのまま出荷するコストの方が、往々にして深刻だ。

LLMにおけるN-Version Programmingの姿

古典的な手法は安全重要システムから来ている。独立に書かれた3つのプログラムが同じ入力を処理する。voterが出力を比較する。2つが一致し1つが一致しない場合、多数派が勝ち、少数派は検査対象としてフラグが立つ。

LLMでは、3つのエンジニアリングチームは不要だ。3つのAPIキーがあればいい。独立性は弱い。モデルは訓練データを共有している。だが、分岐は依然として測定可能であり、有用だ。重複するコーパスで訓練された2つのモデルでも、異なる事実をhallucinateしたり、曖昧性を異なるように解釈したり、正反対のエッジケースで失敗したりすることがある。

問題は、不一致が起きるかどうかではない。起きるのは確実だ。問題は、起きたときにどうするかだ。

実際に機能するシンプルな不一致検出器

ここにPythonで書かれた具体的なシステムがある。同じプロンプトを複数モデルに送信し、構造化出力を抽出し、素朴な文字列一致ではなくドメイン認識のルールで比較する。

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の出力にとって無意味だ。2つのモデルが{"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),
)

これが実際にバグを捕捉する場面

不一致検出は、明らかなエラーを見つけるためのものではない。明らかなエラーは、もっと単純な手段ですでに捕捉されている。

これは、単一のモデルには正しく見える微妙なエラーを捕捉するためのものだ。私たちが最も頻繁に見る3つのパターンを挙げよう:

確信に満ちたhallucinationの分岐。 あるモデルが製品SKUをでっち上げる。別のモデルは異なるSKUをでっち上げる。両方の出力はスキーマに対してvalidだ。不一致が、何かがでっち上げられているという唯一のシグナルだ。

境界条件の分岐。 あるモデルは$999.99のトランザクションを”high value”と分類する。別のモデルは”standard”と分類する。閾値はプロンプト中で曖昧だ。不一致が、プロンプトの仕様が不十分であることを教えてくれる。

時間的なdrift。 あるモデルはISO 8601形式の日付を返す。別のモデルは同じ日付を異なるタイムゾーンで返す。プロンプトに”today”と書いてあったため、モデル間で推論時の時計の前提が異なるからだ。

どのケースでも、どちらの出力も明らかに壊れているわけではない。不一致が、バグレポートなのだ。

コストモデルは驚くほど許容範囲内だ

3回のAPIコールは1回の3倍のコストがかかる。これは表面的な計算だ。本当の計算は、結果をどう使うかに依存する。

すべてのリクエストで3つのモデルを動かし、多数派の出力を採用すれば、金を焼いているようなものだ。ほとんどのチームはこれをすべきではない。

より賢いパターンは階層型だ:

  1. Fast path: 安価な1つのモデルがリクエストを処理する。
  2. Audit path: リクエストのサンプル、あるいはconfidence閾値を超えたすべてのリクエストを、非同期に2つ目のモデルに送る。
  3. Escalation path: audit pathで不一致が検出されたら、3つ目のモデルがtiebreakする。不一致はログに記録され、プロンプトエンジニアリングのレビュー対象となる。

これは、実際に精査が必要なリクエストのサブセットに対してのみ3倍のコストを支払うということだ。多くのワークロードでは、それはトラフィックの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の呼び出しこそが、長期的な価値が宿る場所だ。すべての不一致は、プロンプトがどこで曖昧か、スキーマがどこで制約不足か、あるいは訓練例がどこで衝突しているかを教えてくれるデータポイントだ。

これが修正しないもの

LLMを用いたN-version programmingには本当の限界があり、そうでないふりをすると危険だ。

共有された失敗モード。 プロンプトにタイポが含まれているために両方のモデルが失敗した場合、両方とも同じように失敗する。モデルは、手書きの3つのプログラムのように独立しているわけではない。アーキテクチャ、訓練目的、インターネットの大部分を共有している。

体系的なbias。 プロンプトが暗黙的にbiasをエンコードしている場合、複数のモデルがそれを一貫して再現する可能性がある。不一致検出は分岐を捕捉するだけで、共有された誤りは捕捉しない。

スケールにおけるコスト。 月数十億リクエストの規模になれば、たとえ5%のaudit rateでも高くなる。経済性はhigh-stakesな判断には機能するが、すべてのautocompleteには機能しない。

escalation pathにおけるレイテンシ。 2つのモデルが不一致で、3つ目が必要になったとき、critical pathに2往復分が追加される。リアルタイムのユースケースでは、primaryの結果を出荷し、不一致を非同期に解決し、一時的な誤りの可能性を受け入れる必要があるかもしれない。

過剰エンジニアリングなしに始める方法

初日から完全なvoting frameworkは必要ない。最もhigh-stakesなプロンプトでのpairwise comparisonから始めよ。

間違った回答が実際に金銭的コストや信頼を損なう構造化出力の呼び出しを1つ選べ。バックグラウンドジョブで2つ目のモデル呼び出しを追加する。ドメイン固有のcomparatorで出力を比較する。不一致をログに記録する。週次でログをレビューする。

2週間後には、プロンプトが堅牢か災難かがわかるだろう。ほとんどのプロンプトは、その作者が考えているよりも脆弱だ。不一致ログは、正直さのチェックなのだ。

データが揃えば、resolverを構築するか、プロンプトをチューニングするか、あるいはそのタスクが完全自動化には曖昧すぎると受け入れるかを決定できる。

真の勝利はフィードバックループにある

LLM n-version programmingの目標は、完璧なvoterを構築することではない。目標は、本番環境での不一致とプロンプト改善の間のループを閉じることだ。

2つのモデルが不一致を起こすたびに、仕様が不完全な箇所を見つけたことになる。出力だけでなく仕様を修正せよ。時間をかけて、不一致率は下がる。十分に下がれば、auditサンプルを減らしたり、tiebreakerモデルをダウングレードしたりできる。

システムは良くなるほど安くなる。これは、スケールが増大するほど高くなるほとんどの安全メカニズムとは正反対だ。

1つのプロンプト、2つのモデル、1行のログから始めよ。不一致が、どこを見ればいいか正確に教えてくれる。