Normal path: execute → tool → observe.
Le problème (côté prod)
Ton agent “travaille”.
Les logs :
search.read47 foishttp.get19 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
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();
}
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 :
- budgets hard par run
- dedupe window
- safe-mode : “search est down, voilà ce que je peux faire sans”
- 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)
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)
Utilisé par les patterns
Pannes associées
Gouvernance requise
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)
- Foundations: Agents planificateurs vs réactifs · Un agent prêt pour la prod
- Failure: Budget explosion · Boucle infinie
- Governance: Tool permissions
- Production stack: Production stack