Módulo 7: Comparación técnica de proveedores

Benchmark de latencia

Latencia es la dimensión más fácil de medir mal. Un sample de 1 request no te dice nada — la varianza es enorme. Un promedio sin percentiles oculta el peor caso, que es lo que importa para la experiencia de usuario.

En esta cápsula vas a construir un harness Python que mide P50, P95 y P99 de latencia para cada proveedor que cubrimos en el path. Al final tendrás una tabla defendible con números que puedes mostrar en una reunión.

Al terminar vas a poder:

  • Diseñar un benchmark de latencia que evita los errores típicos (cold start contaminando datos, sample chico, prompts irrelevantes)
  • Implementar el harness para OpenAI, OpenRouter, Ollama, Modal con la misma estructura
  • Interpretar P50/P95/P99 correctamente y explicar la diferencia
  • Exportar resultados a JSON/CSV para análisis y comparaciones cruzadas

Por qué importa

Tu producto no se entrega "latencia promedio". Si tu P95 es 25s, un usuario de cada 20 espera 25s. Para un chatbot interactivo, eso es producto roto aunque el promedio se vea bien.

Saber medir percentiles de latencia te permite:

  • Decidir si un proveedor cumple tu SLA real (no su SLA marketing)
  • Detectar cuándo un proveedor degrada (P99 sube de 8s a 30s en un día) antes que los usuarios reporten
  • Negociar con vendors mostrando datos propios, no anecdotas

Modelo mental: percentiles

MétricaQué significa
P50 (mediana)"La mitad de mis requests es más rápida que esto"
P95"El 95% de mis usuarios espera menos que esto"
P99"Solo el 1% de mis usuarios espera más que esto"
Max"El peor caso medido"

No uses promedio (mean). El promedio es la métrica favorita de los ingenuos: un solo request de 30s "ahoga" 100 requests de 1s y te da promedio 1.3s — escondiendo que un usuario sufrió 30s.

Requests: [1.0, 1.1, 0.9, 1.2, 1.0, 30.0, 1.1, 0.8, 1.3, 1.0]
Mean:  3.94s   ← engañoso (¡un usuario esperó 30s!)
P50:   1.05s   ← honesto: la mediana
P95:   30.0s   ← honesto: el peor 5%

Diseño del harness

Cinco decisiones de diseño explícitas, todas importantes:

1. Warm-up antes de medir. El primer request a un proveedor con cold start (Modal, Ollama recién levantado) tiene latencia anómala. Manda 3-5 requests dummy primero, ignora esos.

2. Sample size de 50-100. Menos y los percentiles no son estables. Más es overkill (toma muchísimo tiempo, especialmente con Ollama local).

3. Prompts representativos de tu caso, no genéricos. Bencheo de "Hola, ¿cómo estás?" no predice el comportamiento con un prompt RAG de 2000 tokens. Usa 3-5 prompts típicos de tu producto.

4. Misma cantidad de tokens output. Si OpenAI genera 50 tokens y Modal 500, el segundo "tarda más" pero no porque sea más lento sino porque genera más. Fija max_tokens igual.

5. Mide tiempo de wall-clock, no de servidor. Lo que le importa al usuario es desde "mando request" hasta "recibo respuesta", incluyendo red. Usa time.perf_counter() alrededor de la llamada HTTP completa.


Implementación: harness modular

Crea benchmark_latency.py:

# benchmark_latency.py
import os
import time
import json
import statistics
from dataclasses import dataclass, field
from typing import Callable

# ============================================================
# Modelos de datos
# ============================================================
@dataclass
class ResultadoBenchmark:
    proveedor: str
    modelo: str
    samples: int
    p50: float
    p95: float
    p99: float
    max_: float
    errores: int
    latencias_raw: list[float] = field(default_factory=list)


# ============================================================
# Prompts representativos (¡ajusta a tu caso real!)
# ============================================================
PROMPTS = [
    "Explica REST en 3 frases.",
    "Resume las ventajas de PostgreSQL frente a MongoDB para datos transaccionales.",
    "¿Qué es prompt injection y cómo se mitiga?",
    "Escribe una función Python que valida emails con regex.",
    "Compara Docker y Kubernetes en términos de cuándo usar cada uno.",
]


