Перейти к основному содержанию

Как остановить цикл вызовов инструментов у ИИ‑агента

11 мин чтенияAI API

Как остановить зацикливание ИИ‑агента до следующего вызова инструмента и доказать исправление на пяти воспроизводимых сценариях.

LoopGuard до выполнения проверяет повтор и идемпотентность, а после результата разводит успех, допустимый повтор и ошибку по отдельным путям.

Если ИИ‑агент повторяет один инструмент, сначала остановите текущий run, запретите новые retries и handoff, сохраните trace и заблокируйте следующий эквивалентный вызов до исполнения. После этого ставьте защиту в runtime: жесткий лимит ограничивает ущерб, детектор прогресса обнаруживает зацикливание, идемпотентность не дает повторить уже выполненное действие, а явный terminal result завершает run.

Исправление принято, когда выполняются пять условий:

  • следующий опасный вызов заблокирован до side effect;
  • run завершен с машинно читаемой причиной;
  • уже выполненная запись не повторилась;
  • в trace видно, какой guard сработал;
  • старый сбой воспроизводится на fake tool и теперь детерминированно останавливается.

LoopGuard до выполнения проверяет повтор и идемпотентность, а после результата разводит успех, допустимый повтор и ошибку по отдельным путям.

Аварийная остановка за пять минут

Не начинайте инцидент с переписывания system prompt. Инструкция «не повторяйся» может улучшить поведение модели, но не может отозвать уже поставленную задачу или запретить сетевой вызов.

  1. Остановите consumer или вызовите AbortController для текущего run. Не удаляйте durable state.
  2. Заморозьте автоматические retries, sub-agent scheduling и handoff для этого run_id.
  3. Поставьте deny-rule перед tool executor: тот же run_id больше не выполняет новые side effects.
  4. Сохраните последние tool calls, нормализованные аргументы, результаты, progress_version, idempotency keys и внешний статус задачи.
  5. Завершите run причиной operator_cancelled_loop или loop_guard_triggered, а не обычным timeout.

Если инструмент успел изменить CRM, отправить письмо или провести деплой, остановка агента не означает rollback. Сначала запросите состояние целевой системы и найдите operation record. Повторный вызов «на всякий случай» способен создать второй side effect.

Реальный инцидент n8n с повторным вызовом HubSpot показывает именно эту границу: успешное действие продолжало повторяться до максимума итераций. Четкий критерий завершения помог сценарию, но production-защита все равно должна жить перед executor, а не только в тексте prompt.

Определите тип цикла по trace

Слово «зациклился» скрывает несколько разных отказов. Сравнивайте не рассуждения модели, а действия и внешнее состояние.

Симптом в traceЧто проверитьПравильная реакция
Один tool и те же argsНормализованный fingerprintЗаблокировать третий эквивалентный вызов или более ранний локальный порог
Args слегка меняютсяСемантика цели и внешний state deltaСчитать jitter-loop, если состояние не меняется
read → check → readКороткий цикл последних signaturesПрервать цикл, не увеличивать общий лимит
Tool вернул success, затем вызван сноваOperation ledger и terminal flagВернуть сохраненный результат, не повторять side effect
Timeout или 429 повторяетсяRetry budget, backoff, новый момент времениРазрешить ограниченный retry после изменения precondition
permission_denied, invalid input, policy blockКласс ошибкиЗавершить или передать человеку; не retry
Вызовы разные, но задача стоитПроверяемый progress_versionОстановить как no_progress

Прогресс — не фраза агента «я продвинулся». Это подтверждаемое изменение: новый cursor, изменившийся статус заказа, принятая запись, уменьшившийся список незавершенных элементов или checkpoint с более высокой версией. Когда такого сигнала нет, разнообразные сообщения модели не превращают stall в работу.

Пять разных механизмов, а не один лимит

МеханизмНа какой вопрос отвечаетЧего не гарантирует
Hard capКакой максимальный ущерб допускает run?Что агент остановится рано и по правильной причине
Progress detectorДвигается ли внешняя задача?Что уже выполненный side effect не повторится после restart
Idempotency ledgerВыполнялась ли эта операция?Что модель выберет другой план
Terminal resultМожно ли продолжать этот run?Автоматический rollback внешней системы
Recovery policyЧто изменилось перед retry?Безопасность при обходе guard другим worker

Текущие официальные контракты подтверждают эту границу. OpenAI Agents SDK продолжает runner после tool calls и handoffs до реальной точки остановки. LangChain middleware отдельно предоставляет ограничения model calls и tool calls. Google ADK LoopAgent требует ограниченного числа итераций или явного выхода. Это полезные backstops, но ни один общий framework cap не знает, что конкретное обновление в вашей CRM уже было выполнено.

LoopGuard перед каждым tool execution

Ниже — framework-neutral TypeScript. Guard не пытается оценивать скрытые рассуждения модели. Он работает с тем, что приложение может проверить: лимиты, call fingerprint, короткий цикл, монотонную версию прогресса, terminal status и durable idempotency key.

