Ir al contenido principal
Cadena de agentes IA rota: timeout propagado

Cadena de agentes IA rota: timeout propagado

AI Integration
7 min readPor Daily Miranda Pardo

El agente C lleva 40 segundos sin responder. El agente B lleva 40 segundos esperando al agente C. El agente A lleva 40 segundos esperando al agente B. El usuario lleva 40 segundos mirando un spinner.

No hay logs de error. No hay alertas. La pipeline entera está congelada porque nadie le dijo al sistema cómo cancelar cuando una pieza se cuelga.

Este es el error de arquitectura más silencioso en sistemas de agentes IA: cada agente tiene su propio timeout, pero los timeouts no se propagan. Cuando el agente del extremo cuelga, el coste no es un timeout — es la suma de todos los timeouts en cadena.

El problema: timeouts que se acumulan, no que se cancelan

Imagina una pipeline típica: el usuario hace una petición, el agente orquestador (A) llama al agente de análisis (B), que llama al agente de redacción (C). Tres agentes, cada uno con un timeout de 30 segundos.

Si C se cuelga — porque la API del LLM está saturada, porque el input es inusualmente largo, porque hay un bug en el prompt — ¿qué ocurre?

  • C espera hasta su timeout propio: 30 segundos
  • B espera que C responda. No sabe que C está colgado. Espera 30 segundos más
  • A espera que B responda. No sabe nada de C. Espera 30 segundos más

Resultado: el usuario espera 90 segundos antes de ver un error. Durante esos 90 segundos, los tres agentes están consumiendo conexiones abiertas, memoria y créditos de API en espera activa. Si tienes diez usuarios simultáneos en este estado, multiplica por diez.

El problema no está en los timeouts individuales. Están en que nadie propaga la señal de cancelación a través de la cadena.

Por qué el patrón estándar no es suficiente

El instinto natural al construir una cadena de agentes es algo así:

async function runChain(input: string): Promise<string> {
  const step1 = await agentA(input)       // timeout: 30s
  const step2 = await agentB(step1)       // timeout: 30s
  const step3 = await agentC(step2)       // timeout: 30s
  return step3
}

Cada agente tiene su propio try/catch y su propio setTimeout. Parece correcto. El problema: si C lanza un error después de 30 segundos, B ya lleva 30 segundos bloqueado esperando que C resuelva. Cuando B recibe el error de C, lanza el suyo propio y A repite el proceso.

Lo que queremos es lo contrario: si cualquier nodo de la cadena falla o es cancelado, todos los nodos anteriores y posteriores se cancelan inmediatamente. No acumulan. No esperan.

Patrón 1: AbortController propagado

La solución correcta usa AbortController y pasa la misma señal AbortSignal a todos los nodos de la cadena. Cuando una señal se aborta, todos los que escuchan esa señal se cancelan al instante.

// Cada agente acepta una AbortSignal y la respeta
async function agentC(input: string, signal: AbortSignal): Promise<string> {
  const response = await fetch('https://api.anthropic.com/v1/messages', {
    method: 'POST',
    signal,  // la señal llega hasta el fetch
    body: JSON.stringify({ /* ... */ }),
    headers: { /* ... */ },
  })
  if (!response.ok) throw new Error(`LLM error: ${response.status}`)
  return await response.json()
}

// La cadena comparte la misma señal
async function runChain(input: string, signal: AbortSignal): Promise<string> {
  const step1 = await agentA(input, signal)
  if (signal.aborted) throw new Error('Cancelled after step 1')

  const step2 = await agentB(step1, signal)
  if (signal.aborted) throw new Error('Cancelled after step 2')

  return await agentC(step2, signal)
}

// El orquestador crea el AbortController con un timeout global
const controller = new AbortController()
const globalTimeout = setTimeout(() => controller.abort('global-timeout'), 25_000)

try {
  const result = await runChain(userInput, controller.signal)
  clearTimeout(globalTimeout)
  return result
} catch (err) {
  if (controller.signal.aborted) {
    // Cancelación limpia: todos los agentes ya pararon
  }
  throw err
}

Con este patrón, si el timeout de 25 segundos se dispara, el abort() llega instantáneamente a todos los fetch() activos en la cadena. No esperan. No acumulan.

Patrón 2: timeout budget que se decrementa

