Неправильный ответ выглядит точно так же, как правильный
Вы отправляете промпт в 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-вызова стоят втрое дороже одного. Это поверхностная арифметика. Реальная арифметика зависит от того, что вы делаете с результатами.
Если вы запускаете три модели на каждый запрос и берёте вывод большинства, вы сжигаете деньги. Большинство команд не должны так делать.
Более умный паттерн — многоуровневый:
- Fast path: Одна дешевая модель обрабатывает запрос.
- Audit path: Выборка запросов или все запросы выше порога уверенности отправляются второй модели асинхронно.
- 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.
Система становится дешевле по мере улучшения. Это противоположность большинству механизмов безопасности, которые становятся дороже с ростом масштаба.
Начните с одного промпта, двух моделей и одной строки в логе. Разногласия точно скажут вам, куда смотреть.