- BrainTools - https://www.braintools.ru -

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

Всем привет, меня зовут Сергей Прощаев и в этой статье расскажу про аварию, которую сложно поймать тестами.

Сервис на базе LLM [1] три недели работает нормально, а потом начинает отдавать отчёты, где с середины примерно двадцать раз подряд идёт один и тот же абзац, а дальше — обрыв на полуслове.

Пользователи присылают скриншоты, GPU занят ошибки [2].

Из четырёх версий, которые обычно звучат на разборе такого инцидента, три неверны, а одна при неаккуратном применении делает хуже.

В литературе этот класс проблем описывают как text degeneration или repetition loop. Дальше пользуюсь коротким «петля повтора».


Я Tech Lead и руководитель направления Java | Kotlin разработки в FinTech & E‑commerce и преподаю на курсах разработки и архитектуры в ОТУС.

Разбираю эту историю с позиции эксплуатации production‑систем, а не обучения [3] моделей.

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

Опубликованный сторонний кейс с реальными измерениями идёт отдельным блоком.

Рис. 1. Как выглядит деградация генерации в проде: осмысленный вывод превращается в повтор, а счёт идёт по GPU-времени

Рис. 1. Как выглядит деградация генерации в проде: осмысленный вывод превращается в повтор, а счёт идёт по GPU‑времени

Сначала договоримся о терминах: повтор повтору рознь

Под словом «зациклилась» прячутся три разных отказа, и лечатся они по‑разному.

  • Токеновый повтор — модель залипает на одном токене: «the the the the». Ловится тривиально.

  • Повтор фрагмента — воспроизводится целая фраза, заголовок или абзац. Именно этот случай мы и разбираем, и именно он даёт бимодальные хвосты в метриках.

  • Семантическая петля — модель ходит по кругу разными словами. Формально текст не повторяется, детектор n‑грамм такое не увидит. Это в основном сюжет рассуждающих моделей.

Всё, что написано ниже про диагностику и защиту, относится ко второму типу; про третий будет отдельная оговорка в конце.

Условие: что видит дежурный инженер

Внутренний сервис генерации отчётов.

Бэкенд на Kotlin, синхронный REST наружу, стриминг внутрь. Модель открытая, класса 30B, развёрнута локально на vLLM, доступ через OpenAI‑совместимый API.

Нагрузка около 8 RPS в пике, нормальный ответ занимает 600–900 токенов.

Параметры запроса, которые бэкенд отправляет в vLLM:

# (Python) параметры генерации, с которыми сервис работал три недели
SamplingParams(
    temperature=0.7,
    top_p=0.9,
    repetition_penalty=1.0,   # по умолчанию
    presence_penalty=0.0,
    frequency_penalty=0.0,
    max_tokens=4096,
    stop=None,
)

Сигналы за сутки, 41 200 запросов:

Сигнал

Наблюдение

Что это может значить

finish_reason

6,6% завершились по length

Не ошибка сама по себе, но повод присмотреться

Длина «плохих» ответов

Ровно 4096, все до единой

Упор в жёсткий потолок, а не естественный конец

Распределение длин

Два выраженных режима: 740 и 4096

Между модами почти пусто

Хвост ответа

Повторяющийся абзац

Признак деградации, а не длинной мысли

p50 / p99 latency

4,1 с / 38 с

Ресурсный эффект уже виден в SLO

Системный промпт — 112 строк: блок инструкций нумерованным списком с вложенными буллетами и таблица с описанием, как отвечать в разных сценариях.

Писали продакт и аналитик, выглядит аккуратно.

Ограничения: модель менять нельзя, дообучать нечем и некогда, переезд на другого провайдера не рассматривается. Чинить надо на своей стороне.

Попробуйте решить

Вы дежурный. У вас есть таблица сигналов, конфиг и промпт. С чего начнёте?

  • A. Поднять repetition_penalty до 1.2 и presence_penalty до 1.5 — параметры для того и существуют.

  • B. Снизить случайности [4] — меньше странного вывода.

  • C. Увеличить max_tokens до 8192, модель просто не успевает закончить мысль.

  • D. Проверить инфраструктуру: версию vLLM, квантизацию KV‑кэша, шаблон чата.

  • E. Ничего из перечисленного: сначала воспроизвести и снять один замер.

Запомните свой ответ и заодно прикиньте второй вопрос: какая строка таблицы самая говорящая и почему именно она?

