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

Отмена стриминга LLM: как не платить за лишние токены

Отмена стриминга LLM помогает остановить лишние токены, когда пользователь ушёл со страницы. Разберём сигналы, таймауты, логи и проверки.

Отмена стриминга LLM: как не платить за лишние токены

Что ломается после закрытия экрана

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

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

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

Из-за этого ломается сразу несколько вещей:

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

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

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

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

Для команды это еще и проблема наблюдаемости. В отчетах виден "успех", а в продукте - пустое место или оборванный ответ. Когда таких запросов становится много, учет расходов быстро теряет точность.

Где уходят деньги и время

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

На коротких ответах это почти незаметно. На длинных сводках, чатах поддержки или генерации SQL счет растет быстро. Пользователь не читает эти 500-800 токенов, но провайдер их уже посчитал, а сервер успел принять поток, обработать его и держать соединение дольше, чем нужно.

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

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

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

Если запрос проходит через несколько слоев, например клиент, ваш бэкенд, API-шлюз и провайдера модели, полезно хранить аудит-след на каждом этапе. В системах вроде AI Router это проще сделать через один OpenAI-совместимый вызов и общие аудит-логи: тогда быстрее видно, где пропал сигнал отмены и в какой точке начали капать лишние токены.

Как заметить, что клиент уже ушел

Ждать жалоб поздно. Если экран закрыли, вкладку обновили или приложение ушло в фон, сервер может еще получать токены и платить за них. Значит, отмену надо ловить в тот момент, когда клиент рвет соединение, а не когда задача уже закончилась сама.

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

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

Причины остановки лучше разделить на несколько явных событий:

  • client_abort - клиент сам отменил запрос;
  • network_close - соединение оборвалось;
  • upstream_timeout - провайдер не ответил вовремя;
  • server_timeout - сервер сам прервал ожидание.

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

После этого сверьте локальную отмену с тем, что произошло у провайдера. Сервер мог получить abort, закрыть SSE для клиента и при этом не остановить генерацию выше по цепочке. Тогда пользователь уже ушел, а модель еще 10-20 секунд пишет ответ. Для учета расходов полезно хранить рядом два факта: время отмены у клиента и время реальной остановки у провайдера.

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

Как остановить генерацию по шагам

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

Рабочая схема проста: у запроса должен быть один общий сигнал отмены. Не отдельный для браузера и не отдельный для бэкенда, а один источник правды, который проходит весь путь до LLM API.

Обычно схема выглядит так:

  1. Клиент создает сигнал отмены в момент старта запроса. Если пользователь закрыл вкладку, нажал "стоп" или приложение потеряло соединение, этот сигнал срабатывает сразу.
  2. Бэкенд принимает тот же сигнал и привязывает его к своему обработчику. Если клиент ушел, сервер не ждет, пока модель закончит ответ сама.
  3. Сервер пробрасывает отмену дальше, в исходящий запрос к модели или в шлюз.
  4. Как только сигнал пришел, сервер закрывает стрим до клиента и сразу обрывает исходящий запрос к модели. Не делайте это с длинной паузой между шагами.
  5. Система ждет короткое подтверждение остановки, обычно 1-3 секунды. Если подтверждения нет, сервер завершает запрос принудительно и пишет причину в лог.

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

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

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

Что сделать на стороне сервера

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

Когда клиент рвет соединение, сервер не должен ждать, пока модель закончит ответ. Как только приложение получило abort, снимайте задачу с воркера, закрывайте поток к провайдеру и освобождайте память. Иначе пользователь уже ушел, а API или GPU все еще тратят токены и время.

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

Таймауты и статусы

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

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

Статус cancelled храните отдельно от failed и timeout. Это разные события. Failed означает ошибку кода, сети или провайдера. Timeout означает, что сервер дождался предела и сам остановил запрос. Cancelled означает, что пользователь или верхний сервис передумал раньше. Если смешать их в один статус, метрики начнут врать.

