Budget Controls pour agents IA (steps, temps, $) + Code

Si ton agent peut dépenser du temps et de l’argent sans limite, il le fera. Une policy de budget qui stoppe proprement et expose des stop reasons.
Sur cette page
  1. Le problème (côté prod)
  2. Pourquoi ça casse en prod
  3. 1) Les équipes budgètent *un* truc et oublient le reste
  4. 2) Les retries sont multiplicatifs dans une loop
  5. 3) Budgets sans stop reasons = invisibles
  6. 4) Un budget dispersé dans le codebase ne tient pas
  7. Exemple d’implémentation (code réel)
  8. Incident réel (avec chiffres)
  9. Compromis
  10. Quand NE PAS l’utiliser
  11. Checklist (copier-coller)
  12. Config par défaut sûre (JSON/YAML)
  13. FAQ (3–5)
  14. Pages liées (3–6 liens)
Flux interactif
Scénario:
Étape 1/3: Execution

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 :

  1. un agent est une loop, et une loop n’arrête pas par politesse
  2. 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_seconds
  • max_tool_calls
  • max_usd
  • loop_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)
PYTHON
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.",
      }
JAVASCRIPT
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 :

  1. budgets durs dans un choke point
  2. stop reasons dans les logs + alerting
  3. dedupe/caching sur tool args répétés
  4. 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)

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)

Un token budget suffit ?
Non. Les tools coûtent souvent plus cher que les tokens. Budgète steps/time/tool-calls et $.
Je dois mettre des budgets par tenant ?
Oui. Les tiers (free/standard/enterprise) marchent bien. Les budgets font partie du produit.
Le cost tracking doit être précis ?
Pas parfait. Les guards ont besoin d’un ordre de grandeur. La facturation exacte peut venir après.
Je réponds quoi au user quand un run est stoppé ?
Un résultat partiel + un stop reason clair, et quoi faire ensuite (réduire le scope, etc.).

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)

Pas sur que ce soit votre cas ?

Concevez votre agent ->
⏱️ 7 min de lectureMis à jour Mars, 2026Difficulté: ★★★
Implémenter dans OnceOnly
Budgets + permissions you can enforce at the boundary.
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
writes:
  require_approval: true
  idempotency: true
controls:
  kill_switch: { enabled: 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)
  • Permissions outils (allowlist / blocklist)
  • Kill switch & arrêt incident
  • Idempotence & déduplication
  • Audit logs & traçabilité
Mention intégrée : OnceOnly est une couche de contrôle pour des systèmes d’agents en prod.
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.