Tool spam loops (failure mode + фікси + код)

  • Побач ранні сигнали, поки рахунок не поліз вгору.
  • Зрозумій, що ламається в проді й чому.
  • Скопіюй guardrails: budgets, stop reasons, validation.
  • Знай, коли це не справжня root cause.
Сигнали виявлення
  • Tool calls на run зростають (або повторюються з args hash).
  • Витрати/токени ростуть без кращих результатів.
  • Retries стають постійними (429/5xx).
Коли агент викликає той самий tool знов і знов, ти за це платиш. Як tool spam з’являється в проді й як зупинити його бюджетами та дедупом.
На цій сторінці
  1. Проблема (з реального продакшену)
  2. Чому це ламається в продакшені
  3. 1) Немає бюджету на tool calls (або є лише step budget)
  4. 2) Tool output трохи недетермінований
  5. 3) Немає dedupe window
  6. 4) Немає пам’яті “я це вже робив”
  7. 5) Retries множать спам
  8. Приклад реалізації (реальний код)
  9. Реальний інцидент (з цифрами)
  10. Компроміси
  11. Коли НЕ варто
  12. Чекліст (можна копіювати)
  13. Безпечний дефолтний конфіг (JSON/YAML)
  14. FAQ (3–5)
  15. Пов’язані сторінки (3–6 лінків)
Інтерактивний флоу
Сценарій:
Крок 1/2: Execution

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
PYTHON
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 val
JAVASCRIPT
import 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:

  1. hard budgets на tool calls per run
  2. dedupe window за tool+args
  3. safe-mode: “зараз пошук нестабільний; ось що я можу без нього”
  4. алерти на спайки 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)

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)

Хіба більше пошуку не краще?
Не якщо це один і той самий пошук 30 разів. У проді повторні tool calls — симптом, а не старанність.
Чи треба дедупити між різними run’ами?
Зазвичай ні. Дедуп у межах run (або короткого вікна). Cross-run кеш потребує складної інвалідації.
Де мають жити ретраї?
В одному choke point: tool gateway. Якщо ретраї є і в tool, і в агенті — ти будуєш storms.
Що повертати, коли бюджет закінчився?
Partial results + чіткий stop reason. Тихі таймаути вчать користувачів спамити refresh.

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 лінків)

Не впевнені, що це ваш кейс?

Спроєктувати агента →
⏱️ 6 хв читанняОновлено Бер, 2026Складність: ★★☆
Реалізувати в OnceOnly
Guardrails for loops, retries, and spend escalation.
Використати в 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
controls:
  loop_detection:
    enabled: true
    dedupe_by: [tool, args_hash]
  retries:
    max: 2
    backoff_ms: [200, 800]
stop_reasons:
  enabled: true
logging:
  tool_calls: { enabled: true, store_args: false, store_args_hash: true }
Інтегровано: продакшен-контрольOnceOnly
Додай guardrails до агентів з tool-calling
Зашип цей патерн з governance:
  • Бюджетами (кроки / ліміти витрат)
  • Kill switch та аварійна зупинка
  • Audit logs та трасування
  • Ідемпотентність і dedupe
  • Дозволами на інструменти (allowlist / blocklist)
Інтегрована згадка: OnceOnly — контрольний шар для продакшен агент-систем.
Приклад policy (концепт)
# Example (Python — conceptual)
policy = {
  "budgets": {"steps": 20, "seconds": 60, "usd": 1.0},
  "controls": {"kill_switch": True, "audit": True},
}
Автор

Цю документацію курують і підтримують інженери, які запускають AI-агентів у продакшені.

Контент створено з допомогою AI, із людською редакторською відповідальністю за точність, ясність і продакшн-релевантність.

Патерни та рекомендації базуються на постмортемах, режимах відмов і операційних інцидентах у розгорнутих системах, зокрема під час розробки та експлуатації governance-інфраструктури для агентів у OnceOnly.