# ============================================================
# Helpers de medición
# ============================================================
def percentil(valores: list[float], p: int) -> float:
    if not valores:
        return 0.0
    ordenados = sorted(valores)
    idx = int(len(ordenados) * p / 100)
    return ordenados[min(idx, len(ordenados) - 1)]


def medir_latencia(
    nombre: str,
    modelo: str,
    invocar: Callable[[str], None],
    samples: int = 50,
    warmup: int = 3,
) -> ResultadoBenchmark:
    """
    Mide latencia de un proveedor.

    `invocar(prompt)` es una callable que llama al proveedor.
    Su latencia se mide; su respuesta se descarta.
    """
    print(f"\n→ Benchmarking {nombre} ({modelo})")
    print(f"  Warmup: {warmup} requests (ignorados)")

    # Warm-up
    for i in range(warmup):
        try:
            invocar(PROMPTS[i % len(PROMPTS)])
        except Exception as e:
            print(f"  Warmup error #{i}: {e}")

    print(f"  Midiendo {samples} samples...")
    latencias = []
    errores = 0
    for i in range(samples):
        prompt = PROMPTS[i % len(PROMPTS)]
        inicio = time.perf_counter()
        try:
            invocar(prompt)
            latencia = time.perf_counter() - inicio
            latencias.append(latencia)
        except Exception as e:
            errores += 1
            print(f"  Sample #{i} error: {e}")
        if (i + 1) % 10 == 0:
            print(f"    {i + 1}/{samples} completos")

    return ResultadoBenchmark(
        proveedor=nombre,
        modelo=modelo,
        samples=len(latencias),
        p50=percentil(latencias, 50),
        p95=percentil(latencias, 95),
        p99=percentil(latencias, 99),
        max_=max(latencias) if latencias else 0,
        errores=errores,
        latencias_raw=latencias,
    )


# ============================================================
# Adaptadores por proveedor
# ============================================================
MAX_TOKENS = 150  # Fija para comparación justa

def adaptador_openai(prompt: str):
    from openai import OpenAI
    client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
    client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        max_tokens=MAX_TOKENS,
    )


def adaptador_openrouter(prompt: str):
    from openai import OpenAI
    client = OpenAI(
        base_url="https://openrouter.ai/api/v1",
        api_key=os.environ["OPENROUTER_API_KEY"],
    )
    client.chat.completions.create(
        model="mistralai/mistral-7b-instruct",
        messages=[{"role": "user", "content": prompt}],
        max_tokens=MAX_TOKENS,
    )


def adaptador_ollama(prompt: str):
    import httpx
    httpx.post(
        "http://localhost:11434/api/chat",
        json={
            "model": "mistral",
            "messages": [{"role": "user", "content": prompt}],
            "stream": False,
            "options": {"num_predict": MAX_TOKENS},
        },
        timeout=120,
    ).raise_for_status()


def adaptador_modal(prompt: str):
    import httpx
    httpx.post(
        os.environ["MODAL_BASE_URL"] + "/chat",
        headers={"Authorization": f"Bearer {os.environ['MODAL_API_TOKEN']}"},
        json={"prompt": prompt, "max_tokens": MAX_TOKENS},
        timeout=120,
    ).raise_for_status()


