Du hast fünf Implementierungen derselben Funktion. Drei liefern dasselbe Ergebnis. Eine weicht leicht ab. Eine wirft eine Exception. Welche ist richtig?

Die meisten Teams greifen standardmäßig zur Mehrheitsentscheidung. Das funktioniert gut, wenn die Outputs identisch sind und die Fehler offensichtlich. Es bricht zusammen, sobald deine Implementierungen auf subtile Weise voneinander abweichen oder wenn jede Variante ein anderes Ergebnis liefert. N-version Programming löst nur die erste Hälfte des Problems: mehrere Versionen laufen zu lassen. Die schwierigere Hälfte ist die Entscheidung, welchem Output man vertraut.

Was ist N-version Programming?

N-version Programming ist eine Fehlertoleranztechnik, bei der mehrere unabhängige Implementierungen derselben Spezifikation ausgeführt und deren Ergebnisse kombiniert werden. Die klassische Form ist die dreifache modulare Redundanz (triple modular redundancy): drei Systeme stimmen ab, und die Mehrheit gewinnt. Sie geht zurück auf sicherheitskritische Hardware – etwa in der Avionik –, wo ein einzelner Bug Menschenleben kosten könnte.

Dieselbe Idee ist im AI-Engineering wieder aufgetaucht. Wenn du ein LLM bittest, Code zu generieren, könntest du fünf verschiedene Completions samplen. Wenn du einen Legacy-Parser und einen Rewrite hast, könntest du beide parallel laufen lassen und vergleichen. Die Hardware-Wurzeln zeigen sich. Wir haben die Architektur übernommen, ohne immer auch die Auswahl-Logik zu übernehmen, die sie funktionieren lässt.

In Hardware sind die Outputs Bits. In Software sind es strukturierte Daten, Strings, Rankings oder Side Effects. Die Mehrheitsentscheidung setzt voraus, dass Gleichheit billig zu berechnen und häufig zu finden ist. Für die meisten Software-Probleme trifft keine der beiden Annahmen zu.

Die Voting-Falle: warum “am häufigsten” nicht “am richtigsten” ist

Letztes Jahr bin ich bei einem Refactoring der Search-Ranking auf dieses Problem gestoßen. Wir hatten fünf Ranking-Algorithmen: das Legacy-System, zwei modellbasierte Ansätze und zwei heuristische Baselines. Bei einer Beispiel-Query lieferten vier davon unterschiedliche Top-10-Listen. Keine stimmte exakt überein. Es gab keine Mehrheit, für die man hätte stimmen können.

Das ist der Normalfall, nicht der Edge Case. Verschiedene Implementierungen optimieren für verschiedene Dinge. Eine könnte Recency bevorzugen. Eine andere könnte Popularität gewichten. Eine dritte könnte einen Bug haben, der nur dienstags auftritt. Wenn du nach exaktem String-Match abstimmst, fesselst du dich an die mittelmäßigste Implementierung – diejenige, die den fadesten, am wenigsten überraschenden Output liefert.

Exakt-Match-Voting versagt auch geräuschlos. Zwei Implementierungen können dieselbe falsche Antwort liefern, weil sie eine schlechte Annahme oder einen kopierten Bug teilen. Korrelierte Failures brechen die N-version-Redundanz vollständig. Wenn drei deiner fünf Parser auf demselben verzerrten Datensatz trainiert wurden, bedeutet ihre Übereinstimmung nichts.

Wie Differential Testing tatsächlich funktioniert

Der bessere Ansatz ist Differential Testing mit strukturiertem Vergleich. Statt zu fragen “welche Outputs sind identisch”, fragst du “welcher Output ist laut Kriterien, die ich definieren und messen kann, der beste”.

Beginne damit, eine Äquivalenzfunktion für deine Domain zu definieren. Für Search-Rankings könntest du mit normalisiertem Discounted Cumulative Gain (NDCG) vergleichen. Für JSON-Parser könntest du die resultierenden Objekt-Graphen vergleichen. Für String-Outputs könntest du semantische Ähnlichkeit oder einen Downstream-Task-Score verwenden. Der Schlüssel ist, dass Gleichheit zu einem Spektrum wird, nicht zu einem binären Schalter.

Definiere als Nächstes eine Scoring-Funktion, die jeden Output auf einen Skalar abbildet. Hier kommt Domain-Knowledge ins Spiel. Eine Scoring-Funktion für eine Code-Generation-Task könnte Compilation-Erfolg, Test-Pass-Rate, Runtime-Performance und Output-Länge kombinieren. Die genauen Gewichte sind weniger wichtig als die Tatsache, dass sie explizit sind.

Mit Scores in der Hand wird die Auswahl zu einer einfachen Optimierung. Wähle den Output mit dem höchsten Score. Bei Gleichstand verwende eine Fallback-Heuristik, etwa die Implementierung mit der niedrigsten historischen Fehlerrate zu bevorzugen.

Hier ist ein Selector, den du anpassen kannst. Er führt N Varianten aus, bewertet jeden Output und gibt den besten zusammen mit diagnostischen Metadaten zurück:

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)

Die score_fn ist der Ort, an dem du kodierst, was “beste” für dein Problem bedeutet. Die equivalence_fn behandelt den Fall, dass die Scores gleichstanden, indem sie misst, wie ähnlich ein Output dem Rest des Pakets ist. Das verallgemeinert die Mehrheitsentscheidung zu etwas, das funktioniert, wenn keine zwei Outputs exakt übereinstimmen.

