틀린 답은 옳은 답과 똑같이 보인다

프롬프트를 GPT-4o에 보낸다. 신뢰도 0.97의 JSON blob이 돌아온다. 똑같은 프롬프트를 Claude 3.5 Sonnet에 보낸다. 다른 JSON blob이 돌아오는데, 신뢰도 역시 0.97이다. 두 모델 모두 확신에 찬다. 두 모델 모두 각기 다른 방식으로 틀렸다.

이것은 가상의 시나리오가 아니다. 수천 건 이상의 의미 있는 LLM 워크로드를 실행하면, 두 유능한 모델이 사실, 분류, 또는 구조화된 출력에서 의견이 엇갈리는 경우를 반드시 마주하게 된다. 불일치 자체가 하나의 신호다. 대부분의 팀은 이를 무시한다.

N-version 프로그래밍, 즉 여러 구현체를 실행하고 결과에 투표하는 오래된 아이디어는 대부분의 소프트웨어에겐 너무 비싸다고 여겨졌다. LLM을 사용할 때는 “구현체”가 그저 다른 API 호출일 뿐이다. 비용은 실재하지만, 틀린 답을 납품하는 비용이 훨씬 더 큰 경우가 많다.

LLM을 위한 N-Version 프로그래밍은 어떤 모습인가

고전적인 버전은 safety-critical 시스템에서 유래했다. 독립적으로 작성된 세 개의 프로그램이 동일한 입력을 처리한다. voter가 출력을 비교한다. 두 개가 일치하고 하나가 일치하지 않으면 다수결로 승리하고, 소수는 검사를 위해 플래그가 붙는다.

LLM을 사용할 때는 세 개의 엔지니어링 팀이 필요 없다. 세 개의 API 키면 충분하다. 독립성은 약하다. 모델들이 학습 데이터를 공유하기 때문이다. 하지만 차이는 여전히 측정 가능하고 유용하다. 중복되는 말뭉치 위에서 학습된 두 모델도 여전히 서로 다른 사실을 hallucination할 수 있고, 모호성을 다르게 해석하거나, 정반대의 edge case에서 실패할 수 있다.

문제는 그들이 의견이 다른지가 아니다. 다를 것이다. 문제는 다를 때 무엇을 하느냐는 것이다.

실제로 작동하는 간단한 불일치 탐지기

여기 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 출력에 무의미하다. 두 모델이 {"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),
)

이것이 실제로 버그를 잡아내는 곳

불일치 탐지는 명백한 오류를 찾는 것이 아니다. 명백한 오류는 이미 더 단순한 수단으로 잡힌다.

이것은 단일 모델에게는 올바르게 보이는 미묘한 오류를 잡아내는 것이다. 가장 흔히 보는 세 가지 패턴은 다음과 같다:

Confident hallucination divergence. 한 모델이 제품 SKU를 지어낸다. 다른 모델은 다른 SKU를 지어낸다. 두 출력 모두 스키마에 대해 검증을 통과한다. 불일치가 유일하게 “무언가가 지어낸 것”이라는 신호다.

Boundary condition splits. 한 모델은 $999.99 거래를 “high value”로 분류한다. 다른 모델은 “standard”로 분류한다. 임계값이 프롬프트에서 모호하다. 불일치는 프롬프트가 충분히 구체화되지 않았음을 알려준다.

Temporal drift. 한 모델은 ISO 8601 형식의 날짜를 반환한다. 다른 모델은 같은 날짜를 다른 시간대로 반환하는데, 프롬프트에 “today”라고 적혀 있고 모델들의 추론 시점 시계 가정이 다르기 때문이다.

각 경우에 출력 중 어느 것도 명백히 망가지지 않았다. 불일치가 버그 리포트다.

비용 모델은 놀랍게도 감내할 만하다

세 번의 API 호출은 한 번의 세 배 비용이 든다. 이는 표면적인 계산이다. 진짜 계산은 결과를 어떻게 사용하느냐에 달려 있다.

