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

Если ИИ‑агент бесконечно вызывает инструменты, сначала запретите новые исполнения для всей текущей задачи — включая повторы, дочерних агентов и повторные запуски. Сохраните журнал вызовов и состояние уже начатых операций. Затем выясните, действительно ли задача продвигается: новые сообщения модели и прошедшее время сами по себе этого не доказывают.
Рабочая защита проверяет следующий вызов до исполнения, ограничивает обращения к модели и инструментам, а при остановке возвращает вызывающему коду явный результат. После тайм-аута записи сначала нужно установить, была ли операция выполнена. Повторять её вслепую опасно даже после остановки самого агента.
Что сделать, пока агент ещё работает
Поставьте блокировку в месте, через которое проходят все новые вызовы этой задачи. Кнопка остановки в интерфейсе полезна только тогда, когда она доходит до исполнителя: прекращение показа ответа не доказывает прекращения фоновой работы.
- Зафиксируйте состояние задачи как остановленное. Очередь, автоматические повторы и дочерние исполнители должны проверять это состояние перед отправкой новой работы.
- Запросите отмену через предусмотренный механизм среды выполнения. Отдельно проверьте состояние уже работающих исполнителей и операций: запрос отмены ещё не является подтверждением их остановки.
- Сохраните идентификатор задачи, время, последние вызовы, классифицированные результаты, остаток бюджетов и последнюю подтверждённую контрольную точку. Секреты и персональные данные в аргументах скройте.
- Разделите операции на подтверждённо завершённые, подтверждённо не выполненные и операции с неизвестным исходом. Последние требуют сверки с целевой системой до повторной записи.
- Верните вызывающему коду причину остановки и разрешённое следующее действие. Не превращайте исключение лимита в новый автоматический запуск с обнулёнными счётчиками.
Например, для Amazon Bedrock AgentCore существует StopRuntimeSession. Он относится к конкретной сессии среды выполнения; его идемпотентный токен защищает повтор запроса остановки, а не отменяет проведённый платёж или отправленное письмо. Выбирайте механизм своей среды, сохраняя эту границу: остановка новых действий не откатывает уже зафиксированные изменения.
Как отличить зацикливание от полезного повторения
Смотрите на последовательность действий и проверяемое состояние задачи. Один и тот же инструмент может законно читать следующие страницы или опрашивать долгую операцию. Разные инструменты могут бесконечно ходить по кругу.
| Наблюдение | Что проверить | Следующее действие |
|---|---|---|
| Повторяется один вызов с теми же аргументами | Изменились ли данные, курсор или версия целевого состояния? | При отсутствии изменения остановить повтор до исполнения |
| Аргументы каждый раз немного меняются | Меняется ли цель операции или только формулировка поиска? | Проверить отсутствие прогресса по задаче; одного совпадения строк недостаточно |
Чередуются read → check → read → check | Есть ли новое подтверждённое состояние между вызовами? | Прервать короткий цикл без прогресса |
| Запись вернула успех, но агент пишет снова | Завершена ли операция и завершена ли вся задача? | Получить сохранённый результат операции; не повторять запись |
| После тайм-аута агент снова вызывает запись | Установлен ли исход первого запроса? | Сначала сверить операцию, затем решить, допустим ли повтор |
| Отказ доступа повторяется после пауз | Изменились ли права или допустимые условия действия? | Остановиться или передать человеку; пауза не исправляет запрет |
| При каждом чтении продвигается курсор | Курсор принадлежит этому набору данных, не повторяется и ведёт к конечному обходу? | Разрешить продолжение в пределах общего бюджета |
Версию прогресса повышает код приложения после проверки результата, а не модель после фразы «почти готово». Подходящие признаки — новый обработанный элемент, подтверждённое изменение состояния или проверенный переход к следующей странице. Изменение времени, счётчика попыток или строки «в работе» не означает продвижения.
Опрос статуса может некоторое время не показывать изменений. Для него нужен отдельный ограниченный период ожидания, частота опроса и общий срок завершения. Это разрешение подождать, а не новый прогресс. Если срок истёк, приложение сохраняет незавершённую задачу и прекращает опрос.

