Ir al contenido principal
Concurrencia en agentes IA: 429, colas y semáforos

Concurrencia en agentes IA: 429, colas y semáforos

AI Integration
6 min readPor Daily Miranda Pardo

Tu pipeline procesa facturas. En staging funciona: diez llamadas al agente, todas responden, los datos llegan a Supabase. Lo despliegas para el cliente de turno. Llega el primer cierre mensual: 200 facturas en cola. Lanzas el proceso. Dos minutos después, el 70% de las llamadas devuelven 429 Too Many Requests. El agente no está roto. Tu arquitectura de concurrencia, sí.

Este error aparece en casi todos los proyectos que escalan de "funciona en local" a "funciona con carga real". Y la causa siempre es la misma: lanzar todas las llamadas al LLM al mismo tiempo, sin ningún control.

El error que todo el mundo comete primero

La versión ingenua es tentadora porque parece eficiente:

// ❌ Esto revienta en producción con más de 20 tareas simultáneas
const results = await Promise.all(
  invoices.map(invoice => callAgent(invoice))
);

En local, con 10 facturas de prueba, funciona perfecto. En producción, con 200 facturas reales llegando en el mismo instante, mandas 200 peticiones simultáneas a la API del LLM. Las primeras 20 o 30 entran. Las demás reciben 429. Las que fallan, si tienes algún retry, reintentan al mismo tiempo. El pico se repite. El sistema entra en un bucle de reintentos que empeora la situación.

Esto no es un problema del modelo. No es un problema de tu código de negocio. Es un problema de flujo de concurrencia sin control.

Los rate limits de Anthropic se miden en dos dimensiones: peticiones por minuto (RPM) y tokens por minuto (TPM). Promise.all viola ambas a la vez.

Patrón 1: Semáforo de concurrencia

Un semáforo limita cuántas operaciones pueden ejecutarse en paralelo al mismo tiempo. Las que superan el límite esperan en cola. Cuando una termina, libera su slot y la siguiente entra.

class Semaphore {
  private queue: Array<() => void> = [];
  private running = 0;

  constructor(private readonly limit: number) {}

  async acquire(): Promise<void> {
    if (this.running < this.limit) {
      this.running++;
      return;
    }
    await new Promise<void>(resolve => this.queue.push(resolve));
    this.running++;
  }

  release(): void {
    this.running--;
    const next = this.queue.shift();
    if (next) next();
  }
}

async function withSemaphore<T>(
  tasks: Array<() => Promise<T>>,
  limit: number
): Promise<T[]> {
  const sem = new Semaphore(limit);
  return Promise.all(
    tasks.map(async task => {
      await sem.acquire();
      try {
        return await task();
      } finally {
        sem.release();
      }
    })
  );
}

// ✅ Máximo 5 llamadas al agente en paralelo
const results = await withSemaphore(
  invoices.map(inv => () => callAgent(inv)),
  5
);

Con limit: 5, en lugar de mandar 200 llamadas de golpe, mantienes siempre exactamente 5 activas. Cuando una termina, entra la siguiente. El tiempo total es mayor, pero el pipeline no explota.

El valor correcto del límite depende del tier de tu API. Con el tier estándar de Anthropic, entre 3 y 8 llamadas paralelas suele ser el rango seguro. Con tiers más altos, puedes subir a 20-30.

Patrón 2: Retry con backoff exponencial y jitter

El semáforo resuelve el problema estructural, pero los 429 transitorios siguen apareciendo: picos de tráfico, momentos donde la API está bajo presión, ventanas donde tu aplicación no es la única consumiendo cuota. Necesitas una capa de retry que no genere una tormenta de reintentos.

El error clásico es el retry ingenuo: esperar un segundo fijo y reintentar. Si 10 llamadas fallan al mismo tiempo y todas esperan exactamente 1 segundo, vuelven a atacar la API al mismo tiempo. El 429 se repite.