El AbortController compartido resuelve el problema del timeout global. Pero hay un segundo problema más sutil: cada agente necesita saber cuánto tiempo le queda del presupuesto total, no solo si ha sido cancelado.

Si el agente A decide "tengo 25 segundos, le paso la señal a B", B no sabe que ya pasaron 5 segundos en A. B asume que tiene todo el tiempo del mundo. Cuando B pasa la señal a C, el presupuesto real restante ya es mucho menor.

La solución es un helper que crea señales encadenadas con timeouts decrecientes:

function withBudget(parentSignal: AbortSignal, budgetMs: number): AbortSignal {
  const controller = new AbortController()
  
  // Se cancela si el padre se cancela
  parentSignal.addEventListener('abort', () => {
    controller.abort(parentSignal.reason)
  }, { once: true })
  
  // O si se agota el presupuesto propio
  const timer = setTimeout(() => {
    controller.abort(`budget-exceeded-${budgetMs}ms`)
  }, budgetMs)
  
  // Limpia el timer si el padre cancela primero
  controller.signal.addEventListener('abort', () => clearTimeout(timer), { once: true })
  
  return controller.signal
}

// Uso en la cadena: presupuesto total 20s, A usa 5s, B usa 8s, C usa el resto
async function runChain(input: string, parentSignal: AbortSignal) {
  const start = Date.now()

  const signalA = withBudget(parentSignal, 5_000)
  const step1 = await agentA(input, signalA)

  const elapsed = Date.now() - start
  const remaining = Math.max(0, 20_000 - elapsed)

  const signalB = withBudget(parentSignal, Math.min(8_000, remaining))
  const step2 = await agentB(step1, signalB)

  const remainingFinal = Math.max(0, 20_000 - (Date.now() - start))
  const signalC = withBudget(parentSignal, remainingFinal)
  return await agentC(step2, signalC)
}

Este patrón garantiza que la suma de los timeouts parciales nunca supera el presupuesto total. Si A tarda más de lo esperado, B y C tienen menos tiempo, y el sistema falla rápido en lugar de acumular latencia.

Detectar desconexión del cliente en Next.js

Hay un tercer escenario que la mayoría ignora: el usuario cierra la pestaña o la red cae mientras la cadena está ejecutándose. El cliente HTTP ya desconectó, pero los agentes siguen corriendo y gastando tokens.

En Next.js App Router, la request del API route expone su propia señal:

// app/api/agent/route.ts
export async function POST(request: Request) {
  const body = await request.json()
  
  // Esta señal se aborta automáticamente si el cliente desconecta
  const { signal } = request
  
  try {
    const result = await runChain(body.input, signal)
    return Response.json({ result })
  } catch (err) {
    if (signal.aborted) {
      // El cliente se fue. No hace falta devolver nada.
      return new Response(null, { status: 499 })
    }
    return Response.json({ error: 'Pipeline failed' }, { status: 500 })
  }
}

Pasando request.signal directamente a la cadena, cualquier desconexión del cliente cancela toda la pipeline instantáneamente. No se gastan más tokens. No se acumulan conexiones abiertas.

El resultado en producción

La diferencia entre una cadena de agentes sin y con propagación de timeout es visible en métricas:

  • Latencia de error: pasa de "suma de todos los timeouts" a "primer timeout que se dispara"
  • Consumo de tokens en fallos: cae cerca de cero para peticiones que el cliente ya no espera
  • Conexiones abiertas en picos de carga: se reducen drásticamente porque los agentes liberan recursos al cancelarse

No es una optimización prematura. En cualquier implementación real de integración IA en producción que use pipelines con más de un agente, este patrón es la diferencia entre un sistema que escala y uno que se cuelga bajo carga real.

Conclusión

Los timeouts individuales por agente no protegen la pipeline completa. Solo propagan el error de forma acumulativa, multiplicando la latencia y el coste de cada fallo.

El patrón correcto tiene tres capas: AbortController compartido para cancelación inmediata, timeout budget decremental para garantizar el presupuesto total, y propagación de la señal del cliente HTTP para liberar recursos cuando el usuario ya no está.

Si estás construyendo un sistema de agentes en producción y quieres diseñar la arquitectura desde el principio sin estos problemas, cuéntame tu caso en WhatsApp. Revisamos la arquitectura y te digo exactamente dónde está el riesgo.

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.