Tool spam loops (failure mode + fixes + code)

  • Repère la panne tôt, avant que la facture grimpe.
  • Comprends ce qui casse en prod, et pourquoi.
  • Copie des garde-fous : budgets, stop reasons, validation.
  • Sache quand ce n’est pas la vraie cause.
Signaux de détection
  • Tool calls/run explosent (ou se répètent avec args hash).
  • Spend/tokens montent sans amélioration des outputs.
  • Retries passent de rares à constants (429/5xx).
Quand un agent appelle le même tool en boucle, tu payes. Voilà comment le tool spam arrive en prod et comment le stopper.
Sur cette page
  1. Le problème (côté prod)
  2. Pourquoi ça casse en prod
  3. 1) Pas de budget tool-calls
  4. 2) Output légèrement non déterministe
  5. 3) Pas de dedupe window
  6. 4) Pas de mémoire “j’ai déjà essayé”
  7. 5) Retries qui se multiplient
  8. Exemple d’implémentation (code réel)
  9. Incident réel (avec chiffres)
  10. Compromis
  11. Quand NE PAS l’utiliser
  12. Checklist (copier-coller)
  13. Config par défaut sûre (JSON/YAML)
  14. FAQ (3–5)
  15. Pages liées (3–6 liens)
Flux interactif
Scénario:
Étape 1/2: Execution

Normal path: execute → tool → observe.

Le problème (côté prod)

Ton agent “travaille”.

Les logs :

  • search.read 47 fois
  • http.get 19 fois
  • request timeout

L’utilisateur voit : rien.

Toi tu vois : une facture.

Le tool spam est un “premier incident” classique parce que ça ressemble à de la persévérance. En vrai, c’est juste une boucle.

Pourquoi ça casse en prod

1) Pas de budget tool-calls

Un budget de steps ne suffit pas si un step fait 5 tool calls. Il faut : steps, tool calls, temps, spend.

2) Output légèrement non déterministe

Search bouge, le web bouge, l’ordre bouge. Si l’agent cherche “la certitude”, il va re-caller. La certitude n’est pas une stop condition.

3) Pas de dedupe window

Même tool + mêmes args = bug, pas diligence.

4) Pas de mémoire “j’ai déjà essayé”

Un agent réactif a besoin d’un scratchpad, sinon il rediscover les mêmes impasses.

5) Retries qui se multiplient

Si le tool retry et l’agent ré-essaye aussi, tu fais des storms.

Exemple d’implémentation (code réel)

Gateway anti-spam minimal :

  • budget de tool calls par run
  • dedupe window (tool+args hash)
  • cache cheap
PYTHON
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 val
JAVASCRIPT
import 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();
}

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;
}
}

Ça ne “résout” pas les agents. Ça évite juste de payer 30 fois la même requête.

Incident réel (avec chiffres)

On avait un agent support qui utilisait search.read pour trouver des pages KB.

Pendant un outage vendor, les résultats devenaient instables (timeouts + partiels). L’agent a continué à chercher “parce que parfois ça marche”.

Impact :

  • tool calls/run : 3 → 28
  • rate limits qui ont dégradé d’autres services
  • spend + tools : +$310 sur la journée

Fix :

  1. budgets hard par run
  2. dedupe window
  3. safe-mode : “search est down, voilà ce que je peux faire sans”
  4. alerting sur tool_calls/run

Ce n’est pas de la curiosité. C’est l’absence de freins.

Compromis

  • Cache/dedupe peut masquer des changements réels (stabilité vs fraîcheur).
  • Budgets coupent des runs “presque finis”.
  • Safe-mode baisse la qualité, améliore la fiabilité et les coûts.

Quand NE PAS l’utiliser

  • Si la fraîcheur est critique, garde des fenêtres de cache courtes.
  • Si un tool est déterministe et cheap, dedupe est moins vital (budgets restent).
  • Si c’est déterministe, fais un workflow.

Checklist (copier-coller)

  • [ ] Max tool calls/run
  • [ ] Max time/run
  • [ ] Dedupe window (tool, args hash)
  • [ ] Cache read tools (TTL court)
  • [ ] Retries à un seul endroit (gateway)
  • [ ] Loop detection (répétitions)
  • [ ] Stop reasons explicites
  • [ ] Alerting sur tool_calls/run, spend/run, latency/run

Config par défaut sûre (JSON/YAML)

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)

Plus de recherche, ce n’est pas mieux ?
Pas si c’est la même recherche 30 fois. En prod, les calls répétitifs sont un symptôme.
Dedupe entre runs ?
En général non. Dedupe dans un run (ou fenêtre courte). Le cache cross-run nécessite une invalidation sérieuse.
Où mettre les retries ?
Un choke point : le tool gateway. Agent + tool qui retry = storm.
Que renvoyer quand on hit le budget ?
Du partiel + une stop reason claire. Les timeouts silencieux entraînent les users à refresh.

Q : Plus de recherche, ce n’est pas mieux ?
R : Pas si c’est la même recherche 30 fois. En prod, les calls répétitifs sont un symptôme.

Q : Dedupe entre runs ?
R : En général non. Dedupe dans un run (ou fenêtre courte). Le cache cross-run nécessite une invalidation sérieuse.

Q : Où mettre les retries ?
R : Un choke point : le tool gateway. Agent + tool qui retry = storm.

Q : Que renvoyer quand on hit le budget ?
R : Du partiel + une stop reason claire. Les timeouts silencieux entraînent les users à refresh.

Pages liées (3–6 liens)

Pas sur que ce soit votre cas ?

Concevez votre agent ->
⏱️ 6 min de lectureMis à jour Mars, 2026Difficulté: ★★☆
Implémenter dans OnceOnly
Guardrails for loops, retries, and spend escalation.
Utiliser dans OnceOnly
# onceonly guardrails (concept)
version: 1
budgets:
  max_steps: 25
  max_tool_calls: 12
  max_seconds: 60
  max_usd: 1.00
policy:
  tool_allowlist:
    - search.read
    - http.get
controls:
  loop_detection:
    enabled: true
    dedupe_by: [tool, args_hash]
  retries:
    max: 2
    backoff_ms: [200, 800]
stop_reasons:
  enabled: true
logging:
  tool_calls: { enabled: true, store_args: false, store_args_hash: true }
Intégré : contrôle en productionOnceOnly
Ajoutez des garde-fous aux agents tool-calling
Livrez ce pattern avec de la gouvernance :
  • Budgets (steps / plafonds de coût)
  • Kill switch & arrêt incident
  • Audit logs & traçabilité
  • Idempotence & déduplication
  • Permissions outils (allowlist / blocklist)
Mention intégrée : OnceOnly est une couche de contrôle pour des systèmes d’agents en prod.
Exemple de policy (concept)
# Example (Python — conceptual)
policy = {
  "budgets": {"steps": 20, "seconds": 60, "usd": 1.0},
  "controls": {"kill_switch": True, "audit": True},
}
Auteur

Cette documentation est organisée et maintenue par des ingénieurs qui déploient des agents IA en production.

Le contenu est assisté par l’IA, avec une responsabilité éditoriale humaine quant à l’exactitude, la clarté et la pertinence en production.

Les patterns et recommandations s’appuient sur des post-mortems, des modes de défaillance et des incidents opérationnels dans des systèmes déployés, notamment lors du développement et de l’exploitation d’une infrastructure de gouvernance pour les agents chez OnceOnly.