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étrica | Qué 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:
- Aceptar el argumento
--samples Npor CLI (en vez de hardcodear 50) - Aceptar
--prompts archivo.txtpara cargar prompts de un archivo (uno por línea) - 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
- Brendan Gregg — Latency Heat Maps — visualizar latencia más allá de percentiles.
- The Tail at Scale (Dean, Barroso) — paper clásico sobre por qué P99 importa más que P50.
- OpenAI API status — verifica si proveedor estaba degradado durante tu benchmark.
- HDR Histogram — herramienta para distribuciones de latencia con resolución alta.