# ============================================================
# Runner
# ============================================================
def main():
    resultados: list[ResultadoBenchmark] = []

    if os.environ.get("OPENAI_API_KEY"):
        resultados.append(
            medir_latencia("OpenAI", "gpt-4o-mini", adaptador_openai)
        )
    if os.environ.get("OPENROUTER_API_KEY"):
        resultados.append(
            medir_latencia(
                "OpenRouter", "mistral-7b-instruct", adaptador_openrouter
            )
        )
    if os.environ.get("OLLAMA_BASE_URL") or _esta_ollama_corriendo():
        resultados.append(
            medir_latencia("Ollama (local)", "mistral", adaptador_ollama)
        )
    if os.environ.get("MODAL_BASE_URL"):
        resultados.append(
            medir_latencia("Modal", "mistral-7b-instruct-v0.3", adaptador_modal)
        )

    # Tabla
    print("\n\n=== RESULTADOS ===\n")
    print(f"{'Proveedor':<20} {'Modelo':<28} {'P50':>7} {'P95':>7} {'P99':>7} {'Max':>7} {'Err':>5}")
    print("-" * 84)
    for r in resultados:
        print(
            f"{r.proveedor:<20} {r.modelo:<28} "
            f"{r.p50:>6.2f}s {r.p95:>6.2f}s {r.p99:>6.2f}s "
            f"{r.max_:>6.2f}s {r.errores:>5}"
        )

    # Exportar a JSON
    with open("resultados_latencia.json", "w") as f:
        json.dump(
            [
                {
                    "proveedor": r.proveedor,
                    "modelo": r.modelo,
                    "samples": r.samples,
                    "p50": r.p50,
                    "p95": r.p95,
                    "p99": r.p99,
                    "max": r.max_,
                    "errores": r.errores,
                }
                for r in resultados
            ],
            f,
            indent=2,
        )
    print("\n→ Resultados guardados en resultados_latencia.json")


def _esta_ollama_corriendo() -> bool:
    import httpx
    try:
        httpx.get("http://localhost:11434/api/tags", timeout=2)
        return True
    except Exception:
        return False


if __name__ == "__main__":
    main()

Ejecutar

Configura las API keys / URLs que quieras benchmarkear:

export OPENAI_API_KEY=sk-...
export OPENROUTER_API_KEY=sk-or-...
# Ollama corre en localhost; no necesita config si está activo
export MODAL_BASE_URL=https://tu-usuario--llm-api-final-web.modal.run
export MODAL_API_TOKEN=...

pip install openai httpx
python benchmark_latency.py

Output esperado (números ilustrativos, vas a obtener los tuyos):

→ Benchmarking OpenAI (gpt-4o-mini)
  Warmup: 3 requests (ignorados)
  Midiendo 50 samples...
    10/50 completos
    ...

=== RESULTADOS ===

Proveedor            Modelo                          P50     P95     P99     Max   Err
------------------------------------------------------------------------------------
OpenAI               gpt-4o-mini                   1.42s   2.31s   3.18s   3.20s     0
OpenRouter           mistral-7b-instruct           2.14s   3.05s   4.42s   4.51s     1
Ollama (local)       mistral                       4.21s   5.93s   6.85s   7.12s     0
Modal                mistral-7b-instruct-v0.3      1.95s   2.74s  18.34s  19.20s     0

Cómo leer los resultados

Fíjate en patrones, no en números absolutos:

OpenAI: P50 bajo, P99 cercano a P50 → consistente. Buen producto para latencia predecible.

OpenRouter: Similar a OpenAI pero un escalón más lento. Latencia agregada por la capa de proxy.

Ollama (local): P50 más alto porque tu hardware (CPU/GPU local) es menos potente que el de los providers, pero consistente (P50→P99 cercanos) porque no compartes infraestructura con nadie.

Modal: P50 competitivo, P99 muy alto — el peor 1% está disparado. ¿Por qué? Cold starts. Si tu Modal no tiene min_containers=1, algunos requests pegan un container frío y pagan 18-30s. Es exactamente el patrón que diagnosticas con percentiles.

Esto vale más que la tabla: entender por qué Modal P99 es alto te dice qué configurar (warm pool, snapshot) para arreglarlo.


Variaciones del benchmark

Variación 1 — Distinto largo de prompt. Tu producto real maneja prompts de varios tamaños. Modifica PROMPTS para incluir prompts cortos (50 chars) y largos (2000 chars). Vas a ver que algunos proveedores degradan más que otros con prompts largos.

Variación 2 — Concurrencia. El benchmark anterior es secuencial: 1 request a la vez. Agrega concurrencia (con asyncio o concurrent.futures) para medir P95 bajo 10 requests paralelos. Esto revela limitaciones de throughput que el bench secuencial oculta.