Разбор вариантов

На разборе похожих инцидентов обычно звучат ровно эти версии.

A. «Поднять repetition_penalty и presence_penalty».

Звучит убедительно: параметр называется буквально «штраф за повтор».

Проблема в двух местах.

  • Во‑первых, в vLLM presence_penalty и frequency_penalty применяются к уже сгенерированным токенам, тогда как repetition_penalty учитывает токены и из промпта, и из вывода — то есть вы наказываете модель за использование терминов из собственного контекста.

  • Во‑вторых, для структурированного вывода чрезмерное значение ухудшает качество: повторяющиеся имена полей JSON, ключи, заголовки таблиц и идентификаторы — легитимные повторы, и они тоже попадают под штраф.

Ниже я приведу опубликованный кейс, где агрессивные штрафы не помогли и совпали с ухудшением метрики.

B. «Снизить температуру».

Интуитивно правильно, но небезопасно как первая диагностическая мера. Низкая температура делает выбор детерминированнее и сильнее прижимает генерацию к самому вероятному продолжению.

Здесь стоит быть конкретным: документация Qwen3 для thinking‑режима рекомендует temperature 0.6, top_p 0.95, top_k 20, min_p 0 и отдельно предупреждает не использовать greedy decoding, поскольку он способен приводить к деградации качества и бесконечным повторам.

Для некоторых рассуждающих моделей снижение температуры не убирает петлю, а делает её устойчивее.

C. «Увеличить max_tokens».

Этот ответ появляется, когда finish_reason = length читают буквально.

Но у длин ответа два режима: нормальные укладываются в 800 токенов, а «плохие» упираются ровно в потолок.

Это не нехватка бюджета, а отсутствие условия остановки. Подняв лимит до 8192, вы удвоите стоимость аварии.

D. «Проверить инфраструктуру».

Это не экзотическая версия.

В трекере vLLM регулярно появляются сообщения, где повреждённый или расходящийся вывод возникал при определённых конфигурациях квантизации KV‑кэша, offloading или спекулятивного декодирования — без всякой ошибки на уровне приложения.

Правило такое:

Если деградация появилась после смены версии, backend, квантизации, KV‑кэша или спекулятивного декодирования, инфраструктурная гипотеза поднимается в приоритете и проверяется одной из первых.

Если менялось сразу несколько вещей, первым действием будет не выбор гипотезы, а сбор полного diff конфигурации.

E — то, с чего стоит начинать в нашем случае.

Не с гипотезы и не с изменения параметров, а с воспроизведения и замера.

Но сначала — о том, что вообще может происходить внутри.

Механика: как может возникать устойчивое состояние генерации

Важно не упростить до неверного.

У трансформера нет правила «если токен уже встретился, его вероятность автоматически растёт» — так работает как раз repetition_penalty, только в обратную сторону.

Вероятность следующего токена определяется состоянием модели и всем контекстом целиком.

Механизм другой.

Авторегрессионная модель вычисляет каждый следующий токен с учётом собственного предыдущего вывода.

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

Рис. 2. Схема принципиальная: как может складываться самоподдерживающийся режим генерации и что он даёт инфраструктуре

Рис. 2. Схема принципиальная: как может складываться самоподдерживающийся режим генерации и что он даёт инфраструктуре

Главное, что нужно вынести из схемы:

max_tokens из предохранителя превращается в фактический механизм остановки.

Здоровый запрос завершается сам, деградировавший — только по лимиту, а поскольку он производит примерно в пять с половиной раз больше выходных токенов, он занимает inference‑мощность существенно дольше.

Насколько вырастет latency, из одного этого отношения не следует: влияют ещё prefill, батчинг и давление на KV‑кэш.

История описана давно: ещё в 2019 году в работе The Curious Case of Neural Text Degeneration показали, что поиск максимума правдоподобия вгоняет генерацию в повтор.

Формулировка, которая хорошо это сворачивает:

Модель не ломается в инженерном смысле — она просто перестаёт считать остановку лучшим вариантом.

Порядок проверки гипотез

Шаг 1. Ничего не менять и зафиксировать отпечаток инцидента.

Первый прогон должен быть максимально близок к продовому.

Прежде чем что‑то крутить, запишите:

