Circuit breaker para agentes IA: la tool cae y el modelo inventa
Un martes por la tarde, la API de búsqueda de un cliente empezó a devolver 500. Un despliegue suyo mal hecho: tres minutos de caída.
El agente que consumía esa API estuvo cuarenta minutos haciendo tonterías caras.
Primero reintentó. Normal. Luego, al ver que la herramienta seguía fallando, hizo lo que hacen los modelos cuando se les cierra una puerta: buscar otra. Llamó a una tool que no tocaba, cambió los parámetros "por si acaso" y en el paso 14 se inventó tres productos con sus precios.
Faltaba un circuit breaker para agentes IA. El patrón es viejo — Michael Nygard lo describió en Release It! en 2007 para microservicios y Martin Fowler lo popularizó después — pero cuando en medio del reintento hay un LLM, cambia una pieza fundamental. Y esa pieza es la que casi nadie implementa.
Qué es un circuit breaker para agentes IA
Un circuit breaker para agentes IA es una máquina de estados que envuelve la ejecución de cada tool: cuenta los fallos de infraestructura dentro de una ventana de tiempo y, al superar un umbral, deja de llamar a la API y devuelve al modelo un resultado estructurado que le dice que esa herramienta no está disponible y qué debe hacer en su lugar.
La diferencia con el circuit breaker clásico de microservicios está en quién recibe el corte. Allí el consumidor es código, que obedece un 503 y ejecuta su rama de fallback. Aquí el consumidor es un LLM, que interpreta el error y decide por su cuenta. Y si no se lo dices tú, lo que decide es reintentar o inventarse el dato.
Los tres estados del circuit breaker en un agente
El breaker es una máquina de estados que envuelve la ejecución de una herramienta.
CLOSED. Todo pasa. Vas contando fallos en una ventana de tiempo. Si en los últimos 60 segundos hay 4 fallos de infraestructura, abres.
OPEN. Rechazas sin llamar a la API. Esto es lo importante: el execute de la tool ni siquiera hace fetch. Devuelve en microsegundos. No hay timeout de 30 segundos, no hay latencia, no hay una API agonizante recibiendo más carga de la que ya no puede atender.
HALF_OPEN. Pasado el tiempo de reset, dejas pasar una sola llamada de prueba. Si funciona, vuelves a CLOSED. Si falla, vuelves a OPEN y el contador de espera empieza otra vez. Ojo con esto en un agente: si dejas pasar todas las llamadas de un turno en half-open, el modelo puede lanzar tres tool calls en paralelo y le acabas metiendo tres peticiones a un servicio que se está levantando.
Hasta aquí es idéntico a un microservicio. La diferencia empieza en lo que devuelves cuando el circuito está abierto.
Qué devolver al modelo cuando el circuito está abierto
Cuando un servicio A tiene el circuito abierto contra el servicio B, devuelve un 503 y quien lo consume es código. El código no negocia: ve el 503 y ejecuta la rama de fallback que escribiste.
En un agente, quien recibe la respuesta de la tool es un modelo de lenguaje. Y un modelo de lenguaje sí negocia.
Si le devuelves esto:
{ "error": "request failed" }
El modelo va a reintentar. No porque sea tonto, sino porque no tiene ninguna forma de saber que existe un circuito y que está abierto. Desde su punto de vista una llamada ha fallado, y lo razonable ante una llamada que falla es intentarlo otra vez, quizá con otros parámetros.
Has puesto un breaker que ahorra la petición HTTP pero no ahorra ni una sola iteración del loop ni un solo token. El agente sigue quemando pasos hasta agotar el presupuesto que le pusiste en stopWhen — si es que se lo pusiste, que de eso hablo en el post del agentic loop.
El resultado de una tool es un canal de comunicación con el modelo. Es prompt. Úsalo como tal.
{
"ok": false,
"toolUnavailable": true,
"retryAfterSeconds": 27,
"instruction": "La herramienta \"searchCatalog\" está fuera de servicio por fallos repetidos del proveedor. No vuelvas a llamarla durante los próximos 27 segundos: cualquier intento se rechazará sin llegar a la API. Usa \"searchCatalogSnapshot\" (catálogo cacheado de hace unas horas) y avisa en tu respuesta final de que los precios pueden estar desactualizados. Si el usuario pedía stock en tiempo real, dile que ese dato no está disponible ahora. No lo estimes ni lo inventes."
}
Cuatro cosas, y las cuatro hacen falta:
- Qué herramienta está caída, por su nombre exacto — el mismo que ve en la definición de tools.
- Cuánto tiempo, en segundos concretos. Un "temporalmente" no le dice nada.
- La prohibición explícita de reintentar, con el motivo: no es que vaya a fallar, es que ni siquiera va a salir de tu servidor.
- Qué hacer en su lugar, en concreto. Y la orden de no inventarse lo que la API le habría dado, que es exactamente lo que hizo el agente de mi cliente en el paso 14.
Y la alternativa que le ofreces tiene que existir de verdad en el toolset. Mandar al modelo a una herramienta que no le has dado es pedirle justo lo que intentas evitar: que se la invente.
Añade también una línea al system prompt explicando el protocolo: "si una tool devuelve toolUnavailable: true, esa herramienta no está disponible en este turno; sigue las instrucciones del campo instruction y no la vuelvas a llamar". El modelo cumple bastante bien cuando la instrucción es específica y llega en el sitio donde toma la decisión.
Qué errores abren el circuito de una tool (y cuáles no)
Aquí es donde la mayoría de implementaciones se rompen, y se rompen hacia el lado peligroso: abriendo el circuito de una API que funciona perfectamente.
Los 5xx cuentan. Los timeouts cuentan. Los errores de red cuentan. Los 429 cuentan también, porque cuando un servicio te dice que vas demasiado rápido, lo correcto es dejar de llamarlo un rato.
Los 4xx de validación no cuentan nunca. Si el modelo manda { query: 42 } donde había que mandar un string, la API devuelve un 400 y eso no significa que la API esté rota. Significa que el modelo la está llamando mal. Si sumas ese 400 al contador, un modelo torpe con los argumentos te abre el circuito de un servicio sano — y a partir de ahí has convertido un problema de prompt en una caída de herramienta.
Distinguir "la herramienta está rota" de "el modelo la está llamando mal" es la diferencia entre un breaker que te salva y uno que sabotea al agente.
| Error | ¿Cuenta para abrir? | Por qué |
|---|---|---|
| 5xx | Sí | El servicio está roto |
| 429 | Sí | Saturado: lo correcto es dejar de llamarlo un rato |
| Timeout / AbortError | Sí | Sin timeout no hay fallo que contar, solo un agente esperando |
ECONNREFUSED, ECONNRESET, ENOTFOUND |
Sí | La API no está ahí |
| 400, 422 | Nunca | El modelo mandó argumentos mal formados |
| 404 | Nunca | El recurso no existe; la API respondió bien |
| 409 | Nunca | Conflicto de estado, no caída |
| Error desconocido | No | Ante la duda no penalizas: un falso positivo tumba una herramienta sana |
// tool-errors.ts
export class ToolHttpError extends Error {
constructor(readonly status: number, message: string) {
super(message);
this.name = "ToolHttpError";
}
}
const NETWORK_ERRORS = /ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|fetch failed/i;
export function isInfrastructureFailure(error: unknown): boolean {
if (error instanceof ToolHttpError) {
// 5xx: el servicio está roto. 429: saturado, y lo correcto es dejar de llamar.
// 400, 404, 409, 422: los argumentos venían mal. Eso es el modelo, no la API.
return error.status >= 500 || error.status === 429;
}
// AbortSignal.timeout() lanza un AbortError / TimeoutError
if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) {
return true;
}
if (error instanceof Error) {
// Ojo con el runtime: en Bun el código de red viaja en error.cause.code,
// no en el mensaje. Mirar solo message deja pasar un ECONNREFUSED.
const code = (error as { cause?: { code?: string } }).cause?.code;
if (code && NETWORK_ERRORS.test(code)) return true;
return NETWORK_ERRORS.test(error.message);
}
// Ante la duda, no penalizas: un falso positivo tumba una herramienta sana
return false;
}
La política de "ante la duda no cuenta" es deliberada. Un breaker que no abre cuando debía te cuesta unos reintentos. Un breaker que abre cuando no debía te deja al agente sin una herramienta buena durante medio minuto, y el modelo se pone creativo.
La mitad de estos 4xx los evitas antes de que ocurran con schemas estrictos en la definición de la tool. Es el mismo trabajo de blindaje que vemos en el curso de Zod para TypeScript: si el argumento no valida, ni siquiera llega a salir una petición.
Cómo implementar un circuit breaker en TypeScript
Factory con estado en cierre, sin dependencias. Umbral de fallos, ventana deslizante, timeout de reset y una única prueba en half-open.
// circuit-breaker.ts
export type BreakerState = "CLOSED" | "OPEN" | "HALF_OPEN";
export class CircuitOpenError extends Error {
constructor(readonly toolName: string, readonly retryAfterMs: number) {
super(`Circuito abierto para la herramienta "${toolName}"`);
this.name = "CircuitOpenError";
}
}
export interface BreakerOptions {
name: string;
failureThreshold?: number;
windowMs?: number;
resetTimeoutMs?: number;
isFailure?: (error: unknown) => boolean;
onStateChange?: (from: BreakerState, to: BreakerState) => void;
}
export function createCircuitBreaker({
name,
failureThreshold = 4,
windowMs = 60_000,
resetTimeoutMs = 30_000,
isFailure = () => true, // ¡ojo! sobrescríbelo siempre con isInfrastructureFailure => {},
}: BreakerOptions) {
let state: BreakerState = "CLOSED";
let failures: number[] = [];
let openedAt = 0;
let probeInFlight = false;
const transition = (next: BreakerState) => {
if (next === state) return;
onStateChange(state, next);
state = next;
};
const currentState = (now: number): BreakerState => {
if (state === "OPEN" && now - openedAt >= resetTimeoutMs) {
transition("HALF_OPEN");
}
return state;
};
return {
name,
// getState() no es puro: dispara la transición OPEN -> HALF_OPEN. Si lo
// polleas desde un exportador de métricas, la transición la provoca la
// observabilidad y no el tráfico real.
getState: () => currentState(Date.now()),
getRetryAfterMs: () => Math.max(0, resetTimeoutMs - (Date.now() - openedAt)),
async execute<T>(fn: () => Promise<T>): Promise<T> {
const now = Date.now();
const phase = currentState(now);
if (phase === "OPEN") {
throw new CircuitOpenError(name, resetTimeoutMs - (now - openedAt));
}
// En half-open solo pasa una petición: las demás siguen rechazadas
if (phase === "HALF_OPEN" && probeInFlight) {
// Espera corta a propósito: si la prueba en vuelo cierra el circuito, no
// quieres haberle dicho al modelo que abandone la tool medio minuto
throw new CircuitOpenError(name, 1_000);
}
if (phase === "HALF_OPEN") probeInFlight = true;
try {
const result = await fn();
if (phase === "HALF_OPEN") {
probeInFlight = false;
failures = [];
transition("CLOSED");
}
return result;
} catch (error) {
if (!isFailure(error)) {
// No es culpa de la herramienta: no toca el contador
if (phase === "HALF_OPEN") probeInFlight = false;
throw error;
}
const failedAt = Date.now();
failures = failures.filter((t) => failedAt - t < windowMs); // ventana deslizante
failures.push(failedAt);
if (phase === "HALF_OPEN" || failures.length >= failureThreshold) {
openedAt = failedAt;
probeInFlight = false;
failures = [];
transition("OPEN");
}
throw error;
}
},
};
}
export type CircuitBreaker = ReturnType<typeof createCircuitBreaker>;
Un fallo en half-open reabre directamente, sin esperar a acumular el umbral. Es intencionado: si la prueba falla, el servicio sigue caído y no hay nada que discutir.
Ahora el wrapper que convierte la excepción en un resultado que el modelo entiende, integrado con la definición de tools del Vercel AI SDK:
// with-breaker.ts
import { tool } from "ai";
import { z } from "zod";
import { createCircuitBreaker, CircuitOpenError, type CircuitBreaker } from "./circuit-breaker";
import { ToolHttpError, isInfrastructureFailure } from "./tool-errors";
interface UnavailableInfo {
toolName: string;
retryAfterSeconds: number;
}
export function withBreaker<TArgs, TResult>(
breaker: CircuitBreaker,
onOpen: (info: UnavailableInfo) => Record<string, unknown>,
execute: (args: TArgs) => Promise<TResult>,
) {
return async (args: TArgs) => {
try {
return { ok: true, data: await breaker.execute(() => execute(args)) };
} catch (error) {
if (error instanceof CircuitOpenError) {
return onOpen({
toolName: error.toolName,
retryAfterSeconds: Math.max(1, Math.ceil(error.retryAfterMs / 1000)),
});
}
// Este fallo puede ser justo el que acaba de abrir el circuito: el modelo
// tiene que enterarse ahora, no en la siguiente iteración
if (breaker.getState() === "OPEN") {
return onOpen({
toolName: breaker.name,
retryAfterSeconds: Math.max(1, Math.ceil(breaker.getRetryAfterMs() / 1000)),
});
}
// Fallo puntual con el circuito cerrado: el modelo aún puede reintentar,
// pero necesita saber qué falló para no repetir la misma llamada
return { ok: false, error: error instanceof Error ? error.message : "Error desconocido" };
}
};
}
const searchBreaker = createCircuitBreaker({
name: "searchCatalog",
failureThreshold: 4,
windowMs: 60_000,
resetTimeoutMs: 30_000,
isFailure: isInfrastructureFailure,
});
export const searchCatalog = tool({
description: "Busca productos en el catálogo en tiempo real",
inputSchema: z.object({ query: z.string().min(2) }),
execute: withBreaker(
searchBreaker,
({ toolName, retryAfterSeconds }) => ({
ok: false,
toolUnavailable: true,
retryAfterSeconds,
instruction:
`La herramienta "${toolName}" está fuera de servicio por fallos repetidos del proveedor. ` +
`No vuelvas a llamarla durante los próximos ${retryAfterSeconds} segundos: cualquier ` +
`intento se rechazará sin llegar a la API. Usa "searchCatalogSnapshot" y avisa en tu ` +
`respuesta final de que los precios pueden estar desactualizados. Si el usuario pedía ` +
`stock en tiempo real, dile que ese dato no está disponible ahora. No lo inventes.`,
}),
async ({ query }: { query: string }) => {
const res = await fetch(`${process.env.CATALOG_API}/search?q=${encodeURIComponent(query)}`, {
signal: AbortSignal.timeout(4_000),
});
if (!res.ok) throw new ToolHttpError(res.status, `Búsqueda falló con ${res.status}`);
return res.json();
},
),
});
Fíjate en el segundo if del catch: el fallo que abre el circuito también tiene que hablarle al modelo. Si esperas a la siguiente llamada para avisarle, has regalado una iteración entera del loop justo en el peor momento, el momento en que acabas de decidir que la herramienta está muerta.
El ejemplo va sobre el AI SDK de Vercel 7, donde el schema de la tool se declara en inputSchema. Ese nombre existe desde la 5: si sigues en la 4.x el campo se llama parameters y el resto del wrapper no cambia.
Fíjate también en el AbortSignal.timeout(4_000). Sin timeout explícito no hay breaker que valga: una petición colgada no genera un fallo que contar, genera un agente esperando. El timeout es lo que convierte "lento" en "fallido", y sin eso el patrón entero no arranca. Es el tipo de detalle que trato en programación defensiva en TypeScript.
Un breaker por herramienta, nunca uno global
Si la API de búsqueda está caída, la base de datos sigue respondiendo perfectamente. Un breaker global convierte un fallo parcial en una caída total del agente: pierdes cuatro herramientas sanas por culpa de una rota.
Un registro por nombre de tool y listo:
const breakers = new Map<string, CircuitBreaker>();
export const breakerFor = (name: string, options: Partial<BreakerOptions> = {}): CircuitBreaker => {
const existing = breakers.get(name);
if (existing) return existing;
const created = createCircuitBreaker({ name, isFailure: isInfrastructureFailure, ...options });
breakers.set(name, created);
return created;
};
Y una advertencia que cuesta una tarde de depuración: el estado del breaker tiene que vivir fuera de la petición. Si creas el breaker dentro del handler del chat, cada conversación arranca con el contador a cero y el patrón no protege absolutamente nada. Ámbito de módulo como mínimo. Si corres en serverless con varias instancias, el estado compartido va a Redis o cada instancia aprenderá por su cuenta que la API está caída — y pagarás el aprendizaje N veces.
Y los umbrales no son iguales para todas: una API de pagos crítica aguanta 6 fallos antes de abrir, un scraper de enriquecimiento prescindible abre a los 2.
El fallback: qué le das al modelo cuando no hay datos
Tienes tres opciones, y elegir mal aquí desperdicia el breaker.
Respuesta cacheada. El último snapshot bueno. Sirve para catálogos, listados y configuración. Obligatorio decirle al modelo que los datos son viejos y de cuándo son, para que lo declare en su respuesta.
Herramienta degradada. Búsqueda local en vez de búsqueda semántica remota. Peor resultado, cero dependencia externa.
Seguir sin el dato, declarándolo. La opción más honesta y la más infravalorada. El agente termina la tarea con la información que tiene y dice explícitamente qué no pudo comprobar. Mucho mejor que un dato inventado con toda la confianza del mundo.
Y una cuarta que a veces es la correcta: parar y escalar al humano. Si la herramienta caída era imprescindible para la tarea, seguir es peor que rendirse. Igual que con los guardrails de ejecución, la decisión de frenar es parte del diseño, no un fallo.
Cómo saber si tu breaker está bien calibrado
Un breaker sin métricas es un valor mágico que alguien puso hace seis meses. Registra el cambio de estado con onStateChange y mira tres números:
Aperturas por hora y por herramienta. Si una tool abre 5 veces por hora contra una API que su proveedor jura estar sana, tu umbral es demasiado bajo o estás contando 4xx que no deberías. Revisa el clasificador antes que el umbral.
Tiempo total en OPEN. Es tu indisponibilidad real de esa capacidad. Si una herramienta pasa el 20% del día en OPEN, el problema ya no es el breaker: es el proveedor, y toca renegociarlo o buscar alternativa.
Ratio de half-open que vuelven a abrir. El indicador de flapping. Por encima del 70% significa que tu resetTimeoutMs es demasiado corto y estás probando un servicio que aún no se ha levantado, gastando una llamada de tool en cada intento. Alarga el backoff de forma progresiva: 30s, 60s, 2 min. La versión de arriba usa un resetTimeoutMs fijo; para escalarlo, multiplícalo por el número de aperturas consecutivas antes de asignar openedAt.
Y una cuarta que solo existe en agentes: qué hizo el modelo después de recibir el fallback. Loguea la siguiente tool call tras un toolUnavailable. Si el modelo vuelve a llamar a la herramienta caída, tu mensaje no está siendo lo bastante claro y toca reescribirlo. Los pasos que se ahorra el agente los ves directamente en el consumo de tokens por tarea.
Por dónde empezar con el circuit breaker en tu agente
Coge tu agente. Mira la tool que llama al servicio externo menos fiable — todos tenemos una. Ponle un timeout explícito, un breaker propio con isFailure que ignore los 4xx de validación, y un mensaje de fallback escrito para el modelo y no para tu log.
Esa única herramienta es el 80% del beneficio. El resto es replicar el patrón.
La idea de fondo: en un agente, cualquier mecanismo de defensa que no le hable al modelo se queda a medias. Puedes cortar la petición HTTP, pero si no le explicas al LLM qué ha pasado y qué esperas de él, el modelo rellenará el hueco con lo que se le ocurra. Y lo que se le ocurre suele ser caro.
Esta forma de pensar la arquitectura — decidir antes de escribir código qué hace el sistema cuando algo falla — es exactamente el enfoque del curso Construye con IA: de la idea al producto. Y si quieres ver estos patrones montados sobre proyectos reales, con las métricas puestas y funcionando, en Dominicode Labs es donde los estamos rodando.
Preguntas frecuentes
¿Qué diferencia hay entre un circuit breaker y un simple retry con backoff?
El retry insiste; el breaker deja de insistir. Son complementarios: el backoff resuelve el fallo puntual dentro de una misma llamada, y el breaker resuelve el fallo sostenido a lo largo de muchas llamadas. Sin breaker, tu retry con backoff se ejecuta entero en cada una de las 14 iteraciones del agente contra un servicio que lleva minutos caído.
¿Cuántos fallos deben abrir el circuito de una tool?
Entre 3 y 5 dentro de una ventana de 60 segundos funciona bien como punto de partida. Con umbral 1 o 2 abres por un pico transitorio; por encima de 8 el agente ya habrá gastado medio presupuesto de pasos antes de que el breaker reaccione. Ajústalo por criticidad: más tolerancia en herramientas imprescindibles, menos en las prescindibles.
¿Debe contar un error 400 de una tool para abrir el circuito?
No. Un 400, un 404 o un 422 casi siempre significan que el modelo mandó argumentos mal formados, no que la API esté rota. Si los cuentas, acabas abriendo el circuito de un servicio sano por culpa del LLM y dejando al agente sin una herramienta que funcionaba. Cuentan los 5xx, los timeouts, los errores de red y los 429.
¿Dónde guardo el estado del breaker si mi agente corre en serverless?
En un almacén compartido tipo Redis, con el nombre de la herramienta como clave. Si lo dejas en memoria de proceso, cada instancia fría descubre por su cuenta que el proveedor está caído y pagas ese descubrimiento tantas veces como instancias tengas. Para un servidor de larga vida, el ámbito de módulo basta.
¿El circuit breaker sustituye al límite de pasos del agente?
No, resuelven cosas distintas. El límite de pasos acota cuánto puede trabajar el agente en total; el breaker impide que una herramienta rota consuma esos pasos sin aportar nada. Van juntos: el breaker devuelve el control rápido y con instrucciones, y el límite de pasos sigue siendo la red de seguridad final.
Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.
¿Te resultó útil este artículo?
Compártelo con tu comunidad y ayuda a otros desarrolladores.
