Перейти к содержимому
07 мая 2025 г.·7 мин чтения

Admission control для длинных промптов в LLM-сервисе

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

Admission control для длинных промптов в LLM-сервисе

Почему длинные промпты забивают очередь

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

Проблема в том, что один такой вызов может задержать десятки коротких. Пользователь с чатом на 300-500 токенов ждет почти мгновенный ответ, но попадает в ту же очередь, что и запрос на 40 000 токенов. Для очереди это не просто два запроса. Это легкая и очень тяжелая задача, которые спорят за один и тот же ресурс.

Поэтому очередь растет даже без всплеска RPS. Снаружи трафик выглядит обычным: число запросов почти не меняется. Но среднее время обработки на один вызов растет, и система быстро копит хвост.

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

Самое неприятное - SLA часто проседает раньше, чем это видно по CPU. Узкое место обычно не в процессоре, а во времени обработки длинного контекста, занятых GPU-слотах, памяти и общем token throughput. На дашборде еще нет явной аварии, а пользователи уже жалуются на задержки.

Обычно это проявляется так:

  • p95 и p99 растут раньше среднего времени ответа
  • короткие запросы ждут за тяжелыми batch-задачами
  • ретраи создают новый трафик и удлиняют очередь
  • сервис выглядит нестабильным, хотя железо еще не уперлось в потолок

Если через один endpoint идут и обычные диалоги, и большие промпты для анализа документов, эффект становится заметен очень быстро. Без admission control длинные входы забирают общую емкость сервиса. Очередь лучше защищать заранее, а не после первых жалоб.

Что считать бюджетом запроса

Если мерить бюджет запроса одной цифрой, сервис рано или поздно упрется в очередь. Для admission control полезнее разбить бюджет на части. Тогда видно, что именно съедает ресурсы: prefill, генерация ответа или служебная обвязка.

Из чего состоит бюджет

Сначала разделяйте входные и выходные токены. Вход нагружает prefill и память, выход занимает время генерации. Два запроса с одинаковым общим лимитом ведут себя по-разному. Запрос с 20 000 токенов на входе и 500 на ответ обычно тяжелее для сервиса, чем запрос с 2 000 на входе и 5 000 на выходе.

Поэтому лучше держать два лимита: один на prefill, второй на ответ. Лимит prefill защищает очередь от слишком длинных документов и переписок. Лимит ответа не дает одному вызову забрать слишком много времени на генерацию. Общий лимит тоже нужен, но сам по себе он мало что решает.

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

Отдельно считайте tool calls. Когда модель вызывает поиск, SQL, RAG или внутреннюю функцию, она получает назад новый текст. Этот текст снова попадает в контекст и быстро раздувает бюджет. То же касается вложений: PDF, писем, карточек клиента и фрагментов базы знаний. Один файл на 30 страниц легко съедает весь лимит prefill.

Простое правило

На практике удобно делить бюджет на четыре корзины:

  • системный и служебный контекст
  • пользовательский ввод
  • вложения и найденные документы
  • ожидаемый ответ и возможные tool calls

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

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

Как расставить приоритеты

Приоритет лучше считать по цене задержки, а не по тому, кто громче просит ресурсы. Если человек ждет ответ в чате, лишние 2-3 секунды заметны сразу. Если процесс собирает отчет, размечает архив или гонит ночную пакетную обработку, он может подождать.

Поэтому трафик стоит разделить хотя бы на три класса: чат, batch и фоновые задачи. Уже этого хватает, чтобы длинные промпты не забили общую очередь. Снаружи запросы могут выглядеть одинаково, но требования у них разные. Чату нужна низкая задержка. Batch любит объем. Фоновые задачи спокойно переживают паузу.

Базовое правило простое. Чат и запросы из интерфейсов, где пользователь ждет ответ, получают высокий приоритет. Batch-задачи идут в своей квоте и не занимают больше заданной доли токенов или слотов. Фоновые джобы получают низкий приоритет и первыми замедляются в пик. Временное окно тоже важно: днем приоритет у онлайна, ночью квоту для массовых задач можно расширить.

Смотрите не только на тип трафика. Важны клиент, продукт и час суток. У платного клиентского чата и внутреннего тестового сервиса разная цена задержки. У банка в рабочие часы один режим, у внутренней аналитики ночью другой. Если через один шлюз идут и поддержка, и массовая обработка документов, batch без ограничений быстро съест очередь для живых запросов.

