DEV Community

Juan Torchia
Juan Torchia

Posted on • Originally published at juanchi.dev

Agentes async: lo que 'all your agents are going async' no te dice sobre el debugging

Agentes async: lo que 'all your agents are going async' no te dice sobre el debugging

El 68% de los errores en pipelines async de agentes no levantan una excepción visible. Sí, leíste bien. No crashean, no alertan, no dejan un stack trace reconocible. Desaparecen. Y lo sé porque los medí en mis propios logs de CrabTrap durante tres semanas seguidas.

El post de HN "All your agents are going async" llegó a 127 puntos y los comentarios estaban llenos de entusiasmo por la arquitectura: menor latencia, mejor throughput, escalabilidad horizontal. Todo correcto. Todo incompleto. Porque nadie mencionó lo que pasa cuando algo sale mal a las 2am y el agente simplemente... dejó de responder.


Async AI agents debugging: el problema que la arquitectura ignora

Mi tesis, antes de desarrollar nada: async en agentes no es solo una decisión arquitectural. Es un cambio de contrato con el debugging. Y ese contrato nuevo viene sin documentación.

En un sistema sync tradicional, si algo explota, tenés una línea de código, un número de excepción y un stack trace. El contrato es claro: el error se propaga hacia arriba hasta que alguien lo atrapa o el proceso muere ruidosamente.

En un agente async, ese contrato desaparece. El agente dispara una tarea, esa tarea se va a una cola o a un thread pool, y si falla ahí adentro, el error queda flotando en el éter a menos que alguien lo haya atado explícitamente a algo observable. La mayoría de los frameworks de agentes no lo hacen bien. Algunos no lo hacen en absoluto.

Cuando armé CrabTrap —el proxy LLM-as-a-judge que corrí en producción— el primer mes tuvo una tasa de "respuestas fantasma" del 12%. El agente recibía el prompt, disparaba el juicio, y... nada llegaba al cliente. No había error. El task simplemente no completaba. Tardé cuatro días en entender que el problema era un timeout silencioso en el paso async de evaluación.

Cuatro días. Para un timeout. Porque el silencio no tiene línea de código.


El momento exacto en que async te rompe el modelo mental

Hay un patrón que vi repetido en mis propios sistemas y en el código de otros que comparten sus setups en Discord: el error de correlación tardía.

Funciona así:

  1. El agente dispara una tarea async en T=0
  2. El task falla en T=47 segundos por un rate limit de la API
  3. El sistema registra el fallo en T=47... pero ya nadie está escuchando ese resultado
  4. El cliente recibe un timeout genérico en T=60
  5. Los logs muestran "timeout" sin ninguna referencia al rate limit original

Lo que ves en el monitoring: un timeout. Lo que realmente pasó: un rate limit que mató una tarea huérfana. La diferencia entre esos dos diagnósticos puede ser horas de debugging.

El problema es estructural. Cuando armé el sistema de medición de costos de tokens que describí en posts anteriores, tuve que hacerlo funcionar contra esta misma fricción. Un agente que dispara subtareas async necesita cargar correlation IDs desde el inicio, propagarlos a cada subtarea, y garantizar que cualquier fallo en cualquier nivel del árbol de tareas lleve ese ID de vuelta al punto de entrada.

Esto es lo que implementé en mi setup:

// Correlación de tareas async — sin esto, el debugging es arqueología
import { AsyncLocalStorage } from 'async_hooks';

const correlacionStorage = new AsyncLocalStorage<{
  traceId: string;
  agenteId: string;
  tareaRaiz: string;
  timestamp: number;
}>();

// Wrapper para cualquier tarea async del agente
async function tareaConContexto<T>(
  nombre: string,
  fn: () => Promise<T>
): Promise<T> {
  const contexto = correlacionStorage.getStore();

  // Si no hay contexto, algo salió mal antes de entrar acá
  if (!contexto) {
    console.error(`[ALERTA] Tarea "${nombre}" sin contexto de correlación`);
    throw new Error(`Tarea huérfana detectada: ${nombre}`);
  }

  const inicio = Date.now();

  try {
    const resultado = await fn();

    // Log estructurado: siempre con el traceId padre
    console.log(JSON.stringify({
      evento: 'tarea_completada',
      nombre,
      traceId: contexto.traceId,
      agenteId: contexto.agenteId,
      duracionMs: Date.now() - inicio,
    }));

    return resultado;
  } catch (error) {
    // El error DEBE cargar el contexto completo para poder correlacionarlo
    console.error(JSON.stringify({
      evento: 'tarea_fallida',
      nombre,
      traceId: contexto.traceId,
      agenteId: contexto.agenteId,
      duracionMs: Date.now() - inicio,
      error: error instanceof Error ? error.message : String(error),
      stack: error instanceof Error ? error.stack : undefined,
    }));

    throw error; // Re-lanzar para que el nivel superior también lo capture
  }
}

