volver al blog

Tu política de reintentos está pensada para APIs, no para LLM

ia-generativallmarquitecturacostes

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 RESTLlamada a un LLM
Coste de un intento fallidodespreciablelos tokens de entrada, siempre; los de salida, si llegó a generar
Latencia típica50–300 ms2–60 s
Timeout útiluno, totaldos: primer token y duración completa
Reintento idénticomisma respuestarespuesta distinta
Señal de sobrecarga429, 503429, 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-aftersí, esperando lo que dice la cabecerael proveedor te está diciendo cuándo
429 sin cabecerasí, con backoff y jittercuota compartida, tormenta probable
500, 503, 529sí, límite bajofallo transitorio del lado de allá
Timeout de primer tokensínada se ha generado, el coste es cero
Timeout totalno automáticoya lo has pagado casi entero
400 por contexto excedidonoreintentar da el mismo error; hay que recortar
401, 403nocredencial o permiso, no carga
Filtro de contenidonodeterminista 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.