Стриминг ответов нейросети: SSE на Python и JavaScript
15 июля 2026 г. · Константин Романков · 7 мин чтения
Чтобы получать ответ нейросети по мере генерации, передайте stream: true в запросе к /v1/chat/completions. Сервер вернёт поток Server-Sent Events: каждое событие — строка data: <json> с фрагментом текста в choices[0].delta.content, конец потока — маркер data: [DONE]. Первый токен приходит за доли секунды, и пользователь читает ответ, пока модель ещё пишет. Ниже — рабочие примеры на Python и JavaScript и разбор типичных ошибок.
Зачем нужен стриминг
Без стриминга запрос к языковой модели работает по принципу «всё или ничего»: соединение висит, пока модель не сгенерирует ответ целиком. Для развёрнутого ответа это 10–30 секунд пустого экрана — пользователь не понимает, работает сервис или завис.
Стриминг сокращает время до первого токена до долей секунды и меняет ощущение от продукта:
- интерфейс оживает сразу — пользователь видит, что запрос принят;
- читать можно по ходу генерации, а не после неё;
- бессмысленную генерацию легко прервать на первых строках, не дожидаясь конца;
- длинные ответы перестают упираться в ограничения времени ожидания у клиентов и промежуточных серверов.
Сама генерация быстрее не становится — модель пишет с той же скоростью. Меняется воспринимаемая скорость: разница между «чат печатает» и «приложение зависло».
Как устроен поток SSE: разбор чанков
При stream: true сервер отвечает с заголовком Content-Type: text/event-stream и держит соединение открытым. Каждый чанк — отдельное SSE-событие: строка data: <json>, события разделены пустой строкой (\n\n). Внутри — объект chat.completion.chunk:
{
"id": "chatcmpl-abc123",
"object": "chat.completion.chunk",
"created": 1781000000,
"model": "anthropic/claude-haiku-4.5",
"choices": [
{
"index": 0,
"delta": { "content": "Рекурсия" },
"finish_reason": null
}
]
}
Правила потока:
- Первый чанк обычно несёт
delta.role: "assistant", текст начинается со второго. - Каждый следующий чанк добавляет фрагмент в
delta.content— полный ответ собирается конкатенацией. - Последний содержательный чанк приходит с
finish_reason: "stop"(илиlength,tool_calls). - Затем приходит чанк с полем
usage— итоговыеprompt_tokens,completion_tokens,total_tokens. Hubris добавляетstream_options: { include_usage: true }ко всем потоковым запросам автоматически, поэтомуusageприходит всегда. - Поток закрывается строкой
data: [DONE]— это литерал-маркер, а не JSON.
Точный формат чанков и заголовков описан в документации по потоковой передаче, все параметры запроса — в справке /v1/chat/completions.
Стриминг на Python: openai SDK
Понадобится API-ключ Hubris: зарегистрируйтесь на hubris.pw по коду из письма, создайте ключ в разделе «Ключи» и пополните баланс в разделе «Биллинг» — через СБП, картой или счётом для юридического лица. Первые шаги по пунктам — в руководстве по быстрому старту.
from openai import OpenAI
client = OpenAI(
base_url="https://api.hubris.pw/v1",
api_key="sk-gw-...", # ваш ключ из раздела «Ключи»
)
stream = client.chat.completions.create(
model="anthropic/claude-haiku-4.5",
messages=[{"role": "user", "content": "Объясни рекурсию в двух абзацах"}],
stream=True,
)
for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content:
print(chunk.choices[0].delta.content, end="", flush=True)
if chunk.usage:
print(f"\n\nПотрачено токенов: {chunk.usage.total_tokens}")
SDK сам разбирает SSE-поток: цикл for chunk in stream отдаёт уже готовые объекты. Три детали, на которых спотыкаются:
flush=Trueобязателен — иначе Python буферизует вывод, и «постепенная печать» превращается в выдачу всего текста разом;- проверка
chunk.choicesнужна: финальный чанк сusageприходит с пустым спискомchoices; delta.contentбываетNoneв служебных чанках — обращайтесь к нему только после проверки.
В примере используется Claude Haiku 4.5 — быстрая модель за 106,11 ₽ за миллион входных и 530,53 ₽ за миллион сгенерированных токенов (цены актуальны на июнь 2026). Стриминг тарифицируется так же, как обычный запрос, — по фактическим токенам.
Стриминг на JavaScript: for await
В Node.js и браузере официальный пакет openai возвращает асинхронный итератор — поток читается циклом for await:
import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://api.hubris.pw/v1",
apiKey: process.env.HUBRIS_API_KEY,
});
const stream = await client.chat.completions.create({
model: "anthropic/claude-haiku-4.5",
messages: [{ role: "user", content: "Объясни рекурсию в двух абзацах" }],
stream: true,
});
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content;
if (delta) process.stdout.write(delta);
if (chunk.usage) console.log(`\n\nПотрачено токенов: ${chunk.usage.total_tokens}`);
}
Для кнопки «Остановить» в интерфейсе передайте AbortController — итерация прервётся, соединение закроется:
const controller = new AbortController();
// по клику на «Остановить»: controller.abort()
const stream = await client.chat.completions.create(
{ model: "anthropic/claude-haiku-4.5", messages, stream: true },
{ signal: controller.signal },
);
Если SDK не подходит и вы читаете поток через fetch + ReadableStream, помните: декодированные фрагменты нельзя разбирать поодиночке — нужен накопительный буфер, как в разборе ошибок ниже.
Обрывы соединения и типичные ошибки
Обрыв на стороне клиента. Закрытая вкладка, упавший процесс или controller.abort() не останавливают генерацию мгновенно: сервис дочитывает ответ модели до конца, и его полная стоимость списывается с баланса. Прерывание — инструмент интерфейса, а не экономии. Чтобы ограничить расход на один запрос, задавайте max_tokens.
Обрыв на стороне сети. SSE-поток не возобновляется с места разрыва: повторный запрос — это новая генерация с нуля. Сохраняйте полученные фрагменты по мере прихода, тогда при сбое вы сможете показать частичный ответ и предложить пользователю продолжить.
Склейка и разрыв чанков. Самая частая ошибка ручного разбора — обрабатывать каждый сетевой фрагмент как целое событие. TCP может разрезать строку data: посередине JSON или склеить три события в один фрагмент. Правильный подход — накапливать байты в буфер и разбирать только завершённые события:
buffer = ""
for raw in response.iter_content(chunk_size=None):
buffer += raw.decode("utf-8")
while "\n\n" in buffer:
event, buffer = buffer.split("\n\n", 1)
if not event.startswith("data: "):
continue
payload = event[len("data: "):]
if payload == "[DONE]":
break
chunk = json.loads(payload) # дальше — обработка delta
[DONE] — не JSON. Попытка json.loads("[DONE]") уронит разбор на самом последнем событии. Сравнивайте строку с маркером до парсинга.
Буферизация на промежуточных серверах. Если ответ «приходит весь сразу в конце», чанки склеивает ваш обратный прокси. У nginx отключите буферизацию для потоковых маршрутов (proxy_buffering off; либо уважение заголовка X-Accel-Buffering: no) и не сжимайте text/event-stream.
Частые вопросы
Стриминг стоит дороже обычного запроса?
Нет. Тарификация одинаковая — по фактическим входным и сгенерированным токенам, итог приходит в поле usage финального чанка. Запрашивать stream_options отдельно не нужно: Hubris включает передачу usage для всех потоковых запросов сам.
Можно ли остановить генерацию и не платить за остаток?
Прерывание соединения не отменяет начатую генерацию: ответ дочитывается на стороне сервиса, и списывается его полная стоимость. Надёжный способ ограничить расход — параметр max_tokens: модель остановится ровно на заданном пределе с finish_reason: "length".
Какие модели поддерживают stream: true?
Практически все текстовые модели каталога — от компактных до флагманских. Формат чанков при этом один и тот же, код из примеров выше не придётся менять при смене модели.
Чем delta отличается от message?
В обычном ответе весь текст лежит в choices[0].message.content. В стриминге поле называется delta и несёт только очередной фрагмент — полный текст собирается конкатенацией всех delta.content. Если ваш код после включения stream: true «не видит ответ», почти наверняка он всё ещё читает message.
Все модели из статьи доступны в Hubris — единый API, оплата в рублях.