Action is proposed as structured data (tool + args).
Проблема (з реального продакшену)
Агент “працює” у staging.
Потім приходить прод і ти дізнаєшся дві речі:
- агент — це loop, а loop не зупиняється з ввічливості
- фінанси — не monitoring система (але вони тебе точно знайдуть)
Ми бачили це багато разів:
- flaky tool додає retries
- retries множать tool calls
- tool calls множать tokens (“ось що сталося… спробуй ще раз”)
- і твій “кілька центів” агент раптом робить $8–$20 за один run
У масштабі це не “баг”. Це сюрприз-підписка, яку CFO не підписував.
Бюджети — це не “оптимізація”. Це safety controls. Вони визначають, що робити, якщо агент не може завершити задачу.
Якщо ти не вирішиш — вирішить агент. І зазвичай його рішення: “ще одна спроба”.
Чому це ламається в продакшені
1) Команди лімітують одну метрику і забувають про інші
Класика: “у нас є token budget”.
Ок. Агент витратив $0.04 на токени і $6 на browser automation.
У проді мінімально потрібні:
max_stepsmax_secondsmax_tool_callsmax_usd
2) Retries стають мультиплікатором у loop
Один retry не проблема. Retries всередині loop + retries tool’ів = множник вартості.
3) Бюджети без stop reasons — невидимі
Якщо run закінчується timeout’ом, користувачі просто повторять. Це створює більше run’ів.
Хочеш явні stop reasons:
max_secondsmax_tool_callsmax_usdloop_detected
Stop reasons = observability.
4) Бюджетний контроль “по всьому коду” не працює
Якщо бюджети перевіряються:
- інколи в агенті
- інколи в tool wrapper’і
- інколи ніде
…ти пропустиш шлях.
Поклади бюджети в choke point: run loop + tool gateway.
Приклад реалізації (реальний код)
Production-shaped budget guard:
- постійні перевірки
- трекінг model + tool costs (approx ок)
- typed stop reason для логів/алертів
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;
}Реальний інцидент (з цифрами)
Ми мали “невинного” агента, який шукав по внутрішній документації.
Tool інколи повертав 5xx. Tool layer ретраїла (логічно). Агент ретраїв теж (логічно). У loop “логічно” швидко перетворюється на “чому горить бюджет”.
Імпакт за один день:
- p95 cost/request з $0.06 → $2.10
- 429/5xx зросли (агент наполягав)
- сапорт отримав “повільно” тікети (бо так, повільно)
Fix:
- жорсткі бюджети в choke point
- stop reasons у логах + алертах
- dedupe/caching для повторних args
- read-only degrade mode замість “хай крутиться”
Компроміси
- Бюджети інколи відрізають легітимні run’и. Це краще, ніж нескінченні loops.
- Потрібні stop reasons + UX, інакше користувачі просто повторять.
- Точний cost tracking складний; approximate достатньо як guardrail.
Коли НЕ варто
- Якщо ти взагалі не можеш оцінити cost — почни хоча б зі steps/time/tool-calls.
- Навіть у детермінованих workflow бюджети корисні (менш жорсткі, але є).
Чекліст (можна копіювати)
- [ ] Steps budget (
max_steps) - [ ] Time budget (
max_seconds) - [ ] Tool-call budget (
max_tool_calls) - [ ] Spend budget (
max_usd) або approximate - [ ] Stop reasons логувати + показувати
- [ ] Enforcement у tool gateway + run loop
- [ ] Alerting на сплеск budget stops
Безпечний дефолтний конфіг (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)
Використовується в патернах
Пов’язані відмови
Q: Token budget достатньо?
A: Ні. Tools часто дорожчі за tokens. Лімітуй steps/time/tool-calls і $.
Q: Потрібні бюджети per-tenant?
A: Так. Tiers працюють добре. Бюджети — частина продукту.
Q: Наскільки точним має бути cost tracking?
A: Не ідеальним. Для guardrails вистачає приблизного. Точна білінг-модель може бути пізніше.
Q: Що відповідати користувачу при budget stop?
A: Partial результат + stop reason + що робити далі (звузити scope тощо).
Пов’язані сторінки (3–6 лінків)
- 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