Normal path: execute → tool → observe.
El problema (en producción)
Una dependencia se vuelve flaky.
Tu agente reacciona llamándola más.
Ahora la dependencia está más flaky.
Ahora el agente la llama todavía más.
Ese es el cuento completo de fallos en cascada en sistemas con agentes: amplifican.
En producción el daño no es solo “el agente falló”. Es:
- saltan rate limits en servicios no relacionados
- se atascan colas
- on-call pierde la capacidad de distinguir incidentes reales de “ruido del agente”
- y tu agente se convierte en un load test que nadie pidió
Por qué esto se rompe en producción
Los agentes son loops. Los loops amplifican feedback. Eso no es “AI”. Eso es control de sistemas.
1) Retries ingenuos
Retries son necesarios. Retries sin backoff/jitter son una estampida.
Si 1.000 runs retrían a la vez, acabas de crear un segundo outage.
2) El agente retría y el tool retría
Es común tener:
- retries en el cliente HTTP
- retries en el wrapper del tool
- comportamiento del loop: “intenta otra vez”
Multiplica eso y obtienes tormentas.
3) No hay circuit breaker
Cuando un tool está claramente degradado (timeouts, 5xx), necesitas dejar de llamarlo por un rato. Sin breaker, sigues pegándole a una dependencia caida y la empeoras.
4) No hay bulkheads (límites de concurrencia)
Si un tool va lento, no quieres que se coma todos los workers. Límites por tool evitan que una dependencia consuma todo.
5) No hay safe-mode / fallback
A veces el comportamiento correcto es:
- devolver resultados parciales
- parar temprano con razón clara
- cambiar a cache / last-known-good
Los agentes que “tienen que triunfar” tienden a thrash.
Ejemplo de implementación (código real)
Un patrón pequeño de circuit breaker + bulkhead que puedes poner delante de un tool.
from dataclasses import dataclass
import time
from typing import Callable, Any
@dataclass
class Breaker:
fail_threshold: int = 5
open_for_s: int = 30
failures: int = 0
opened_at: float | None = None
def allow(self) -> bool:
if self.opened_at is None:
return True
if time.time() - self.opened_at > self.open_for_s:
# half-open: reset and try again
self.failures = 0
self.opened_at = None
return True
return False
def on_success(self) -> None:
self.failures = 0
self.opened_at = None
def on_failure(self) -> None:
self.failures += 1
if self.failures >= self.fail_threshold:
self.opened_at = time.time()
class Bulkhead:
def __init__(self, *, max_in_flight: int) -> None:
self.max_in_flight = max_in_flight
self.in_flight = 0
def enter(self) -> None:
if self.in_flight >= self.max_in_flight:
raise RuntimeError("bulkhead full")
self.in_flight += 1
def exit(self) -> None:
self.in_flight = max(0, self.in_flight - 1)
def guarded_tool_call(fn: Callable[..., Any], *, breaker: Breaker, bulkhead: Bulkhead, **kwargs) -> Any:
if not breaker.allow():
raise RuntimeError("circuit open (fail fast)")
bulkhead.enter()
try:
out = fn(**kwargs)
breaker.on_success()
return out
except Exception:
breaker.on_failure()
raise
finally:
bulkhead.exit()export class Breaker {
constructor({ failThreshold = 5, openForS = 30 } = {}) {
this.failThreshold = failThreshold;
this.openForS = openForS;
this.failures = 0;
this.openedAt = null;
}
allow() {
if (!this.openedAt) return true;
const elapsedS = (Date.now() - this.openedAt) / 1000;
if (elapsedS > this.openForS) {
this.failures = 0;
this.openedAt = null;
return true;
}
return false;
}
onSuccess() {
this.failures = 0;
this.openedAt = null;
}
onFailure() {
this.failures += 1;
if (this.failures >= this.failThreshold) this.openedAt = Date.now();
}
}
export class Bulkhead {
constructor({ maxInFlight = 10 } = {}) {
this.maxInFlight = maxInFlight;
this.inFlight = 0;
}
enter() {
if (this.inFlight >= this.maxInFlight) throw new Error("bulkhead full");
this.inFlight += 1;
}
exit() {
this.inFlight = Math.max(0, this.inFlight - 1);
}
}
export async function guardedToolCall(fn, { breaker, bulkhead, args }) {
if (!breaker.allow()) throw new Error("circuit open (fail fast)");
bulkhead.enter();
try {
const out = await fn(args);
breaker.onSuccess();
return out;
} catch (e) {
breaker.onFailure();
throw e;
} finally {
bulkhead.exit();
}
}Esto no es “resiliencia enterprise”. Es un cinturón de seguridad. Sin eso, los agentes convierten dependencias flaky en incidentes del sistema entero.
Incidente real (con números)
Teníamos un agente que llamaba un API de vendor para enrichment. El vendor empezó a timeoutear de forma intermitente.
Nuestro sistema tenía:
- retries de cliente (2)
- retries del wrapper del tool (2)
- el loop del agente “intenta otra vez” (prácticamente ilimitado)
Impacto:
- el API del vendor pasó de “flaky” a “down”
- nuestro pool de workers se saturó
- p95 latencia en endpoints no relacionados subió ~3x
- on-call gastó ~2 horas aislando el blast radius
Fix:
- circuit breaker (fail fast 30s tras umbral)
- bulkhead de concurrencia por tool
- retries en un solo sitio, con backoff + jitter
- safe-mode: saltar enrichment y devolver resultado parcial
El agente no causó el fallo inicial. Lo escaló.
Trade-offs
- Failing fast baja el “success rate” durante outages parciales. Previene outages completos.
- Bulkheads pueden rechazar requests bajo carga. Mejor que saturación global.
- Safe-mode devuelve respuestas menos completas. Mantiene el sistema vivo.
Cuándo NO usarlo
- Si el tool es interno y ya tiene SLOs robustos, quizá no necesitas breakers por tool (igual mantén budgets).
- Si no puedes definir safe-mode, no corras loops autónomos durante outages.
- Si necesitas completitud estricta, usa workflows async, no agentes sincrónicos.
Checklist (copiar/pegar)
- [ ] Timeouts en cada tool call
- [ ] Retries en un solo sitio (gateway), con backoff + jitter
- [ ] Circuit breaker por tool (fail fast)
- [ ] Bulkhead de concurrencia por tool
- [ ] Budgets por run (tiempo/tool calls/gasto)
- [ ] Safe-mode (resultados parciales)
- [ ] Alertas: breaker open rate, error rates, latencia por tool
Config segura por defecto (JSON/YAML)
tools:
timeouts_s: { default: 10 }
retries: { max_attempts: 2, backoff_ms: [250, 750], jitter: true }
circuit_breaker:
fail_threshold: 5
open_for_s: 30
bulkhead:
max_in_flight: 10
safe_mode:
enabled: true
allow_partial: true
FAQ (3–5)
Usado por patrones
Fallos relacionados
- AI Agent Infinite Loop (Detectar + arreglar, con código)
- Explosión de presupuesto (cuando un agente quema dinero) + fixes + código
- Tool Spam Loops (fallo del agente + fixes + código)
- Incidentes de exceso de tokens (prompt bloat) + fixes + código
- Corrupción de respuestas de tools (schema drift + truncation) + código
Gobernanza requerida
Q: ¿Los retries no son buenos?
A: Son buenos con backoff y límites. Retries sin límite en loops es cómo amplificas outages.
Q: ¿Dónde van los circuit breakers?
A: En el tool gateway, no en prompts. Un choke point.
Q: ¿Qué es safe-mode?
A: Comportamiento degradado: menos tools, read-only, cache, resultados parciales y stop reason claro.
Q: ¿Necesito esto para cada tool?
A: Empieza por los externos/flaky/caros. Con el tiempo, sí: toda dependencia externa necesita timeouts y budgets.
Páginas relacionadas (3–6 links)
- Foundations: Cómo usan tools los agentes · Agente listo para producción
- Failure: Outage parcial · Tool spam loops
- Governance: Tool permissions (allowlists)
- Production stack: Production agent stack