Неправильный ответ выглядит точно так же, как правильный

Вы отправляете промпт в GPT-4o. Он возвращает JSON с уверенностью 0.97. Вы отправляете тот же промпт в Claude 3.5 Sonnet. Он возвращает другой JSON, тоже с уверенностью 0.97. Обе модели звучат уверенно. Обе ошибаются, каждая по-своему.

Это не гипотетический сценарий. Если вы запускаете любую нетривиальную нагрузку на LLM на несколько тысяч запросов, вы столкнётесь с ситуациями, когда две способные модели не соглашаются по фактам, классификациям или структурированному выводу. Само разногласие — это сигнал. Большинство команд его игнорирует.

N-version programming, старая идея запуска нескольких реализаций и голосования за результат, считалась слишком дорогой для большинства программного обеспечения. С LLM «реализации» — это просто разные API-вызовы. Стоимость реальна, но стоимость выпуска неправильного ответа часто выше.

Как выглядит N-version programming для LLM

Классическая версия пришла из систем, критичных к безопасности. Три независимо написанные программы обрабатывают один и тот же вход. Компонент voter сравнивает выводы. Если две согласны, а одна — нет, побеждает большинство, а меньшинство помечается для проверки.

С LLM вам не нужны три инженерные команды. Вам нужны три API-ключа. Независимость слабее: модели делят обучающие данные, но расхождение всё ещё измеримо и полезно. Две модели, обученные на пересекающихся корпусах, всё равно могут галлюцинировать разные факты, по-разному интерпретировать неоднозначность или падать на противоположных крайних случаях.

Вопрос не в том, будут ли они расходиться. Будут. Вопрос в том, что вы делаете, когда это произойдёт.

Простой детектор разногласий, который действительно работает

Вот конкретная система на 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),
)

Где это реально ловит баги

Обнаружение разногласий — это не про поиск очевидных ошибок. Очевидные ошибки и так ловятся более простыми средствами.

Речь идёт о поимке тонких ошибок, которые выглядят корректно для любой отдельной модели. Вот три паттерна, которые мы видим чаще всего:

Уверенное расхождение галлюцинаций. Одна модель выдумывает артикул товара (SKU). Другая выдумывает другой артикул. Оба вывода проходят валидацию по схеме. Разногласие — единственный сигнал, что что-то выдумано.

Расхождение на граничных условиях. Одна модель классифицирует транзакцию на $999.99 как “high value”. Другая — как “standard”. Порог неоднозначен в промпте. Разногласие говорит вам, что промпт недостаточно специфицирован.

Временной дрейф. Одна модель возвращает дату в формате ISO 8601. Другая — ту же дату в другом часовом поясе, потому что в промпте сказано “today”, а у моделей разные предположения о времени во время inference.

В каждом случае ни один из выводов не является очевидно сломанным. Разногласие — это баг-репорт.

Модель затрат на удивление терпима

Три API-вызова стоят втрое дороже одного. Это поверхностная арифметика. Реальная арифметика зависит от того, что вы делаете с результатами.

Если вы запускаете три модели на каждый запрос и берёте вывод большинства, вы сжигаете деньги. Большинство команд не должны так делать.

Более умный паттерн — многоуровневый:

  1. Fast path: Одна дешевая модель обрабатывает запрос.
  2. Audit path: Выборка запросов или все запросы выше порога уверенности отправляются второй модели асинхронно.
  3. Escalation path: Когда audit path обнаруживает разногласие, третья модель разрешает спор. Разногласие логируется для review промпт-инженерии.

Это означает, что вы платите втрое только за подмножество запросов, которые действительно нуждаются в проверке. Для многих нагрузок это менее 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 — это то место, где живёт долгосрочная ценность. Каждое разногласие — это datapoint, который говорит вам, где ваш промпт неоднозначен, где ваша схема недостаточно ограничена или где ваши обучающие примеры противоречат друг другу.

Что это не исправляет

N-version programming с LLM имеет реальные ограничения, и притворство обратным делает её опасной.

Общие режимы отказа. Если обе модели падают, потому что в промпте опечатка, обе упадут одинаково. Модели не являются независимыми так же, как три программы, написанные от руки. Они делят архитектуру, цели обучения и большие куски интернета.

Систематическое смещение. Если ваш промпт неявно кодирует bias, несколько моделей могут воспроизводить его согласованно. Обнаружение разногласий ловит только расхождение, а не общую ошибочность.

Стоимость в масштабе. Даже 5% аудит становится дорогим, если ваш объём — миллиарды запросов в месяц. Экономика работает для высокорисковых решений, а не для каждого autocomplete.

Задержка на escalation path. Когда две модели не согласны и вам нужна третья, вы добавили два round trip на критический путь. Для real-time сценариев вам может понадобиться выпустить primary result и разрешить разногласие асинхронно, принимая кратковременное окно потенциальной ошибочности.

Как начать без over-engineering

Вам не нужен полноценный voting framework в первый день. Начните с pairwise comparison для вашего самого важного промпта.

Выберите один вызов structured output, где неправильный ответ реально стоит денег или доверия. Добавьте второй вызов модели в background job. Сравните выводы с помощью domain-specific comparator. Логируйте разногласия. Просматривайте логи еженедельно.

Через две недели вы будете знать, насколько ваш промпт надёжен или катастрофичен. Большинство промптов хрупче, чем думают их авторы. Лог разногласий — это честность-чек.

Как только у вас появятся данные, вы сможете решить, строить ли resolver, настраивать ли промпт, или принять, что задача слишком неоднозначна для полностью автоматизированной обработки.

Реальная победа — это feedback loop

Цель LLM n-version programming не в том, чтобы построить идеальный voter. Цель — замкнуть петлю между production-разногласиями и улучшением промптов.

Каждый раз, когда две модели не соглашаются, вы нашли место, где ваша спецификация неполна. Исправляйте спецификацию, а не только вывод. Со временем частота разногласий падает. Когда она упадёт достаточно низко, вы можете сократить аудит-выборку или понизить модель tiebreaker.

Система становится дешевле по мере улучшения. Это противоположность большинству механизмов безопасности, которые становятся дороже с ростом масштаба.

Начните с одного промпта, двух моделей и одной строки в логе. Разногласия точно скажут вам, куда смотреть.