Gestión de límites de solicitudes y reintentos en las API de LLM
A las 09:12 de un martes, nuestro flujo de procesamiento de resúmenes parecía funcionar bien: 96 procesos de trabajo activos, una latencia mediana del modelo de unos 2.8 segundos y ninguna tasa elevada de errores 5xx. Entonces, un cliente subió un lote acumulado de 38,000 solicitudes de soporte.
En cuatro minutos, el flujo de procesamiento estaba «caído» sin que ningún servidor lo estuviera. Estábamos alcanzando dos límites distintos a la vez:
requests_per_minute: demasiadas llamadas a la APItokens_per_minute: demasiado volumen de tokens de entrada y salida
La lógica ingenua de reintentos empeoró la situación. Cada proceso de trabajo recibía 429, esperaba un segundo y reintentaba al mismo tiempo que los demás. Eso generaba una oleada de reintentos, que producía más 429, lo que generaba otra oleada. El sistema había creado accidentalmente un ataque sincronizado de denegación de servicio contra su propia cuota.
Gestionar los límites de uso de los LLM no consiste solo en «añadir una espera exponencial». En producción, debes considerar los tokens, las colas, la concurrencia, los reintentos, la idempotencia, los proveedores alternativos y el comportamiento de la facturación como un único sistema.
Los dos límites que importan: RPM y TPM
La mayoría de las API de LLM aplican alguna combinación de:
- RPM: solicitudes por minuto
- TPM: tokens por minuto
- RPD / TPD: solicitudes o tokens por día
- Límites de solicitudes simultáneas: número máximo de llamadas en curso
- Límites específicos de cada modelo: límites más altos para modelos más pequeños y más bajos para modelos de vanguardia
Lo importante: Los límites de RPM y TPM se alcanzan de formas distintas.
Si envías muchos prompts pequeños, alcanzas primero el límite de RPM. Si envías menos prompts pero más grandes, alcanzas primero el límite de TPM.
Ejemplo:
Limit:
- 600 requests/minute
- 300,000 tokens/minute
Workload A:
- 1,000 short classification calls/minute
- 200 tokens each
- 200,000 tokens/minute total
Result:
- TPM is fine
- RPM fails
Workload B:
- 100 long document analysis calls/minute
- 5,000 tokens each
- 500,000 tokens/minute total
Result:
- RPM is fine
- TPM fails
Un problema frecuente: los desarrolladores limitan las solicitudes, pero no los tokens. Eso funciona en el entorno de pruebas, donde los prompts son cortos. Luego, los usuarios de producción pegan PDF, historiales de chat, registros o contratos legales, y el presupuesto de tokens se agota.
En la práctica, considero que cada llamada a un LLM reserva dos recursos:
cost = {
requests: 1,
tokens: estimated_prompt_tokens + max_output_tokens
}
Aunque el modelo devuelva menos tokens de salida que max_output_tokens, reservar el límite superior evita asignar más capacidad de la disponible.
Qué ocurre realmente cuando ignoras el límite de TPM
Supongamos que tienes:
TPM limit: 120,000
Average request: 4,000 input tokens + 1,000 max output tokens
Reserved cost: 5,000 tokens
Tu rendimiento máximo real no es «tantos procesos de trabajo como sea posible». Es:
120,000 / 5,000 = 24 requests per minute
Eso equivale, de media, a una solicitud cada 2.5 segundos.
Si ejecutas 100 procesos de trabajo, es posible que todas las solicitudes del primer lote se inicien correctamente si el proveedor permite ráfagas de tráfico. Después, la cuenta alcanza un límite infranqueable. Cada solicitud posterior recibe 429, y los reintentos se acumulan.
La solución no consiste solo en reducir el número de procesos de trabajo. Necesitas un planificador que tenga en cuenta las reservas de tokens.
Un limitador de uso mínimo que tiene en cuenta los tokens
Para un único proceso, un algoritmo de cubeta de tokens suele ser suficiente. La capacidad se repone con el tiempo y cada solicitud debe obtener tanto una unidad de capacidad para solicitudes como capacidad para tokens.
Aquí tienes un ejemplo simplificado en Python:
import asyncio
import time
from dataclasses import dataclass
@dataclass
class Bucket:
capacity: float
refill_per_second: float
tokens: float
updated_at: float
def refill(self):
now = time.monotonic()
elapsed = now - self.updated_at
self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_per_second)
self.updated_at = now
async def acquire(self, amount: float):
while True:
self.refill()
if self.tokens >= amount:
self.tokens -= amount
return
missing = amount - self.tokens
sleep_for = missing / self.refill_per_second
await asyncio.sleep(min(sleep_for, 1.0))
class LLMRateLimiter:
def __init__(self, rpm: int, tpm: int):
now = time.monotonic()
self.request_bucket = Bucket(
capacity=rpm,
refill_per_second=rpm / 60,
tokens=rpm,
updated_at=now,
)
self.token_bucket = Bucket(
capacity=tpm,
refill_per_second=tpm / 60,
tokens=tpm,
updated_at=now,
)
self.lock = asyncio.Lock()
async def acquire(self, estimated_tokens: int):
async with self.lock:
await self.request_bucket.acquire(1)
await self.token_bucket.acquire(estimated_tokens)
Uso:
estimated_tokens = prompt_tokens + max_output_tokens
await limiter.acquire(estimated_tokens)
response = await client.chat.completions.create(
model="gpt-5.5",
messages=messages,
max_tokens=max_output_tokens,
)
Esto es deliberadamente sencillo. En un sistema distribuido, usa Redis, bloqueos consultivos de Postgres o una cola con despacho centralizado. El principio importante es el mismo: limita tanto el número de solicitudes como el presupuesto de tokens antes de llamar a la API.
Reintentos: la espera progresiva es necesaria, pero no suficiente
Los reintentos son útiles para:
- Respuestas transitorias
429respuestas - Tiempos de espera de red agotados
- Errores temporales
500,502,503, o504errores - Sobrecarga del proveedor
- Conexiones restablecidas
Los reintentos son peligrosos para:
- Errores de validación
- Errores de longitud de contexto
- Fallos de autenticación
- Rechazos por políticas de seguridad
- Prompts defectuosos que fallan de forma determinista
- Llamadas a herramientas no idempotentes
La política de reintentos debe clasificar primero los errores.
| Tipo de error | ¿Reintentar? | Acción habitual |
|---|---|---|
429 rate_limit_exceeded | Sí | Aplicar una espera progresiva, respetar las cabeceras de reintento y reducir la concurrencia |
500/502/503/504 | Sí | Aplicar una espera progresiva con variación aleatoria y un número limitado de intentos |
| Tiempo de espera agotado antes de recibir el cuerpo de la respuesta | Quizá | Reintentar con una clave de idempotencia si se admite |
| Longitud de contexto excedida | No | Comprimir, dividir en fragmentos o enrutar a un modelo con mayor contexto |
| Clave de API no válida | No | Desactivar la clave y alertar al operador |
| Rechazo por contenido o seguridad | No | Devolver un fallo controlado o reformular la solicitud |
| El efecto secundario de la herramienta ya se ha ejecutado | No/Quizá | Requiere un diseño que garantice la idempotencia |
La forma básica de la espera progresiva:
import random
def retry_delay(attempt: int, base=0.5, cap=30.0):
exponential = min(cap, base * (2 ** attempt))
return random.uniform(0, exponential)
Ese random.uniform(0, exponential) es el componente de variación aleatoria. No lo omitas.
Sin variación aleatoria:
100 workers fail
100 workers sleep 1s
100 workers retry
100 workers fail
100 workers sleep 2s
100 workers retry
Con variación aleatoria:
100 workers fail
workers retry across a spread of 0-1s
then 0-2s
then 0-4s
El segundo patrón da margen al proveedor y a tu propia cola para recuperarse.
Respeta las cabeceras de reintento cuando estén presentes
Muchas API incluyen una indicación como:
Retry-After: 12
o cabeceras de restablecimiento específicas del proveedor. Si la API te indica una espera concreta, úsala como mínimo:
delay = max(header_retry_after_seconds, retry_delay(attempt))
await asyncio.sleep(delay)
En la práctica, también limito el tiempo total de reintento. Una solicitud de chat de cara al usuario no debería reintentarse silenciosamente durante dos minutos mientras el usuario mira un indicador de carga. Un trabajo por lotes en segundo plano puede esperar mucho más.
Ejemplo de política:
{
"interactive_chat": {
"max_attempts": 3,
"max_total_retry_seconds": 8
},
"batch_summarization": {
"max_attempts": 8,
"max_total_retry_seconds": 300
}
}Las colas son mejores que las avalanchas de procesos de trabajo
Si tienes más trabajo que cuota disponible, los procesos de trabajo no deberían llamar todos directamente a la API del LLM. Coloca una cola delante.
Una arquitectura práctica:
App/API
-> Job queue
-> Rate-aware dispatcher
-> Provider client
-> Result store
El despachador se encarga de:
- Contabilizar las RPM
- Contabilizar los TPM
- Seleccionar el modelo
- la política de reintentos
- el cambio a un proveedor alternativo
- los límites de concurrencia
- la prioridad de la cola
Esto hace visible la sobrecarga. En lugar de que miles de tareas fallen con 429, ves cómo aumenta el número de tareas en la cola y el tiempo de espera estimado.
En los sistemas basados en Redis, me gusta separar los trabajos «listos para ejecutarse» de los trabajos con «reintento diferido»:
llm:ready
llm:delayed
llm:dead_letter
Un trabajo fallido no vuelve inmediatamente a ready. Se le asigna una marca de tiempo para el reintento programado. Un pequeño planificador devuelve a la cola de trabajos listos aquellos cuyo momento de ejecución ya ha llegado.
{
"job_id": "sum_7f13",
"model": "claude-sonnet-4.6",
"estimated_tokens": 6200,
"attempt": 2,
"not_before": "2026-02-03T09:14:22Z"
}
Ese not_before evita las avalanchas de reintentos.
El control de concurrencia no es lo mismo que la limitación de tasa
Los límites de concurrencia restringen el trabajo en curso. Los límites de tasa restringen el trabajo por intervalo de tiempo.
Necesitas ambos.
Imagina que cada llamada tarda 20 segundos y tu límite de RPM es 60. Un limitador basado únicamente en RPM permite una solicitud por segundo. Después de 20 segundos, tienes unas 20 llamadas simultáneas. Si la latencia aumenta a 90 segundos, las mismas RPM generan 90 llamadas simultáneas. Eso puede superar los límites del proveedor o de los recursos locales.
Usa un semáforo para controlar la concurrencia:
provider_concurrency = asyncio.Semaphore(20)
async def call_model(job):
async with provider_concurrency:
await limiter.acquire(job.estimated_tokens)
return await send_request(job)
Un error habitual: adquirir el semáforo demasiado pronto hace que los procesos de trabajo permanezcan inactivos, ocupando plazas de concurrencia escasas mientras esperan tokens de autorización de tasa. Por lo general, primero debes obtener capacidad de tasa y después adquirir una plaza de concurrencia justo antes de la llamada de red. Si la espera del limitador puede ser larga, evita ocupar recursos de los procesos de trabajo durante esa espera.
Degradación controlada: dedica calidad donde importa
Cuando los límites de tasa aprietan, tienes otras opciones además de fallar.
Por ejemplo, un asistente de atención al cliente podría reducir sus prestaciones de esta forma:
| Nivel de presión | Estrategia | Impacto en el usuario |
|---|---|---|
| Normal | Usar el modelo configurado más potente | La mejor calidad |
| Presión leve sobre los TPM | Reducir max_tokens, recortar el contexto recuperado | Respuestas ligeramente más cortas |
| Presión moderada | Dirigir las tareas sencillas a un modelo más pequeño | Mínimo si la clasificación o el enrutamiento son sencillos |
| Presión alta | Poner en cola los trabajos no urgentes | Trabajo en segundo plano retrasado |
| Presión extrema | Devolver una respuesta almacenada en caché o basada en una plantilla | Menor calidad, pero con disponibilidad |
Ejemplos concretos:
- Dirige el etiquetado de tickets a Haiku 4.5, Qwen o una variante pequeña de Llama en lugar de usar un modelo de vanguardia.
- Usa Sonnet 4.6 para el razonamiento habitual y reserva Opus 4.8 o GPT-5.5 para los casos que requieran escalamiento.
- Traslada el análisis de contextos largos a Fable 5 o Gemini 3 solo cuando el prompt realmente necesite esa ventana de contexto.
- Para los trabajos por lotes, pausa los inquilinos de baja prioridad antes de afectar al tráfico interactivo.
La contrapartida es la complejidad. Cada ruta alternativa necesita pruebas, observabilidad y aceptación por parte del equipo de producto. En algunos ámbitos, una respuesta peor puede ser más perjudicial que una respuesta tardía.
Alternativas con varias claves y varios proveedores
El enrutamiento con varias claves y varios proveedores puede mejorar la resiliencia, pero es fácil implementarlo mal.
Hay tres patrones distintos:
| Patrón | Qué resuelve | Riesgo |
|---|---|---|
| Varias claves, mismo proveedor o cuenta | Aislamiento operativo | Puede infringir las condiciones del proveedor si se usa para eludir los límites |
| Varias cuentas o regiones | Aislamiento de fallos | Mayor complejidad de facturación y gobernanza |
| Varios proveedores o modelos | Disponibilidad y flexibilidad de costes | Variabilidad de las respuestas e incompatibilidad de los prompts |
No uses varias claves para eludir los límites a escondidas. Úsalas para una separación legítima: inquilinos, entornos, productos o ámbitos de facturación.
Una política de enrutamiento clara podría ser así:
{
"task": "support_ticket_summary",
"primary": {
"provider": "anthropic",
"model": "claude-sonnet-4.6"
},
"fallbacks": [
{
"provider": "openai",
"model": "gpt-5.5",
"when": ["provider_5xx", "rate_limit_after_retry_budget"]
},
{
"provider": "google",
"model": "gemini-3",
"when": ["context_length_needed", "primary_unavailable"]
}
],
"degrade_to": {
"provider": "open_model_cluster",
"model": "qwen",
"when": ["batch_low_priority"]
}
}
Si usas un agregador o una plataforma como AI Prime Tech para acceder a varios modelos, siguen aplicándose las mismas reglas de ingeniería: lleva un control explícito de la idempotencia, los presupuestos, las condiciones para recurrir a alternativas y las reglas de facturación final.
Evitar la doble facturación al recurrir a alternativas
Lo difícil de recurrir a una alternativa es saber si la primera solicitud se completó.
Considera esta secuencia:
Client sends request to Provider A
Provider A processes it successfully
Network connection drops before client receives response
Client retries with Provider B
Both providers bill
Two completions may exist
No puedes resolver esto por completo desde el lado del cliente a menos que el proveedor admita claves de idempotencia o la consulta de solicitudes. Pero puedes reducir los daños.
Usa claves de idempotencia cuando estén disponibles
Genera una clave estable por cada operación lógica:
import hashlib
import json
def idempotency_key(task_name, tenant_id, input_payload):
canonical = json.dumps(input_payload, sort_keys=True, separators=(",", ":"))
digest = hashlib.sha256(canonical.encode()).hexdigest()
return f"{tenant_id}:{task_name}:{digest}"
Guarda la operación antes de enviarla:
{
"operation_id": "acme:summarize:9d2a...",
"status": "in_flight",
"provider": "anthropic",
"model": "claude-sonnet-4.6",
"created_at": "2026-02-03T09:12:01Z"
}
Después, actualízala una vez completada:
{
"operation_id": "acme:summarize:9d2a...",
"status": "completed",
"provider_request_id": "req_abc123",
"usage": {
"input_tokens": 4180,
"output_tokens": 612
}
}
Antes de reintentar con otro proveedor, consulta la tabla de operaciones. Si otro proceso de trabajo ya la ha completado, devuelve el resultado almacenado.
Distingue los tipos de tiempo de espera agotado
No todos los tiempos de espera agotados son iguales:
- Tiempo de espera de conexión agotado: probablemente la solicitud nunca llegó al proveedor
- Tiempo de espera de lectura agotado antes de recibir las cabeceras: resultado incierto
- Tiempo de espera agotado durante la transmisión de tokens: casi con certeza el proveedor la procesó
- Cancelación del cliente: depende de si la cancelación se propagó
Para los casos inciertos, prefiero una política de reintentos conservadora:
interactive:
- retry connect timeout
- do not cross-provider retry after partial stream
- show recoverable error if status unknown
batch:
- retry after reconciliation delay
- prefer same provider with idempotency key
- fallback only after retry budget expires
Transmite con cuidado
La transmisión en streaming mejora la latencia percibida, pero complica los reintentos. Una vez que has enviado una respuesta parcial al usuario, reintentar con otro modelo puede producir una continuación diferente.
Para el chat en streaming, suelo considerar la «recepción del primer token» como el punto a partir del cual ya no se puede reintentar de forma transparente. A partir de ahí, los fallos se hacen visibles:
"The response was interrupted. Retry?"
Eso es menos elegante que una recuperación silenciosa, pero evita unir respuestas incompatibles de distintos modelos.
Observabilidad: las métricas que realmente ayudan
Necesitas algo más que la tasa de éxito.
Registra al menos:
llm_requests_total{provider,model,status}
llm_tokens_reserved_total{provider,model}
llm_tokens_used_total{provider,model}
llm_retries_total{provider,model,error_type}
llm_queue_depth{priority,task_type}
llm_queue_wait_seconds{priority,task_type}
llm_rate_limit_hits_total{provider,model,limit_type}
llm_fallbacks_total{from_provider,to_provider,reason}
llm_duplicate_suppressed_total{task_type}
En los incidentes reales, el panel más útil del cuadro de mando a menudo no es el de latencia. Es:
queue_wait_seconds p50 / p95
rate_limit_hits by provider and model
tokens_reserved vs tokens_used
fallback rate by reason
Si tokens_reserved es sistemáticamente mucho mayor que tokens_used, tus reservas son demasiado conservadoras. Si se acerca demasiado al uso real o queda por debajo, estás asignando más capacidad de la disponible y alcanzarás el límite de TPM inesperadamente.
Un envoltorio práctico para reintentos
Aquí tienes un patrón compacto para reintentar solo los fallos que lo permiten:
import asyncio
import random
import time
RETRYABLE_STATUS = {429, 500, 502, 503, 504}
class LLMError(Exception):
def __init__(self, status, message="", retry_after=None):
self.status = status
self.retry_after = retry_after
super().__init__(message)
def jittered_backoff(attempt, base=0.5, cap=20):
return random.uniform(0, min(cap, base * (2 ** attempt)))
async def call_with_retries(fn, max_attempts=4, max_elapsed=20):
started = time.monotonic()
for attempt in range(max_attempts):
try:
return await fn()
except LLMError as error:
elapsed = time.monotonic() - started
if error.status not in RETRYABLE_STATUS:
raise
if attempt == max_attempts - 1 or elapsed >= max_elapsed:
raise
delay = jittered_backoff(attempt)
if error.retry_after is not None:
delay = max(delay, error.retry_after)
remaining = max_elapsed - elapsed
await asyncio.sleep(min(delay, remaining))
El envoltorio es deliberadamente sencillo. La sofisticación debe estar en la clasificación, las colas, la idempotencia y el enrutamiento, no en un bucle mágico de reintentos que lo intente indefinidamente.
Conclusiones prácticas
- Modela los límites de tasa tanto en RPM como en TPM. La regulación del tráfico basada en tokens evita sorpresas con los prompts grandes.
- Usa retroceso exponencial con variación aleatoria, y respeta las cabeceras de reintento cuando el proveedor las proporcione.
- Coloca una cola y un despachador entre tu aplicación y los proveedores de LLM para cargas de trabajo por lotes o de gran volumen.
- Control la concurrencia por separado de los límites de tasa; de lo contrario, los picos de latencia pueden generar demasiadas llamadas en curso.
- Diseño degradación controlada de forma deliberada: modelos más pequeños, un contexto más corto, trabajo en segundo plano en cola o respuestas en caché.
- Trata conmutación a proveedores alternativos como una función de fiabilidad, no como una licencia para enviar solicitudes duplicadas indiscriminadamente.
- Usa claves de idempotencia y registros de operaciones para reducir la doble facturación y suprimir las respuestas generadas duplicadas.
- Sé transparente con los usuarios cuando una respuesta en streaming falle después de haber emitido tokens.
- Crea paneles centrados en la espera en cola, la reserva de tokens, los eventos de límite de tasa, los reintentos y los motivos de conmutación a alternativas.
- Mantén sencilla la lógica de reintentos; el verdadero trabajo en producción consiste en asignar presupuestos, programar la ejecución y saber cuándo no reintentar.
Una sola clave de API para Claude Opus 5.5, Sonnet 5, Haiku 4.5 y Fable 5.1, además de GPT-6 modelos. Pago por uso, sin suscripción.
Obtén tu clave de API →