model revision
tokenizer revision
chat template + режим thinking/non-thinking
EOS / stop-токены
sampling params (в точности продовые)
quantization
KV cache настройки
vLLM version
hardware
профиль конкурентности

Шаблон чата — один из первых объектов проверки, особенно для рассуждающих моделей: он влияет на режим thinking и на структуру итогового промпта, и именно из‑за него модель иногда не видит нужный стоп‑токен.

Шаг 2. Воспроизвести на неизменённой конфигурации.

Несколько прогонов сохранённого проблемного запроса с фиксированным seed.

Здесь нужна честная оговорка: одного seed для online‑инференса недостаточно. vLLM не гарантирует побитовую воспроизводимость по умолчанию, для этого есть отдельный режим batch invariance, да и он работает при совпадении железа и версии.

Поэтому мы говорим не «воспроизвели детерминированно», а «поймали устойчиво повторяющееся поведение».

Шаг 3. Снять logprobs в области начала повтора.

Это ключевой замер, и его почти никто не делает.

Код ниже — иллюстрация подхода, а не готовый сниппет: структура logprobs зависит от версии и от того, идёте вы через offline API или через сервер.

# (Python) иллюстрация: смотрим на распределение в области начала повтора
from vllm import LLM, SamplingParams

llm = LLM(model="<ваша модель>")
params = SamplingParams(
    temperature=0.7,     # ровно как в проде, не greedy
    top_p=0.9,
    max_tokens=4096,
    logprobs=5,          # сохранить top-N logprobs для анализа
    seed=42,
)
out = llm.generate([bad_prompt], params)[0].outputs[0]
for i, step in enumerate(out.logprobs):
    top = sorted(step.items(), key=lambda kv: kv[1].logprob, reverse=True)
    print(i, [(t.decoded_token, round(t.logprob, 3)) for _, t in top])

Важно не переоценить этот замер.

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

Говорить о деградации имеет смысл, когда совпадает несколько признаков:

  • найден повторяющийся фрагмент;

  • повтор устойчиво продолжается;

  • finish_reason = length;

  • распределение в этой области заметно концентрированнее обычного;

  • EOS проигрывает продолжениям;

  • поведение [5] воспроизводится на нескольких прогонах.

Тогда формулировка честная:

наблюдения согласуются с гипотезой о петле повтора.

Шаг 4. Менять по одной переменной.

Если инфраструктурный отпечаток не менялся, следующий кандидат — промпт.

Бинарный поиск: вырезаем блоки по половине и сравниваем долю деградаций. Небольшая серия прогонов годится для первичной локализации, но не является доказательством эффекта: при базовой частоте около 6% нужный размер выборки считается исходя из этой частоты и требуемой статистической мощности.

Если промпт не влияет вовсе — возвращаемся к сравнению квантизованной и BF16‑сборки, настроек KV‑кэша, шаблона чата и версии движка.

Шаг 5. Только теперь — сэмплинг.

Параметры подбираются на тестовом наборе с замером качества, а не меняются вслепую.

Явные stop‑условия помогают там, где формат ответа допускает заранее известный терминатор;

для свободного текста, который повторяет произвольный абзац, ставить туда нечего, и нужен детектор.

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

Главная мысль схемы: диагностика идёт от дешёвых наблюдений к дорогим изменениям, а защита строится слоями.

Один слой не спасает:

  • промпт портится следующим релизом;

  • детектор пропускает семантическую петлю;

  • обрезка ломает смысл.

Что в итоге сделали в разбираемом сценарии:

  1. Блок с нумерованным списком и таблицей переписали связным текстом, длину промпта сократили примерно на треть.

  2. Включили детекцию повтора на стороне движка.

  3. repetition_penalty не трогали.

Доля ответов, упирающихся в потолок, упала с 6,6% до 0,4%.

И вот здесь важная оговорка, без которой цифра ничего не значит:

снизить эту долю можно просто тем, что вы начали агрессивнее обрывать генерацию.

Поэтому одновременно нужно смотреть на:

  • долю ретраев;

  • средний объём выходных токенов;

  • оценку качества ответов.

Если качество просело, вы не починили модель, а научились раньше убивать запросы.

Сторонний кейс: когда параметры не сработали

Это уже не реконструкция, а опубликованный производственный кейс — правда, community‑материал, а не рецензируемое исследование.

Команда, эксплуатировавшая Qwen3 на vLLM, столкнулась с деградацией примерно в 15% запросов у одного из агентов.