모든 요청에 세 모델을 실행하고 다수결 출력을 취하면 돈을 태우는 셈이다. 대부분의 팀은 이렇게 하면 안 된다.

더 현명한 패턴은 tiered다:

  1. Fast path: 저렴한 모델 하나가 요청을 처리한다.
  2. Audit path: 일부 샘플 요청, 또는 신뢰도 임계값 이상의 모든 요청이 두 번째 모델에 비동기로 전송된다.
  3. Escalation path: 감사 경로가 불일치를 감지하면, 세 번째 모델이 동점을 깬다. 불일치는 프롬프트 엔지니어링 검토를 위해 로깅된다.

이는 실제로 면밀한 검토가 필요한 요청 하위 집합에 대해서만 세 배 비용을 지불한다는 의미다. 많은 워크로드에서 이는 트래픽의 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 프로그래밍에는 실제 한계가 있으며, 그렇지 않다고 가장하면 위험해진다.

Shared failure modes. 프롬프트에 오타가 있어서 두 모델이 모두 실패하면, 둘 다 똑같은 방식으로 실패할 것이다. 모델들은 세 개의 손으로 작성된 프로그램만큼 독립적이지 않다. 아키텍처, 학습 목표, 인터넷의 방대한 부분을 공유한다.

Systematic bias. 프롬프트가 암묵적으로 편향을 인코딩하고 있다면, 여러 모델이 일관되게 이를 재현할 수 있다. 불일치 탐지는 divergence만 잡아낼 뿐, 공유된 오류는 잡아내지 못한다.

Cost at scale. 월간 수십억 건의 요청 규모라면 5%의 감사율조차 비싸진다. 경제성은 high-stakes 결정에는 맞지, 모든 autocomplete에는 맞지 않는다.

Latency on the escalation path. 두 모델이 의견이 달라 세 번째가 필요하면, critical path에 두 번의 round trip이 추가된다. 실시간 사용 사례에서는 기본 결과를 우선 납품하고 불일치를 비동기로 해결하며, 짧은 잠재적 오류 시간을 감수해야 할 수도 있다.

과도한 엔지니어링 없이 시작하는 방법

첫날부터 완전한 투표 프레임워크가 필요 없다. 가장 중요한 프롬프트에서 pairwise comparison부터 시작하라.

잘못된 답이 실제로 돈이나 신뢰를 잃게 하는 하나의 구조화된 출력 호출을 고른다. 백그라운드 작업에 두 번째 모델 호출을 추가한다. 도메인 특화 comparator로 출력을 비교한다. 불일치를 로깅한다. 로그를 매주 검토한다.

두 주 후면 프롬프트가 탄탄한지 재앙인지 알게 될 것이다. 대부분의 프롬프트는 작성자가 생각하는 것보다 취약하다. 불일치 로그는 정직성 검사다.

데이터가 생기면 resolver를 구축할지, 프롬프트를 튜닝할지, 또는 작업이 완전 자동화 처리에는 너무 모호하다고 받아들일지 결정할 수 있다.

진정한 승리는 피드백 루프다

LLM N-version 프로그래밍의 목표는 완벽한 voter를 만드는 것이 아니다. 목표는 프로덕션의 불일치와 프롬프트 개선 사이의 루프를 닫는 것이다.

두 모델이 의견이 다를 때마다, 사양이 불완전한 지점을 발견한 것이다. 출력뿐 아니라 사양을 고쳐라. 시간이 지나면 불일치율이 떨어진다. 충분히 떨어지면 감사 샘플을 줄이거나 tiebreaker 모델을 다운그레이드할 수 있다.

시스템은 나아질수록 저렴해진다. 이는 규모가 커질수록 비싸지는 대부분의 안전 메커니즘과 정반대다.

하나의 프롬프트, 두 개의 모델, 한 줄의 로그로 시작하라. 불일치가 정확히 어디를 봐야 할지 알려줄 것이다.