hubris
Войти
Стриминг ответов нейросети: SSE на Python и JavaScript

Стриминг ответов нейросети: 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: разбор чанков

Сегментированные световые импульсы в волокне — 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
    }
  ]
}

Правила потока:

  1. Первый чанк обычно несёт delta.role: "assistant", текст начинается со второго.
  2. Каждый следующий чанк добавляет фрагмент в delta.content — полный ответ собирается конкатенацией.
  3. Последний содержательный чанк приходит с finish_reason: "stop" (или length, tool_calls).
  4. Затем приходит чанк с полем usage — итоговые prompt_tokens, completion_tokens, total_tokens. Hubris добавляет stream_options: { include_usage: true } ко всем потоковым запросам автоматически, поэтому usage приходит всегда.
  5. Поток закрывается строкой 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, оплата в рублях.