Normal path: execute → tool → observe.
Проблема (з реального продакшену)
Твій агент “працює”.
Логи кажуть:
search.readвикликаний 47 разівhttp.getвикликаний 19 разів- запит усе одно в таймауті
Користувач бачить: нічого.
Ти бачиш: рахунок.
Tool spam — один із найчастіших “перших інцидентів” у проді, бо він не виглядає катастрофою. Він виглядає як “агент старається”. Насправді це зазвичай loop із красивішим текстом.
Чому це ламається в продакшені
Tool spam майже ніколи не має одну причину. Це комбо дрібних помилок.
1) Немає бюджету на tool calls (або є лише step budget)
Step budget не допомагає, якщо один “step” може викликати 5 tools. Потрібні обидва:
- max steps
- max tool calls
- max time
- max spend
2) Tool output трохи недетермінований
Пошук недетермінований. Сторінки змінюються. Результати переупорядковуються.
Якщо агент очікує “same input → same output”, він буде пробувати, доки “не стане впевненим”. Confidence — не stop condition.
3) Немає dedupe window
Якщо агент викликає той самий tool з тими самими args — це не “ретельність”. Це баг.
Фікс нудний: кешуй tool calls за (tool_name, args_hash) в межах run (або короткого вікна).
4) Немає пам’яті “я це вже робив”
Reactive loop’ам потрібен scratchpad:
- “я вже шукав X”
- “я вже фетчив Y”
- “це не допомогло, бо Z”
Без цього агент знову знаходить ті самі dead ends.
5) Retries множать спам
Якщо tool має retries і агент теж “ретраїть”, ре-емітячи виклик, ти отримуєш:
- tool retry storm
- плюс agent loop
Так плавляться rate limits.
Приклад реалізації (реальний код)
Мінімальний “anti-spam” tool gateway:
- budget на tool calls в run
- dedupe window
- дешевий кеш по args hash
import hashlib
import json
import time
from dataclasses import dataclass
from typing import Any, Callable
def stable_hash(obj: Any) -> str:
raw = json.dumps(obj, sort_keys=True, ensure_ascii=False).encode("utf-8")
return hashlib.sha256(raw).hexdigest()
@dataclass
class ToolBudgets:
max_calls: int = 12
dedupe_window_s: int = 60
class ToolSpamDetected(RuntimeError):
pass
class ToolGateway:
def __init__(self, *, impls: dict[str, Callable[..., Any]], budgets: ToolBudgets):
self.impls = impls
self.budgets = budgets
self.calls = 0
self.cache: dict[str, tuple[float, Any]] = {}
def call(self, name: str, args: dict[str, Any]) -> Any:
self.calls += 1
if self.calls > self.budgets.max_calls:
raise ToolSpamDetected(f"tool budget exceeded (calls={self.calls})")
key = f"{name}:{stable_hash(args)}"
now = time.time()
hit = self.cache.get(key)
if hit:
ts, val = hit
if now - ts <= self.budgets.dedupe_window_s:
return val
fn = self.impls.get(name)
if not fn:
raise RuntimeError(f"unknown tool: {name}")
val = fn(**args)
self.cache[key] = (now, val)
return valimport crypto from "node:crypto";
export class ToolSpamDetected extends Error {}
export function stableHash(obj) {
const raw = JSON.stringify(obj);
return crypto.createHash("sha256").update(raw).digest("hex");
}
export class ToolGateway {
constructor({ impls = {}, budgets = { maxCalls: 12, dedupeWindowS: 60 } } = {}) {
this.impls = impls;
this.budgets = budgets;
this.calls = 0;
this.cache = new Map(); // key -> { ts, val }
}
call(name, args) {
this.calls += 1;
if (this.calls > this.budgets.maxCalls) {
throw new ToolSpamDetected("tool budget exceeded (calls=" + this.calls + ")");
}
const key = name + ":" + stableHash(args);
const now = Date.now() / 1000;
const hit = this.cache.get(key);
if (hit && now - hit.ts <= this.budgets.dedupeWindowS) return hit.val;
const fn = this.impls[name];
if (!fn) throw new Error("unknown tool: " + name);
const val = fn(args);
this.cache.set(key, { ts: now, val });
return val;
}
}Це не “виліковує агентів”. Це вирішує одну нудну річ: повтори з тими самими args не спалюють бюджет.
Все одно потрібні:
- loop detection в агент-лупі
- stop reasons
- і спосіб віддати partial results, коли бюджет вичерпано
Реальний інцидент (з цифрами)
Ми зашипили support-агента, який використовував search.read, щоб знайти релевантні KB сторінки.
Під час vendor-outage пошуку результати стали нестабільні (таймаути + partial responses). Агент сприйняв це як “недостатньо впевненості” й продовжив шукати.
Impact (за один ранок):
- середні tool calls/run: 3 → 28
- спрацювали rate limits і деградували інші сервіси
- витрати на модель + tools: +$310 за день (майже в нікуди)
Fix:
- hard budgets на tool calls per run
- dedupe window за tool+args
- safe-mode: “зараз пошук нестабільний; ось що я можу без нього”
- алерти на спайки
tool_calls/run
Tool spam — не “цікавість моделі”. Це відсутність гальм.
Компроміси
- Кеш/дедуп ховає реальні зміни (добре для стабільності, погано для freshness).
- Budgets можуть обрізати “майже готові” рани (краще, ніж банкрутні рани).
- Safe-mode знижує якість, але підвищує надійність і контроль вартості.
Коли НЕ варто
- Якщо freshness важливіший за cost — не кешуй агресивно (менші вікна).
- Якщо tool детермінований і дешевий — дедуп може бути зайвий (але budgets лишай).
- Якщо задача детермінована — не потрібен агент. Пиши workflow.
Чекліст (можна копіювати)
- [ ] Max tool calls per run
- [ ] Max time per run
- [ ] Dedupe window per (tool, args hash)
- [ ] Cache read tools (short TTL)
- [ ] Retry policy в одному місці (gateway), не в агенті + tool
- [ ] Loop detection: повтори action key зупиняють run
- [ ] Stop reasons: tool budget vs time budget vs loop detected
- [ ] Алерти:
tool_calls/run,spend/run,latency/run
Безпечний дефолтний конфіг (JSON/YAML)
budgets:
max_steps: 25
max_tool_calls: 12
max_seconds: 60
tools:
dedupe_window_s: 60
cache_ttl_s: 30
retries:
max_attempts: 2
retryable_status: [408, 429, 500, 502, 503, 504]
FAQ (3–5)
Використовується в патернах
Пов’язані відмови
Q: Хіба більше пошуку не краще?
A: Не якщо це один і той самий пошук 30 разів. У проді повторні tool calls — симптом, а не старанність.
Q: Чи треба дедупити між різними run’ами?
A: Зазвичай ні. Дедуп у межах run (або короткого вікна). Cross-run кеш потребує складної інвалідації.
Q: Де мають жити ретраї?
A: В одному choke point: tool gateway. Якщо ретраї є і в tool, і в агенті — ти будуєш storms.
Q: Що повертати, коли бюджет закінчився?
A: Partial results + чіткий stop reason. Тихі таймаути вчать користувачів спамити refresh.
Пов’язані сторінки (3–6 лінків)
- Foundations: Planning vs reactive agents · Production-ready агент
- Failure: Budget explosion · Infinite loop
- Governance: Tool permissions (allowlists)
- Production stack: Production agent stack