Saltar al contenido principal

Cómo detener un agente de IA atrapado en un bucle de herramientas

10 min de lecturaAI API

Un procedimiento práctico para parar un bucle de llamadas a herramientas sin perder la evidencia ni repetir un efecto externo.

LoopGuard que bloquea repeticiones antes de ejecutar y separa éxito, error reintentable y error fatal en rutas terminales distintas.

Si tu agente de IA repite la misma herramienta, bloquea primero las nuevas ejecuciones. Congela las herramientas que escriben, cobran, envían, despliegan o modifican datos; guarda la traza y el último estado válido; después cancela el worker desde el runtime. No intentes arreglar un bucle vivo añadiendo otro mensaje al prompt.

El incidente está contenido cuando la siguiente llamada insegura no se ejecuta, ningún efecto completado se repite, la ejecución termina con una razón consultable y el caso original pasa una prueba de replay dentro del presupuesto. Alcanzar max_iterations solo demuestra que saltó un techo: no demuestra recuperación, rollback ni idempotencia.

LoopGuard que bloquea repeticiones antes de ejecutar y separa éxito, error reintentable y error fatal en rutas terminales distintas.

Protocolo de parada: seis acciones en orden

Un bucle (loop) de herramientas no es una llamada telefónica: es una secuencia en la que el modelo propone herramientas, recibe resultados y vuelve a actuar sin llegar a un estado terminal. Durante el incidente, sigue este orden:

  1. Pausa el dispatcher. El planner no puede crear nuevos tool calls, jobs ni subagentes.
  2. Cierra los efectos. Deshabilita temporalmente correo, pagos, CRM, deploys, escrituras en base de datos y cambios de archivos.
  3. Conserva la evidencia. Guarda run_id, versión de estado, herramientas, argumentos canónicos, resultados, errores, timestamps y último progreso confirmado.
  4. Cancela en la capa de ejecución. Termina runner, worker o proceso. «No vuelvas a llamar a la herramienta» puede orientar al modelo, pero no es un hard stop.
  5. Asigna una razón. Por ejemplo: same_call_repeat, no_progress, fatal_tool_result, budget_exceeded u operator_stop.
  6. Reconcilia el efecto real. Averigua si la última operación se confirmó aunque su respuesta se perdiera.

Si no sabes si la herramienta llegó a escribir, trátala como posiblemente completada. Antes de reintentar, consulta una clave de idempotencia o un registro durable de operación. Un segundo intento «por si acaso» es precisamente cómo un bucle técnico se convierte en duplicados de negocio.

¿Qué bucle tienes?

La solución depende de la trayectoria observada, no de la palabra «bucle».

Síntoma en la trazaDiagnóstico probableEvidencia decisivaAcción segura
Misma herramienta, mismos argumentos y mismo resultadoReintento idénticoHuella estable y versión sin cambiosBloquear antes de la próxima ejecución
Los argumentos varían, pero no aparece información nuevaJitter sin progresoArgumentos normalizados, clase de resultado y estadoParar o exigir otra estrategia
A y B se alternanCiclo cortoSecuencia repetida sin avanceRomper ciclo y escalar
La herramienta devuelve éxito y vuelve a ejecutarseÉxito no reconocidoRegistro de operación y transición terminal ausenteDevolver resultado guardado, no repetir
Se repite un error de permiso, esquema o políticaFallo permanenteMisma clase de error y misma precondiciónTerminar o pedir intervención
Timeout/429 con backoff acotadoFallo potencialmente transitorioDeadline, contador y precondición temporal distintaReintentar dentro de un presupuesto pequeño
Se repite el nombre, pero avanza un cursorRepetición productivaCursor, versión o trabajo completado creceContinuar bajo el cap global

La huella exacta solo detecta el caso más fácil. Un agente puede reordenar claves JSON, cambiar un filtro irrelevante o alternar dos herramientas. Por eso necesitas dos señales separadas:

  • identidad de acción: qué herramienta se propuso sobre qué estado;
  • progreso verificable: qué cambió fuera del texto del modelo.

No uses «creo que avancé» como señal. Prefiere un cursor nuevo, una versión de fila, un test que pasa, un artefacto validado o un checkpoint durable.

Las cinco capas que no debes mezclar

Los frameworks ya reconocen que el runtime controla la terminación:

  • La guía del runner de OpenAI Agents SDK explica que el bucle continúa tras tools y handoffs hasta una parada real.
  • El middleware de LangChain separa límites de llamadas de modelo, límites de herramientas, retries e interrupción humana.
  • Google ADK documenta máximo de iteraciones y salida explícita para LoopAgent.

Esas API son adaptadores útiles, pero cada capa responde una pregunta distinta:

CapaRespondeNo garantiza
Hard cap¿Superó pasos, tiempo, tokens, llamadas o coste?Que hubiera o no progreso
Detector de repetición¿Ya vimos esta acción o ciclo?Que repetir fuera incorrecto
Detector de progreso¿Cambió estado externo o salida validada?Que el efecto sea idempotente
Idempotencia¿La operación de negocio ya se confirmó?Que la tarea completa haya terminado
Resultado terminal y recovery¿Éxito, retry, fallo, bloqueo o humano?El techo global de recursos

Configurar solo max_turns deja que el mismo efecto se ejecute varias veces antes del corte. Configurar solo un hash no ve ciclos A→B→A. Configurar solo instrucciones confía el límite a un sistema probabilístico.

La regla práctica es: el cap contiene; el progreso decide; la idempotencia protege; el estado terminal cierra; la política de recuperación elige el siguiente paso.

LoopGuard: una implementación transversal

Este LoopGuard en TypeScript se ejecuta antes y después de cada herramienta. progressVersion debe venir de estado durable o salida validada siempre que sea posible.

ts
type ToolStatus = | "success" | "retryable_error" | "fatal_error" | "permission_denied" | "policy_blocked" | "needs_human"; type ToolCall = { tool: string; args: unknown; progressVersion: number; sideEffectKey?: string; }; type ToolResult = { status: ToolStatus; progressVersion: number; message: string; retryAfterMs?: number; }; type StopReason = | "step_budget" | "time_budget" | "same_call_repeat" | "no_progress" | "fatal_tool_result" | "needs_human"; function stable(value: unknown): string { if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`; if (value && typeof value === "object") { const pairs = Object.entries(value as Record<string, unknown>) .sort(([a], [b]) => a.localeCompare(b)) .map(([key, item]) => `${JSON.stringify(key)}:${stable(item)}`); return `{${pairs.join(",")}}`; } return JSON.stringify(value); } export class LoopGuard { private readonly startedAt = Date.now(); private readonly calls = new Map<string, number>(); private steps = 0; private noProgress = 0; private lastProgressVersion: number; constructor( private readonly limits = { maxSteps: 12, maxSameCallExecutions: 2, maxNoProgressExecutions: 3, maxWallMs: 60_000, }, initialProgressVersion = 0, ) { this.lastProgressVersion = initialProgressVersion; } before(call: ToolCall): StopReason | null { if (this.steps >= this.limits.maxSteps) return "step_budget"; if (Date.now() - this.startedAt >= this.limits.maxWallMs) { return "time_budget"; } const fingerprint = stable({ tool: call.tool, args: call.args, progressVersion: call.progressVersion, }); const previous = this.calls.get(fingerprint) ?? 0; if (previous >= this.limits.maxSameCallExecutions) { return "same_call_repeat"; } this.calls.set(fingerprint, previous + 1); this.steps += 1; return null; } after(result: ToolResult): StopReason | null { const terminal = [ "fatal_error", "permission_denied", "policy_blocked", ].includes(result.status); if (terminal) return "fatal_tool_result"; if (result.status === "needs_human") return "needs_human"; if (result.progressVersion > this.lastProgressVersion) { this.lastProgressVersion = result.progressVersion; this.noProgress = 0; } else { this.noProgress += 1; } return this.noProgress >= this.limits.maxNoProgressExecutions ? "no_progress" : null; } }

Los valores del ejemplo son límites de demostración, no una recomendación universal. Derívalos de trayectorias correctas: cuántos pasos necesita una tarea normal, qué latencia acepta el usuario y cuánto daño causaría un efecto duplicado.

Al reanudar, pasa la versión persistida del checkpoint como initialProgressVersion; de lo contrario, el primer resultado sin cambios puede parecer progreso nuevo. success confirma la operación de la herramienta, no siempre la tarea completa: el runner exterior debe hacer una transición validada o devolver la respuesta final. needs_human y las clases fatales detienen el runner de inmediato.

Antes de hacer hash:

  • ordena claves;
  • elimina defaults irrelevantes;
  • conserva la versión de estado relevante;
  • no metas secretos ni payloads privados en logs.

Para procesos paralelos o reinicios, almacena contadores, versiones y operaciones fuera de memoria. Un Map local desaparece con el worker y no evita una carrera.

El tool contract que permite terminar

Una herramienta no debería responder solo con texto ambiguo. Devuelve una clase terminal y la evidencia necesaria para decidir.

Estado¿Reintentar?Cambio obligatorio
successNo repetir la misma operaciónFinalizar o iniciar otra transición
retryable_errorSí, de forma acotadaDebe cambiar tiempo, salud, endpoint u otra precondición
fatal_errorNoCorregir input, código o estrategia
permission_deniedNo automáticoUna persona cambia autorización
policy_blockedNo automáticoResolver con el owner de política
needs_humanNoReanudar solo con input humano registrado

El caso público de n8n muestra una frontera útil: HubSpot respondió correctamente, pero el estado/instrucción seguía representando la tarea como incompleta y el agente repitió la herramienta hasta alcanzar el máximo. Aclarar el prompt mejoró ese caso; la garantía de no repetir el efecto sigue perteneciendo al runtime y a la herramienta.

Para un side effect, añade una clave estable:

ts
async function actualizarCliente(input: { customerId: string; operationId: string; patch: Record<string, unknown>; }): Promise<ToolResult> { const anterior = await operations.find(input.operationId); if (anterior?.status === "committed") { return { status: "success", progressVersion: anterior.version, message: "Operación ya confirmada; se devuelve el resultado guardado.", }; } const guardada = await operations.commitOnce(input); return { status: "success", progressVersion: guardada.version, message: "Actualización confirmada.", }; }

operationId identifica la acción de negocio, no el intento del modelo. Si dos workers quieren hacer lo mismo, ambos deben resolver al mismo registro durable.

Cómo recuperar una ejecución detenida

No vuelvas a enviar la misma conversación al mismo agente con contadores a cero. Eso no es recuperación; es una ruta de bypass.

Guarda un sobre de recuperación:

json
{ "run_id": "run_123", "stop_reason": "no_progress", "last_good_progress_version": 4, "blocked_call": "search", "side_effect_state": "none_pending", "allowed_next_actions": ["change_strategy", "needs_human"] }

Después elige solo una salida diseñada:

  • Finalizar: ya existe un éxito terminal; devuelve el resultado registrado.
  • Reintentar: el error es transitorio, queda presupuesto y cambió una precondición.
  • Cambiar estrategia: deshabilita la herramienta o usa una entrada validada distinta.
  • Entregar resultado parcial: conserva lo útil y explica qué falta.
  • Pedir intervención: faltan permisos, aprobación, contexto o una decisión destructiva.
  • Cerrar: fallo permanente, presupuesto agotado o ninguna acción segura.

La nueva ejecución debe enlazar al run detenido y heredar los límites aplicables. No borres la evidencia para que «parezca un intento nuevo».

Cinco fixtures de primera mano

Probamos una simulación JavaScript transversal en el entorno Node.js del repositorio. No usó proveedor, red, credencial ni modelo de pago. El guard aplicó orden estable de argumentos, presupuesto total de pasos, máximo de dos ejecuciones por llamada idéntica, parada tras tres ejecuciones sin progreso, deadline, estados terminales y progressVersion monotónico.

FixtureComportamiento simuladoResultado observado
success_after_oneHerramienta tipo CRM devuelve success a la primeraCompletó tras una ejecución
identical_retryLa misma search({"q":"same"}) devuelve retryable sin progresoLa tercera ejecución se bloqueó como same_call_repeat; solo hubo dos ejecuciones
alternating_cycleread y check se alternan sin avanzarSe detuvo tras tres ejecuciones como no_progress
changed_state_then_successAvanzan cursor y versión de progresoPermitió tres llamadas distintas y terminó en éxito
fatal_no_retryUna escritura devuelve permission_deniedSe detuvo tras una ejecución

La prueba demuestra la ramificación determinista para esos cinco casos. No demuestra que un modelo etiquete siempre bien el progreso. En producción, deriva el avance de estado externo, validación o checkpoint durable.

El fixture productivo evita otro fallo: bloquear cualquier repetición del nombre de herramienta rompería paginación, polling y procesamiento por lotes. Repetir puede ser correcto si existe avance monotónico y el cap global sigue vigente.

Adapta el patrón sin ceder la responsabilidad

SuperficieÚsala paraMantén en tu aplicación
Runner y max-turn handling de OpenAI Agents SDKAcotar runner y tratar su salida/errorProgreso, idempotencia, recuperación
Middleware de modelo/herramienta de LangChainTecho por run, thread o toolFinalización de negocio y operaciones
Límite y salida de Google ADK LoopAgentBucle de workflow acotadoProgreso de dominio y resume seguro
Bucle propioControl total del dispatchGuards, estado durable y pruebas

Revisa nombres y defaults en la documentación oficial antes de fijarlos en configuración. El principio —la terminación se aplica fuera del prompt— es estable; las API concretas cambian.

Checklist de aceptación

Antes de reconectar herramientas reales:

  • La siguiente llamada repetida se bloquea antes de ejecutarse.
  • success cierra la transición correspondiente.
  • Un error permanente no entra en retry.
  • Un error transitorio solo reintenta con deadline y cambio relevante.
  • Un ciclo de dos herramientas activa no_progress.
  • La paginación válida demuestra avance externo.
  • Una operación repetida devuelve el resultado durable.
  • Reiniciar el worker no reinicia idempotencia ni presupuesto global.
  • El run detenido muestra una razón y acciones permitidas.
  • La traza no contiene credenciales ni datos sensibles en bruto.

Cuando el bucle de aplicación ya esté estable, protege también la ruta pagada. El kill switch de gasto API para agentes LLM explica cómo bloquear la próxima llamada al proveedor con una reserva atómica. El LoopGuard controla comportamiento; el spend gate controla gasto. Son controles complementarios.

#Agentes de IA#Tool Calling#Fiabilidad#API Guardrails
Share: