Перейти к содержимому
7 мин чтения

Накладные расходы LLM-шлюза в реальном запросе

Накладные расходы LLM-шлюза нужно разложить по слоям: аутентификация, аудит, лимиты, роутинг, очереди и streaming.

Накладные расходы LLM-шлюза в реальном запросе

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

Прямой вызов модели кажется честной точкой отсчёта, но и он содержит DNS, TLS, установку соединения, отправку промпта, ожидание очереди у провайдера и генерацию. Шлюз надо сравнивать с этим же вызовом при тех же условиях, а не с идеальной картинкой в голове. Цель измерений проста: отделить цену полезных гарантий, таких как аутентификация, лимиты и аудит, от случайных пауз, которые можно убрать.

Сначала определите, что именно считается задержкой

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

Разложите каждый запрос на интервалы, которые не пересекаются:

  • client_to_gateway: от отправки в приложении до приёма шлюзом;
  • gateway_queue: ожидание свободного worker, соединения или внутреннего лимита;
  • gateway_processing: аутентификация, политика, маскирование, выбор маршрута и подготовка запроса;
  • upstream_ttft: от отправки провайдеру до первого байта либо первого события потока;
  • upstream_completion: генерация и передача остатка ответа;
  • gateway_to_client: буферизация, фильтрация и доставка клиенту.

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

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

Не используйте среднее как главный показатель. Среднее хорошо скрывает очередь из десяти запросов, которая возникает раз в несколько минут и бьёт по p99. Для каждого интервала снимайте хотя бы p50, p95, p99, число запросов и долю ошибок. Для потокового режима добавьте распределение TTFT. Для шлюза с лимитами добавьте глубину очереди и долю отклонённых запросов.

Прямой вызов и шлюз надо сравнивать при одинаковой работе модели

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

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

Перед тестом зафиксируйте:

  • конкретный идентификатор модели и параметры генерации;
  • один регион исполнения тестового клиента;
  • одинаковый лимит параллельных запросов;
  • включённый или выключенный streaming на обеих сторонах;
  • правила тайм-аута и повторов;
  • холодные и прогретые соединения как отдельные серии.

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

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

export DIRECT_URL="$DIRECT_URL"
export GATEWAY_URL="$GATEWAY_URL"
export API_KEY="$API_KEY"
export BODY_FILE="request.json"

curl --http1.1 --silent --show-error --output /dev/null \
  --write-out 'route=direct connect=%{time_connect} ttfb=%{time_starttransfer} total=%{time_total} code=%{http_code}\n' \
  --header "Authorization: Bearer $API_KEY" \
  --header 'Content-Type: application/json' \
  --data-binary "@$BODY_FILE" \
  "$DIRECT_URL"

curl --http1.1 --silent --show-error --output /dev/null \
  --write-out 'route=gateway connect=%{time_connect} ttfb=%{time_starttransfer} total=%{time_total} code=%{http_code}\n' \
  --header "Authorization: Bearer $API_KEY" \
  --header 'Content-Type: application/json' \
  --data-binary "@$BODY_FILE" \
  "$GATEWAY_URL"

Вывод должен иметь форму route=gateway connect=0.012 ttfb=0.441 total=1.836 code=200. Это измерение не даёт TTFT для Server-Sent Events и не раскрывает внутренние слои, зато помогает обнаружить очевидную ошибку: шлюз ждёт полный ответ перед отправкой первого байта клиенту, хотя upstream уже начал поток.

Затем переходите к серии с постоянным соединением и контролируемой конкуренцией. Запускайте несколько уровней параллелизма, пока не увидите перелом распределения. Резкий рост p95 при почти неизменном p50 обычно говорит не о медленной модели, а о насыщении очереди, пула соединений или фонового экспортёра телеметрии.

Первым тормозит не роутинг, а очередь перед ним

Очередь создаёт большие хвосты задержки даже тогда, когда каждый фильтр шлюза работает быстро. Это самый частый случай, который ошибочно называют «дорогим роутингом». Условие выбора модели может занимать доли миллисекунды, но запрос ждёт десятки или сотни миллисекунд, потому что все worker заняты сериализацией ответов, записью логов или ожиданием медленного upstream.

