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

Аварийная остановка за пять минут
Не начинайте инцидент с переписывания system prompt. Инструкция «не повторяйся» может улучшить поведение модели, но не может отозвать уже поставленную задачу или запретить сетевой вызов.
- Остановите consumer или вызовите
AbortControllerдля текущего run. Не удаляйте durable state. - Заморозьте автоматические retries, sub-agent scheduling и handoff для этого
run_id. - Поставьте deny-rule перед tool executor: тот же
run_idбольше не выполняет новые side effects. - Сохраните последние tool calls, нормализованные аргументы, результаты,
progress_version, idempotency keys и внешний статус задачи. - Завершите 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.
tstype 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_one | CRM-like tool вернул complete на первом вызове | Завершение после 1 исполнения |
identical_retry | search({"q":"same"}) повторял retryable без progress | Третье исполнение заблокировано как same_call_repeat; side effects = 2 |
alternating_cycle | read и check чередовались без progress | Остановка после 3 исполнений как no_progress |
changed_state_then_success | Cursor и progressVersion увеличивались | Разрешены 3 разных вызова, затем complete |
fatal_no_retry | Write tool вернул permission_denied | Остановка после 1 исполнения |
Пятый fixture особенно важен: permission error не лечится экспоненциальным backoff. Второй показывает, что hard cap на 20 шагов был бы слишком поздним — repeat detector остановил run до третьего одинакового исполнения. Четвертый защищает от ложного срабатывания: polling допустим, когда внешний cursor действительно движется.
Добавьте еще три production-теста:
- После restart тот же idempotency key возвращает сохраненный result и не вызывает инструмент.
- Два worker одновременно получают один key; только один получает право на execution.
- Tool вернул пустую строку; validation gate превращает ее в явный failure, а не в «успех без данных».
Как встроить guard в agent loop
Порядок имеет значение: before() должен выполняться до сетевого вызова и до side effect.
tswhile (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 или 429 | retry_after_change | Наступил backoff, retry budget не исчерпан |
| Тот же read с теми же args | cache | Использовать сохраненный результат без нового side effect |
| Jitter или short cycle без progress | switch_strategy | Новый план меняет инструмент, данные или проверяемый precondition |
| Auth, permission, policy block | escalate или terminate | Только внешнее изменение доступа или политики |
| Side effect уже committed | return_committed_result | Новый execution не разрешается |
| Hard cap или wall-clock cap | terminate | Новый 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.