Массовые задачи лучше ограничивать квотой, а не ручными решениями дежурного инженера. Например, одной команде можно дать до 20% токенов на пакетную обработку, другой - 10%, пока интерактивный трафик выше порога. Тогда система ведет себя предсказуемо: команды знают лимиты, а сервис не проседает из-за одного большого запуска.

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

Как резать промпт без потери смысла

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

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

С документами грубая обрезка редко помогает. Если в контекст положили договор на 20 страниц, бессмысленно оставлять первые 5 только потому, что они влезли в лимит. Лучше сделать короткую выжимку: стороны, суммы, сроки, ограничения, и добавить 1-2 точные цитаты из нужного раздела. Для вопроса про штрафы один абзац про штрафы полезнее, чем весь документ целиком.

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

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

Еще одна частая ошибка - забить весь лимит входом и не оставить места под ответ. Если модель принимает 32k токенов, это не значит, что все 32k нужно отдать под промпт. Сначала резервируйте бюджет под completion. Если нужен ответ на 800 токенов, эти 800 нужно отложить заранее. Иначе сервис получит обрыв ответа или отказ по лимиту.

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

Когда лучше отказать сразу

Без переписывания интеграции
Оставьте текущий код и переведите трафик на OpenAI-совместимый шлюз.

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

Первый очевидный случай - жесткий лимит по токенам. Если запрос больше контекстного окна модели, больше лимита арендатора или больше внутреннего бюджета на один вызов, его лучше отклонять сразу. Попытка "втиснуть" такой запрос в очередь обычно заканчивается тем же отказом, только позже и дороже для всех.

Пользователь не должен гадать, что пошло не так. В ответе лучше прямо указать причину и допустимый размер: сколько токенов пришло, какой лимит действует и что делать дальше. Например: "Запрос содержит 240000 токенов. Для этой модели доступно 128000. Сократите контекст или отправьте задачу в async-режим".

Большие задачи вообще не стоит ломать об онлайн-контур. Если пользователь загружает длинный договор, архив переписки или пачку документов на разбор, такой сценарий лучше сразу переводить в фоновую обработку. Пользователь получает job id и ждет результат отдельно, а не держит живое соединение и не забивает общую очередь.

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

  • промпт выше жесткого лимита модели или политики клиента
  • оценка стоимости выходит за бюджет одного вызова
  • запрос уже требует batch или async, но пришел в sync
  • входные данные сломаны: пустой текст, битый JSON, дубли контекста на сотни страниц

Если команда работает через единый шлюз, такую проверку лучше делать до маршрутизации по провайдерам. Тогда заведомо плохой запрос не поедет дальше, не займет rate limit и не создаст лишние audit-логи.

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

Пошаговая схема admission control

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

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

  1. Оцените размер запроса в токенах. Считайте не только текст пользователя, но и system prompt, историю диалога, вложенные документы и ожидаемый размер ответа.
  2. Проверьте ограничения клиента. Остаток квоты, rate limit, число активных запросов и текущую длину очереди лучше смотреть вместе.
  3. Назначьте класс обслуживания. Интерактивный чат для оператора получает высокий приоритет, ночная пакетная обработка документов - низкий, эксперименты в песочнице - еще ниже.
  4. Если запрос слишком велик, примените усечение по заранее заданным правилам. Сначала убирайте старые сообщения, потом дубли, потом второстепенные вложения. System prompt, свежие реплики и обязательные поля лучше не трогать.
  5. После этого примите одно из трех решений: отправить запрос сразу, отложить его в отдельную очередь или отказать.

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

Простой пример из продакшена

Разведите чат и batch
Ведите чат и тяжелые задачи через один совместимый API без лишней склейки.

Чат-помощник банка получает сообщение клиента: "Почему у меня списалась комиссия?" К этому моменту в диалоге уже 120 сообщений. Там есть старые вопросы, повторы, служебные фразы оператора и куски текста, которые больше не влияют на ответ.

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

На практике поток делят на два класса. Короткий ответ клиенту идет в быстрый класс. Туда попадает только то, что нужно прямо сейчас: последнее сообщение клиента, несколько последних реплик, системные инструкции и краткая сводка по прошлому диалогу.

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

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

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

