Action is proposed as structured data (tool + args).
Le problème (côté prod)
Tu regardes l’usage LLM et tu te dis : « ça va ».
Puis tu reçois la facture du vendor navigateur / du scraper / du data provider. Et tu réalises que tu as mis une limitation de vitesse… sur une seule roue.
Les cost limits ne sont pas sexy. Mais c’est la différence entre :
- « l’agent est parfois cher »
- et « l’agent est un générateur de coût non borné »
Pourquoi ça casse en prod
1) Les équipes limitent les tokens et oublient les tools
Dans des agents réels :
- les tokens sont relativement prévisibles
- les tools sont la partie chaotique (retries, rate limits, travail variable)
Si un tool coûte $0.20 par call, 10 calls = $2. Ça arrive plus vite que ton run ne finisse.
2) Coût sans stop reason = pas opérable
Si tu ne vois qu’un « timeout », personne ne comprend que c’est « trop cher ». Et les utilisateurs relancent. Et ça devient plus cher.
3) Sans caps par tenant, un client peut cramer ton mois
Multi-tenant = un seul client peut ruiner ton graphique si tu ne capes pas.
Exemple d’implémentation (code réel)
Un cost guard simple :
- tokens + tool costs dans un state
- check après chaque step / tool call
- stop reason
max_usd
from dataclasses import dataclass, field
import time
TOOL_USD = {
"browser.run": 0.20,
"search.read": 0.00,
}
@dataclass(frozen=True)
class CostPolicy:
max_usd: float = 1.00
max_seconds: int = 60
@dataclass
class CostState:
started_at: float = field(default_factory=time.time)
tokens_in: int = 0
tokens_out: int = 0
tool_usd: float = 0.0
def elapsed_s(self) -> float:
return time.time() - self.started_at
def estimate_model_usd(tokens_in: int, tokens_out: int) -> float:
return (tokens_in + tokens_out) * 0.000002
class CostExceeded(RuntimeError):
def __init__(self, stop_reason: str, *, state: CostState):
super().__init__(stop_reason)
self.stop_reason = stop_reason
self.state = state
class CostGuard:
def __init__(self, policy: CostPolicy):
self.policy = policy
self.state = CostState()
def total_usd(self) -> float:
return estimate_model_usd(self.state.tokens_in, self.state.tokens_out) + self.state.tool_usd
def check(self) -> None:
if self.total_usd() > self.policy.max_usd:
raise CostExceeded("max_usd", state=self.state)
if self.state.elapsed_s() > self.policy.max_seconds:
raise CostExceeded("max_seconds", state=self.state)
def on_model(self, *, tokens_in: int, tokens_out: int) -> None:
self.state.tokens_in += tokens_in
self.state.tokens_out += tokens_out
self.check()
def on_tool(self, *, tool: str) -> None:
self.state.tool_usd += float(TOOL_USD.get(tool, 0.0))
self.check()const TOOL_USD = { "browser.run": 0.2, "search.read": 0.0 };
export class CostExceeded extends Error {
constructor(stopReason, { state }) {
super(stopReason);
this.stopReason = stopReason;
this.state = state;
}
}
export class CostGuard {
constructor(policy) {
this.policy = policy;
this.state = { startedAtMs: Date.now(), tokensIn: 0, tokensOut: 0, toolUsd: 0 };
}
totalUsd() {
return estimateModelUsd(this.state.tokensIn, this.state.tokensOut) + this.state.toolUsd;
}
check() {
if (this.totalUsd() > this.policy.maxUsd) throw new CostExceeded("max_usd", { state: this.state });
if ((Date.now() - this.state.startedAtMs) / 1000 > this.policy.maxSeconds) {
throw new CostExceeded("max_seconds", { state: this.state });
}
}
onModel({ tokensIn, tokensOut }) {
this.state.tokensIn += tokensIn;
this.state.tokensOut += tokensOut;
this.check();
}
onTool({ tool }) {
this.state.toolUsd += Number(TOOL_USD[tool] ?? 0);
this.check();
}
}
function estimateModelUsd(tokensIn, tokensOut) {
return (tokensIn + tokensOut) * 0.000002;
}Incident réel (avec chiffres)
On a vu une loop de recherche « simple » qui utilisait des tools navigateur.
Le vendor était instable ce jour-là. Les tools retryent. L’agent retry aussi. Et un seul request utilisateur coûte plus que ta moyenne journalière.
Impact :
- p95 spend/request monté à $4.80
- la queue s’est remplie (les runs duraient plus longtemps)
- support a passé ~2h à trier des tickets « c’est lent »
Fix :
- hard cap
max_usd+ stop reason clair - circuit breaker tool-level quand le vendor devient flaky
- caching/dedupe sur les mêmes URLs/queries
Compromis
- Les caps peuvent couper des runs utiles.
- Le coût exact par tool call est dur ; l’approx suffit en guardrail.
- Cost limits sans budgets (steps/time/tool calls) = incomplet.
Quand NE PAS l’utiliser
- Si tu ne peux vraiment pas estimer le coût, mets au moins
max_seconds+max_tool_calls. - Pour des tools read-only internes, tu veux quand même capper dès qu’un vendor facture.
Checklist (copier-coller)
- [ ] cap
max_usdpar run - [ ] stop reason
max_usddans logs + réponse - [ ] coûts tools approximés (conservateur)
- [ ] circuit breaker vendor flaky
- [ ] caps par tenant / tiers
- [ ] alert sur spikes de stops
max_usd
Config par défaut sûre (JSON/YAML)
cost_limits:
max_usd: 1.00
max_seconds: 60
tool_costs_usd:
browser.run: 0.20
search.read: 0.00
stop_reasons:
log: true
surface_to_user: true
FAQ (3–5)
Utilisé par les patterns
Pannes associées
Gouvernance requise
Q: Je peux capper seulement les tools ?
A: Non. Les tokens peuvent aussi exploser (context long, retries). Track les deux.
Q: Comment faire des caps par tenant ?
A: Avec des tiers. Et log le spend par tenant, sinon tu le découvres en fin de mois.
Q: C’est quoi une bonne cap par défaut ?
A: Assez basse pour éviter les surprises, assez haute pour les runs normaux. Commence à $1 et ajuste avec les données.
Q: Pourquoi ne pas juste prendre le modèle le moins cher ?
A: Souvent le modèle n’est pas le plus cher. Les tool calls + retries sont le vrai driver.
Pages liées (3–6 liens)
- Foundations: How agents use tools · How LLM limits affect agents
- Failure: Budget explosion · Token overuse incidents
- Governance: Budget controls · Step limits
- Production stack: Production agent stack