1. Главная
  2. Блог
  3. ИИ-агент застрял в цикле: как мы поймали LLM, которая часами звонила в одну и ту же функцию

ИИ-агент застрял в цикле: как мы поймали LLM, которая часами звонила в одну и ту же функцию

ИИ-агент застрял в цикле: как мы поймали LLM, которая часами звонила в одну и ту же функцию

Утро понедельника. Дежурный инженер открывает Grafana с чашкой кофе и чуть не роняет её на клавиатуру. Один из клиентских ИИ-агентов работает уже второй час — при норме в пару-тройку минут. Токены летят пачками, как будто кто-то оставил открытой заслонку в самолёте. Клиент в чате пишет коротко и по делу: «У вас там всё нормально? Прайс поставщика висит с ночи».

Полезли смотреть логи. И вот что нашли: за это время LLM вызвала функцию normalize_row несколько сотен раз. С одинаковыми аргументами. Каждый раз получала одинаковый ответ. И каждый раз начинала заново.

Так мы, как разработчики ИИ-агентов, впервые столкнулись с тем, что теперь у нас в команде называется «диагноз ЦД» — циклическая деменция агента. И построили механизм её раннего обнаружения. Ниже — как именно и без ретуши.

Почему LLM в принципе может ходить по кругу

Классическая интуиция подсказывает: языковая модель — она же умная, зачем ей повторять один и тот же вызов? На практике причин минимум три, и все три мы наступили лично.

Первая — тихая ошибка валидации. Мы просили модель вернуть JSON по строгой схеме. Одно поле в схеме называлось unit_price, модель упорно возвращала price_per_unit. Валидатор отбрасывал ответ, оркестратор просил переделать, модель искренне пыталась — и так по кругу. В логах агента при этом всё выглядело благородно: «уточняем формат».

Вторая — конфликт между инструкцией и данными. В прайс-листе поставщика была строка с ценой, помеченной как «уточнить у менеджера». Инструмент нормализации отказывался её принимать. Модель вызывала «получи следующую строку», получала ту же самую (курсор так и не сдвинулся из-за ошибки) и снова пыталась.

Третья — банальный дребезг в промпте. Мы явно не запретили «попробовать снова с теми же параметрами», а модель — по своей природе — любит настойчивость. Особенно когда в промпте есть слово «убедись».

Ни в одной из этих ситуаций LLM не понимает, что она в цикле. Она не помнит, что уже десятки раз делала то же самое. Каждая итерация для неё — свежая.

Отпечаток инструмента: минимальная детекция

Первая версия защиты уместилась в несколько десятков строк на Python. Идея простая: для каждого вызова инструмента считаем отпечаток — хеш от нормализованных аргументов вместе с именем функции. Держим кольцевой буфер последних N отпечатков. Если один и тот же отпечаток встречается больше K раз внутри окна — тормозим.

import hashlib, json
from collections import deque

class ToolLoopDetector:
    def __init__(self, window=20, threshold=3):
        self.window = deque(maxlen=window)
        self.threshold = threshold

    def _fingerprint(self, tool_name: str, args: dict) -> str:
        # Нормализуем: сортируем ключи, убираем None, тримим строки
        clean = {k: (v.strip() if isinstance(v, str) else v)
                 for k, v in sorted(args.items()) if v is not None}
        payload = f"{tool_name}::{json.dumps(clean, ensure_ascii=False)}"
        return hashlib.sha256(payload.encode()).hexdigest()[:16]

    def register(self, tool_name: str, args: dict) -> bool:
        fp = self._fingerprint(tool_name, args)
        self.window.append(fp)
        # Возвращаем True, если пора бить тревогу
        return self.window.count(fp) >= self.threshold

Пороги подобрали эмпирически: окно из двадцати последних вызовов, три повтора одного отпечатка — сигнал. Меньше окно — ложные срабатывания на легитимных повторах (например, ретрай сетевой ошибки). Больше порог — цикл успевает пожечь бюджет.

Первая наша версия, если честно, была вообще с окном в пять вызовов. Она мгновенно ловила ложные тревоги на нормальных ретраях по таймауту прокси, у которого лимит соединения — те самые 100 секунд. Пришлось растянуть окно и разделить «повтор из-за сети» и «повтор из-за модели».

За первые дни на этом простом механизме поймали несколько случаев. Большая часть — тот самый конфликт схем и данных. Один — совсем анекдотичный: у одного из заказчиков модель пыталась записать в CRM контакт, у которого в имени был апостроф, а мы не экранировали его в SQL-обёртке. Инструмент возвращал «синтаксическая ошибка», модель добросовестно правила имя. Обратно.

Второй уровень: хеш мирового состояния

Отпечаток инструмента ловит только буквальные повторы. Но бывает похитрее: модель вызывает разные функции с разными аргументами, а реальный мир при этом не меняется. Курсор в базе не двинулся, счётчик не изменился, статус заявки тот же.

Для таких случаев мы добавили второй уровень — хеш состояния мира. После каждого шага агент считает компактную сигнатуру: сколько строк обработано, какой ID текущей задачи, сколько записей в результирующем буфере, номер шага в плане. Если несколько шагов подряд сигнатура не меняется — считаем, что агент топчется на месте.

