La factura se triplicó sin una sola petición nueva
La forma del incidente es siempre parecida. Un proceso nocturno clasifica 20.000 documentos. A las tres de la mañana el proveedor empieza a devolver 529 durante veinte minutos. El cliente oficial trae max_retries=5 de fábrica y nadie lo tocó. Por la mañana los documentos están clasificados, el proceso terminó en verde y la factura del día es tres veces la de ayer.
No hubo un fallo. Hubo una política de reintentos copiada de un mundo donde reintentar no cuesta nada.
Lo que cambia cuando al otro lado hay un modelo
Las heurísticas que usamos con APIs REST —backoff exponencial, cinco intentos, un timeout generoso— se apoyan en dos supuestos que dejan de ser ciertos con un LLM: que un intento fallido es gratis y que la respuesta es la misma cada vez.
| Petición a una API REST | Llamada a un LLM | |
|---|---|---|
| Coste de un intento fallido | despreciable | los tokens de entrada, siempre; los de salida, si llegó a generar |
| Latencia típica | 50–300 ms | 2–60 s |
| Timeout útil | uno, total | dos: primer token y duración completa |
| Reintento idéntico | misma respuesta | respuesta distinta |
| Señal de sobrecarga | 429, 503 | 429, 503, 529 y flujos que se cortan a mitad |
La fila del coste es la que rompe el modelo mental. En una API, el peor caso de una tormenta de reintentos es saturar al proveedor. Aquí el peor caso es saturarlo y pagar por ello: un prompt de 30.000 tokens reintentado cinco veces son 150.000 tokens facturados para obtener una respuesta.
La fila del timeout es la que rompe el código. Un único timeout=60 mezcla dos fallos distintos: el proveedor que no contesta y el proveedor que contesta despacio porque la salida es larga. El primero merece un corte agresivo. El segundo no merece ninguno.
Dos relojes, no uno
Con streaming la distinción es sencilla de implementar y cambia bastante el comportamiento. Un reloj para el primer token, que mide si el proveedor está vivo. Otro para el total, que mide si la generación se ha ido de las manos.
import asyncio, time
async def llamar(cliente, mensajes, *, ttft=8.0, total=180.0):
"""Dos relojes independientes sobre el mismo flujo.
ttft: si el primer token no llega en 8 s, el proveedor está
saturado o la ruta está rota. Cortar pronto y liberar el hueco.
total: tope de seguridad contra bucles de generación. Es alto a
propósito: una respuesta larga y sana tarda, y cortarla obliga a
repetir el prompt entero, que es justo lo que queremos evitar.
"""
inicio = time.monotonic()
flujo = await cliente.stream(mensajes)
trozos, primer_token = [], None
while True:
restante = (ttft if primer_token is None else total) - (time.monotonic() - inicio)
if restante <= 0:
await flujo.aclose()
raise TimeoutError("ttft" if primer_token is None else "total")
try:
trozo = await asyncio.wait_for(flujo.__anext__(), timeout=restante)
except StopAsyncIteration:
break
primer_token = primer_token or time.monotonic()
trozos.append(trozo)
# Devolver lo generado aunque el corte llegue por 'total': un
# parcial largo suele ser recuperable; repetir la llamada no.
return "".join(trozos)
El detalle que más tarda en verse es el último comentario. Cuando cortas por duración total ya has pagado la entrada y casi toda la salida. Tirar ese parcial y reintentar duplica el gasto para obtener, con suerte, lo mismo. Guárdalo, valídalo y decide después.
No todos los errores son reintentables
La segunda mitad del problema es que muchos clientes reintentan por código HTTP, y el código HTTP no distingue entre un problema del proveedor y un problema tuyo.
| Error | ¿Reintentar? | Por qué |
|---|---|---|
429 con retry-after | sí, esperando lo que dice la cabecera | el proveedor te está diciendo cuándo |
| 429 sin cabecera | sí, con backoff y jitter | cuota compartida, tormenta probable |
| 500, 503, 529 | sí, límite bajo | fallo transitorio del lado de allá |
| Timeout de primer token | sí | nada se ha generado, el coste es cero |
| Timeout total | no automático | ya lo has pagado casi entero |
| 400 por contexto excedido | no | reintentar da el mismo error; hay que recortar |
| 401, 403 | no | credencial o permiso, no carga |
| Filtro de contenido | no | determinista para esa entrada |
La línea que más dinero ahorra es la penúltima. Un context_length_exceeded reintentado cinco veces son cinco prompts enormes facturados para obtener cinco veces el mismo error. Ese caso no se resuelve insistiendo, se resuelve recortando el contexto y volviendo a llamar una vez.
Presupuesto de reintentos, no política de reintentos
La idea que vale la pena robarle al libro de SRE de Google, en su capítulo sobre fallos en cascada, es dejar de razonar por petición y empezar a razonar por sistema. Un límite por petición no impide nada: si el proveedor cae, las 20.000 peticiones agotan sus cinco intentos a la vez.
Un presupuesto sí. Se permite reintentar mientras los reintentos no superen un porcentaje del tráfico total. Un 10 % es un punto de partida razonable: es el valor por defecto del presupuesto de reintentos de gRPC. Pasado ese umbral, los fallos se propagan en lugar de amplificarse.
class PresupuestoDeReintentos:
"""Cubo con fugas. Cada llamada aporta saldo; cada reintento gasta.
ratio=0.1 permite un reintento por cada diez llamadas. En régimen
normal sobra de largo. En una caída del proveedor el saldo se agota
en segundos y el sistema deja de insistir, que es lo correcto:
cuando falla todo, reintentar solo añade gasto y latencia.
"""
def __init__(self, ratio=0.1, maximo=100):
self.ratio, self.maximo, self.saldo = ratio, maximo, maximo
def registrar_llamada(self):
self.saldo = min(self.maximo, self.saldo + self.ratio)
def puede_reintentar(self) -> bool:
if self.saldo < 1:
return False
self.saldo -= 1
return True
Con esto, un incidente del proveedor deja de multiplicar tu factura: los primeros fallos se reintentan, el resto se propaga en unos segundos y el proceso se detiene con un error honesto en vez de terminar en verde tres veces más caro.
Degradar antes que insistir
Si la llamada importa de verdad, el siguiente paso no es un sexto intento contra el mismo modelo. Es cambiar de sitio.
- Modelo más pequeño del mismo proveedor. Suele tener otra cuota y otra cola. La calidad baja; la respuesta existe.
- Otro proveedor con el mismo prompt. Merece la pena solo si ya lo has evaluado; un fallback sin evaluar es una regresión silenciosa esperando su turno.
- Respuesta degradada sin modelo. Para clasificación y enrutado, una heurística vieja y una etiqueta de baja confianza valen más que un 503.
Y por encima de todo, un cortacircuitos: tras N fallos consecutivos contra un proveedor, deja de llamarlo durante un minuto. No arregla la caída, pero evita que cada petición nueva pague su propio timeout antes de rendirse.
Por dónde empezar
Tres cosas, hoy, en este orden.
Primero, busca max_retries en tu código. Si no aparece, estás usando el valor por defecto del cliente, que casi nunca es el que quieres. Ponlo a dos y sube solo con datos.
Segundo, separa los dos timeouts. Es el cambio con mejor relación entre esfuerzo y efecto de esta lista.
Tercero, mide lo que hoy no mides: tokens gastados en intentos que no devolvieron nada. Es una métrica incómoda porque nadie la tiene y porque, la primera vez que la miras, nunca es cero.