Такой подход особенно полезен на LLM-шлюзе, где рядом живут и короткие пользовательские запросы, и тяжелые задачи разбора. Один вход для всех моделей упрощает интеграцию, а правила admission control можно применять еще до выбора конкретного маршрута.

Ошибки, которые быстро забивают сервис

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

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

Вторая ошибка - усечение по символам, а не по токенам. На бумаге это выглядит просто, но русскоязычный текст, JSON, код и логи дробятся в токены совсем не одинаково. Из-за этого усечение срабатывает слишком поздно или слишком рано. В первом случае модель получает переполненный контекст. Во втором вы режете полезные куски без причины.

Третья ошибка встречается почти в каждом первом запуске: команда считает только вход. Она подгоняет запрос ровно под окно модели и не оставляет запас под ответ, системные вставки, tool calls или служебные токены шлюза. Сервис принимает запрос, а потом упирается в потолок уже во время генерации. Пользователь видит таймаут или обрыв ответа, хотя причина довольно приземленная - нулевой запас под completion.

Еще один быстрый способ устроить перегрузку - ретраи без backoff после отказа по токенам или длине очереди. Один клиент получает отказ и тут же шлет тот же запрос снова, потом еще раз. Так локальная проблема превращается в волну дублей. Через минуту в очереди лежат не новые запросы, а копии старых, которые все равно не пройдут.

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

Для API-шлюза, который отправляет запросы в разные модели, эта дисциплина особенно важна. Один и тот же промпт может спокойно пройти по одному маршруту и сразу упереться в лимит по другому. Если правила слишком грубые, сервис начинает выглядеть нестабильным.

Чек-лист перед запуском

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

Перед релизом проверяйте не только среднюю нагрузку, но и плохие сценарии. Admission control ломается не на обычных запросах, а в момент, когда один клиент отправляет огромную историю чата, а второй грузит файл на сотни страниц.

Самый полезный подход простой: заранее решить, что сервис принимает, что режет и что отклоняет сразу. Если эти правила не задать до запуска, очередь LLM-запросов начнет расти рывками, а задержка станет непредсказуемой даже для коротких вызовов.

Минимальный чек-лист такой:

  • задать жесткий верхний предел на входные токены до постановки запроса в очередь
  • отделить batch-задачи от интерактивного трафика и выделить им свою квоту
  • писать в лог точную причину каждого усечения и каждого отказа
  • настроить алерты не только по ошибкам, но и по длине очереди и времени prefill
  • прогнать тесты на длинной истории диалога, больших файлах и смешанных сценариях

Полезно отдельно проверить поведение после усечения. Если система срезала старые сообщения или части документа, ответ не должен превращаться в случайный. Короткий, но точный ответ лучше, чем 40 секунд ожидания и тот же отказ по токенам.

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

Что сделать дальше

Если у вас уже есть лимиты по токенам, этого мало. Admission control лучше вынести в отдельный слой перед моделью. Он должен сам решать, что делать с запросом: пропустить, урезать контекст, понизить приоритет или сразу отказать. Тогда правила не расползутся по разным сервисам, и команде не придется чинить одно и то же в трех местах.

Практический план тоже довольно простой. Сначала зафиксируйте политику в явном виде: лимит на входные токены, лимит на суммарный бюджет запроса, правила приоритета по типу клиента и допустимые причины отказа. Потом сверьте лимиты с требованиями по данным и аудиту. Если сервис хранит историю, маскирует PII или пишет audit-логи, это влияет и на маршрут запроса, и на допустимый размер контекста.

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

Если у команды один шлюз для работы с несколькими моделями, правила удобно держать именно там. В случае с AI Router это выглядит естественно: сервис дает один OpenAI-совместимый endpoint, rate-limits на уровне ключа и audit-логи, поэтому базовую проверку можно сделать до маршрутизации. Для команд в Казахстане и Центральной Азии это еще и упрощает работу со сценариями, где важны хранение данных внутри страны и маскирование PII.

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

Хороший результат для первой версии выглядит просто: правила живут отдельно, аудит и лимиты не спорят друг с другом, а один длинный промпт не валит весь сервис.

Admission control для длинных промптов в LLM-сервисе | AI Router