def world_hash(ctx) -> str:
    signature = {
        "rows_processed": ctx.cursor,
        "queue_size": len(ctx.pending),
        "result_count": len(ctx.results),
        "current_step": ctx.plan_step,
    }
    return hashlib.md5(
        json.dumps(signature, sort_keys=True).encode()
    ).hexdigest()[:8]

Порог тут другой: около пяти итераций без изменения мирового хеша. Ложных срабатываний почти нет — если агент реально работает, хоть один счётчик да сдвинется.

Что делать, когда цикл пойман

Просто убить сессию — соблазнительно, но плохо. Клиенту нужен результат, пусть даже частичный. Поэтому у нас три реакции по нарастающей.

Первая — жёсткая инъекция в контекст. В следующий системный ход агент получает сообщение: «Ты вызвал normalize_row с одинаковыми параметрами несколько раз подряд и получил одинаковый ответ. Не повторяй этот вызов. Опиши, что именно ты пытаешься понять, и попроси помощи у оператора либо переходи к следующей задаче». В большинстве случаев модель приходит в себя и продолжает по-человечески.

Вторая — принудительная смена стратегии. Мы прячем от модели «залипший» инструмент на несколько шагов и оставляем только сопутствующие: ask_human, skip_current, finalize_partial. Модель вынуждена сойти с колеи. Работает ещё в заметной доле случаев.

Третья — эскалация к живому оператору. Хвост, который не решается сам, уходит на человека с подробным контекстом: что пыталась сделать модель, на чём споткнулась, какие альтернативы уже перебирала. Оператор обычно закрывает вопрос быстро — а мы получаем прецедент, который пойдёт в тесты.

Тут есть неочевидная штука, про которую редко пишут. Инъекцию мы сначала писали в казённом стиле: «Система: остановись». Модель на такое реагировала плохо — начинала оправдываться, а не работать. Помогло переформулировать её от первого лица, как совет коллеги: «слушай, ты вот это уже делал, попробуй иначе». Звучит смешно, но повторов после этого стало ощутимо меньше.

Что дал детектор на практике

Мы включили детектор на нескольких клиентских агентах в тестовом контуре и несколько недель собирали статистику. Общий вывод честный: «залипшие» сессии до внедрения случались редко, но каждая пожирала львиную долю дневного бюджета на LLM. Классическое распределение Парето наизнанку — считанные проценты инцидентов съедали заметную часть счёта за токены.

После внедрения детектор ловит цикл в среднем на первом-втором десятке вызовов. Средняя длительность аварийной сессии — минуты вместо часов. Счёт за GigaChat Pro у пилотных клиентов заметно просел, при том что число запусков даже немного выросло. Ложные срабатывания за это время можно пересчитать по пальцам — все на легитимных ретраях после сетевых сбоев, лечатся настройкой окна.

Люди на стороне бизнеса реагируют на это интереснее, чем на графики. У одного из заказчиков менеджер, который раньше каждое утро писал «ну что, опять зависло?», просто перестал писать. У другого — руководитель отдела снабжения впервые пришёл с вопросом не «почему опять всё встало», а «а можно ещё вот такой сценарий добавить». Для нас это был лучший маркер, чем любая метрика.

Иногда спрашивают про цену ИИ-агента с такой защитой. По факту сам детектор — это пара сотен строк кода и никакой отдельной инфраструктуры, так что на стоимость решения он влияет минимально. А вот на счёт за LLM в проде — очень даже, и в правильную сторону.

Что мы вынесли

Первое: не полагайтесь на разум LLM в вопросе, попала ли она в цикл. Модель этого не видит. Внешний детектор — обязательный компонент любого агента, который живёт дольше пяти шагов, будь то ИИ-агент на сайт для клиентского чата или бэкграунд-обработчик прайсов ночью.

Второе: одного уровня защиты недостаточно. Отпечаток инструмента ловит буквальные повторы, хеш состояния — топтание на месте разными путями. Нужны оба.

Третье: реакция на цикл — это тоже дизайн, а не «свитч в аварийку». «Убить и извиниться» — плохой ответ клиенту, который ждёт нормализованный прайс с ночи. Каскад «мягкая инъекция → смена стратегии → человек» вытягивает подавляющее большинство случаев.

И четвёртое, самое неожиданное. Логи детектора — это не только про надёжность. Это лучший источник для улучшения промптов. Каждая пойманная петля — конкретный тест-кейс: вот входные данные, вот момент, когда модель споткнулась, вот что помогло. Наши промпты стали заметно короче и точнее после того, как мы начали регулярно разбирать «зал славы» пойманных циклов.

В наш сервис ИИ-агентов такая защита теперь включается по умолчанию — не потому что красиво звучит в презентации, а потому что без неё дежурный инженер рано или поздно роняет кофе на клавиатуру. Проверено.

Вопрос напоследок: а вы уверены, что ваш агент прямо сейчас не сидит в цикле, о котором никто не знает?

Похожие статьи

Хотите так же?

Обсудим, как это внедрить у вас

Опишите задачу — подберём ИИ-решение под ваш процесс и бюджет: что сделать агентом, какие нужны данные и интеграции, с чего начать пилот.