Лимит итераций не равен лимиту инструментов
Задайте границу каждому виду работы. Одна итерация модели может вызвать несколько инструментов; новый запуск процесса не обязательно означает новую пользовательскую задачу.
| Ограничение | Что считать | Где сохранять |
|---|---|---|
| Обращения к модели | Каждый запрос, включая повтор и дочернего агента | В общем бюджете пользовательской задачи |
| Исполнения инструментов | Каждую отправку исполнителю, включая внутренние повторы | На общей границе исполнения |
| Запуск контроллера | Работу одной конкретной попытки | В состоянии этой попытки и общем бюджете |
| История диалога | Работу между сообщениями и возобновлениями | В устойчивом состоянии с тем же идентификатором |
| Время и расходы | Срок задачи и допустимые затраты | Перед новым запросом, с общими правилами резервирования |
У n8n Tools Agent параметр Max Iterations по документации ограничивает число обращений модели для ответа; значение по умолчанию — 10. Это не предел числа отдельных внешних операций и не бюджет всех повторных запусков workflow.
В LangChain для JavaScript/TypeScript ограничения вызовов модели и инструментов разделены. У toolCallLimitMiddleware поведение continue по умолчанию блокирует превышающие лимит вызовы, но модель и другие инструменты могут продолжать работу. Режим error прерывает выполнение исключением; режим end имеет ограничения при нескольких одновременно ожидающих вызовах. Лимит на запуск сбрасывается при новом пользовательском ходе, а предел для диалога требует сохранения состояния с тем же идентификатором и checkpointer. Поэтому локальный лимит нельзя считать общим бюджетом задачи.
Там же toolRetryMiddleware по умолчанию допускает две повторные попытки после первоначальной, то есть до трёх исполнений. Его onFailure: "continue" возвращает ошибку модели, которая может предложить новый вызов. Если повторяется ещё и внешний контроллер, число действий растёт на обоих уровнях. Классификация повторяемых ошибок зависит от версии; в документации отдельно описано изменение начиная с LangChain 1.5.9. Каждый внутренний повтор должен расходовать общий бюджет.
OpenAI Agents SDK продолжает цикл после вызовов инструментов и передач управления до точки завершения. Достигнутый предел и пауза для согласования — разные состояния. В Google ADK предел LoopAgent считает циклы подагентов, а не все вложенные обращения к модели и инструментам; для Python и Go документация помечает этот шаблон как прежний подход после перехода к ADK 2.0. Сверяйте точные параметры со своей версией, а общий бюджет храните выше отдельных запусков.
Ограниченный контроллер: исполнять, завершить или остановиться
Ниже — самостоятельный пример на JavaScript для одного последовательного исполнителя с синхронными подставными инструментами. Он не использует агентный SDK, модель или сеть. Счётчики принадлежат объекту состояния и сохраняются между вызовами; завершение необратимо в рамках этого объекта. beforeModel() нужно вызывать перед каждым выбором следующего действия, а call() — перед каждым исполнением инструмента.
inspect() — доверенный адаптер приложения. Он проверяет результат и состояние предметной области, затем возвращает status, монотонную version и, при необходимости, taskDone. Возвращать сюда текст модели без проверки нельзя. В демонстрации эту роль выполняют синтетические данные.
const canonical = (x) => {
if (Array.isArray(x)) return `[${x.map(canonical).join(",")}]`;
if (x && typeof x === "object") {
return `{${Object.keys(x).sort().map(k =>
`${JSON.stringify(k)}:${canonical(x[k])}`).join(",")}}`;
}
return JSON.stringify(x);
};
function makeGuard(now, limits = {}) {
const cap = { models: 12, tools: 8, repeats: 2,
stalled: 3, ms: 30000, ...limits };
const s = { models: 0, tools: 0, version: 0, stalled: 0,
recent: [], deadline: now() + cap.ms, terminal: null };
const operations = new Map(); // Только память одного исполнителя.
const stop = (reason, next = "review", value = null) => {
s.terminal ??= { state: reason === "complete" ? "completed" : "stopped",
reason, next, value };
return s.terminal;
};
const boundary = () => s.terminal ??
(now() >= s.deadline ? stop("deadline") : null);
return {
state: s, operations,
beforeModel() {
const halted = boundary();
if (halted) return halted;
if (s.models >= cap.models) return stop("model_budget");
s.models++;
return null;
},
call(call, execute, inspect) {
const halted = boundary();
if (halted) return halted;
// Адаптер допускает лишь нормализованные JSON-аргументы.
if (!call || !["read", "write"].includes(call.kind) ||
typeof call.tool !== "string" || !call.args ||
typeof call.args !== "object") return stop("invalid_call");
let sig;
try { sig = call.tool + ":" + canonical(
JSON.parse(JSON.stringify(call.args))); }
catch { return stop("invalid_call"); }
const key = call.operationKey;
if (call.kind === "write" && (typeof key !== "string" || !key))
return stop("missing_operation_key");
const old = key ? operations.get(key) : null;
if (old) {
if (old.sig !== sig) return stop("operation_conflict");
if (old.state !== "done") return stop("unknown_commit", "reconcile");
return stop("operation_already_recorded", "use_saved_result", old.value);
}
if (s.tools >= cap.tools) return stop("tool_budget");
if (s.recent.filter(x => x === sig).length >= cap.repeats)
return stop("repeated_call");
const r = s.recent;
if (r.length >= 3 && r.at(-3) === r.at(-1) &&
r.at(-2) === sig && r.at(-1) !== sig) return stop("short_cycle");
s.tools++;
s.recent.push(sig);
if (call.kind === "write") operations.set(key, { sig, state: "pending" });
let checked;
try { checked = inspect(execute(call), call); }
catch {
if (call.kind === "write") operations.set(key, { sig, state: "unknown" });
return stop(call.kind === "write" ? "unknown_commit" : "unknown_result",
call.kind === "write" ? "reconcile" : "review");
}
const valid = checked &&
["progress", "waiting", "operation_done", "complete",
"retryable", "fatal", "human_required", "unknown"].includes(checked.status) &&
Number.isSafeInteger(checked.version) && checked.version >= s.version;
if (!valid || checked.status === "unknown") {
if (call.kind === "write") operations.set(key, { sig, state: "unknown" });
return stop(call.kind === "write" ? "unknown_commit" : "unknown_result",
call.kind === "write" ? "reconcile" : "review");
}
if (call.kind === "write") {
// Любой неподтверждённый исход записи требует сверки.
if (checked.committed !== true) {
operations.set(key, { sig, state: "unknown" });
return stop("unknown_commit", "reconcile");
}
operations.set(key, { sig, state: "done", value: checked.value });
}
if (s.terminal) return s.terminal;
if (checked.version > s.version) {
s.version = checked.version; s.stalled = 0; s.recent = [];
} else s.stalled++;
// Сохраняем подтверждённую запись даже при истечении срока.
if (now() >= s.deadline) return stop("deadline");
if (checked.status === "complete") return checked.taskDone === true
? stop("complete", "none", checked.value) : stop("unverified_completion");
if (checked.status === "fatal") return stop("fatal");
if (checked.status === "human_required") return stop("human_required", "human");
if (checked.status === "retryable") return stop("retryable", "resume_after_change");
if (s.stalled >= cap.stalled) return stop("no_progress");
return { state: "running", version: s.version };
},
cancel() { return stop("cancelled"); },
exhaust() { return stop("plan_exhausted"); }
};
}
// Вызывающий код обязан вернуть остановку, а не просить модель попробовать снова.
function runFake(plan, guard, execute, inspect) {
for (const call of plan) {
const halted = guard.beforeModel();
if (halted) return halted;
const result = guard.call(call, execute, inspect);
if (result.state !== "running") return result;
}
return guard.exhaust();
}
let clock = 0;
const guard = makeGuard(() => clock);
const read = { kind: "read", tool: "read_page", args: { page: 1 } };
const result = runFake([read, read, read], guard,
() => ({ status: "waiting", version: 0 }), raw => raw);
console.log(result.reason, guard.state.tools); // repeated_call 2В этом примере лимит инструментов считает фактические входы в подставной исполнитель, а лимит модели — вызовы beforeModel(), имитирующие выбор следующего действия. Код не перехватывает скрытые повторы стороннего SDK: при интеграции их тоже нужно проводить через общую границу. Исчерпание списка действий завершает демонстрацию как plan_exhausted, без ложного сообщения об успехе.
waiting разрешает лишь ограниченное ожидание: оно не сбрасывает отсутствие прогресса. Проверенный рост версии сбрасывает окно повторов, но не общий бюджет и срок задачи. Значения лимитов здесь выбраны для демонстрации; перенесите их в настройки своей задачи после определения допустимого объёма работы.
Нормализация сортирует ключи JSON, но не распознаёт смысл похожих запросов. Поля, которые различают операции — получатель, сумма, версия обновления, — нельзя выбрасывать ради удобного совпадения. Не публикуйте полные аргументы в журнале; даже хеш чувствительного значения не является гарантией конфиденциальности.
Запись выполнена, но задача ещё не закончена
Ключ операции должен обозначать одно намеренное действие в предметной области: например, обновление конкретной версии заказа. Случайный call_id каждой попытки для этого не подходит: разные попытки одного действия должны ссылаться на одну операцию. Вместе с ключом храните отпечаток значимых входных данных. Один ключ с разными данными — конфликт, а не повод выполнить ещё одну запись.
В примере повтор подтверждённой операции возвращает сохранённое значение и причину operation_already_recorded. Это остановка текущего исполнения, а не объявление всей задачи выполненной. После записи может оставаться проверка результата или другое независимое действие. Контроллер должен использовать уже подтверждённый результат и отдельно проверить критерий завершения пользовательской задачи.
Map в примере не обеспечивает исполнение ровно один раз при параллельной работе или после перезапуска. Схема «сначала найти запись, затем записать» тоже не защищает от двух одновременных исполнителей. Для внешних записей нужны атомарное резервирование права исполнения в устойчивом хранилище, состояние операции и поддерживаемая целевой системой идемпотентность либо сверка по её идентификатору. Простое сохранение истории диалога не заменяет такой журнал.
Если исполнитель отправил запрос, но не получил ответ, состояние остаётся неизвестным. Даже если тайм-аут выиграл в Promise.race, проигравшая операция может продолжать работать. До подтверждения исхода запретите повторную запись: найдите внешний результат, подтвердите отсутствие операции или передайте сверку человеку. Если внешний сервис не позволяет установить исход, нельзя обещать отсутствие дублей.