Ищите очередь в трёх местах. Первая находится до кода приложения: лимиты входящих соединений, accept backlog, балансировщик, TLS-терминация. Вторая живёт в самом процессе: очередь worker, пул файловых дескрипторов, пул HTTP-соединений, ограничение одновременных потоков. Третья появляется в зависимостях: Redis для распределённого лимита, база аудита, коллектор трасс, сервис политик.

Плохой диагноз выглядит так: «маршрутизатор добавляет 180 мс». Хороший диагноз выглядит так: «p99 внутренней очереди растёт после 24 одновременных запросов, потому что пул исходящих соединений к выбранному провайдеру ограничен, а потоковые ответы удерживают соединения до завершения». Во втором случае есть что исправлять.

Проверьте закон очередей на собственных данных. Если входящий поток почти равен максимальной пропускной способности, даже маленькие колебания времени обработки поднимают хвосты. Увеличить число worker иногда помогает, но иногда только переносит очередь в CPU, сеть или downstream. Нельзя лечить ожидание в Redis добавлением процессов, если каждый новый процесс делает ещё больше запросов в тот же Redis.

У очереди должен быть лимит и понятное поведение при переполнении. Бесконечная очередь создаёт красивый график успешных ответов до момента, когда пользователи начинают ждать слишком долго, а затем отменяют запросы. Ограниченная очередь с быстрым отказом даёт неприятный, но управляемый сигнал. Клиент может показать пользователю состояние, переключить задачу в фон или сделать повтор с задержкой.

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

Аутентификация редко дорога, пока не ходит по сети

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

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

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

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

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

Логирование может съесть пропускную способность

Разделите маршруты и задержки
AI Router направляет запросы к 500+ моделям через единый OpenAI-совместимый эндпоинт.

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

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

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

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

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

Маршрутизация должна быть вычислимой без второго LLM-вызова

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

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

Сделайте правила маршрута наблюдаемыми. Для каждого решения сохраняйте короткую причину, например tenant_policy, region_requirement, capacity_fallback или requested_model. Не записывайте весь набор внутренних оценок на каждый запрос. Достаточно знать, какое правило выиграло и был ли это fallback.

Роутер не должен открывать соединение к каждому кандидату «на всякий случай». Один выбранный маршрут, один пул соединений, один понятный timeout budget. Fallback запускайте после классифицированной ошибки: недоступность, перегрузка, нарушение регионального ограничения или явный отказ провайдера. Не переключайте модель после пользовательской ошибки в промпте, после неверного формата тела или после того, как upstream уже начал поток. Иначе клиент получает ответ от другой модели там, где ожидает предсказуемость.

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

Стриминг меняет восприятие задержки, но не отменяет полную работу

Меняйте модели без переписывания
После смены base_url ваши SDK, код и промпты продолжают работать без изменений.

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

Нужны четыре отметки времени: приём запроса шлюзом, отправка upstream, первый байт от upstream и первый байт клиенту. Разница между третьей и четвёртой отметками показывает цену обработки потока внутри шлюза. Если она растёт с длиной ответа, проверьте буферизацию, синхронный аудит каждого фрагмента, фильтры содержимого и блокировки при записи клиенту.

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

Клиент может быть медленнее модели. Если шлюз читает upstream быстро, а записывает клиенту медленно, буферы растут. Если он перестаёт читать upstream, upstream может остановиться или закрыть соединение. Выберите политику заранее: ограниченный буфер с отменой, backpressure до upstream, либо отсоединение клиента при превышении лимита. У каждой есть последствия для стоимости и полноты аудита, поэтому политику надо проверять отдельным тестом с искусственно медленным клиентом.

Один trace должен показать цену каждого слоя

Распределённая трасса полезна только тогда, когда она отвечает на вопрос о времени, а не превращается в коллекцию красиво названных спанов. W3C Trace Context определяет заголовки traceparent и tracestate для переноса контекста между компонентами и требует корректной обработки контекста при его передаче. В спецификации отдельно сказано не помещать персональные или чувствительные данные в эти заголовки.

Для одного LLM-запроса достаточно дерева такого вида:

