Normal path: execute → tool → observe.
El problema (en producción)
Tu agente está “trabajando”.
Los logs dicen:
search.readllamado 47 veceshttp.getllamado 19 veces- la request igual timeouteó
El usuario ve: nada.
Tú ves: factura.
El tool spam es uno de los “primeros incidentes” más comunes porque no parece catastrófico. Parece “el agente se está esforzando”. En realidad, casi siempre es un loop con texto más bonito.
Por qué esto se rompe en producción
El tool spam casi nunca viene de una sola causa. Es un ecosistema de errores pequeños.
1) No hay presupuesto de tool calls (o solo hay presupuesto de pasos)
Un step budget no sirve si un “paso” puede disparar 5 tools. Necesitas ambos:
- max steps
- max tool calls
- max tiempo
- max gasto
2) El output del tool es ligeramente no determinista
Search no es determinista. Las páginas cambian. Los resultados basados en tiempo se reordenan.
Si el agente espera “mismo input → mismo output”, seguirá intentando hasta que “se sienta confiado”. Confianza no es una stop condition.
3) No hay dedupe window
Si el agente llama el mismo tool con los mismos args, eso no es “thoroughness”. Es un bug.
El fix es aburrido: cachea tool calls por (tool_name, args_hash) dentro de un run (o dentro de una ventana corta).
4) El agente no tiene memoria de “ya lo intenté”
Los loops reactivos necesitan un scratchpad:
- “Busqué X”
- “Fetcheé Y”
- “Esto no ayudó porque Z”
Sin eso, el agente vuelve a descubrir el mismo callejón sin salida.
5) Los retries multiplican el spam
Si el tool tiene retries y el agente también retría re-emitiendo la llamada, consigues:
- una tormenta de retries del tool
- más el loop del agente
Así derrites rate limits.
Ejemplo de implementación (código real)
Un gateway mínimo “anti-spam”:
- presupuesto de tool calls por run
- dedupe window por tool
- caching barato por hash de args
import hashlib
import json
import time
from dataclasses import dataclass
from typing import Any, Callable
def stable_hash(obj: Any) -> str:
raw = json.dumps(obj, sort_keys=True, ensure_ascii=False).encode("utf-8")
return hashlib.sha256(raw).hexdigest()
@dataclass
class ToolBudgets:
max_calls: int = 12
dedupe_window_s: int = 60
class ToolSpamDetected(RuntimeError):
pass
class ToolGateway:
def __init__(self, *, impls: dict[str, Callable[..., Any]], budgets: ToolBudgets):
self.impls = impls
self.budgets = budgets
self.calls = 0
self.cache: dict[str, tuple[float, Any]] = {}
def call(self, name: str, args: dict[str, Any]) -> Any:
self.calls += 1
if self.calls > self.budgets.max_calls:
raise ToolSpamDetected(f"tool budget exceeded (calls={self.calls})")
key = f"{name}:{stable_hash(args)}"
now = time.time()
hit = self.cache.get(key)
if hit:
ts, val = hit
if now - ts <= self.budgets.dedupe_window_s:
return val
fn = self.impls.get(name)
if not fn:
raise RuntimeError(f"unknown tool: {name}")
val = fn(**args)
self.cache[key] = (now, val)
return valimport crypto from "node:crypto";
export class ToolSpamDetected extends Error {}
export function stableHash(obj) {
const raw = JSON.stringify(obj);
return crypto.createHash("sha256").update(raw).digest("hex");
}
export class ToolGateway {
constructor({ impls = {}, budgets = { maxCalls: 12, dedupeWindowS: 60 } } = {}) {
this.impls = impls;
this.budgets = budgets;
this.calls = 0;
this.cache = new Map(); // key -> { ts, val }
}
call(name, args) {
this.calls += 1;
if (this.calls > this.budgets.maxCalls) {
throw new ToolSpamDetected("tool budget exceeded (calls=" + this.calls + ")");
}
const key = name + ":" + stableHash(args);
const now = Date.now() / 1000;
const hit = this.cache.get(key);
if (hit && now - hit.ts <= this.budgets.dedupeWindowS) return hit.val;
const fn = this.impls[name];
if (!fn) throw new Error("unknown tool: " + name);
const val = fn(args);
this.cache.set(key, { ts: now, val });
return val;
}
}Esto no “arregla agentes”. Arregla una cosa aburrida: llamadas repetidas con los mismos args dejan de quemar presupuesto.
Igual necesitas:
- loop detection en el agent loop
- stop reasons claros
- y poder devolver resultados parciales cuando se llegue al budget
Incidente real (con números)
Shipeamos un agente de soporte que usaba search.read para encontrar páginas relevantes de la KB.
Durante un outage del proveedor de search, los resultados se volvieron inestables (timeouts + respuestas parciales). El agente lo interpretó como “no tengo suficiente confianza” y siguió buscando.
Impacto (una mañana):
- tool calls promedio por run: 3 → 28
- se dispararon rate limits y degradaron otros servicios
- gasto modelo + tools: +$310 ese día (mayormente basura)
Fix:
- budgets duros de tool calls por run (hard stop)
- dedupe window por tool+args
- safe-mode: “no puedo buscar ahora; aquí está lo que sé sin search”
- alertas cuando
tool_calls/runse dispare
El tool spam no es “curiosidad”. Es falta de frenos.
Trade-offs
- Caching/dedupe puede esconder cambios reales (bien para estabilidad, mal para frescura).
- Los budgets pueden cortar runs “casi listos” (mejor que runs que te arruinan).
- Safe-mode baja calidad, pero sube fiabilidad y control de coste.
Cuándo NO usarlo
- Si la frescura importa más que el coste, no cachees agresivo (ventanas más pequeñas).
- Si un tool es determinista y barato, dedupe quizá no aporta (aun así, mantén budgets).
- Si la tarea es determinista, no uses un agente. Usa un workflow.
Checklist (copiar/pegar)
- [ ] Max tool calls por run
- [ ] Max tiempo por run
- [ ] Dedupe window por (tool, args hash)
- [ ] Cache de read tools (TTL corto)
- [ ] Retry policy en un solo sitio (gateway), no en agente + tool
- [ ] Loop detection: action keys repetidas paran el run
- [ ] Stop reasons: tool budget vs time budget vs loop detected
- [ ] Alertas:
tool_calls/run,spend/run,latency/run
Config segura por defecto (JSON/YAML)
budgets:
max_steps: 25
max_tool_calls: 12
max_seconds: 60
tools:
dedupe_window_s: 60
cache_ttl_s: 30
retries:
max_attempts: 2
retryable_status: [408, 429, 500, 502, 503, 504]
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
- Outage parcial (fallo del agente + degrade mode + 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: ¿No es mejor buscar más?
A: No si es la misma búsqueda 30 veces. En producción, tool calls repetidas son un síntoma, no diligencia.
Q: ¿Debo deduplicar entre runs?
A: Normalmente no. Dedupe dentro de un run (o ventana corta). Cache cross-run necesita invalidación cuidadosa.
Q: ¿Dónde van los retries?
A: En un choke point: el tool gateway. Si el agente y el tool retrían, creas tormentas.
Q: ¿Qué devuelvo cuando se acaba el budget?
A: Resultados parciales + stop reason claro. Timeouts silenciosos entrenan a la gente a refrescar.
Páginas relacionadas (3–6 links)
- Foundations: Planning vs reactive agents · Agente listo para producción
- Failure: Explosión de presupuesto · Infinite loop
- Governance: Tool permissions (allowlists)
- Production stack: Production agent stack