Заметки

Стриминг ответов модели: как текст появляется по мере генерации

M
Markabus
·16 августа 2026 г.
Крупный план монитора, на экране наполовину написанный ответ модели с мигающим курсором; на заднем плане в расфокусе терминал и редактор кода

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

Разберём, как стриминг устроен на уровне протокола, чем отличаются реализации у Claude, OpenAI и Gemini, и что чаще всего ломается, когда такое решение выезжает в продакшен.

Зачем это нужно

Ключевая метрика здесь — TTFT (time to first token, время до первого токена). Без стриминга пользователь ждёт полное время генерации: модель пишет ответ на 600 слов — значит, экран пустой все двадцать секунд. Со стримингом первый фрагмент приходит за 0,3–1,5 секунды, а дальше текст «печатается». Общее время генерации при этом не меняется ни на миллисекунду — меняется только воспринимаемая скорость. Но именно она определяет, дождётся человек ответа или закроет вкладку.

Второй мотив чисто технический: длинные запросы иначе просто не доезжают. В документации Anthropic прямо сказано, что при больших значениях max_tokens официальные SDK требуют стриминга, иначе HTTP-соединение отваливается по таймауту. Даже если вы не собираетесь показывать текст по частям, запрос всё равно приходится делать потоковым и собирать ответ целиком уже у себя — в Python это client.messages.stream(...) и затем get_final_message(), в TypeScript — finalMessage().

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

Как это устроено: server-sent events

Все три крупных провайдера используют один и тот же транспорт — SSE (server-sent events, «события, посылаемые сервером»). Это часть стандарта HTML: обычное HTTP-соединение, которое сервер не закрывает и в которое дописывает текстовые сообщения по мере готовности. Ответ отдаётся с заголовком Content-Type: text/event-stream, а каждое событие выглядит примерно так:

event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" привет"}}

Разделитель между событиями — пустая строка. Клиент читает поток построчно, парсит JSON из каждой строки data: и дорисовывает текст в интерфейс.

Логичный вопрос: почему не WebSocket? Потому что здесь не нужен полноценный дуплекс. Данные идут только в одну сторону — от сервера к клиенту, — а запрос уже отправлен обычным POST. SSE работает поверх штатного HTTP, не требует отдельного протокола апгрейда, дружит с балансировщиками и HTTP/2. Единственное неудобство: браузерный объект EventSource умеет только GET-запросы, поэтому в вебе поток обычно читают через fetch() и response.body.getReader(), разбирая SSE вручную или библиотекой.

Claude: события Messages API

У Anthropic стриминг включается параметром "stream": true в запросе к Messages API (как отправить первый запрос — в гайде Claude API: как получить ключ и сделать первый запрос). Дальше приходит строго упорядоченная последовательность событий:

  1. message_start — объект сообщения с пустым content;
  2. для каждого блока контента: content_block_start, затем серия content_block_delta, затем content_block_stop;
  3. одно или несколько message_delta — изменения верхнего уровня финального сообщения (в том числе итоговая статистика по токенам);
  4. message_stop — конец потока.

Сами дельты бывают разных типов, и это важно: text_delta — обычный текст; input_json_delta — кусочки JSON с аргументами вызываемого инструмента; thinking_delta и signature_delta — содержимое расширенного размышления и его подпись. Если вы просто конкатенируете всё подряд, в интерфейс полезет служебщина.

Отдельно стоит обработать два случая. ping — пустые события, которые сервер шлёт для поддержания соединения; их надо молча игнорировать. И события типа error, которые могут прилететь посреди уже начавшегося потока, например overloaded_error при перегрузке. То есть ответ может оборваться после того, как половина текста уже показана пользователю, — интерфейс должен это переживать.

OpenAI: Responses API и возобновляемые потоки

