У вас есть пять реализаций одной и той же функции. Три возвращают одинаковый результат. Одна немного отличается. Одна выбрасывает исключение. Какая из них правильная?

Большинство команд по умолчанию используют голосование большинства. Это работает, когда выходные данные идентичны, а ошибки очевидны. Но всё рушится, как только реализации расходятся в незначительных деталях или когда каждый вариант возвращает разный ответ. N-version programming решает только первую половину проблемы: запуск нескольких версий. Более сложная половина — решить, какому выходу доверять.

Что такое N-version programming?

N-version programming — это техника отказоустойчивости, при которой запускаются несколько независимых реализаций одной и той же спецификации, а их результаты объединяются. Классическая форма — тройное модульное резервирование: три системы голосуют, и побеждает большинство. Она восходит к критически важному оборудованию, например авионике, где одна ошибка может стоить жизни.

Та же идея возродилась в AI-инжиниринге. Когда вы просите LLM сгенерировать код, вы можете сэмплировать пять разных completions. Когда у вас есть legacy-парсер и его переписанная версия, вы можете запускать оба параллельно и сравнивать. Корни в железе очевидны. Мы позаимствовали архитектуру, но не всегда позаимствовали логику выбора, которая делает её работоспособной.

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

Ловушка голосования: почему «самый распространённый» не означает «самый правильный»

Я столкнулся с этим в прошлом году при рефакторинге поискового ранжирования. У нас было пять алгоритмов ранжирования: legacy-система, два подхода на основе моделей и два эвристических baseline. На тестовом запросе четыре из них вернули разные top-10 списки. Ни один не совпал точно. Не за кого было голосовать.

Это нормальный случай, а не edge case. Разные реализации оптимизируют разные вещи. Одна может отдавать предпочтение свежести. Другая — весу популярности. Третья может содержать баг, который проявляется только по вторникам. Если голосовать по точному совпадению строк, вы привяжете себя к самой посредственной реализации — той, что возвращает самый безликий, наименее удивительный вывод.

Голосование по точному совпадению также молча проваливается. Две реализации могут вернуть одинаковый неправильный ответ, потому что они разделяют ошибочное предположение или скопированный баг. Коррелированные отказы полностью разрушают избыточность N-version. Если три из пяти парсеров обучены на одном и том же смещённом датасете, их согласие ничего не значит.

Как на самом деле работает дифференциальное тестирование

Лучший подход — дифференциальное тестирование со структурированным сравнением. Вместо вопроса «какие выходные данные идентичны» вы спрашиваете «какой выход лучше согласно критериям, которые я могу определить и измерить».

Начните с определения функции эквивалентности для вашей предметной области. Для поискового ранжирования можно сравнивать с помощью normalized discounted cumulative gain (NDCG). Для JSON-парсеров — результирующие object graphs. Для строковых выходов — semantic similarity или score downstream-задачи. Ключевой момент в том, что равенство становится спектром, а не бинарным переключателем.

Затем определите scoring function, которая отображает каждый выход в скаляр. Здесь вступает в игру экспертиза предметной области. Scoring function для задачи генерации кода может комбинировать успешность компиляции, долю пройденных тестов, runtime performance и длину выхода. Точные веса менее важны, чем то, что они явно заданы.

Имея на руках scores, выбор становится простой оптимизацией. Берите выход с наивысшим score. При ничьей используйте fallback-эвристику — например, отдавайте предпочтение реализации с наименьшим историческим уровнем ошибок.

Вот selector, который можно адаптировать. Он запускает N вариантов, оценивает каждый выход и возвращает лучший вместе с диагностическими метаданными:

from dataclasses import dataclass
from typing import Callable, List, Optional, TypeVar
import statistics

T = TypeVar("T")

@dataclass
class VariantResult:
    variant_id: str
    output: Optional[T]
    error: Optional[Exception]
    score: float = 0.0