Variación 3 — Different times of day. Latencia varía por hora (proveedores tienen tráfico desigual). Corre el benchmark a las 10am y a las 11pm tu hora; compara. A veces hay diferencias del 30%+.


Trampas comunes

Trampa 1 — "Medí en 10 minutos. Resultado: OpenAI ganó." 10 minutos es ventana muy chica. La degradación de OpenAI por congestión es errática. Para datos confiables, corre el benchmark 3 veces en distintos momentos y combina.

Trampa 2 — "No hice warmup y Modal salió pésimo." Sin warmup, el primer request a Modal puede ser 60s (cold start completo). Eso jala el promedio. El harness arriba ya tiene warmup=3 — déjalo activo.

Trampa 3 — "Usé prompts ridículamente cortos." "Hola" como prompt no predice tu caso real. Si tu producto manda prompts de 1500 caracteres, usa prompts de 1500 caracteres en el benchmark.

Trampa 4 — "Comparo latencia pero los modelos son diferentes." GPT-4o vs Mistral 7B no es comparación de infraestructura — es comparación de modelo + infraestructura. Sé explícito en tu reporte: "OpenAI sirve gpt-4o-mini a 1.4s P50; OpenRouter sirve Mistral 7B a 2.1s P50". No digas "OpenAI es más rápido que OpenRouter" en abstracto.

Trampa 5 — "Mi laptop bencheo Ollama en GPU integrada." Si tu Ollama corre en MacBook M2 con 16GB unified memory, los números serán muy distintos a Ollama en una máquina con A100. Reporta tu hardware.


Ejercicio

Modifica el harness para:

  1. Aceptar el argumento --samples N por CLI (en vez de hardcodear 50)
  2. Aceptar --prompts archivo.txt para cargar prompts de un archivo (uno por línea)
  3. Imprimir, además de percentiles, la latencia mínima (P0) y el coeficiente de variación (std/mean) para mostrar consistencia
Ver solución
import argparse

def parse_args():
    p = argparse.ArgumentParser()
    p.add_argument("--samples", type=int, default=50)
    p.add_argument("--prompts", type=str, help="Archivo con un prompt por línea")
    return p.parse_args()


def cargar_prompts(path: str | None) -> list[str]:
    if not path:
        return PROMPTS
    with open(path) as f:
        return [linea.strip() for linea in f if linea.strip()]


# En main():
args = parse_args()
prompts = cargar_prompts(args.prompts)
# ... pasa `samples=args.samples` y `prompts=prompts` al harness

# En medir_latencia, agrega:
min_ = min(latencias) if latencias else 0
cv = statistics.stdev(latencias) / statistics.mean(latencias) if len(latencias) > 1 else 0

# En la impresión, agrega columnas Min y CV

CV > 0.5 indica latencia muy inconsistente (probable cold start o congestión). CV < 0.2 indica proveedor estable.


Resumen

Aprendiste:

  • ✅ Percentiles (P50/P95/P99) cuentan la historia real, no el promedio
  • ✅ Harness modular con adaptadores por proveedor (misma estructura, distinto cliente)
  • ✅ Cinco decisiones de diseño: warmup, sample size, prompts representativos, tokens fijos, wall-clock
  • ✅ Interpretar resultados: cold start (Modal P99 alto), throughput (degradación con concurrencia)

Checkpoint: si tienes un JSON con percentiles de al menos 2 proveedores y entiendes qué dice tu P99 sobre cada uno, estás listo.


Siguiente cápsula

03 — Benchmark de costo. Latencia es la mitad del trade-off; costo es la otra mitad. Vamos a calcular costo real por request para cada proveedor a tres escalas de tráfico distintas, y ver cuándo cambia el ganador económico.


Recursos

  1. Brendan Gregg — Latency Heat Maps — visualizar latencia más allá de percentiles.
  2. The Tail at Scale (Dean, Barroso) — paper clásico sobre por qué P99 importa más que P50.
  3. OpenAI API status — verifica si proveedor estaba degradado durante tu benchmark.
  4. HDR Histogram — herramienta para distribuciones de latencia con resolución alta.