La mauvaise réponse ressemble exactement à la bonne

Vous envoyez un prompt à GPT-4o. Il retourne un blob JSON avec un score de confiance de 0,97. Vous envoyez le même prompt à Claude 3.5 Sonnet. Il retourne un blob JSON différent, également avec un score de confiance de 0,97. Les deux modèles paraissent certains. Les deux se trompent, mais de façons différentes.

Ce n’est pas une hypothèse. Si vous exécutez une charge de travail LLM non triviale sur plus de quelques milliers de requêtes, vous rencontrerez des cas où deux modèles compétents divergent sur des faits, des classifications ou des sorties structurées. Le désaccord lui-même est un signal. La plupart des équipes l’ignorent.

La programmation N-version, cette vieille idée d’exécuter plusieurs implémentations et de voter sur le résultat, était considérée comme trop coûteuse pour la plupart des logiciels. Avec les LLM, les « implémentations » ne sont que différents appels d’API. Le coût est réel, mais le coût d’envoyer une mauvaise réponse est souvent pire.

À quoi ressemble la programmation N-version pour les LLM

La version classique vient des systèmes critiques pour la sécurité. Trois programmes écrits indépendamment traitent la même entrée. Un votant compare les sorties. Si deux sont d’accord et un non, la majorité l’emporte et la minorité est signalée pour inspection.

Avec les LLM, vous n’avez pas besoin de trois équipes d’ingénierie. Vous avez besoin de trois clés d’API. L’indépendance est plus faible, les modèles partagent les données d’entraînement, mais la divergence reste mesurable et utile. Deux modèles entraînés sur des corpus qui se chevauchent peuvent quand même halluciner des faits différents, interpréter l’ambiguïté différemment ou échouer sur des cas limites opposés.

La question n’est pas de savoir s’ils divergent. Ils le feront. La question est de savoir ce que vous faites quand cela arrive.

Un détecteur de désaccord simple qui fonctionne réellement

Voici un système concret en Python. Il envoie le même prompt à plusieurs modèles, extrait la sortie structurée et les compare à l’aide de règles adaptées au domaine plutôt qu’une simple égalité de chaînes.

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

Le choix de conception clé est le comparateur. L’égalité de chaînes est inutile pour la sortie d’un LLM. Deux modèles peuvent retourner {"category": "refund"} et {"category": "Refund"} et ne pas être d’accord sur rien d’important.

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

Où cela détecte réellement des bugs

La détection de désaccord ne consiste pas à trouver des erreurs évidentes. Les erreurs évidentes sont déjà détectées par des moyens plus simples.

Il s’agit de détecter des erreurs subtiles qui semblent correctes pour tout modèle isolé. Voici les trois motifs que nous voyons le plus souvent :

Divergence d’hallucination confiante. Un modèle invente un SKU de produit. Un autre en invente un différent. Les deux sorties valident le schéma. Le désaccord est le seul signal indiquant que quelque chose est fabriqué.

Fractionnements sur les conditions aux limites. Un modèle classe une transaction de 999,99 $ comme « haute valeur ». Un autre la classe comme « standard ». Le seuil est ambigu dans le prompt. Le désaccord vous indique que le prompt est sous-spécifié.

Dérive temporelle. Un modèle retourne une date au format ISO 8601. Un autre retourne la même date dans un fuseau horaire différent parce que le prompt disait « aujourd’hui » et les modèles ont des hypothèses d’horloge différentes au moment de l’inférence.

Dans chaque cas, aucune sortie n’est manifestement erronée. Le désaccord est le rapport de bug.

Le modèle de coût est étonnamment tolérable

Trois appels d’API coûtent trois fois plus qu’un seul. C’est le calcul superficiel. Le vrai calcul dépend de ce que vous faites des résultats.

Si vous exécutez trois modèles à chaque requête et prenez la sortie majoritaire, vous brûlez de l’argent. La plupart des équipes ne devraient pas faire cela.