class NVersionSelector:
    def __init__(
        self,
        score_fn: Callable[[T], float],
        equivalence_fn: Optional[Callable[[T, T], float]] = None,
        tie_breaker: Optional[Callable[[List[VariantResult]], VariantResult]] = None,
    ):
        self.score_fn = score_fn
        self.equivalence_fn = equivalence_fn or (lambda a, b: 1.0 if a == b else 0.0)
        self.tie_breaker = tie_breaker

    def select(self, variants: List[Callable[..., T]], *args, **kwargs) -> VariantResult:
        results: List[VariantResult] = []

        for variant in variants:
            try:
                output = variant(*args, **kwargs)
                results.append(VariantResult(
                    variant_id=variant.__name__,
                    output=output,
                    error=None,
                ))
            except Exception as exc:
                results.append(VariantResult(
                    variant_id=variant.__name__,
                    output=None,
                    error=exc,
                ))

        # Score only successful runs
        for r in results:
            if r.output is not None:
                r.score = self.score_fn(r.output)

        # Filter to successful, scored results
        valid = [r for r in results if r.error is None and r.output is not None]
        if not valid:
            raise RuntimeError("All variants failed")

        best_score = max(r.score for r in valid)
        candidates = [r for r in valid if r.score == best_score]

        if len(candidates) == 1:
            return candidates[0]

        if self.tie_breaker:
            return self.tie_breaker(candidates)

        return self._default_tie_break(candidates, valid)

    def _default_tie_break(
        self, candidates: List[VariantResult], all_valid: List[VariantResult]
    ) -> VariantResult:
        # Prefer the candidate whose output is most similar to other outputs
        def consensus_score(candidate: VariantResult) -> float:
            similarities = [
                self.equivalence_fn(candidate.output, other.output)
                for other in all_valid
                if other.variant_id != candidate.variant_id
            ]
            return statistics.mean(similarities) if similarities else 0.0

        return max(candidates, key=consensus_score)

score_fn — это место, где вы кодируете, что означает «лучший» для вашей задачи. equivalence_fn обрабатывает случай ничьей, измеряя, насколько выход похож на остальные. Это обобщает голосование большинства в нечто, что работает, когда никакие два выхода не совпадают точно.

Оценка реализаций с помощью мультикритериальной оценки

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

Первый уровень — hard filter. Выходы, которые не проходят компиляцию, нарушают инварианты или падают с ошибкой, отбрасываются немедленно. Никаких частичных баллов.

Второй уровень — soft score для выживших. Это может быть weighted sum, но веса должны быть явными и регулируемыми. Если заметите, что selector систематически предпочитает быстрые, но неправильные ответы, можно увеличить вес correctness, не переписывая архитектуру.

def score_generated_code(output: str) -> float:
    if not compiles(output):
        return -1.0  # Hard filter

    tests_passed = run_test_suite(output)
    execution_time = benchmark(output)
    line_count = len(output.splitlines())

    # Weighted sum on survivors only
    return (
        0.6 * tests_passed
        + 0.3 * (1.0 / (1.0 + execution_time))
        + 0.1 * (1.0 / (1.0 + line_count))
    )

Веса выше произвольны. Подбирайте их на held-out validation set и обновляйте при изменении требований. Не прячьте их внутри класса, где они станут невидимы.

Когда это ломается: коррелированные отказы и runtime-стоимость

N-version selection не бесплатен. Запуск пяти вариантов означает пятикратные вычисления, latency и нагрузку на поддержку. Если ваши реализации — это вызовы LLM, стоимость измеряется в долларах и секундах. Если это микросервисы — в глубине очереди и количестве потоков.

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

Также есть вопрос, что делать, когда сам selector ошибается. Если у вашей scoring function есть слепое пятно, вы будете систематически выбирать плохие выходы и никогда не заметите. Мониторьте распределение выборов. Если один вариант никогда не выбирается, он либо бесполезен, либо ваша score function смещена. В любом случае стоит разобраться.

Собираем всё воедино: selector, который можно задеплоить сегодня

Чтобы начать, не нужна распределённая система. Оберните существующую функцию в selector, добавьте одну альтернативную реализацию и определите один критерий scoring, который важен для вас. Запускайте их параллельно. Сравнивайте выходы. Логируйте scores.

Через неделю логирования разберите случаи, где варианты расходились. Это расхождение — подарок. Оно показывает, где ваша спецификация неоднозначна, где score function ошибается или где у одной реализации есть баг, которого нет у остальных.

Масштабируйтесь постепенно. Два варианта с хорошим selector побеждают пять вариантов с голосованием большинства.

FAQ: коррелированные отказы, stateful-системы и runtime-оверхед

Что если все пять реализаций возвращают разные результаты?

Это ожидаемый случай для нетривиальных задач. Используйте scoring function для ранжирования выходов вместо поиска точных совпадений. Если вы не можете определить хорошую score function, проблема не в selector. Проблема в том, что вы ещё не знаете, что означает «правильно» для вашей предметной области.

Как предотвратить коррелированные отказы между вариантами?

Проводите аудит общих зависимостей, скопированного кода и общих обучающих данных. Заставьте хотя бы один вариант использовать другой язык или фреймворк. Цель — некоррелированное распределение ошибок, что сложнее, чем кажется.

Работает ли N-version programming для stateful-систем?

Она плохо работает для систем с побочными эффектами. Если ваша функция пишет в базу данных или списывает деньги с кредитной карты, запуск её пять раз разрушителен. N-version selection лучше всего работает для чистых функций, read-only запросов или операций, где побочный эффект можно выполнить после выбора.

Какой оверхед даёт запуск пяти вариантов?

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