Cadena de agentes IA rota: timeout propagado
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.