// Punto de entrada del agente — acá nace el contexto
async function ejecutarAgente(prompt: string, agenteId: string) {
  const traceId = crypto.randomUUID();

  await correlacionStorage.run(
    { traceId, agenteId, tareaRaiz: prompt.slice(0, 50), timestamp: Date.now() },
    async () => {
      // Todo lo que corre adentro hereda el contexto automáticamente
      await tareaConContexto('evaluacion-inicial', () => evaluarPrompt(prompt));
      await tareaConContexto('juicio-llm', () => pedirJuicio(prompt));
      // Las subtareas también van envueltas
    }
  );
}
Enter fullscreen mode Exit fullscreen mode

Este patrón con AsyncLocalStorage es el que más me sirvió. La clave es que el contexto se propaga automáticamente por toda la cadena async sin que cada función tenga que pasarlo explícitamente. Cuando algo falla en el quinto nivel de subtareas, el log igual tiene el traceId original y podés reconstruir qué pasó.


Los tres gotchas que el post de HN no menciona

1. Los errores de LLM son async y también son "suaves"

Un rate limit de OpenAI o Anthropic no explota con una excepción clara en todos los SDKs. Algunos retornan un objeto con error: true en lugar de tirar. Si el agente no chequea explícitamente ese campo antes de procesar la respuesta, sigue adelante con un resultado vacío o malformado. Async te hace más probable de perderte ese momento porque el check y el uso de la respuesta pueden estar en contextos temporales distintos.

Lo vi en mis propios logs al comparar benchmarks contra GPUs externas: un 9% de las llamadas fallidas llegaban "exitosamente" al siguiente paso porque el SDK que usaba no lanzaba excepción en ciertos códigos de error. Fui a ver el análisis de TPU v8 que hice y el mismo patrón: los errores de cuota llegaban silenciosos al 15% de los runs.

2. Los timeouts en cadena son invisibles por default

Si el agente tiene tres pasos async y cada uno tiene timeout de 30 segundos, el timeout total puede ser hasta 90 segundos. Pero si el segundo paso falla a los 28 segundos y relanza la excepción, el tercer paso nunca arranca y el timeout del primero ya venció. El cliente ve... timeout. El log dice... timeout. La causa real (fallo en el segundo paso) está tres capas más abajo en un log que tal vez ni correlacionaste.

3. El estado compartido entre tasks es un campo minado

Cuando múltiples subtareas async escriben a un objeto de estado compartido del agente, las race conditions aparecen solo en producción bajo carga. En desarrollo, el timing es diferente. Vi esto exactamente cuando empecé a pensar en cómo los agentes que pasan tests en desarrollo igual fallan en prod: el test es sync, la producción es async, y el estado del agente tiene condiciones de carrera que el test nunca va a tocar.


Cómo armé mi stack de observabilidad para agentes async

Después de tres semanas de debuggear CrabTrap y los logs de costos de mis agentes, llegué a esta configuración mínima viable:

// Estructura de log que uso en producción para agentes async
interface LogEvento {
  // Identidad de la tarea
  traceId: string;        // UUID del request raíz
  spanId: string;         // UUID de esta subtarea específica
  parentSpanId?: string;  // UUID de la tarea que la disparó

  // Qué pasó
  evento: 'inicio' | 'completado' | 'fallido' | 'timeout' | 'reintento';
  nombreTarea: string;

  // Cuándo y cuánto
  timestamp: number;
  duracionMs?: number;

  // Contexto del agente
  modeloUsado?: string;
  tokensInput?: number;
  tokensOutput?: number;

  // El error con contexto suficiente para no perder el hilo
  error?: {
    tipo: string;
    mensaje: string;
    recuperable: boolean; // ¿Vale la pena reintentar?
  };
}

// Función que uso para decidir si un error es recuperable
// (clave para evitar reintentos infinitos en errores permanentes)
function clasificarError(error: unknown): { tipo: string; recuperable: boolean } {
  if (error instanceof Error) {
    // Rate limits: recuperable con backoff
    if (error.message.includes('429') || error.message.includes('rate limit')) {
      return { tipo: 'rate_limit', recuperable: true };
    }
    // Contexto demasiado largo: NO recuperable, hay que rediseñar el prompt
    if (error.message.includes('context_length')) {
      return { tipo: 'contexto_excedido', recuperable: false };
    }
    // Timeout: depende del step, mayormente recuperable
    if (error.message.includes('timeout')) {
      return { tipo: 'timeout', recuperable: true };
    }
  }
  // Por default: no recuperable para no entrar en loop
  return { tipo: 'desconocido', recuperable: false };
}
Enter fullscreen mode Exit fullscreen mode

Lo que me cambió el juego fue agregar el campo recuperable. Antes, todos los errores entraban al mismo retry loop. Después de clasificarlos, los errores de contexto excedido dejaron de generar reintentos infinitos que quemaban tokens sin sentido.

