Всем привет, меня зовут Сергей Прощаев и в этой статье расскажу про аварию, которую сложно поймать тестами.
Сервис на базе LLM три недели работает нормально, а потом начинает отдавать отчёты, где с середины примерно двадцать раз подряд идёт один и тот же абзац, а дальше — обрыв на полуслове.
Пользователи присылают скриншоты, GPU занят decode, p99 растёт, аномальные запросы генерируют в разы больше выходных токенов — и при этом в логах приложения ни одной ошибки.
Из четырёх версий, которые обычно звучат на разборе такого инцидента, три неверны, а одна при неаккуратном применении делает хуже.
В литературе этот класс проблем описывают как text degeneration или repetition loop. Дальше пользуюсь коротким «петля повтора».
Я Tech Lead и руководитель направления Java | Kotlin разработки в FinTech & E‑commerce и преподаю на курсах разработки и архитектуры в ОТУС.
Разбираю эту историю с позиции эксплуатации production‑систем, а не обучения моделей.
Оговорка про жанр: ниже — реконструкция инцидента на синтетическом наборе вводных, конфиг и метрики собраны так, чтобы задача решалась по тексту.
Опубликованный сторонний кейс с реальными измерениями идёт отдельным блоком.

Сначала договоримся о терминах: повтор повтору рознь
Под словом «зациклилась» прячутся три разных отказа, и лечатся они по‑разному.
-
Токеновый повтор — модель залипает на одном токене: «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 запросов:
|
Сигнал |
Наблюдение |
Что это может значить |
|---|---|---|
|
|
6,6% завершились по |
Не ошибка сама по себе, но повод присмотреться |
|
Длина «плохих» ответов |
Ровно 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, только в обратную сторону.
Вероятность следующего токена определяется состоянием модели и всем контекстом целиком.
Механизм другой.
Авторегрессионная модель вычисляет каждый следующий токен с учётом собственного предыдущего вывода.
Если после некоторого момента контекст приводит модель в область распределения, где продолжение повторяющегося фрагмента оказывается наиболее вероятным, возникает самоподдерживающийся режим: очередной повтор становится частью нового контекста, который снова благоприятствует тому же продолжению.
Главное, что нужно вынести из схемы:
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‑условия помогают там, где формат ответа допускает заранее известный терминатор;
для свободного текста, который повторяет произвольный абзац, ставить туда нечего, и нужен детектор.

Главная мысль схемы: диагностика идёт от дешёвых наблюдений к дорогим изменениям, а защита строится слоями.
Один слой не спасает:
-
промпт портится следующим релизом;
-
детектор пропускает семантическую петлю;
-
обрезка ломает смысл.
Что в итоге сделали в разбираемом сценарии:
-
Блок с нумерованным списком и таблицей переписали связным текстом, длину промпта сократили примерно на треть.
-
Включили детекцию повтора на стороне движка.
-
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 может работать нестабильно даже при корректных настройках: повторять фрагменты, уходить в бесконечную генерацию или нарушать формат ответа. Чтобы находить причины таких сбоев, важно понимать механику работы моделей и инструменты контроля их поведения.
Разобраться в устройстве LLM и подходах к созданию надёжных AI‑систем помогут бесплатные открытые уроки:
-
8 сентября в 20:00. «Что надо знать про работу LLM моделей». Записаться
-
23 сентября в 18:00. «Structured Outputs: как заставить LLM всегда возвращать то, что вам нужно». Записаться
Полный список уроков на сентябрь собрали в дайджесте.
Автор: sproshchaev


