LLM зацикливается: как найти причину, не трогая параметры?. DevOps.. DevOps. llm.. DevOps. llm. Natural Language Processing.. DevOps. llm. Natural Language Processing. vllm.. DevOps. llm. Natural Language Processing. vllm. автоматизация.. DevOps. llm. Natural Language Processing. vllm. автоматизация. Блог компании OTUS.. DevOps. llm. Natural Language Processing. vllm. автоматизация. Блог компании OTUS. большие языковые модели.. DevOps. llm. Natural Language Processing. vllm. автоматизация. Блог компании OTUS. большие языковые модели. генерация текста.. DevOps. llm. Natural Language Processing. vllm. автоматизация. Блог компании OTUS. большие языковые модели. генерация текста. инференс.. DevOps. llm. Natural Language Processing. vllm. автоматизация. Блог компании OTUS. большие языковые модели. генерация текста. инференс. искусственный интеллект.. DevOps. llm. Natural Language Processing. vllm. автоматизация. Блог компании OTUS. большие языковые модели. генерация текста. инференс. искусственный интеллект. Машинное обучение.. DevOps. llm. Natural Language Processing. vllm. автоматизация. Блог компании OTUS. большие языковые модели. генерация текста. инференс. искусственный интеллект. Машинное обучение. нейросети.. DevOps. llm. Natural Language Processing. vllm. автоматизация. Блог компании OTUS. большие языковые модели. генерация текста. инференс. искусственный интеллект. Машинное обучение. нейросети. промпты.

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

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

Пользователи присылают скриншоты, GPU занят decode, p99 растёт, аномальные запросы генерируют в разы больше выходных токенов — и при этом в логах приложения ни одной ошибки.

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

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


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

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

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

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

Рис. 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. Снизить temperature до 0.2: меньше случайности — меньше странного вывода.

  • 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 проигрывает продолжениям;

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

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

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

Шаг 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 года такие циклы разбирают как самоподдерживающиеся петли внимания.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Автор: sproshchaev

Источник