Начали с параметров:

  • прогнали 360 запросов в пяти раундах;

  • подняли presence_penalty до 1.5 по рекомендации из документации — автор сообщает, что эффекта не было;

  • добавили более агрессивные штрафы поверх.

Доля деградаций, по его данным, выросла обратно с 10% до 15%.

Сработало другое.

В собственном системном промпте нашли:

  • нумерованные списки;

  • параллельные буллеты;

  • таблицы.

Промпт сократили примерно на 46%, заменив списки связной речью, и, по отчёту автора, деградация ушла в ноль.

Из этого не следует, что нумерованные списки и таблицы плохи сами по себе.

Следует более узкий вывод:

повторяющиеся структурные шаблоны коррелировали с деградацией на конкретной модели и конкретной нагрузке, и влияние нужно измерять у себя.

Я бы предпочёл, чтобы про эту связь знал каждый, кто пишет системные промпты:

мы годами учились выносить инструкции в аккуратные пронумерованные пункты, и как минимум иногда эта привычка стреляет в ногу.

Что забрать в свой прод

Проверьте, что умеет ваш движок, прежде чем писать своё.

Начиная с vLLM 0.17.0 в SamplingParams есть поле repetition_detection:

  • Движок ищет повторяющиеся N‑граммы в выходных токенах и завершает генерацию досрочно.

  • Проверка живёт на уровне планировщика, сработавший запрос получает статус FINISHED_REPETITION и stop_reason = "repetition_detected".

Появляется он не магически, его нужно сознательно сконфигурировать:

# (Python) включение встроенной детекции повтора
from vllm import SamplingParams
from vllm.sampling_params import RepetitionDetectionParams

params = SamplingParams(
    temperature=0.7,
    top_p=0.9,
    max_tokens=4096,
    repetition_detection=RepetitionDetectionParams(
        min_pattern_size=3,    # 0 означает 1; должен быть <= max
        max_pattern_size=20,   # 0 отключает детекцию
        min_count=4,           # должен быть >= 2
    ),
)

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

Механизм n‑граммный и работает по выходным токенам, а в JSON, Markdown‑таблицах, коде и аргументах инструментов повтор одинаковых токенов сплошь и рядом является частью корректного формата.

Отсюда цикл, о котором стоит помнить заранее:

появилась петля → поставили детектор → детектор счёл легитимную структуру повтором → преждевременный обрыв → новый отказ, созданный самой защитой.

Порог калибруется на реальных ответах сервиса, а не берётся из примера в документации.

Решите, где детектор должен жить.

Уровни не взаимоисключающие, но у каждого свой профиль:

Уровень

Плюс

Минус

Движок (vLLM)

Дешевле всего, обрыв раньше всех, экономит decode

Видит только сигналы уровня токенов

Gateway / стрим

Не зависит от backend, единая политика на все модели

Реагирует позже, уже разбирает поток

Приложение

Знает семантику ответа и формат домена

Реагирует последним, дороже по коду

Собственный детектор оправдан, когда нужен другой сигнал:

  • семантический повтор;

  • свой порог;

  • нестандартный fallback.

# (Python) намеренно упрощённый детектор повтора для стриминга
from collections import Counter

def is_degenerate(tokens, window=256, n=16, threshold=0.65):
    """Доля повторяющихся n-грамм в последнем окне. Вызывать
    периодически, например раз в 64 токена, а не на каждом шаге."""
    tail = tokens[-window:]
    if len(tail) < n * 3:
        return False
    grams = [tuple(tail[i:i + n]) for i in range(len(tail) - n + 1)]
    counts = Counter(grams)
    repeated = sum(c - 1 for c in counts.values() if c > 1)
    return repeated / len(grams) >= threshold

Код пересчитывает окно целиком; при большом числе параллельных генераций состояние n‑грамм лучше поддерживать инкрементально.

И размер n‑граммы, и порог привязаны к конкретному токенизатору:

одинаковый текст на разных токенизаторах даёт разные границы.

Определите метрику так, чтобы она пережила внедрение защиты.

Это тонкий момент.

Если завязать определение на finish_reason = length, то после включения детекции настоящие петли перестанут попадать в метрику:

они теперь заканчиваются не по лимиту, а по repetition_detected.

Поэтому:

degeneration_rate = generations_detected_as_degenerate / total_generations

