1. Главная
  2. Блог
  3. ИИ-агент продолжает считать после того, как клиент закрыл вкладку: история одной утечки токенов

ИИ-агент продолжает считать после того, как клиент закрыл вкладку: история одной утечки токенов

ИИ-агент продолжает считать после того, как клиент закрыл вкладку: история одной утечки токенов

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

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

Так мы познакомились с тем, как стриминг ответов LLM умеет жечь токены даже после того, как пользователь ушёл пить кофе.

Что такое «отвал» в стриминге и почему он невидим

Когда ИИ-агент возвращает ответ через Server-Sent Events, снаружи всё выглядит просто. FastAPI открывает соединение, по чанку отдаёт куски ответа LLM, фронт печатает их живой строкой. В идеальном мире, если клиент закрыл вкладку, TCP-сокет рвётся, FastAPI ловит исключение на следующей попытке записи, мы отменяем вызов LLM и расходимся.

В реальном мире между FastAPI и браузером стоит ещё минимум два уровня — обратный прокси (у нас Nginx) и CDN (Cloudflare). Каждый держит свой буфер. Пока буфер не переполнится или не выйдет таймаут, TCP-обрыв до приложения не доедет. А LLM-клиент в это время крутит свой async for chunk in stream, складывает токены в очередь и терпеливо дожидается финального [DONE].

Параллельно GigaChat Pro считает токены по факту генерации. Сгенерировал — заплатил, прочитал кто-то результат или нет — вопрос уже не к тарификатору. То есть деньги списываются прямо сейчас, а узнаём мы об этом позже, когда сводки сойдутся.

Где конкретно текли токены

Разложил трассы по сценариям. Получилось три группы.

Первая — пользователи с мобильного. Спросил у агента, получил первые две строчки, переключил приложение, операционка убила вкладку. Соединение умирает мягко, без RST-пакета. Прокси этого не замечает и держит сессию ещё минуту-полторы.

Вторая — менеджеры в CRM. Открыли карточку клиента, кликнули «Подсказка от агента», начали читать, передумали, нажали «Закрыть». Фронт честно отправил AbortController.abort(). Но прокси в этот момент уже отдавал ответ из собственного буфера и просто переставал писать в сокет — до нашего бэкенда сигнал не долетал.

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

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

Почему стандартный способ не помог

В FastAPI есть штатный паттерн: раз в несколько итераций дёргать await request.is_disconnected() и прерываться. Звучит здорово. Работает плохо.

Метод полагается на то, что транспорт сообщит о разрыве. А транспорт не сообщает, пока буферы не разгребутся. Я воспроизвёл руками: запустил длинный ответ, закрыл вкладку, поставил print в цикл стриминга. is_disconnected() вернул True почти через минуту после того, как я закрыл вкладку. За это время GigaChat спокойно догенерировал ещё сотни токенов.

Дальше попробовали отключить буферизацию Nginx через proxy_buffering off и X-Accel-Buffering: no в заголовках. Помогло частично — Nginx перестал держать ответ. Но Cloudflare продолжил: он для SSE буферизует до 100 секунд независимо от заголовков, если у вас не корпоративный тариф с настройкой на своей стороне.

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

Решение: heartbeat в обе стороны

Сделали по-другому. В SSE-потоке завели два независимых события — data с полезной нагрузкой и ping для проверки жизни. Фронт в ответ на каждый пинг стучит встречным pong через отдельный лёгкий POST к /stream/{session_id}/alive. Бэкенд держит в памяти словарь «последний pong по сессии». В цикле генерации между чанками смотрим: если последний pong старше нескольких секунд — клиент ушёл, отменяем.

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

async def stream_answer(session_id: str, prompt: str):
    last_pong[session_id] = time.monotonic()

    async with giga_client.stream(prompt) as response:
        async for chunk in response:
            if time.monotonic() - last_pong[session_id] > 12:
                await response.aclose()
                raise ClientGoneError(session_id)
            yield f"data: {chunk.json()}\n\n"

            if chunk.index % 20 == 0:
                yield "event: ping\ndata: {}\n\n"

Ключевая строчка — await response.aclose(). SDK GigaChat в этот момент отправляет HTTP-разрыв на сервер модели, и генерация останавливается на их стороне. Это то самое отличие, которое экономит деньги: без aclose мы бы просто перестали читать поток, а модель продолжила бы считать.

Подвох в том, что не всякий HTTP-клиент честно прокидывает отмену. Пришлось проверять руками: открыли поток, оборвали, посмотрели в логах модели — реально ли генерация остановилась. У одного из фоллбэк-провайдеров обнаружили, что закрытие соединения не отменяет инференс, а только отписывает клиента от ответа. Для него пришлось делать явный вызов /cancel по request_id.

Что ещё всплыло по дороге

История с батчем оказалась интереснее. Когда мы говорим «автономные ИИ-агенты», подразумеваем, что клиента-человека у них нет вообще: это серверный код, который читает поток в цикле и что-то с ним делает. Ему некому слать pong — механизм с heartbeat к нему просто не приложишь.

Пришлось делать иначе. Каждый воркер регистрирует себя в Redis с TTL 30 секунд и продлевает регистрацию, пока работает. Если воркер умер или подвис — TTL истекает, отдельный сторож-корутина это видит и отменяет связанный LLM-запрос. Тот же приём мы потом переиспользовали в оркестрации на n8n: ИИ-агенты, которые ходят в модель через ноды n8n, ловили ту же самую проблему, и Redis-сторож её закрывает единообразно.

Отдельная тема — ИИ-агент как искусственный интеллект, к которому обращаются другие сервисы через MCP. Связку «ИИ-агент MCP» мы у себя тоже проверили: если внешний сервис-клиент разрывает MCP-сессию, мы обязаны отменить всё, что за ней стояло, включая незавершённый вызов LLM. Иначе получаем ровно тот же букет: клиента нет, а модель считает.

Ещё пришлось аккуратно обработать гонку. Пользователь нажал «Стоп», фронт отправил отмену, но в этот же момент бэкенд уже дописал финальный ответ в базу. Договорились так: при явной отмене фиксируем «прервано пользователем» и сохраняем то, что успело накопиться. Менеджер в CRM видит, что произошло, и не теряет частичный ответ. Это оказалось важнее чистой метрики: люди быстро успокоились, когда перестали думать, что «система съела мой ответ».

Что стало после

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

В деньгах — счёт за GigaChat Pro на сценариях со стримингом заметно просел. Для нашего объёма сумма чувствительная, но важнее другое: пропала странность, когда счёт за токены не сходился с метриками использования. Раньше у нас было два числа — «сколько LLM посчитал» и «сколько пользователи прочитали», и они расходились на десятки процентов. Теперь сходятся в пределах шума, и это сильно облегчает планирование бюджета и разговоры с руководством.

Что забрать с собой

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

Полагаться на TCP-разрыв нельзя: между вашим приложением и пользователем слишком много буферов. Полагаться на is_disconnected нельзя: он узнаёт об отвале последним. Единственный честный способ — активный heartbeat от клиента и явное закрытие потока к LLM при его отсутствии. А для автономных ИИ-агентов, у которых человека на том конце нет, — сторож в Redis с TTL, который убивает зависшие вызовы.

И отдельно проверьте свой SDK. Закрытие потока на стороне клиента и отмена инференса на стороне сервера — это разные действия, и не каждый провайдер их связывает. Проверка занимает пятнадцать минут, экономия — заметная доля счёта за модель.

А у вас в проде стриминг есть? Знаете, сколько токенов в нём не доходит до читателя?

Похожие статьи

Хотите так же?

Обсудим, как это внедрить у вас

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