La respuesta incorrecta se ve exactamente igual a la correcta
Envías un prompt a GPT-4o. Devuelve un blob JSON con una confianza de 0.97. Envías el mismo prompt a Claude 3.5 Sonnet. Devuelve un blob JSON diferente, también con una confianza de 0.97. Ambos modelos suenan seguros. Ambos están equivocados de distintas maneras.
Esto no es hipotético. Si ejecutas cualquier carga de trabajo no trivial con LLMs durante más de unos pocos miles de requests, encontrarás casos en los que dos modelos capaces discrepan sobre hechos, clasificaciones o salida estructurada. La discrepancia en sí es una señal. La mayoría de los equipos la ignoran.
La programación N-version, la vieja idea de ejecutar múltiples implementaciones y votar sobre el resultado, se consideraba demasiado costosa para la mayoría del software. Con los LLMs, las “implementaciones” son solo distintas llamadas a la API. El costo es real, pero el costo de enviar la respuesta incorrecta suele ser peor.
Cómo se ve la programación N-version para LLMs
La versión clásica proviene de sistemas críticos de seguridad. Tres programas escritos independientemente procesan la misma entrada. Un votante compara las salidas. Si dos coinciden y uno no, la mayoría gana y la minoría se marca para inspección.
Con los LLMs, no necesitas tres equipos de ingeniería. Necesitas tres API keys. La independencia es más débil, los modelos comparten datos de entrenamiento, pero la divergencia sigue siendo medible y útil. Dos modelos entrenados en corpus superpuestos pueden alucinar hechos distintos, interpretar la ambigüedad de forma diferente o fallar en casos límite opuestos.
La pregunta no es si discrepan. Lo harán. La pregunta es qué haces cuando lo hacen.
Un detector de discrepancias simple que realmente funciona
Aquí tienes un sistema concreto en Python. Envía el mismo prompt a múltiples modelos, extrae la salida estructurada y las compara usando reglas conscientes del dominio en lugar de una igualdad de strings ingenua.
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
La elección de diseño clave es el comparator. La igualdad de strings es inútil para la salida de un LLM. Dos modelos podrían devolver {"category": "refund"} y {"category": "Refund"} y no discrepar en nada que importe.
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),
)
Dónde esto realmente detecta bugs
La detección de discrepancias no se trata de encontrar errores obvios. Los errores obvios ya se detectan con medios más simples.
Se trata de atrapar errores sutiles que se ven correctos para cualquier modelo individual. Aquí están los tres patrones que vemos con más frecuencia:
Divergencia por alucinación confiada. Un modelo inventa un SKU de producto. Otro inventa un SKU diferente. Ambas salidas validan contra el schema. La discrepancia es la única señal de que algo está fabricado.
Divisiones en condiciones de borde. Un modelo clasifica una transaction de $999.99 como “high value”. Otro la clasifica como “standard”. El umbral es ambiguo en el prompt. La discrepancia te dice que el prompt está poco especificado.
Deriva temporal. Un modelo devuelve una fecha en ISO 8601. Otro devuelve la misma fecha en una zona horaria diferente porque el prompt decía “today” y los modelos tienen distintas suposiciones de reloj en tiempo de inferencia.
En cada caso, ninguna salida está obviamente rota. La discrepancia es el reporte de bug.
El modelo de costos es sorprendentemente tolerable
Tres llamadas a la API cuestan tres veces más que una. Esa es la matemática superficial. La matemática real depende de qué haces con los resultados.
Si ejecutas tres modelos en cada request y tomas la salida mayoritaria, estás quemando dinero. La mayoría de los equipos no deberían hacer esto.
El patrón más inteligente es por niveles:
- Fast path: Un modelo barato maneja el request.
- Audit path: Una muestra de requests, o todos los requests por encima de un umbral de confianza, se envían a un segundo modelo de forma asíncrona.
- Escalation path: Cuando el audit path detecta una discrepancia, un tercer modelo desempata. La discrepancia se registra para revisión de prompt engineering.
Esto significa que pagas el triple solo para el subconjunto de requests que realmente necesitan escrutinio. Para muchas cargas de trabajo, eso es menos del 5% del tráfico.
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
La llamada a log_disagreement es donde reside el valor a largo plazo. Cada discrepancia es un punto de datos que te dice dónde tu prompt es ambiguo, dónde tu schema está poco restringido o dónde tus ejemplos de entrenamiento entran en conflicto.
Lo que esto no arregla
La programación N-version con LLMs tiene límites reales, y pretender lo contrario la hace peligrosa.
Modos de fallo compartidos. Si ambos modelos fallan porque el prompt contiene un typo, ambos fallarán de la misma manera. Los modelos no son independientes de la misma forma que tres programas escritos a mano. Comparten arquitectura, objetivos de entrenamiento y grandes fragmentos de internet.
Sesgo sistemático. Si tu prompt codifica implícitamente un sesgo, múltiples modelos pueden reproducirlo consistentemente. La detección de discrepancias solo atrapa divergencia, no incorrectitud compartida.
Costo a escala. Incluso una tasa de audit del 5% se vuelve costosa si tu volumen es de miles de millones de requests por mes. La economía funciona para decisiones de alto riesgo, no para cada autocomplete.
Latencia en el escalation path. Cuando dos modelos discrepan y necesitas un tercero, has agregado dos round trips al critical path. Para casos de uso en tiempo real, puede que necesites enviar el resultado primario y resolver la discrepancia de forma asíncrona, aceptando una breve ventana de potencial incorrectitud.
Cómo empezar sin sobre-ingeniería
No necesitas un framework de votación completo desde el día uno. Empieza con comparación por pares en tu prompt de mayor riesgo.
Elige una llamada de salida estructurada donde una respuesta incorrecta realmente cueste dinero o confianza. Agrega una segunda llamada a modelo en un background job. Compara las salidas con un comparator específico del dominio. Registra discrepancias. Revisa los logs semanalmente.
Después de dos semanas, sabrás si tu prompt es sólido o un desastre. La mayoría de los prompts son más frágiles de lo que sus autores creen. El log de discrepancias es una verificación de honestidad.
Una vez que tengas datos, puedes decidir si construir un resolver, ajustar tu prompt o aceptar que la tarea es demasiado ambigua para un manejo completamente automatizado.
La verdadera victoria es el ciclo de retroalimentación
El objetivo de la programación N-version con LLMs no es construir un votante perfecto. El objetivo es cerrar el ciclo entre la discrepancia en producción y la mejora del prompt.
Cada vez que dos modelos discrepan, has encontrado un lugar donde tu especificación está incompleta. Arregla la spec, no solo la salida. Con el tiempo, la tasa de discrepancia baja. Cuando baja lo suficiente, puedes reducir la muestra de audit o degradar el modelo tiebreaker.
El sistema se vuelve más barato a medida que mejora. Eso es lo opuesto a la mayoría de los mecanismos de seguridad, que se vuelven más costosos a medida que aumenta la escala.
Empieza con un prompt, dos modelos y una línea de log. Las discrepancias te dirán exactamente dónde mirar.