Implementierungen mit Multi-Criteria-Evaluation bewerten

Ein einzelner Skalar-Score ist sauber, aber gefährlich, wenn du zu viele Dimensionen in eine Zahl kollabierst. Ich bevorzuge einen Two-Tier-Ansatz.

Tier eins ist ein Hard Filter. Outputs, die bei der Compilation scheitern, Invarianten verletzen oder abstürzen, werden sofort verworfen. Keine Teilnoten.

Tier zwei ist ein Soft Score für die Überlebenden. Das kann eine gewichtete Summe sein, aber halte die Gewichte explizit und anpassbar. Wenn du feststellst, dass dein Selector durchgehend schnelle, aber falsche Antworten bevorzugt, kannst du das Correctness-Gewicht erhöhen, ohne die Architektur neu zu schreiben.

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))
    )

Die Gewichte oben sind willkürlich. Optimiere sie gegen einen Held-out-Validation-Set und aktualisiere sie, wenn sich deine Anforderungen ändern. Vergrabe sie nicht in einer Klasse, wo sie unsichtbar werden.

Wenn das zusammenbricht: korrelierte Failures und Runtime-Kosten

N-version Selection ist nicht kostenlos. Fünf Varianten laufen zu lassen bedeutet fünfmal den Compute, fünfmal die Latenz und fünfmal den Wartungsaufwand. Wenn deine Implementierungen LLM-Calls sind, werden diese Kosten in Dollar und Sekunden gemessen. Wenn sie Microservices sind, wird er in Queue-Tiefe und Thread-Count gemessen.

Das größere Risiko ist der korrelierte Failure. Fünf Implementierungen geben dir nicht fünf unabhängige Ziehungen aus einer Bug-Verteilung. Sie teilen sich Sprachen, Libraries, Trainingsdaten und menschliche Autoren. Wenn alle fünf denselben regulären Ausdruck verwenden, um Daten zu parsen, werden alle bei derselben malformed Input brechen. Diversität ist schwer durchzusetzen. Sie erfordert ein aktives Auditing auf gemeinsame Dependencies und kopierte Logik.

Es stellt sich auch die Frage, was zu tun ist, wenn der Selector selbst falsch ist. Wenn deine Scoring-Funktion eine blinde Stelle hat, wirst du durchgehend schlechte Outputs auswählen und es nie bemerken. Überwache die Auswahl-Verteilung. Wenn eine Variante nie gewählt wird, ist sie entweder nutzlos oder deine Score-Funktion ist voreingenommen. In beiden Fällen solltest du nachforschen.

Zusammenbauen: ein Selector, den du heute deployen kannst

Du brauchst kein Distributed System, um anzufangen. Wickle deine bestehende Funktion in einen Selector, füge eine alternative Implementierung hinzu und definiere ein einzelnes Scoring-Kriterium, das dir wichtig ist. Führe sie parallel aus. Vergleiche die Outputs. Logge die Scores.

Nach einer Woche Logging überprüfe die Fälle, in denen die Varianten uneinig waren. Diese Uneinigkeit ist ein Geschenk. Sie sagt dir, wo deine Spezifikation mehrdeutig ist, wo deine Score-Funktion falsch ist oder wo eine Implementierung einen Bug hat, den die anderen nicht haben.

Skaliere schrittweise hoch. Zwei Varianten mit einem guten Selector schlagen fünf Varianten mit Mehrheitsentscheidung.

FAQ: Korrelierte Failures, Stateful Systems und Runtime-Overhead

Was, wenn alle fünf Implementierungen unterschiedliche Ergebnisse liefern?

Das ist der erwartete Fall für nichttriviale Probleme. Verwende eine Scoring-Funktion, um die Outputs zu ranken, statt nach exakten Übereinstimmungen zu suchen. Wenn du keine gute Score-Funktion definieren kannst, ist das Problem nicht der Selector. Es ist, dass du noch nicht weißt, was “korrekt” für deine Domain bedeutet.

Wie verhindere ich korrelierte Failures über Varianten hinweg?

Auditiere gemeinsame Dependencies, kopierten Code und gemeinsame Trainingsdaten. Erzwinge, dass mindestens eine Variante eine andere Sprache oder ein anderes Framework verwendet. Das Ziel sind unkorrelierte Error-Verteilungen, was schwieriger ist, als es klingt.

Funktioniert N-version Programming für Stateful Systems?

Es funktioniert schlecht für Systeme mit Side Effects. Wenn deine Funktion in eine Datenbank schreibt oder eine Kreditkarte belastet, ist es destruktiv, sie fünfmal auszuführen. N-version Selection funktioniert am besten für Pure Functions, Read-only-Queries oder Operationen, bei denen du den Side Effect nach der Auswahl ausführen kannst.

Wie viel Overhead erzeugt das Ausführen von fünf Varianten?

Die Latenz ist durch die langsamste Variante begrenzt, es sei denn, du führst sie parallel aus. CPU und Memory skalieren linear. Beginne mit zwei Varianten und messe, bevor du dich auf fünf festlegst. Die operativen Kosten sind oft wichtiger als die Auswahl-Logik.