Во внутренние метрики отправляйте одну причину отмены на каждый запрос. Не набор флагов, а одно итоговое поле: client_abort, model_timeout, gateway_deadline или manual_cancel. Тогда в отчетах видно, где уходят лишние токены, а где проблема в логике сервиса.

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

Ошибки, из-за которых счет все равно растет

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

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

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

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

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

Обычно проблема выглядит так:

  • фронт закрыл соединение, но бэкенд не отправил cancel дальше;
  • сервер отправил cancel, но не сохранил request_id провайдера;
  • отмена дошла до шлюза, но не до конечной модели;
  • команда не проверяет в логах, где генерация реально остановилась;
  • высокий max_tokens оставляет слишком дорогой запас.

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

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

Пример из обычного сценария

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

Пользователь открыл чат поддержки, задал вопрос про условия тарифа и почти сразу свернул приложение. Такое случается постоянно: человека отвлекли, ему позвонили или он просто решил вернуться позже. На экране поток текста уже не виден, но серверный запрос еще жив.

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

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

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

После исправления команда не меняла модель. Она поменяла поведение цепочки запроса:

  • фронтенд отправляет событие отмены сразу при закрытии экрана;
  • API-шлюз пробрасывает abort в тот же запрос, а не просто рвет соединение с клиентом;
  • бэкенд завершает чтение стрима и закрывает задачу без ретраев;
  • метрики отдельно считают отмененные ответы и токены после ухода клиента.

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

Для продуктовой команды это одна из самых дешевых доработок по эффекту. Вы не меняете UX, не переписываете промпты и не ищете новую модель. Вы просто перестаете платить за текст, который никто не увидел.

Быстрая проверка перед релизом

Проведите отмену через шлюз
Передайте один request_id через AI Router и быстрее найдите хвост лишних токенов.

Перед релизом нужен тест, который ломает обычный сценарий. Запустите длинный ответ, дождитесь середины и закройте вкладку или экран. Если все настроено правильно, расход токенов перестанет расти почти сразу, а не через 20-30 секунд.

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

Перед выпуском проверьте пять вещей:

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

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

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

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

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

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

Что внедрить дальше

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

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

Что свести в одну панель

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

Держите рядом хотя бы четыре метрики:

  • сколько стримов клиент оборвал сам;
  • сколько запросов сервер остановил по таймауту;
  • сколько токенов ушло после разрыва клиентской сессии;
  • какие модели дают самый дорогой хвост после отмены.

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

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

Если у вас несколько команд и несколько маршрутов к моделям, удобно вынести контроль на уровень шлюза. В AI Router можно централизованно держать маршрутизацию, аудит-логи и лимиты по API-ключу, а не собирать эту логику заново в каждом сервисе. Это особенно удобно, когда один чат идет на hosted-модели, другой - через внешнего провайдера, а правила отмены и учета расходов должны оставаться общими.

С чего начать без большой переделки

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

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

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

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

Почему токены продолжают списываться после закрытия экрана?

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

Достаточно ли просто закрыть SSE или WebSocket?

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

Как понять, что клиент уже ушел, а генерация еще идет?

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

Какие причины отмены лучше писать в лог?

Держите причины отдельно. Обычно хватает client_abort, network_close, server_timeout, upstream_timeout и manual_cancel. Тогда вы сразу видите, кто первым остановил запрос и где именно пошел лишний расход.

Как настроить таймауты, чтобы не путать отмену и сбой?

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

Нужно ли ограничивать max_tokens, если отмена уже работает?

Да, почти всегда. Если обычный ответ укладывается в 300–500 токенов, не ставьте 4000 просто про запас. При пропущенной отмене высокий max_tokens быстро превращает один брошенный стрим в заметный счет.

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

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

Как быстро проверить отмену перед релизом?

Откройте длинный стрим и закройте экран на середине. Потом проверьте, перестали ли почти сразу расти токены, цена и время жизни запроса. Если интерфейс показывает отмену, а биллинг еще бежит 10–30 секунд, релиз рано выпускать.

Почему в отчетах все выглядит нормально, хотя пользователи не видят ответ?

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

Что дает единый шлюз вроде AI Router в таких сценариях?

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