Estrategias de Recuperación de Errores y Reintentos en Bucles de Agentes
Cuando un orquestador entrega trabajo a un subagente o una herramienta, esa llamada puede fallar por completo, colgarse indefinidamente o regresar con un resultado que parece correcto pero es erróneo o incompleto. Esta página cubre cómo detectar cada uno de esos modos de fallo dentro de un bucle de agente y responder con reintentos, mecanismos de respaldo y disyuntores, para que una llamada fallida no derribe toda la ejecución.
Resumen
Un bucle de agente que delega a subagentes o herramientas tiene una nueva superficie de fallo más allá de "la llamada a la API generó un error": un subagente puede atascarse y nunca regresar, o regresar con un resultado que es técnicamente exitoso pero inútil.
Reintentar ciegamente no es una estrategia. Un reintento solo ayuda para fallos transitorios, empeora una dependencia rota al sobrecargarla y puede duplicar efectos secundarios si la llamada subyacente no fue idempotente.
Esta página trata la recuperación de errores a nivel de orquestación como tres decisiones en capas: reintentar la misma llamada un número limitado de veces con retroceso (backoff), recurrir a una ruta más barata o diferente cuando se agotan los reintentos, y dejar de llamar a una dependencia por completo una vez que ha fallado claramente más allá de un umbral, revisándola más tarde.
También cubre la detección de un subagente atascado, uno que nunca genera un error y nunca regresa, ya que ese modo de fallo necesita un tiempo de espera explícito en lugar de un manejador de excepciones.
Esto se limita a los fallos dentro del propio límite de orquestación: una invocación de subagente, una llamada a herramienta, un bucle hijo atascado. No se trata de la mecánica de reintentar una llamada bruta a la API de Claude, que Estrategias de Reintento y Retroceso Exponencial para Fallos Transitorios de la API de Claude cubre por separado.
Receta
Tarjeta de receta de referencia rápida, lista para copiar y pegar.
import asyncio
import random
from claude_agent_sdk import query, AgentOptions, SubagentConfig
async def call_subagent_with_retry(prompt, options, *, max_attempts=3, timeout=45.0):
for attempt in range(1, max_attempts + 1):
try:
return await asyncio.wait_for(
run_subagent(prompt, options), timeout=timeout
)
except (asyncio.TimeoutError, RuntimeError):
if attempt == max_attempts:
raise
delay = (2 ** (attempt - 1)) + random.uniform(0, 0.5)
await asyncio.sleep(delay)
async def run_subagent(prompt, options):
result = None
async for message in query(prompt=prompt, options=options):
if message.get("type") == "subagent_result":
result = message
if result is None:
raise RuntimeError("subagent produced no result")
return resultCuándo usar esto:
- Una invocación de subagente o llamada a herramienta puede fallar transitoriamente y un segundo intento probablemente tendrá éxito.
- Un subagente puede colgarse sin generar una excepción, por lo que necesita un tiempo de espera explícito para detectar un atasco.
- Los fallos repetidos contra la misma dependencia deben dejar de generar más carga una vez que esté claramente caída.
- Desea una ruta de respaldo más barata o simple cuando el enfoque principal sigue fallando, en lugar de que toda la ejecución falle.
Ejemplo de Trabajo
import asyncio
import random
import time
from dataclasses import dataclass, field
from claude_agent_sdk import query, AgentOptions, SubagentConfig
class CircuitOpenError(Exception):
"""Se genera cuando un disyuntor rechaza una llamada porque su dependencia no está saludable."""
@dataclass
class CircuitBreaker:
"""Disyuntor por dependencia con una sonda semierta basada en tiempo de espera (cooldown)."""
failure_threshold: int = 3
cooldown_seconds: float = 30.0
_failures: int = field(default=0, init=False)
_opened_at: float | None = field(default=None, init=False)
def before_call(self) -> None:
if self._opened_at is None:
return
elapsed = time.monotonic() - self._opened_at
if elapsed < self.cooldown_seconds:
raise CircuitOpenError(
f"circuito abierto, reintentar en {self.cooldown_seconds - elapsed:.1f}s"
)
# El tiempo de espera (cooldown) ha transcurrido: permitir una sonda semierta a continuación.
def record_success(self) -> None:
self._failures = 0
self._opened_at = None
def record_failure(self) -> None:
self._failures += 1
if self._failures >= self.failure_threshold and self._opened_at is None:
self._opened_at = time.monotonic()
async def run_subagent_with_timeout(prompt: str, options: AgentOptions, *, per_message_timeout: float = 20.0):
"""Consume mensajes de query(), generando TimeoutError si el stream se atasca entre mensajes."""
stream = query(prompt=prompt, options=options).__aiter__()
result = None
while True:
try:
message = await asyncio.wait_for(stream.__anext__(), timeout=per_message_timeout)
except StopAsyncIteration:
break
if message.get("type") == "subagent_result":
result = message
if result is None:
raise RuntimeError("subagent produced no usable result")
return result
async def call_with_retry(make_call, *, max_attempts: int = 3, base_delay: float = 1.0):
"""Reintenta una llamada asíncrona con retroceso exponencial con jitter."""
last_error: Exception | None = None
for attempt in range(1, max_attempts + 1):
try:
return await make_call()
except (asyncio.TimeoutError, RuntimeError) as exc:
last_error = exc
if attempt == max_attempts:
break
delay = base_delay * (2 ** (attempt - 1)) + random.uniform(0, 0.5)
await asyncio.sleep(delay)
raise last_error
# Un disyuntor por dependencia, indexado por nombre de subagente.
breakers = {
"deep-research": CircuitBreaker(failure_threshold=3, cooldown_seconds=30.0),
"quick-search": CircuitBreaker(failure_threshold=3, cooldown_seconds=15.0),
}
DEEP_RESEARCH = SubagentConfig(
name="deep-research",
description="Ejecuta un pase de investigación exhaustivo y de varios pasos y cita fuentes.",
allowed_tools=["web_search", "web_fetch"],
)
QUICK_SEARCH = SubagentConfig(
name="quick-search",
description="Ejecuta una única búsqueda web rápida y resume los principales resultados.",
allowed_tools=["web_search"],
)
async def research_topic(topic: str) -> dict:
"""Intenta primero el subagente exhaustivo; recurre al barato en caso de fallo repetido."""
for name, subagent, timeout in [
("deep-research", DEEP_RESEARCH, 60.0),
("quick-search", QUICK_SEARCH, 20.0),
]:
breaker = breakers[name]
try:
breaker.before_call()
except CircuitOpenError:
print(f"[{name}] circuito abierto, saltando directamente al respaldo")
continue
options = AgentOptions(allowed_tools=[], subagents=[subagent])
try:
result = await call_with_retry(
lambda: run_subagent_with_timeout(
f"Usa el subagente {name} para investigar: {topic}",
options,
per_message_timeout=timeout,
),
max_attempts=2,
)
breaker.record_success()
result["served_by"] = name
return result
except (asyncio.TimeoutError, RuntimeError, CircuitOpenError) as exc:
breaker.record_failure()
print(f"[{name}] falló después de los reintentos: {exc}, intentando la siguiente ruta")
continue
raise RuntimeError(f"todas las rutas de investigación fallaron para el tema: {topic}")
asyncio.run(research_topic("recent developments in battery recycling"))Lo que esto demuestra:
- Un tiempo de espera por mensaje envuelto alrededor del generador asíncrono de
query()para capturar un subagente que se atasca sin generar nunca una excepción. - Una utilidad genérica
call_with_retryque aplica retroceso exponencial con jitter a cualquier objeto awaitable, mantenida separada de la detección de atascos específica del subagente. - Un
CircuitBreakerindexado por nombre de subagente, de modo que el fallo dedeep-researchno afecte al disyuntor independiente dequick-search. - Una escalera de respaldo que intenta primero el subagente exhaustivo y caro y recurre a uno más barato solo cuando los reintentos y el circuito de la primera ruta dicen que no.
- El resultado de respaldo etiquetado con
served_by, para que los llamadores puedan distinguir una respuesta degradada de la principal.
Inmersión Profunda
Cómo Funciona
- Una invocación de subagente a través de
query()puede fallar de tres maneras distintas: genera una excepción (un error de herramienta, un resultado mal formado), se atasca (no llega ningún mensaje y no se genera ninguna excepción), o se completa con un resultado que es técnicamente válido pero vacío o incorrecto. - Los reintentos y la detección de atascos son mecanismos diferentes. Los reintentos responden a una excepción generada; la detección de atascos necesita un tiempo de espera explícito, ya que un subagente colgado produce silencio, no una excepción, por lo que envolver el flujo de mensajes en
asyncio.wait_fores lo que convierte un atasco en algo que la lógica de reintento puede capturar. - Un disyuntor se sitúa por encima de los reintentos: no decide si una llamada tiene éxito, decide si vale la pena intentar la llamada en absoluto, basándose en el historial reciente de esa dependencia.
before_call()se corta inmediatamente una vez que se cruza el umbral de fallo, por lo que un subagente claramente defectuoso deja de absorber presupuesto de reintento. - El estado semierto es lo que evita que un circuito permanezca abierto para siempre: una vez que transcurre el tiempo de espera (cooldown), la siguiente sonda semierta (
half-open probe) pasa, y el resultado de esa única sonda (record_successorecord_failure) decide si el circuito se cierra de nuevo o reinicia su tiempo de espera. - Una ruta de respaldo es una decisión separada tanto de los reintentos como del disyuntor: es lo que hace el orquestador una vez que ha renunciado a la ruta principal para esa llamada, ya sea porque se agotaron los reintentos o porque el circuito ya estaba abierto.
- Cada capa debe poseer una preocupación distinta: los reintentos manejan fallos transitorios, la detección de atascos maneja el silencio, el disyuntor maneja una dependencia que está caída por más tiempo que una sola llamada, y el respaldo maneja lo que hace el orquestador una vez que todo eso ha fallado.
Modos de Fallo de un Vistazo
| Modo de Fallo | Síntoma | Respuesta Correcta |
|---|---|---|
| Error transitorio de herramienta | Excepción generada, probablemente tendrá éxito en un segundo intento | Reintentar con retroceso (backoff) |
| Subagente atascado | Sin mensaje, sin excepción, la llamada simplemente se cuelga | Tiempo de espera (timeout) a través de asyncio.wait_for, luego tratar como un fallo |
| Resultado erróneo/incompleto | La llamada regresa normalmente pero el resultado está vacío o es incorrecto | Validar explícitamente el resultado; tratar el fallo de validación como un fallo de llamada |
| Dependencia caída por un tiempo | Varios fallos consecutivos contra el mismo subagente/herramienta | Disyuntor, dejar de llamarlo hasta que pase el tiempo de espera (cooldown) |
| Ruta principal agotada | Reintentos y circuito dicen que no para esta llamada | Recurrir a un subagente más simple o diferente |
Notas de Python
# asyncio.wait_for solo ayuda si lo aplicas a algo que realmente
# se puede interrumpir entre awaits. Envolver todo el bucle `async for` solo
# pone un tiempo de espera al bucle en su conjunto, no a cada mensaje individual atascado,
# por lo que llama a __anext__() directamente por mensaje cuando necesitas granularidad por mensaje.
stream = query(prompt=prompt, options=options).__aiter__()
message = await asyncio.wait_for(stream.__anext__(), timeout=20.0)
# StopAsyncIteration significa que el stream terminó normalmente, no que falló;
# no dejes que caiga en la misma cláusula except que TimeoutError/RuntimeError.Parámetros y Valores de Retorno
| Parámetro | Tipo | Descripción |
|---|---|---|
failure_threshold | int | Fallos consecutivos antes de que CircuitBreaker se abra |
cooldown_seconds | float | Cuánto tiempo permanece abierto el circuito antes de permitir una sonda semierta |
max_attempts | int | Intentos totales (incluido el primero) para call_with_retry |
base_delay | float | Segundos base para el retroceso exponencial, antes del jitter |
per_message_timeout | float | Segundos de espera para el próximo mensaje antes de tratar el subagente como atascado |
Trampas Comunes
- Reintentar una llamada a herramienta no idempotente. Si una llamada a herramienta tiene un efecto secundario (enviar un correo electrónico, escribir un registro), reintentarla después de un tiempo de espera puede ejecutarla dos veces, ya que el primer intento podría haber tenido éxito antes de que se perdiera la respuesta. Solución: solo reintentar llamadas que sean idempotentes, o adjuntar una clave de idempotencia que la herramienta pueda usar para deduplicar.
- Establecer el tiempo de espera de atasco demasiado ajustado. Un
per_message_timeoutmás corto que la latencia normal del subagente mata llamadas legítimamente lentas pero funcionales, que luego se ven idénticas a un atasco real. Solución: establecer el tiempo de espera a partir del perfil de latencia real de la tarea, no una suposición, y dar a un subagente de estilodeep-researchmás margen que a uno de estiloquick-search. - Reintentar tanto en el padre como en el subagente. Si el orquestador padre reintenta una llamada fallida a un subagente, y el propio bucle interno del subagente también reintenta sus llamadas a herramientas, los fallos se acumulan en muchos más intentos y costos de los previstos. Solución: elegir una capa para poseer los reintentos de un fallo dado, generalmente el orquestador padre para fallos de delegación, y dejar que el bucle interno del subagente maneje sus propios reintentos a nivel de herramienta por separado.
- Un solo disyuntor para todo. Un único disyuntor compartido entre subagentes no relacionados significa que una dependencia inestable activa el circuito para llamadas que no tuvieron nada que ver con el fallo. Solución: indexar los disyuntores por dependencia, uno por nombre de subagente o nombre de herramienta, como se muestra en el ejemplo de trabajo.
- Sin jitter en los retrasos de retroceso. Retrasos exponenciales fijos sin jitter significan que muchas tareas orquestadas concurrentes reintentan en sincronía después de una interrupción compartida, creando un nuevo pico exactamente cuando la dependencia comienza a recuperarse. Solución: agregar un pequeño jitter aleatorio a cada retraso de retroceso, como lo hace
call_with_retryarriba. - Respaldos silenciosos. Si un resultado de respaldo degradado se ve idéntico al resultado principal para el llamador, nadie se da cuenta de que la ruta principal está fallando hasta que es un problema mayor. Solución: etiquetar los resultados de respaldo (como
served_byarriba) y registrar o alertar sobre la tasa de respaldo, no solo tragar el fallo. - Olvidar registrar el éxito en la sonda semierta. Si
record_success()nunca se llama en la sonda que pasa después del tiempo de espera (cooldown), el disyuntor nunca puede cerrarse realmente incluso una vez que la dependencia se ha recuperado. Solución: siempre enrutar ambos resultados de la llamada de sonda a través de la misma rutarecord_success/record_failureque una llamada normal.
Alternativas
| Alternativa | Usar Cuando | No Usar Cuando |
|---|---|---|
| Bucle de reintento hecho a mano en cada sitio de llamada | Un script único y desechable con una sola llamada para proteger | El patrón necesita ser reutilizado en muchos sitios de llamada de subagentes/herramientas |
Una biblioteca de reintentos/disyuntores (ej. tenacity) | Desea primitivas de retroceso y disyuntor probadas en batalla en lugar de mantener las suyas propias | Necesita semántica de tiempo de espera específica para un generador asíncrono atascado, que la mayoría de estas bibliotecas no modelan directamente |
| Dejar que el propio bucle interno del subagente reintente sus llamadas a herramientas | El fallo está dentro del uso de herramientas del propio subagente, no en el límite de delegación padre-hijo | El subagente en sí se ha atascado o no ha regresado; solo el padre puede observar y recuperarse de eso |
| Fallar rápidamente a un punto de control con intervención humana | La acción es de alto riesgo o tiene efectos secundarios donde un reintento ciego o un respaldo es inseguro | El fallo es un error transitorio rutinario donde un reintento automatizado es seguro y barato |
Preguntas Frecuentes
¿Cuál es la diferencia entre un subagente "fallido" y un subagente "atascado"?
- Un fallo genera una excepción (un error de herramienta, un resultado mal formado) que el manejo de excepciones ordinario puede capturar.
- Un atasco no produce ni un resultado ni una excepción, la llamada simplemente nunca se completa, por lo que necesita un tiempo de espera explícito en lugar de un bloque
try/except.
¿Cuántos intentos de reintento debo usar?
No hay un número universal, pero 2-3 intentos totales es un valor predeterminado razonable para reintentos a nivel de orquestación. Más que eso generalmente significa que el fallo no es transitorio, y un respaldo o disyuntor es la mejor respuesta que más reintentos.
¿Se debe reintentar cada llamada a herramienta?
No. Solo reintentar llamadas que sean seguras de repetir, es decir, llamadas idempotentes o aquellas con una clave de deduplicación. Reintentar una llamada con un efecto secundario no dedupicado corre el riesgo de ejecutar ese efecto secundario más de una vez.
¿Cómo se diferencia un respaldo de un reintento?
Un reintento repite la misma llamada a la misma dependencia, esperando que el fallo fuera transitorio. Un respaldo cambia a una ruta completamente diferente, un subagente más simple, una herramienta más barata, una vez que ha decidido que la ruta principal no tendrá éxito esta vez.
¿Debería un disyuntor ser compartido entre tareas orquestadas concurrentes?
Generalmente sí, por dependencia. Si diez tareas concurrentes están llamando cada una al mismo subagente, deberían compartir un disyuntor para ese subagente para que el circuito refleje la salud real de la dependencia, no la vista privada de cada tarea sobre ella.
¿Qué sucede durante el estado semierto?
Una vez que transcurre el tiempo de espera (cooldown), el disyuntor permite que pase exactamente una llamada como sonda. Si esa sonda tiene éxito, el disyuntor se cierra y reanuda las llamadas normales. Si falla, el disyuntor se reabre y comienza de nuevo el tiempo de espera.
¿Cómo detecto un atasco si query() no genera un error para uno?
Envuelve el consumo del flujo de mensajes en asyncio.wait_for, aplicado por mensaje en lugar de alrededor de todo el bucle, para que una brecha más larga que tu tiempo de espera entre mensajes genere TimeoutError que puedas capturar y tratar como un fallo.
¿Dónde deben residir los reintentos, en el orquestador padre o dentro del subagente?
En el límite que realmente observó el fallo. Los fallos de delegación (una llamada a subagente que expiró o generó un error) pertenecen a la lógica de reintento del orquestador padre. Los fallos de herramienta del propio subagente pertenecen al bucle interno de ese subagente, no duplicados por el padre.
¿Necesita el retroceso exponencial jitter?
Sí, para cualquier cosa que se ejecute con concurrencia. Sin jitter, muchos llamadores que reintentan la misma dependencia después de un fallo compartido tienden a reintentar en sincronía, produciendo un nuevo pico de carga en cada intervalo de retroceso en lugar de distribuir los reintentos.
¿Qué debería hacer realmente una ruta de respaldo?
Algo significativamente más barato, más simple o de alcance diferente que la ruta principal. Un respaldo que falla de la misma manera que el principal agrega latencia sin agregar resiliencia.
¿Cómo evito contar doble un fallo entre reintentos y el disyuntor?
Registra un fallo de disyuntor por secuencia de intentos de llamada que finalmente falló, no uno por cada reintento individual dentro de esa secuencia. El ejemplo anterior llama a record_failure() una vez, después de que call_with_retry ha agotado sus intentos, no dentro del propio bucle de reintento.
¿Es seguro validar el resultado de un subagente y tratar un "resultado erróneo" como un fallo?
Sí, y es necesario. Un subagente puede regresar normalmente con un resultado vacío o incorrecto, que no generará un error por sí solo. Validar explícitamente el resultado y generar un error si falla la validación es lo que permite que la lógica de reintento, respaldo y disyuntor lo trate igual que cualquier otro fallo.
Relacionados
- Conceptos Básicos de Orquestación de Agentes - el modelo mental de orquestación en el que se asienta el manejo de fallos de esta página
- Guardrails para Sistemas Multi-Agente: Limitando Costo y Alcance - limitando costo y alcance, incluyendo reintentos descontrolados
- Evaluación de la Calidad de Agentes: Una Lista de Verificación para Preparación en Producción - verificación del comportamiento de recuperación antes de enviar
- Estrategias de Reintento y Retroceso Exponencial para Fallos Transitorios de la API de Claude - la misma mecánica de retroceso y disyuntor aplicada a nivel de llamada API bruta
- Entendiendo Por Qué los Agentes de Claude Fallan en Producción - el panorama más amplio de los modos de fallo de agentes en producción
Versiones de Stack: Escrito contra la línea de modelos de Claude actual a partir de ~junio de 2026 - Claude Fable 5, Claude Opus 4.8, Claude Sonnet 5 (el predeterminado), y Claude Haiku 4.5 - y el Claude Agent SDK (última versión). Los nombres de modelos, versiones de SDK y precios cambian rápidamente; verifique los detalles actuales en platform.claude.com/docs antes de confiar en ellos.