где generation считается деградировавшей, если выполнено любое из:
  - упор в max_tokens при наличии повторяющегося хвоста
  - сработала встроенная детекция движка
  - сработал детектор на уровне приложения или gateway

отдельно как операционные метрики:
  length_finish_rate
  repetition_detected_rate
  retry_rate
  wasted_output_tokens

Последняя особенно полезна: 0,4% деградаций могут стоить дороже 2% обычных ошибок, если съедают непропорционально много decode‑токенов.

Не превратите защиту в усилитель нагрузки.

Прерывание с повторным запросом требует бюджета ретраев, верхней границы попыток, экспоненциального backoff с jitter и идемпотентности — иначе схема:

«плохая генерация → обрыв → ретрай → плохая генерация» начнёт разгонять сама себя.

Бюджет держим на двух уровнях:

Генерация:

  • max output tokens;

  • wall‑clock;

  • порог повтора.

Сервис:

  • дедлайн запроса;

  • лимит конкурентности;

  • circuit breaker.

И клиентский таймаут сам по себе не гарантирует остановку серверной генерации: отмена должна корректно дойти до inference‑движка.

Где это решение не сработает

Детектор на n‑граммах не поймает семантическую петлю — когда модель три раза пересказывает одну мысль разными словами.

Для рассуждающих моделей это отдельный сюжет: в работах 2026 года такие циклы разбирают как самоподдерживающиеся петли внимания [6].

Насколько дистилляция повышает склонность к ним по сравнению с моделями‑учителями — отдельная эмпирическая тема, и однозначного ответа я бы сейчас не давал.

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

Обрезка по последнему целому абзацу — доменный fallback для свободного текста: для JSON, таблиц и кода «последний абзац» не является границей смысла.

И упрощение промпта помогает не всегда: если модель зацикливается на пустом промпте, проблема на уровне весов или инфраструктуры.

Какой навык проверяла эта задача

Не знание параметров vLLM. Их можно посмотреть в документации за минуту.

Задача проверяла способность отличить симптом от причины и выстроить порядок проверки гипотез по стоимости. Инженер, который сразу лезет крутить repetition_penalty, реагирует на название параметра.

Инженер, который сначала фиксирует отпечаток инцидента и воспроизводит его, ничего не меняя, работает с системой. Разница легко выливается в лишние часы диагностики и несколько бесполезных релизов.

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

И ещё она проверяла готовность заподозрить в аварии собственный артефакт.

Системный промпт — такой же production‑артефакт, как код: он попадает в контекст, влияет на распределение и вполне может быть причиной инцидента.

У нас в командах он теперь лежит в репозитории, проходит ревью и покрыт тестами на долю деградаций.

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

Если из всей статьи останется одна мысль, пусть это будет такая:

Один график распределения длины ответа и один список изменений в конфигурации часто экономят часы диагностики.

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

LLM может работать нестабильно даже при корректных настройках: повторять [7] фрагменты, уходить в бесконечную генерацию или нарушать формат ответа. Чтобы находить причины таких сбоев, важно понимать механику работы моделей и инструменты контроля их поведения [8].

Разобраться в устройстве LLM и подходах к созданию надёжных AI‑систем помогут бесплатные открытые уроки:

  • 8 сентября в 20:00. «Что надо знать про работу LLM моделей». Записаться [9]

  • 23 сентября в 18:00. «Structured Outputs: как заставить LLM всегда возвращать то, что вам нужно». Записаться [10]

Полный список уроков на сентябрь собрали в дайджесте. [11]

Автор: sproshchaev

Источник [12]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/35024

URLs in this post:

[1] LLM: https://otus.pw/aeLs/

[2] ошибки: http://www.braintools.ru/article/4192

[3] обучения: http://www.braintools.ru/article/5125

[4] случайности: http://www.braintools.ru/article/6560

[5] поведение: http://www.braintools.ru/article/9372

[6] внимания: http://www.braintools.ru/article/7595

[7] повторять: http://www.braintools.ru/article/4012

[8] поведения: http://www.braintools.ru/article/5593

[9] Записаться : https://otus.pw/rees4/

[10] Записаться: https://otus.pw/dlzc/

[11] в дайджесте.: https://habr.com/ru/companies/otus/articles/1076824/

[12] Источник: https://habr.com/ru/companies/otus/articles/1067766/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1067766

www.BrainTools.ru

Rambler's Top100