Что проверено в офлайн-примере
Пример проверен локально в Node.js с подставными результатами, часами и последовательным журналом операций. В проверках нет модели, SDK, сети, реальных записей или отмены работающего сервиса. Поэтому результат подтверждает ветвление контроллера в заданных ситуациях, а не сходимость любой модели или надёжность распределённого исполнения. Для отдельной проверки короткого цикла порог отсутствия прогресса поднят до 10; иначе стандартный порог остановит эту последовательность ещё раньше.
| Ситуация | Проверяемый результат |
|---|---|
| Неизменный запрос чтения | Два исполнения разрешены, третье заблокировано до исполнителя |
| Короткий цикл двух инструментов | Четвёртое исполнение заблокировано при отсутствии прогресса |
| Новый проверенный курсор при каждом чтении | Работа продолжается до подтверждённого завершения, общий бюджет сохраняется |
| Модель объявила конец без подтверждения задачи | unverified_completion, а не успех |
| Повтор уже записанной операции | Возвращён сохранённый результат; исполнитель записи не вызван снова |
| Один ключ операции с изменёнными данными | operation_conflict до второго исполнения |
| Запись выбросила исключение или вернула неизвестный результат | unknown_commit с действием reconcile |
| Результат чтения пуст или имеет неизвестный статус | unknown_result, без дальнейших вызовов |
| Исчерпан бюджет или срок | Явная остановка; повторный вход сохраняет её причину |
| Нужен человек либо ошибка непоправима | Остановка с отдельной причиной и без автоматического повтора |
При переносе в приложение добавьте проверку реальной границы: все дочерние исполнители, внутренние повторы и возобновления должны пользоваться одним состоянием задачи. Отдельно воспроизведите перезапуск, два одновременных исполнителя, потерю ответа после записи и остановку во время незавершённого запроса. Синхронный пример этих свойств не доказывает.
Когда можно безопасно продолжить
Возобновление начинается с сохранённой причины остановки. Не сбрасывайте счётчики ради нового запуска: новый ход диалога, передача другому агенту и перезапуск процесса не создают новый бюджет исходной задачи.
| Причина | Условие продолжения |
|---|---|
| Временный сбой чтения | Наступил разрешённый срок повтора, ошибка действительно временная, общий бюджет ещё доступен |
| Повторы без прогресса | Выбран другой полезный шаг, основанный на уже проверенных данных; изменились условия задачи |
| Неизвестный исход записи | Завершена сверка внешней операции; сохранён результат или подтверждено, что запись не состоялась |
| Подтверждённая операция | Использован сохранённый результат; следующий шаг не повторяет эту запись |
| Отказ доступа или требование согласования | Получено внешнее разрешение по правилам приложения; уверенность модели не заменяет его |
| Исчерпан общий бюджет | Человек явно решил продолжить задачу с новым пределом; старые расходы и операции сохранены |
| Подтверждённое завершение задачи | Продолжение не требуется |
Терминальное состояние старого исполнения оставьте неизменным. Разрешённое продолжение оформляйте отдельной попыткой, связанной с той же задачей, журналом операций и накопленными расходами. Для пауз согласования следуйте протоколу своей среды: например, руководство OpenAI Agents SDK описывает продолжение прерванного состояния. Не подменяйте такую паузу обычным повтором с новой пустой историей.
Почему после успешного инструмента агент вызывает его снова
Успех инструмента может не отвечать условию завершения из инструкции. В обсуждении n8n от августа 2025 года автор описал повторные обновления HubSpot после успешного ответа; уточнение условий использования инструмента улучшило поведение. Это отдельный пользовательский случай, а не гарантия исправления всех циклов одним промптом.
Проверьте также параметры драйвера. В OpenAI function calling режим required требует один или несколько вызовов функций. Если приложение принудительно оставляет его на каждом шаге, обычный финальный ответ без инструмента может быть недоступен в этом запросе. Переключение режима должно зависеть от проверенного состояния задачи, а не от желания модели обойти защиту.
Чёткая инструкция, однозначные результаты и хороший критерий завершения помогают выбрать правильный шаг. Право выполнить его всё равно определяет приложение. Оценка «прогресс есть» или уверенность от 1 до 5 не даёт разрешения на запись, не подтверждает завершение и не увеличивает бюджет.
Частые вопросы
Нужно ли запрещать любой повтор инструмента?
Нет. Повтор может быть следующей страницей или ограниченным опросом операции. Разрешайте его при проверяемом продвижении либо в пределах заранее ограниченного ожидания. Одни новые аргументы или прошедшее время не доказывают полезность повторения.
Достаточно ли вернуть ошибку «лимит достигнут» в ответ инструмента?
Нет. Модель может снова запросить инструмент, если вызывающий контроллер продолжает цикл. Сохраните остановку в состоянии задачи и верните терминальный результат вызывающему коду; он должен прекратить новые обращения и вернуть причину пользователю или оператору.
Можно ли просто очистить память и перезапустить агента?
Очистка памяти может скрыть повтор, потерять состояние записи и обнулить локальный лимит. Сначала сохраните подтверждённые результаты и сверяйте операции с неизвестным исходом. Продолжение должно пользоваться общим бюджетом и журналом исходной задачи.
Тайм-аут означает, что запись не выполнена?
Нет. Сервер мог выполнить действие до разрыва соединения. Тайм-аут сообщает об отсутствии подтверждения у клиента; повтор записи допустим после сверки или по надёжному протоколу идемпотентности целевой системы.
Источники7
Внешние страницы, на которые ссылается это руководство, в порядке упоминания. Последнее обновление: 7 окт. 2026 г..
Источники7
Внешние страницы, на которые ссылается это руководство, в порядке упоминания. Последнее обновление: 7 окт. 2026 г..
- 1.StopRuntimeSessiondocs.aws.amazon.com/bedrock-agentcore/latest/APIReference/API_StopRuntimeSession.html
- 2.n8n Tools Agentdocs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.agent/tools-agent
- 3.LangChain для JavaScript/TypeScriptdocs.langchain.com/oss/javascript/langchain/middleware/built-in
- 4.OpenAI Agents SDKdevelopers.openai.com/api/docs/guides/agents/running-agents
- 5.Google ADKadk.dev/agents/workflow-agents/loop-agents
- 6.обсуждении n8n от августа 2025 годаcommunity.n8n.io/t/agent-goes-into-infinite-tool-call-loop/174472
- 7.OpenAI function callingdevelopers.openai.com/api/docs/guides/function-calling