llm.request
├── gateway.authenticate
├── gateway.policy
├── gateway.rate_limit
├── gateway.route
├── gateway.queue
├── upstream.request
│   ├── upstream.first_byte
│   └── upstream.read_stream
├── gateway.audit_enqueue
└── gateway.client_write

Не создавайте span на каждый токен. На длинном ответе это создаст лавину телеметрии и исказит измерение. Для потока хватит счётчиков числа событий, байтов и времени между первым и последним событием. Событие trace нужно для редкой диагностической детали: fallback, отмены, превышения лимита или ошибки разбора.

OpenTelemetry описывает context propagation как механизм, который связывает спаны в одну трассу, даже если их создают разные компоненты. Используйте этот принцип буквально: trace начинается в приложении, проходит через шлюз и продолжается в исходящем HTTP-клиенте. Если шлюз без причины создаёт новый trace, вы теряете возможность доказать, где именно возникла пауза.

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

Нагрузочный тест должен ломать по одному слою за раз

Добавьте контроль к API
Контентные метки по AI-закону, аудит и лимиты собраны на одной платформе.

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

Рабочая последовательность выглядит так:

  1. Прогрейте соединения и снимите серию с фиксированным небольшим параллелизмом.
  2. Повторите серию с ростом конкуренции, пока не изменится p95 или не появятся ошибки.
  3. Включите один внутренний слой и найдите разницу по его span, CPU, памяти и числу исходящих вызовов.
  4. Проведите тот же тест с длинным потоковым ответом и с медленным клиентом.
  5. Повторите проверку при недоступности одного upstream, чтобы увидеть цену fallback и повторов.

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

Смотрите на пропускную способность как на число завершённых полезных операций при заданном SLO, а не как на максимум принятых запросов. Шлюз, который принял тысячу запросов, поставил их в бесконечную очередь и ответил через минуту, не стал производительнее. Он просто отложил ошибку.

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

В критическом пути должны остаться только обязательные решения

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

Это не аргумент за «тонкий прокси» без контроля. Бизнес-системе нужны аудит, PII-маскирование, лимиты и управляемый выбор моделей. Но эти функции нельзя оценивать общей надбавкой к задержке, потому что часть из них обязана быть синхронной, а часть должна работать вне пользовательского пути.

AI Router позволяет командам сохранить OpenAI-совместимый клиентский контракт, сменив адрес API, но после подключения всё равно стоит измерить собственные политики, аудит и сценарии маршрутизации, а не считать шлюз невидимым. Полезный результат такого замера не «шлюз добавляет N миллисекунд», а таблица с ценой каждого слоя, границей его очереди и решением, которое можно проверить повторно.

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

Часто задаваемые вопросы

Сколько задержки добавляет LLM-шлюз?

Нет, если измерять его как часть всего пути, а не как один лишний HTTP-переход. Шлюз обычно добавляет проверку ключа, выбор маршрута, лимиты, аудит и телеметрию. Вопрос в том, сколько времени каждый слой занимает в вашем SLO и не создаёт ли он очередь под нагрузкой.

Что важнее измерять: TTFT или полную задержку?

TTFT измеряет время до первого токена при потоковом ответе, а полная задержка включает генерацию всего ответа и передачу последних байтов клиенту. Для чата пользователи чаще замечают TTFT. Для пакетной обработки и коротких JSON-ответов полная задержка часто важнее.

Можно ли сравнить прямой вызов и шлюз одним curl-запросом?

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

Может ли rate limiting заметно замедлить LLM-запросы?

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

Какие данные нужно логировать в LLM-шлюзе?

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

Почему шлюз медленный, хотя провайдер отвечает быстро?

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

Нужно ли включать streaming при нагрузочном тесте?

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

Можно ли маршрутизировать LLM-запросы без большой задержки?

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

Какие перцентили нужны для оценки LLM-шлюза?

Обычно p95 и p99. Среднее скрывает редкие очереди, паузы сборщика памяти, повторные попытки и медленные записи аудита, хотя именно они ломают пользовательский опыт. Добавьте также долю ошибок, отменённых запросов, TTFT и глубину очереди.

Как найти самый дорогой слой в LLM-шлюзе?

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