awesome-everything EN
↑ Обратно к восхождению

AI / LLM

Стриминг ответов LLM: SSE, частичные токены и прокси, который их съедает

Суть Стриминг меняет общее время генерации на time-to-first-token. SSE шлёт delta-события, которые ты накапливаешь; аргументы tool-call приходят как частичный JSON, его нельзя парсить до конца. Классика прода: буферизующий прокси держит весь ответ — и стриминг тихо умирает.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на junior-высоте — поверхность
◷ 16 min

На ноутбуке демо было идеальным: вводишь промпт, слова появляются мгновенно, курсор летит по экрану. Потом это выкатили за корпоративный 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_deltacontent_block_stop → затем message_delta (несёт причину остановки и финальный расход токенов) → message_stop. У OpenAI имена другие, но идея та же: старт, поток дельт, терминальное событие. Ты никогда не получаешь ответ одним кадром; ты накапливаешь дельты в снимок.

Событие SSEНесётЧто делаешь
message_startid сообщения, роль, пустой contentОткрой пузырь ассистента; запусти таймер TTFT
content_block_startИндекс блока + тип (text / tool_use)Реши: рендерить как текст или буферить как аргументы tool
content_block_deltatext_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. 1 message_start — приходит конверт с пустым content; запусти таймер TTFT
  2. 2 content_block_start — открывается блок text или tool_use
  3. 3 content_block_delta (×N) — добавляй каждый text_delta или input_json_delta в аккумулятор
  4. 4 content_block_stop — блок завершён; теперь безопасно JSON.parse аргументов tool
  5. 5 message_delta, затем message_stop — причина остановки + финальный расход; финализируй
Вспомните перед уходом
  1. 01
    Приложение стримит идеально на localhost, но в проде каждый ответ появляется разом после долгой паузы. Пройди диагностику и фикс.
  2. 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, со сжатием, выключенным на роуте потока.

Продолжить восхождение ↑Streaming: тест с множественным выбором
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.