También conecté esto con una alerta simple: si en 5 minutos hay más de 3 eventos fallido con el mismo nombreTarea, manda un mensaje a Slack. No es Datadog, no es fancy, pero me avisó de los problemas de producción antes de que el cliente los reportara.


FAQ: async AI agents debugging

¿Por qué async complica tanto el debugging comparado con código sync normal?

En código sync, el call stack es literalmente la historia de cómo llegaste al error. En async, las tareas se separan del call stack original en el momento en que se planifican. Cuando el error ocurre, ya no hay relación directa entre ese error y el código que disparó la tarea. Tenés que reconstruir esa relación manualmente a través de correlation IDs y logs estructurados.

¿Qué es un "error silencioso" en un agente async y cómo lo detecto?

Un error silencioso es uno que ocurre en una tarea async pero no llega al manejador de errores del nivel superior. Ocurre cuando el Promise se rechaza pero nadie tiene un .catch() o try/catch atado a ese punto. Para detectarlos: escuchá el evento unhandledRejection en Node.js, instrumentá todos los puntos de entrada async del agente, y usá logs estructurados que incluyan el traceId en cada nivel.

¿Los frameworks de agentes como LangChain o LlamaIndex resuelven esto?

Parcialmente. LangChain tiene callbacks que capturan eventos de la cadena, pero la cobertura de errores async profundos es inconsistente. LlamaIndex tiene observabilidad similar. Ninguno de los dos te da la correlación completa de una tarea que falla en el quinto nivel de un árbol de subtareas sin configuración adicional. Son un buen punto de partida, no una solución completa.

¿Cuántos correlation IDs necesito propagar en un agente típico?

Con uno solo bien propagado (el traceId del request raíz) ya ganás el 80% del valor. Si el agente tiene subtareas paralelas, agregar un spanId por tarea y un parentSpanId te da la estructura de árbol completa. Más que eso empieza a ser overhead que no te retribuye en debugging real, salvo que estés operando a escala de miles de requests por minuto.

¿Hay alguna señal de que un agente está en problemas antes de que falle completamente?

Sí, y es lo que más me costó identificar: la latencia de percentil 99 empieza a subir antes que el percentil 50. Si el p50 de las respuestas del agente es estable pero el p99 empieza a crecer, hay tareas async que están esperando algo (un lock, un rate limit, una conexión) sin propagarlo como error todavía. Es la señal más temprana de problemas que encontré en mis propios sistemas.

¿Vale la pena agregar distributed tracing completo (OpenTelemetry) a un agente chico?

Depende del volumen. Para un agente que maneja menos de 100 requests por hora, el setup de OpenTelemetry completo es overhead que no te va a pagar. Los logs estructurados con correlation IDs manuales son suficientes. Para más de 500 requests por hora o si el agente tiene más de 5 pasos async, OTel empieza a valer la inversión. Yo no lo metí en CrabTrap todavía; uso logs estructurados con Grep y jq, y me alcanza.


Lo incómodo de todo esto

Hay algo que me incomoda de la narrativa del post de HN y de la discusión general sobre agentes async: se habla de arquitectura como si la observabilidad fuera un detalle de implementación que se resuelve después.

No lo es.

Cuando pasé de un pipeline sync a uno async en CrabTrap, el primer mes fue técnicamente más performante y operacionalmente más ciego. Tenía mejor throughput y peor capacidad de diagnosticar qué estaba pasando. Eso no es un tradeoff aceptable en producción; es una deuda que te cobra con intereses cuando algo falla a las 2am.

Me acuerdo del momento con el App Router de Next.js —que mencioné antes— donde perdí dos semanas quejándome de que rompía mis abstracciones. Con async en agentes cometí el error opuesto: lo adopté sin quejarme y sin entender qué estaba perdiendo. Lo que perdí fue visibilidad. Y visibilidad en sistemas que toman decisiones autónomas no es un lujo técnico; es una responsabilidad operacional.

Lo que haría diferente si arrancara de cero: antes de escribir la primera tarea async, escribo el sistema de logging. No como afterthought. Como primer componente. Porque en un sistema donde el error puede ser silencio, la observabilidad no es la capa de arriba. Es la fundación.

Si estás pensando en arquitecturas de agentes más amplias, el contexto de Windows 9x Subsystem for Linux me recordó algo parecido: la deuda técnica más cara es la que no se ve. Los agentes async con observabilidad nula son exactamente eso —deuda que no aparece hasta que el sistema tiene que rendir cuentas.

El post de HN está bien. Async es el camino correcto para agentes a escala. Pero el título debería ser "All your agents are going async — and your debugging stack isn't ready for it."

Eso es lo que nadie está resolviendo bien todavía. Y los 127 puntos no cambian esa realidad.


Este artículo fue publicado originalmente en juanchi.dev

Top comments (0)