Action is proposed as structured data (tool + args).
Le problème (côté prod)
Ton agent « marche » en staging.
Puis la prod arrive et tu apprends deux choses :
- un agent est une loop, et une loop n’arrête pas par politesse
- la finance n’est pas un système de monitoring (mais ils te pageront quand même)
On a vu le pattern assez souvent :
- un tool flaky déclenche des retries
- les retries multiplient les tool calls
- les tool calls ajoutent des tokens (« voilà ce qui s’est passé… retente »)
- et ton agent « quelques centimes » coûte $8–$20 par run
À l’échelle, ce n’est pas un bug. C’est un abonnement surprise que ton CFO n’a pas signé.
Les budgets ne sont pas de l’optimisation. Ce sont des contrôles de sécurité. Ils décident quoi faire quand l’agent ne peut pas terminer.
Si tu ne décides pas, l’agent décide. Et sa décision, c’est souvent : « encore un essai ».
Pourquoi ça casse en prod
1) Les équipes budgètent un truc et oublient le reste
Erreur classique : « on a un token budget ».
Cool. Ton agent a dépensé $0.04 en tokens et $6 en automation navigateur.
En prod, il te faut au minimum :
max_steps(longueur de loop)max_seconds(temps wall-clock)max_tool_calls(blast radius)max_usd(la ligne “nope”)
2) Les retries sont multiplicatifs dans une loop
Un retry n’est pas le problème. Des retries dans une loop d’agent + retries tool → multiplier de coût.
3) Budgets sans stop reasons = invisibles
Si un run finit en timeout, les gens relancent. Ça crée plus de runs.
Tu veux des stop reasons explicites :
max_secondsmax_tool_callsmax_usdloop_detected
Les stop reasons, c’est de l’observabilité.
4) Un budget dispersé dans le codebase ne tient pas
Si les budgets sont checkés :
- parfois dans l’agent
- parfois dans le tool wrapper
- parfois jamais
…tu rates un chemin.
Mets les budgets dans un choke point : la run loop + le tool gateway.
Exemple d’implémentation (code réel)
Un budget guard “production-shaped” :
- check continu (pas « à la fin »)
- tracking du coût model + tools (approx ok)
- stop reason typé (log + alert)
from dataclasses import dataclass, field
import time
from typing import Any
TOOL_USD = {
"search.read": 0.00,
"http.get": 0.00,
"browser.run": 0.20, # placeholder
}
@dataclass(frozen=True)
class BudgetPolicy:
max_steps: int = 25
max_seconds: int = 60
max_tool_calls: int = 12
max_usd: float = 1.00
@dataclass
class BudgetState:
started_at: float = field(default_factory=time.time)
steps: int = 0
tool_calls: int = 0
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:
# Replace with your real pricing model(s). Approximate is fine for guards.
return (tokens_in + tokens_out) * 0.000002
class BudgetExceeded(RuntimeError):
def __init__(self, stop_reason: str, *, state: BudgetState):
super().__init__(stop_reason)
self.stop_reason = stop_reason
self.state = state
class BudgetGuard:
def __init__(self, policy: BudgetPolicy):
self.policy = policy
self.state = BudgetState()
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.state.steps > self.policy.max_steps:
raise BudgetExceeded("max_steps", state=self.state)
if self.state.elapsed_s() > self.policy.max_seconds:
raise BudgetExceeded("max_seconds", state=self.state)
if self.state.tool_calls > self.policy.max_tool_calls:
raise BudgetExceeded("max_tool_calls", state=self.state)
if self.total_usd() > self.policy.max_usd:
raise BudgetExceeded("max_usd", state=self.state)
def on_step(self) -> None:
self.state.steps += 1
self.check()
def on_model_call(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_call(self, *, tool: str) -> None:
self.state.tool_calls += 1
self.state.tool_usd += float(TOOL_USD.get(tool, 0.0))
self.check()
def run_agent(task: str, *, policy: BudgetPolicy) -> dict[str, Any]:
guard = BudgetGuard(policy)
try:
while True:
guard.on_step()
# model decides next action (pseudo)
action, tokens_in, tokens_out = llm_decide(task) # (pseudo)
guard.on_model_call(tokens_in=tokens_in, tokens_out=tokens_out)
if action.kind == "tool":
guard.on_tool_call(tool=action.name)
obs = call_tool(action.name, action.args) # (pseudo)
task = update_state(task, action, obs) # (pseudo)
continue
return {"status": "ok", "answer": action.final_answer, "usage": guard.state.__dict__}
except BudgetExceeded as e:
return {
"status": "stopped",
"stop_reason": e.stop_reason,
"usage": e.state.__dict__,
"partial": "Stopped by budget. Return partial results + a reason users can understand.",
}const TOOL_USD = {
"search.read": 0.0,
"http.get": 0.0,
"browser.run": 0.2, // placeholder
};
export class BudgetExceeded extends Error {
constructor(stopReason, { state }) {
super(stopReason);
this.stopReason = stopReason;
this.state = state;
}
}
export class BudgetGuard {
constructor(policy) {
this.policy = policy;
this.state = {
startedAtMs: Date.now(),
steps: 0,
toolCalls: 0,
tokensIn: 0,
tokensOut: 0,
toolUsd: 0,
};
}
elapsedS() {
return (Date.now() - this.state.startedAtMs) / 1000;
}
totalUsd() {
return estimateModelUsd(this.state.tokensIn, this.state.tokensOut) + this.state.toolUsd;
}
check() {
if (this.state.steps > this.policy.maxSteps) throw new BudgetExceeded("max_steps", { state: this.state });
if (this.elapsedS() > this.policy.maxSeconds) throw new BudgetExceeded("max_seconds", { state: this.state });
if (this.state.toolCalls > this.policy.maxToolCalls) throw new BudgetExceeded("max_tool_calls", { state: this.state });
if (this.totalUsd() > this.policy.maxUsd) throw new BudgetExceeded("max_usd", { state: this.state });
}
onStep() {
this.state.steps += 1;
this.check();
}
onModelCall({ tokensIn, tokensOut }) {
this.state.tokensIn += tokensIn;
this.state.tokensOut += tokensOut;
this.check();
}
onToolCall({ tool }) {
this.state.toolCalls += 1;
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 eu un agent « inoffensif » censé chercher dans la doc interne.
Un tool renvoyait parfois des 5xx. La couche tool retryait (logique). L’agent retryait aussi (logique). Et dans une loop, « logique » devient vite « pourquoi ça brûle notre budget ».
Impact sur un après-midi :
- p95 coût/request de $0.06 → $2.10
- 429/5xx ont augmenté (l’agent insistait)
- support a reçu des tickets : « c’est lent » (parce que c’était vraiment lent)
Fix :
- budgets durs dans un choke point
- stop reasons dans les logs + alerting
- dedupe/caching sur tool args répétés
- un mode read-only degrade au lieu de laisser courir
Compromis
- Les budgets coupent parfois des runs légitimes. Mieux que des loops infinies.
- Tu as besoin de stop reasons + UX, sinon les gens relancent.
- Le cost tracking exact est difficile ; l’approx suffit comme guardrail.
Quand NE PAS l’utiliser
- Si tu n’as aucune donnée de coût, commence au moins avec steps/time/tool-calls.
- Même en workflows déterministes, garde des budgets (moins stricts, mais présents).
Checklist (copier-coller)
- [ ] Steps budget (
max_steps) - [ ] Time budget (
max_seconds) - [ ] Tool-call budget (
max_tool_calls) - [ ] Spend budget (
max_usd) (ou approximation) - [ ] Stop reasons loggés + surfacés
- [ ] Enforcement dans le tool gateway + la run loop
- [ ] Alerting sur spikes de stops budget
Config par défaut sûre (JSON/YAML)
budgets:
max_steps: 25
max_seconds: 60
max_tool_calls: 12
max_usd: 1.00
stop_reasons:
log: true
surface_to_user: true
FAQ (3–5)
Utilisé par les patterns
Pannes associées
Gouvernance requise
Q: Un token budget suffit ?
A: Non. Les tools coûtent souvent plus cher que les tokens. Budgète steps/time/tool-calls et $.
Q: Je dois mettre des budgets par tenant ?
A: Oui. Les tiers (free/standard/enterprise) marchent bien. Les budgets font partie du produit.
Q: Le cost tracking doit être précis ?
A: Pas parfait. Les guards ont besoin d’un ordre de grandeur. La facturation exacte peut venir après.
Q: Je réponds quoi au user quand un run est stoppé ?
A: Un résultat partiel + un stop reason clair, et quoi faire ensuite (réduire le scope, etc.).
Pages liées (3–6 liens)
- Foundations: What makes an agent production-ready · Why agents fail in production
- Failure: Budget explosion · Tool spam loops
- Governance: Step limits · Kill switch design
- Production stack: Production agent stack