В Responses API стриминг включается так же — stream=True, — но события другие и их заметно больше. Базовый набор: response.created в начале, множество response.output_text.delta с кусками текста, response.completed в конце, плюс error. Отдельные типы событий обслуживают вызовы инструментов и аннотации. Официальные SDK дают типизированные обработчики, так что можно подписаться только на нужные события, а не разбирать все подряд.

Полезная особенность — возобновление потока. Каждое событие несёт поле sequence_number. Если запустить ответ в фоновом режиме (background: true вместе с stream: true) и соединение оборвётся, можно переподключиться к тому же ответу, передав starting_after с последним полученным номером, — и досмотреть поток с места обрыва. Оговорка: начать новый стрим у фонового ответа получится, только если он изначально создавался с stream=true. Для мобильных клиентов с нестабильной сетью это заметно меняет дело.

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

Gemini: Interactions API и legacy-эндпоинт

У Google к 2026 году появился новый Interactions API, где стриминг тоже включается флагом stream=True, а события разбираются по типам — например, step.delta с delta.type == "text". Старый generateContentStream (в REST — метод streamGenerateContent) помечен как legacy, но никуда не делся и по-прежнему широко используется. Важная деталь: чтобы получить именно SSE, а не один большой JSON-массив в конце, к REST-эндпоинту нужно добавить параметр ?alt=sse. И при проверке через curl не забывайте флаг --no-buffer, иначе сам curl соберёт весь вывод и покажет его разом — и вы решите, что стриминг не работает.

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

Буферизация на прокси

Классика: локально всё стримится, на боевом сервере ответ приходит одним куском в конце. Виноват реверс-прокси. У nginx по умолчанию включён proxy_buffering, и он копит ответ бэкенда, пока не наберётся буфер. Лечится proxy_buffering off; для нужного location либо заголовком X-Accel-Buffering: no прямо из приложения. Заодно проверьте, что для text/event-stream отключено gzip-сжатие (компрессор тоже буферизует) и что таймауты (proxy_read_timeout) больше времени самой длинной генерации. Те же грабли есть у облачных балансировщиков и CDN.

Инструменты приходят кусками

Если модель вызывает функцию, её аргументы приезжают не целиком, а по фрагментам JSON. Валидировать такой JSON можно только после закрытия блока — до этого у вас на руках синтаксически битая строка. То же касается ответа по схеме: подробности в заметках Tool calling: как ИИ вызывает инструменты и Структурированный вывод (JSON) от модели.

Разметка рвётся посередине

Модель отдаёт Markdown, и в момент приёма у вас может быть незакрытая тройная кавычка блока кода или половина ссылки. Наивный рендер начинает мигать и «прыгать». Решения два: рендерить в потоке толерантным парсером, который умеет достраивать незакрытые конструкции, либо копить текст и перерисовывать не на каждой дельте, а раз в 50–100 мс.

Статистика приходит в конце

Итоговое количество токенов и причина остановки известны только из финальных событий (message_delta/message_stop у Claude, response.completed у OpenAI). Логирование расходов вешайте туда, а не на первые дельты — и не забудьте про случай, когда поток оборвался и финального события не было вовсе.

Когда стриминг не нужен

Не всё подряд имеет смысл стримить. Если ответ читает программа, а не человек — классификация, извлечение полей, генерация JSON для последующей обработки — поток только усложняет код: всё равно придётся дождаться конца. Фоновые задачи (массовая генерация описаний, ночная обработка каталога) спокойно живут без него, там уместнее батч-режим и очередь. Стриминг оправдан там, где на другом конце сидит человек и смотрит на экран: чат, ассистент, редактор. Для типового чат-бота поддержки он практически обязателен — см. Чат-бот поддержки на ИИ для сайта.

