Budget Controls para agentes IA (pasos, tiempo, $) + Código

Si tu agente puede gastar tiempo y dinero sin límite, lo hará. Una policy de presupuesto que detiene runs de forma segura y expone stop reasons.
En esta página
  1. El problema (en producción)
  2. Por qué esto se rompe en producción
  3. 1) Los equipos presupuestan una cosa y se olvidan del resto
  4. 2) Los retries se vuelven multiplicadores dentro del loop
  5. 3) Budgets sin stop reasons son invisibles
  6. 4) Enforce disperso en el código no funciona
  7. Ejemplo de implementación (código real)
  8. Incidente real (con números)
  9. Trade-offs
  10. Cuándo NO usarlo
  11. Checklist (copiar/pegar)
  12. Config segura por defecto (JSON/YAML)
  13. FAQ (3–5)
  14. Páginas relacionadas (3–6 links)
Flujo interactivo
Escenario:
Paso 1/3: Execution

Action is proposed as structured data (tool + args).

El problema (en producción)

Tu agente “funciona” en staging.

Luego llega el tráfico real y aprendes dos cosas:

  1. un agente es un loop, y los loops no paran por buena educación
  2. finanzas no es un sistema de monitoring (pero te van a avisar igual)

Lo vimos muchas veces:

  • un tool flaky añade retries
  • los retries multiplican tool calls
  • los tool calls suben tokens (“esto pasó… intenta otra vez”)
  • y tu agente “de centavos” hace $8–$20 por run

A escala, eso no es “un bug”. Es una suscripción sorpresa que tu CFO no firmó.

Los budgets no son “optimización”. Son controles de seguridad. Deciden qué pasa cuando el agente no puede terminar.

Si tú no decides, decide el agente. Y suele decidir: “un intento más”.

Por qué esto se rompe en producción

1) Los equipos presupuestan una cosa y se olvidan del resto

Error clásico: “tenemos un token budget”.

Genial. Tu agente gastó $0.04 en tokens y $6 en browser automation.

En prod necesitas mínimo:

  • max_steps
  • max_seconds
  • max_tool_calls
  • max_usd

2) Los retries se vuelven multiplicadores dentro del loop

Un retry no es el problema. Retries dentro del loop + retries del tool = multiplicador de costo.

3) Budgets sin stop reasons son invisibles

Si el run termina en timeout, el usuario reintenta. Eso crea más runs.

Stop reasons explícitos:

  • max_seconds
  • max_tool_calls
  • max_usd
  • loop_detected

Stop reasons = observabilidad.

4) Enforce disperso en el código no funciona

Si los budgets se checkean:

  • a veces en el agente
  • a veces en wrappers
  • a veces nunca

…te perderás un camino.

Pon budgets en un choke point: loop + tool gateway.

Ejemplo de implementación (código real)

Un budget guard “production-shaped”:

  • checks continuos
  • tracking de costo model + tools (approx ok)
  • stop reason tipado para logs/alertas
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;
}

Incidente real (con números)

Tuvimos un agente “inofensivo” que buscaba en docs internas.

Un tool devolvía 5xx a veces. La capa tool retryaba (normal). El agente también (normal). En el loop, “normal” se convierte en “por qué se quema el budget”.

Impacto en una tarde:

  • p95 costo/request de $0.06 → $2.10
  • subieron 429/5xx (el agente insistía)
  • soporte recibió tickets “está lento” (porque estaba lento)

Fix:

  1. budgets duros en un choke point
  2. stop reasons en logs + alertas
  3. dedupe/caching para args repetidos
  4. modo read-only degrade en vez de dejarlo correr

Trade-offs

  • Budgets cortan runs legítimos a veces. Mejor que loops sin fin.
  • Necesitas stop reasons + UX, si no la gente reintenta.
  • El costo exacto es difícil; la aproximación sirve como guardrail.

Cuándo NO usarlo

  • Si no puedes estimar costos, empieza al menos con steps/time/tool-calls.
  • Incluso en workflows deterministas, deja budgets (menos estrictos, pero presentes).

Checklist (copiar/pegar)

  • [ ] Steps budget (max_steps)
  • [ ] Time budget (max_seconds)
  • [ ] Tool-call budget (max_tool_calls)
  • [ ] Spend budget (max_usd) (o approximation)
  • [ ] Stop reasons loggeados + visibles
  • [ ] Enforcement en tool gateway + run loop
  • [ ] Alertas por spikes de stops de budget

Config segura por defecto (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 alcanza?
No. Los tools suelen costar más que tokens. Presupuesta steps/time/tool-calls y $.
¿Budgets por tenant?
Sí. Tiers funcionan bien. Los budgets son parte del producto.
¿Qué tan preciso debe ser el tracking de costo?
No perfecto. Para guardrails alcanza un aproximado. La facturación exacta puede venir después.
¿Qué respondo al usuario cuando se corta por budget?
Resultado parcial + stop reason claro + qué hacer luego (reducir scope, etc.).

P: ¿Un token budget alcanza?
R: No. Los tools suelen costar más que tokens. Presupuesta steps/time/tool-calls y $.

P: ¿Budgets por tenant?
R: Sí. Tiers funcionan bien. Los budgets son parte del producto.

P: ¿Qué tan preciso debe ser el tracking de costo?
R: No perfecto. Para guardrails alcanza un aproximado. La facturación exacta puede venir después.

P: ¿Qué respondo al usuario cuando se corta por budget?
R: Resultado parcial + stop reason claro + qué hacer luego (reducir scope, etc.).

No sabes si este es tu caso?

Disena tu agente ->
⏱️ 7 min de lecturaActualizado Mar, 2026Dificultad: ★★★
Implementar en OnceOnly
Budgets + permissions you can enforce at the boundary.
Usar en 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 }
Integrado: control en producciónOnceOnly
Guardrails para agentes con tool-calling
Lleva este patrón a producción con gobernanza:
  • Presupuestos (pasos / topes de gasto)
  • Permisos de herramientas (allowlist / blocklist)
  • Kill switch y parada por incidente
  • Idempotencia y dedupe
  • Audit logs y trazabilidad
Mención integrada: OnceOnly es una capa de control para sistemas de agentes en producción.
Autor

Esta documentación está curada y mantenida por ingenieros que despliegan agentes de IA en producción.

El contenido es asistido por IA, con responsabilidad editorial humana sobre la exactitud, la claridad y la relevancia en producción.

Los patrones y las recomendaciones se basan en post-mortems, modos de fallo e incidentes operativos en sistemas desplegados, incluido durante el desarrollo y la operación de infraestructura de gobernanza para agentes en OnceOnly.