La solución: backoff exponencial con jitter (ruido aleatorio).

async function withRetry<T>(
  fn: () => Promise<T>,
  maxAttempts = 4,
  baseDelayMs = 1000
): Promise<T> {
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (err: any) {
      const isRateLimit = err?.status === 429 || err?.error?.type === 'rate_limit_error';
      if (!isRateLimit || attempt === maxAttempts - 1) throw err;

      // Backoff exponencial: 1s, 2s, 4s, 8s... más jitter aleatorio
      const jitter = Math.random() * 500;
      const delay = baseDelayMs * Math.pow(2, attempt) + jitter;
      await new Promise(r => setTimeout(r, delay));
    }
  }
  throw new Error('Máximo de reintentos alcanzado');
}

El jitter hace que cada reintento tenga un delay ligeramente diferente al del resto. Las llamadas que fallaron al mismo tiempo ya no reintentan al mismo tiempo. La tormenta de reintentos desaparece.

Combina ambos patrones para tener el control completo:

const results = await withSemaphore(
  invoices.map(inv => () => withRetry(() => callAgent(inv))),
  5
);

Patrón 3: Cola con prioridad

En producción, no todas las tareas son iguales. Un usuario esperando respuesta en tiempo real no debería competir por los mismos slots que un proceso batch de 500 documentos que puede ejecutarse en segundo plano.

La cola de prioridad resuelve exactamente esto: las tareas urgentes se procesan antes, independientemente del orden de llegada.

type Priority = 'high' | 'normal' | 'low';

interface Task<T> {
  fn: () => Promise<T>;
  priority: Priority;
  resolve: (value: T) => void;
  reject: (err: unknown) => void;
}

class PriorityQueue<T> {
  private queues: Record<Priority, Task<T>[]> = {
    high: [],
    normal: [],
    low: []
  };

  enqueue(task: Task<T>): void {
    this.queues[task.priority].push(task);
  }

  dequeue(): Task<T> | undefined {
    return (
      this.queues.high.shift() ??
      this.queues.normal.shift() ??
      this.queues.low.shift()
    );
  }
}

Una petición de usuario real se encola con priority: 'high'. El proceso de batch nocturno usa priority: 'low'. El agente siempre atiende primero lo urgente.

Los errores de producción que estos patrones evitan

El 429 en cascada: sin semáforo, un pico de carga manda 100 llamadas simultáneas, todas fallan, todas reintentan al mismo tiempo, el pico se repite en bucle. El sistema queda inoperable durante minutos.

Las "roturas" falsas del agente: cuando un 429 sin manejo adecuado llega como error genérico, parece que el agente está fallando en su lógica. Horas de debugging para encontrar que la causa es un rate limit, no el modelo.

El batch que bloquea al usuario: sin prioridad, el proceso que reconcilia 200 facturas del mes pasado ocupa todos los slots mientras un usuario espera que el agente le responda en tiempo real.

Los tres patrones trabajan en capas: el semáforo controla el volumen, el retry con jitter absorbe los picos transitorios, y la cola de prioridad garantiza que lo urgente siempre pasa primero.

Si estás construyendo sistemas de integración de agentes IA o pipelines de automatización que necesiten escalar, estos tres patrones son la diferencia entre un pipeline que funciona en demo y uno que aguanta producción real con carga variable.


¿Tienes un pipeline de agentes que falla bajo carga o con picos de tráfico? Cuéntame el escenario y te digo exactamente qué está pasando.

Háblame del proyecto en WhatsApp →

Compartir artículo

LinkedInXWhatsApp

¿Procesos repetitivos en tu empresa?

Descarga gratis el Mapa de Automatización IA — los 5 procesos que más tiempo roban y cómo resolverlos.

Sin spam. Solo el PDF. Puedes darte de baja cuando quieras.

Escrito por Daily Miranda Pardo

Ayudo a empresas a automatizar procesos, crear agentes IA y conectar sistemas inteligentes.