Le motif plus intelligent est à plusieurs niveaux :

  1. Chemin rapide : Un modèle bon marché traite la requête.
  2. Chemin d’audit : Un échantillon de requêtes, ou toutes les requêtes au-dessus d’un seuil de confiance, est envoyé à un second modèle de manière asynchrone.
  3. Chemin d’escalade : Quand le chemin d’audit détecte un désaccord, un troisième modèle tranche. Le désaccord est journalisé pour révision par l’ingénierie de prompt.

Cela signifie que vous payez le triple uniquement pour le sous-ensemble de requêtes qui nécessitent réellement un examen attentif. Pour de nombreuses charges de travail, cela représente moins de 5 % du trafic.

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

L’appel à log_disagreement est là où réside la valeur à long terme. Chaque désaccord est un point de données qui vous indique où votre prompt est ambigu, où votre schéma est sous-contraint ou où vos exemples d’entraînement entrent en conflit.

Ce que cela ne corrige pas

La programmation N-version avec les LLM a de vraies limites, et faire semblant du contraire la rend dangereuse.

Modes de défaillance partagés. Si les deux modèles échouent parce que le prompt contient une faute de frappe, les deux échoueront de la même façon. Les modèles ne sont pas indépendants comme peuvent l’être trois programmes écrits à la main. Ils partagent l’architecture, les objectifs d’entraînement et de larges pans d’Internet.

Biais systématique. Si votre prompt encode implicitement un biais, plusieurs modèles peuvent le reproduire de manière cohérente. La détection de désaccord ne capture que la divergence, pas l’erreur partagée.

Coût à grande échelle. Même un taux d’audit de 5 % devient coûteux si votre volume est de milliards de requêtes par mois. L’économie fonctionne pour les décisions à enjeux élevés, pas pour chaque autocomplétion.

Latence sur le chemin d’escalade. Quand deux modèles divergent et que vous avez besoin d’un troisième, vous avez ajouté deux allers-retours au chemin critique. Pour les cas d’usage en temps réel, vous devrez peut-être envoyer le résultat principal et résoudre le désaccord de manière asynchrone, en acceptant une brève fenêtre d’erreur potentielle.

Comment commencer sans sur-ingénierie

Vous n’avez pas besoin d’un framework de vote complet dès le premier jour. Commencez par une comparaison par paires sur votre prompt à plus haut risque.

Choisissez un appel à sortie structurée où une mauvaise réponse coûte réellement de l’argent ou de la confiance. Ajoutez un second appel de modèle dans une tâche en arrière-plan. Comparez les sorties avec un comparateur spécifique au domaine. Journalisez les désaccords. Examinez les journaux chaque semaine.

Après deux semaines, vous saurez si votre prompt est solide ou un désastre. La plupart des prompts sont plus fragiles que leurs auteurs ne le pensent. Le journal de désaccord est un test d’honnêteté.

Une fois que vous avez des données, vous pouvez décider de construire un résolveur, d’ajuster votre prompt ou d’accepter que la tâche est trop ambiguë pour un traitement entièrement automatisé.

Le vrai gain est la boucle de rétroaction

L’objectif de la programmation N-version pour LLM n’est pas de construire un votant parfait. L’objectif est de fermer la boucle entre le désaccord en production et l’amélioration du prompt.

Chaque fois que deux modèles divergent, vous avez trouvé un endroit où votre spécification est incomplète. Corrigez la spec, pas seulement la sortie. Avec le temps, le taux de désaccord diminue. Quand il a suffisamment baissé, vous pouvez réduire l’échantillon d’audit ou rétrograder le modèle de départage.

Le système devient moins cher au fur et à mesure qu’il s’améliore. C’est l’opposé de la plupart des mécanismes de sécurité, qui deviennent plus coûteux à mesure que l’échelle augmente.

Commencez avec un prompt, deux modèles et une ligne de journal. Les désaccords vous diront exactement où chercher.