AI / LLM
Стриминг ответов LLM: SSE, частичные токены и прокси, который их съедает
На ноутбуке демо было идеальным: вводишь промпт, слова появляются мгновенно, курсор летит по экрану. Потом это выкатили за корпоративный nginx. Теперь каждый пользователь смотрит на спиннер девять секунд, а потом весь ответ вваливается разом. В приложении ничего не менялось. Модель по-прежнему стримила токен за токеном — но прокси с proxy_buffering on тихо держал весь ответ в буфере и сбросил его только в конце. Стриминг был настоящим; пользователь не увидел ни байта из него.
Зачем вообще стримить: TTFT важнее общего времени
Нестримящийся ответ заставляет пользователя ждать всю генерацию. Ответ в 400 токенов на скорости 60 токенов/сек — это примерно семь секунд пустого экрана, а потом всё разом. Стриминг ничего не меняет в этом итоге — генерация всё равно длится семь секунд, — но меняет единственное число, которое чувствует пользователь: time-to-first-token (TTFT), разрыв между отправкой запроса и первым видимым словом.
Для чат-приложений TTFT меньше ~1 секунды ощущается мгновенным; продакшен на горячих путях целит в 200–500 мс. Когда токены пошли, чтение определяет time-per-output-token (TPOT): 50 токенов/сек ощущаются вялыми, 200 токенов/сек (~150 слов/сек) читаются быстрее, чем кто-либо успевает. Стриминг даёт примерно 10–20x улучшения воспринимаемой отзывчивости при нулевом изменении реальных вычислений. Взгляд сеньора: ты не делаешь быстрее, ты гасишь латентность скоростью чтения самого пользователя.
За этим прячутся две формы провала. Reasoning-модели с chain-of-thought могут сидеть на 10–150 секундах TTFT до первого токена — пока они думают, стримить нечего, и наивный UI выглядит замёрзшим. А если транспорт буферизует (следующий раздел), TTFT схлопывается обратно в общее время, и ты получаешь худшее из двух миров: сложность стриминга, латентность батча.
Протокол событий SSE
Каждый крупный провайдер стримит через Server-Sent Events (SSE) — долгоживущий HTTP-ответ с Content-Type: text/event-stream, где сервер пушит разделённые переводом строки кадры data: по одному соединению. Он однонаправленный (сервер→клиент), переживает обычную HTTP-инфраструктуру и автоматически переподключается в браузерном EventSource — именно поэтому он победил WebSockets для этой задачи.
Кадры — это типизированный жизненный цикл, а не сырой текст. Последовательность Anthropic: message_start (конверт, пустой content) → один или несколько блоков, каждый content_block_start → много content_block_delta → content_block_stop → затем message_delta (несёт причину остановки и финальный расход токенов) → message_stop. У OpenAI имена другие, но идея та же: старт, поток дельт, терминальное событие. Ты никогда не получаешь ответ одним кадром; ты накапливаешь дельты в снимок.
| Событие SSE | Несёт | Что делаешь |
|---|---|---|
message_start | id сообщения, роль, пустой content | Открой пузырь ассистента; запусти таймер TTFT |
content_block_start | Индекс блока + тип (text / tool_use) | Реши: рендерить как текст или буферить как аргументы tool |
content_block_delta | text_delta или input_json_delta | Добавь кусок в свой аккумулятор |
content_block_stop | Индекс блока | Теперь блок завершён — безопасно парсить JSON tool |
message_delta / message_stop | Причина остановки, финальный расход | Финализируй; запиши токены для биллинга/метрик |
Накопление частичных токенов — и ловушка частичного JSON
Текст прощающий: каждый text_delta — валидный фрагмент строки, поэтому ты добавляешь и рендеришь сразу. Аккумулятор — это просто text += delta, и UI рисует на ходу.
Tool-вызовы не прощают. Когда модель вызывает функцию, аргументы стримятся кусками input_json_delta — и каждый кусок это фрагмент JSON-документа, а не валидный JSON сам по себе. Дельта может быть {"city": "San Fran, потом cisco", "unit":, потом "celsius"}. Если вызвать JSON.parse на любом промежуточном буфере, ты упадёшь с синтаксической ошибкой. Правило жёсткое: накапливай каждый input_json_delta в строковый буфер и парси только после content_block_stop. Частичную строку можно показать как индикатор «вызываю инструмент…», но действовать по аргументам нельзя, пока блок не закрыт.
Это реальный и повторяющийся прод-баг. Слои прокси и адаптеров (LiteLLM выкатывал несколько таких регрессий) иногда роняют или неверно обрабатывают кадры input_json_delta — tool-вызов приходит с input в виде пустого {}, и твоя функция запускается без аргументов. Если tool-вызов загадочно срабатывает с пустыми аргументами, подозревай слой между тобой и моделью, который проглотил JSON-дельты, а не свой собственный парсер.
Почему это работает
Зачем вообще слать аргументы JSON непарсящимися фрагментами? Потому что модель генерирует их токен за токеном, как любой другой текст — нет момента, когда у провайдера рано появляется «весь объект аргументов». Стриминг их позволяет UI показывать прогресс, а провайдеру — начать слать в момент, когда есть первый токен. Контракт сдвигает разовую стоимость парсинга к границе блока, где JSON гарантированно полон.
Переподключение, возобновление и буферизующий прокси, который всё это убивает
SSE построен на переподключение: сервер может слать id: с каждым событием, и при обрыве соединения браузер переотправляет запрос с заголовком Last-Event-ID, чтобы сервер возобновился после последнего доставленного события. На практике возобновление LLM посреди генерации редко реализуют (нужно переигрывать буферизованные дельты на стороне сервера); большинство приложений трактуют оборванный стрим как «повторить весь ход» и опираются на идемпотентность. Честный дефолт сеньора: проектируй под смерть стрима на токене 200 из 400 и делай свежий запрос безопасным для повтора.
Но больнее всего бьёт провал, которому вообще не нужен обрыв соединения — это буферизация в пути. nginx идёт с proxy_buffering on по умолчанию: он читает ответ апстрима в буфер и пересылает клиенту только когда буфер наполнится или ответ закончится. Для обычной страницы это оптимизация; для SSE это фатально — клиент не получает ничего, пока модель не закончит, и TTFT превращается в общее время, а твой спиннер висит всю генерацию. Та же ловушка возникает на serverless-платформах и CDN, которые буферизуют или вводят лимиты на размер/время ответа, и в любом gzip-слое, который ждёт достаточно байтов для сжатия.
Фиксы конкретны. На nginx: proxy_buffering off, proxy_http_version 1.1, очисти Connection и большой proxy_read_timeout. Когда конфиг прокси трогать нельзя, выставь заголовок ответа X-Accel-Buffering: no (nginx уважает его пер-ответ) плюс Cache-Control: no-cache. Диагностика, экономящая часы: если стриминг работает против localhost, но в стейджинге сбрасывается разом, перестань дебажить свой код — что-то в сетевом пути буферизует.
Твой чат стримит нормально локально, но в проде каждый ответ появляется разом после долгой паузы. Выбери, что расследовать первым.
Tool-вызов стримит свои аргументы кусками input_json_delta. Когда безопасно делать JSON.parse накопленного буфера?
Почему стриминг улучшает UX, хотя не сокращает общее время генерации?
Расставь жизненный цикл SSE для одного стримящегося сообщения:
- 1 message_start — приходит конверт с пустым content; запусти таймер TTFT
- 2 content_block_start — открывается блок text или tool_use
- 3 content_block_delta (×N) — добавляй каждый text_delta или input_json_delta в аккумулятор
- 4 content_block_stop — блок завершён; теперь безопасно JSON.parse аргументов tool
- 5 message_delta, затем message_stop — причина остановки + финальный расход; финализируй
- 01Приложение стримит идеально на localhost, но в проде каждый ответ появляется разом после долгой паузы. Пройди диагностику и фикс.
- 02Почему аргументы tool-вызова надо накапливать перед парсингом, и какой баг возникает, когда промежуточный слой портит эти дельты?
Стриминг не делает генерацию быстрее — он гасит латентность скоростью чтения пользователя, схлопывая time-to-first-token с полной семисекундной генерации до пары сотен миллисекунд, тогда как time-per-output-token определяет чтение, когда токены пошли. Транспорт — это SSE: долгоживущий text/event-stream, где сервер пушит типизированный жизненный цикл — message_start, content_block_start, череда content_block_delta, content_block_stop, затем message_delta и message_stop — и ты накапливаешь дельты в снимок, а не получаешь ответ целиком. Текстовые дельты безопасно рендерить сразу; аргументы tool-вызова приходят фрагментами input_json_delta, которые не валидны как JSON, пока блок не закрыт, поэтому ты буферишь и парсишь только на content_block_stop, а tool-вызов с пустыми аргументами обычно означает, что промежуточный слой съел эти дельты. SSE умеет переподключаться через Last-Event-ID, но большинство приложений трактуют оборванный стрим как повторяемый whole-turn-ретрай. Провал, стирающий весь выигрыш, не требует обрыва: буферизующий обратный прокси или serverless-слой (nginx с proxy_buffering on по умолчанию) держит весь ответ и сбрасывает в конце, превращая TTFT обратно в общее время. Признак — «работает на localhost, батчит в проде», а фикс живёт в конфиге пути — proxy_buffering off или X-Accel-Buffering: no, со сжатием, выключенным на роуте потока.