Чек-лист внедрения

  1. Включить stream: true и разбирать события по типам, а не склеивать всё подряд.
  2. Игнорировать ping и обрабатывать error в середине потока.
  3. Отключить буферизацию и сжатие на прокси, поднять таймауты.
  4. Предусмотреть обрыв: докрутить UI до состояния «ответ не завершён», а где возможно — использовать возобновление по starting_after.
  5. Дать пользователю кнопку «Остановить» и корректно закрывать соединение.
  6. Собирать метрики TTFT и полного времени отдельно — это разные вещи.
  7. Логировать токены по финальным событиям.

Коротко

Стриминг — это не оптимизация скорости, а оптимизация ожидания: генерация быстрее не становится, но интерфейс перестаёт выглядеть зависшим. Технически всё сводится к SSE поверх обычного HTTP; различия между провайдерами — в наборе событий, а не в принципе. Основная работа при внедрении приходится не на API, а на инфраструктуру и обработку ошибок: прокси, обрывы, частичная разметка. Если вы собираете собственного ассистента, эти детали стоит заложить сразу — переделывать интерфейс под поток задним числом обычно дороже, чем сделать его потоковым с самого начала. Про сборку такого сервиса целиком — в гайде Как написать своего AI-агента.

Частые вопросы

Q.Стриминг делает генерацию быстрее?

Нет. Общее время генерации остаётся тем же — модель пишет ответ с той же скоростью. Меняется только время до первого токена (TTFT): вместо пустого экрана на 20 секунд пользователь видит текст уже через доли секунды. Это оптимизация ожидания, а не производительности.

Q.Почему стриминг работает локально, но не работает на боевом сервере?

Почти всегда виноват реверс-прокси. У nginx по умолчанию включён proxy_buffering, и он копит ответ бэкенда вместо того, чтобы отдавать его сразу. Отключите буферизацию для нужного location (proxy_buffering off) или отдавайте заголовок X-Accel-Buffering: no из приложения, а также проверьте, что gzip не применяется к text/event-stream.

Q.Можно ли продолжить поток после обрыва соединения?

У OpenAI — да, если ответ создан с background: true и stream: true. Каждое событие несёт sequence_number, и по параметру starting_after можно переподключиться и досмотреть поток с места обрыва. В остальных случаях обрыв означает потерю недописанного ответа, и интерфейс должен это корректно обрабатывать.

Q.Нужен ли стриминг, если ответ обрабатывает программа, а не человек?

Обычно нет. Для классификации, извлечения полей или генерации JSON поток только усложняет код — результат всё равно нужен целиком. Исключение — очень длинные ответы: при больших max_tokens SDK Anthropic требуют потокового режима, чтобы запрос не отвалился по таймауту, но собирать ответ можно целиком через get_final_message().

Источники

Предыдущая
MCP для WooCommerce: даём ИИ доступ к магазину

Читайте также

Рабочий стол разработчика в тёмной графитовой комнате: ноутбук с админкой интернет-магазина и списком заказов, рядом монитор с JSON-ответом и терминаломЗаметки
15 августа 2026 г.

MCP для WooCommerce: даём ИИ доступ к магазину

WooCommerce научился отдавать товары и заказы ИИ-клиентам по протоколу MCP. Разбираем, как это включить, чем канонические abilities отличаются от устаревшего эндпоинта, какие права выдавать и где подстелить соломки.

Читать →
Монитор с сеткой миниатюр фотографий товаров интернет-магазина и полями подписей под ними, перед экраном на столе стоит замшевый ботинок с той же карточки, рядом фотоаппарат и клавиатураЗаметки
15 августа 2026 г.

Генерация alt-текста нейросетью для WooCommerce

В каталоге на тысячи товаров alt-текст руками не заполнить. Разбираем, каким должен быть хороший alt, где WordPress его хранит, как прогнать весь каталог через мультимодальную модель — плагином или своим скриптом — и во сколько это обойдётся.

Читать →
Разработчик за монитором: в редакторе открыто дерево папок и файл SKILL.md с блоком метаданных — скиллы в Claude CodeЗаметки
13 августа 2026 г.

Скиллы в Claude Code: как научить агента своим процессам

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

Читать →