ts
type Call = { tool: string; args: Record<string, unknown>; idempotencyKey?: string; }; type ToolResult = { status: | "progress" | "complete" | "retryable" | "fatal" | "permission_denied" | "policy_blocked" | "human_required"; progressVersion: number; sideEffectCommitted?: boolean; value?: unknown; }; type StopReason = | "step_cap" | "wall_clock_cap" | "same_call_repeat" | "short_cycle" | "no_progress" | "already_committed" | "complete" | "fatal" | "permission_denied" | "policy_blocked" | "human_required"; type Decision = | { action: "execute"; fingerprint: string } | { action: "stop"; reason: StopReason }; function stable(value: unknown): string { if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`; if (value && typeof value === "object") { const entries = Object.entries(value as Record<string, unknown>) .sort(([a], [b]) => a.localeCompare(b)) .map(([key, item]) => `${JSON.stringify(key)}:${stable(item)}`); return `{${entries.join(",")}}`; } return JSON.stringify(value); } function fingerprint(call: Call): string { return `${call.tool}:${stable(call.args)}`; } class LoopGuard { private steps = 0; private noProgress = 0; private progressVersion: number; private recent: string[] = []; private committed = new Set<string>(); private startedAt = Date.now(); constructor( private limits = { maxSteps: 20, maxSameCall: 2, maxNoProgress: 3, maxWallMs: 60_000, cycleWindow: 8, }, initialProgressVersion = 0, ) { this.progressVersion = initialProgressVersion; } before(call: Call): Decision { if (Date.now() - this.startedAt >= this.limits.maxWallMs) { return { action: "stop", reason: "wall_clock_cap" }; } if (this.steps >= this.limits.maxSteps) { return { action: "stop", reason: "step_cap" }; } if (call.idempotencyKey && this.committed.has(call.idempotencyKey)) { return { action: "stop", reason: "already_committed" }; } const sig = fingerprint(call); const same = this.recent.filter((item) => item === sig).length; if (same >= this.limits.maxSameCall) { return { action: "stop", reason: "same_call_repeat" }; } const n = this.recent.length; const wouldCloseABAB = n >= 3 && this.recent[n - 3] === this.recent[n - 1] && this.recent[n - 2] === sig && this.recent[n - 1] !== sig; if (wouldCloseABAB) { return { action: "stop", reason: "short_cycle" }; } this.steps += 1; this.recent.push(sig); this.recent = this.recent.slice(-this.limits.cycleWindow); return { action: "execute", fingerprint: sig }; } after(call: Call, result: ToolResult): Decision { if (result.sideEffectCommitted && call.idempotencyKey) { this.committed.add(call.idempotencyKey); } if (result.status === "complete") { return { action: "stop", reason: "complete" }; } if ( result.status === "fatal" || result.status === "permission_denied" || result.status === "policy_blocked" || result.status === "human_required" ) { return { action: "stop", reason: result.status }; } if (result.progressVersion > this.progressVersion) { this.progressVersion = result.progressVersion; this.noProgress = 0; // Новый подтвержденный progress открывает новое окно повторов. this.recent = []; } else { this.noProgress += 1; } if (this.noProgress >= this.limits.maxNoProgress) { return { action: "stop", reason: "no_progress" }; } return { action: "execute", fingerprint: fingerprint(call) }; } }

stable() сортирует object keys, поэтому {"q":"x","page":1} и {"page":1,"q":"x"} имеют один fingerprint. При resume передайте сохраненную версию checkpoint как initialProgressVersion, чтобы первый неизменившийся result не считался новым прогрессом. Список recent очищается только после подтвержденного роста progressVersion: одинаковый poll({"job":"42"}) может продолжаться, пока внешняя версия растет, но повтор без нового прогресса остается в том же окне и будет остановлен. Не превращайте нормализацию в неограниченное «семантическое сходство»: разные customer IDs или даты могут означать разные операции. Jitter-loop надежнее подтверждать сочетанием похожей цели и отсутствия внешнего progress.

В production замените Set на durable operation ledger. In-memory запись исчезает при restart и не защищает от параллельного worker. Ledger должен хранить как минимум idempotency_key, статус операции, проверяемый результат, run_id, время и terminal flag.

Сделайте tool result однозначным

Anthropic tool-use contract оставляет исполнение client tools и возврат tool_result приложению. Та же архитектурная граница действует в custom loop: модель не должна угадывать, был ли пустой ответ успехом, transient failure или окончательным запретом.

Практичный контракт:

json
{ "status": "permission_denied", "retryable": false, "progress_version": 7, "terminal": true, "operation_id": "crm-contact-1842-update-9", "next_allowed_action": "human_review" }

Используйте разные статусы:

  • progress: состояние изменилось, задача еще не завершена;
  • complete: задача завершена, новые tool calls запрещены;
  • retryable: повтор возможен только после backoff или изменения precondition;
  • fatal, permission_denied, policy_blocked: terminal без автоматического retry;
  • human_required: run приостановлен в устойчивом checkpoint.

Ветка восстановления должна назвать изменение. «Попробовать еще раз» — не изменение. Допустимы, например, новый cursor, наступивший retry_after, исправленные обязательные args, обновленная авторизация оператором или явно выбранная другая стратегия.

Пять воспроизводимых fixture вместо надежды

Мы проверили guard локально в Node.js без модели, сети, credentials и платных API. Fake tools возвращали явный status и монотонный progressVersion; лимит одинакового вызова был равен двум исполнениям, а no-progress — трем. Результаты доказывают только ветвление этих fixtures, а не универсальную надежность любой модели.

FixtureСимуляцияНаблюдаемый результат
success_after_oneCRM-like tool вернул complete на первом вызовеЗавершение после 1 исполнения
identical_retrysearch({"q":"same"}) повторял retryable без progressТретье исполнение заблокировано как same_call_repeat; side effects = 2
alternating_cycleread и check чередовались без progressОстановка после 3 исполнений как no_progress
changed_state_then_successCursor и progressVersion увеличивалисьРазрешены 3 разных вызова, затем complete
fatal_no_retryWrite tool вернул permission_deniedОстановка после 1 исполнения

Пятый fixture особенно важен: permission error не лечится экспоненциальным backoff. Второй показывает, что hard cap на 20 шагов был бы слишком поздним — repeat detector остановил run до третьего одинакового исполнения. Четвертый защищает от ложного срабатывания: polling допустим, когда внешний cursor действительно движется.

Добавьте еще три production-теста:

  1. После restart тот же idempotency key возвращает сохраненный result и не вызывает инструмент.
  2. Два worker одновременно получают один key; только один получает право на execution.
  3. Tool вернул пустую строку; validation gate превращает ее в явный failure, а не в «успех без данных».

Как встроить guard в agent loop

Порядок имеет значение: before() должен выполняться до сетевого вызова и до side effect.

ts
while (true) { const call = await modelChooseNextTool(messages); const pre = guard.before(call); if (pre.action === "stop") { return finalizeRun(pre.reason); } const result = await executeToolWithIdempotency(call); const post = guard.after(call, result); messages.push(asStructuredToolResult(call, result)); if (post.action === "stop") { return finalizeRun(post.reason); } }

finalizeRun() не должен просто бросать исключение и терять контекст. Сохраните:

  • terminal_reason;
  • последний безопасный checkpoint;
  • вызов, который guard запретил;
  • external state, подтверждающий progress или его отсутствие;
  • operation IDs и idempotency keys;
  • разрешенное следующее действие: none, human_review или resume_after_change.

Если framework выбрасывает max-turn exception, преобразуйте его в такой же terminal envelope. Исключение доказывает только, что cap сработал; оно не доказывает rollback, отсутствие дубля или исправление причины.

Recovery: продолжить, сменить стратегию или остановиться

СостояниеДействиеУсловие возобновления
Transient timeout или 429retry_after_changeНаступил backoff, retry budget не исчерпан
Тот же read с теми же argscacheИспользовать сохраненный результат без нового side effect
Jitter или short cycle без progressswitch_strategyНовый план меняет инструмент, данные или проверяемый precondition
Auth, permission, policy blockescalate или terminateТолько внешнее изменение доступа или политики
Side effect уже committedreturn_committed_resultНовый execution не разрешается
Hard cap или wall-clock capterminateНовый run создается как отдельное операторское решение

Не просите модель самой снять блокировку. Guard, ledger и resume policy принадлежат оркестратору. В multi-agent системе счетчик handoff и progress state должны быть общими: два агента могут передавать задачу друг другу, даже если локальный trace каждого выглядит уникально.

Где заканчивается защита от loop

LoopGuard отвечает на вопрос: «Можно ли выполнить следующий tool call этого run?» Он не заменяет:

  • gateway rate limits;
  • provider budget;
  • атомарное резервирование расходов;
  • credential isolation;
  • policy engine для опасных действий;
  • rollback внешней системы.

После стабилизации цикла добавьте отдельный kill switch расходов API для LLM‑агентов. Он должен блокировать следующий платный model call до провайдера, включая вызовы через sub-agents и tools. Loop detector и spend gate дополняют друг друга: первый видит отсутствие прогресса, второй ограничивает финансовый ущерб на всех маршрутах.

Проверка перед production

  • Guard стоит до каждого tool executor, включая sub-agent и fallback route.
  • Hard caps заданы отдельно для steps, wall-clock, retries и платных model calls.
  • Progress берется из внешнего состояния или валидированного checkpoint.
  • Tool results различают complete, retryable, fatal, permission, policy и human-required.
  • Side effects используют durable idempotency key.
  • Terminal reason сохраняется и виден оператору.
  • Пять fixtures проходят без network/provider calls.
  • Restart и параллельный worker не повторяют committed operation.
  • Productive polling с меняющимся cursor не блокируется.
  • Exact framework APIs и defaults перепроверены по официальным документам.

Если хотя бы один write-tool можно вызвать в обход guard, исправление еще не завершено. Если старый trace нельзя воспроизвести, у вас есть надежда, а не regression test.

#AI Agents#Tool Calling#Loop Detection#Reliability
Поделиться: