Технологии

SSE-стриминг в LLM API: как ускорить интерфейс в разы

8 июля 2026 8 мин чтения Команда AIIN

Представьте чат-бота, который отвечает без стриминга: пользователь нажал Enter — и 30 секунд смотрит на пустой экран со спиннером. Модель всё это время работает, но для человека система выглядит зависшей. Тот же запрос со стримингом ведёт себя иначе: первая буква появляется через 300–500 мс, а текст «печатается» на глазах, словно его пишет живой собеседник. Общее время генерации при этом не меняется — меняется то, что видит пользователь. В этой статье разберём, как устроен SSE-стриминг в OpenAI-совместимом API, и соберём рабочие примеры на curl, JavaScript и Python. AIIN поддерживает стриминг нативно: шлюз проксирует чанки провайдера без дополнительной буферизации.

Как устроен SSE: анатомия stream:true

SSE (Server-Sent Events) — простейший способ отдавать данные потоком по обычному HTTP. Сервер не закрывает соединение после первых байт, а держит его открытым и отправляет события текстом: каждое событие — строка вида data: {...}, разделённая пустой строкой. Никаких WebSocket и двустороннего канала — только однонаправленный поток «сервер → клиент», которого достаточно для печати токенов.

В OpenAI-совместимом API режим включается одним параметром в теле запроса:

{
  "model": "openai/gpt-4o-mini",
  "messages": [{"role": "user", "content": "Объясни SSE"}],
  "stream": true
}

Вместо одного JSON-объекта сервер вернёт ответ с заголовком Content-Type: text/event-stream и потоком чанков chat.completion.chunk. В каждом чанке — дельта: фрагмент текста в choices[0].delta.content. Ваша задача — склеить дельты в полный ответ.

curl: смотрим на поток голыми глазами

Прежде чем писать клиент, посмотрите на сырой поток — это лучший способ понять формат. Ключ -N (--no-buffer) отключает буферизацию вывода curl:

curl -N https://api.aiin.by/v1/chat/completions \
  -H "Authorization: Bearer $AIIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "openai/gpt-4o-mini",
    "messages": [{"role": "user", "content": "Напиши хокку про Минск"}],
    "stream": true
  }'

Ответ приходит порциями — по несколько событий в секунду:

data: {"id":"chatcmpl-...","object":"chat.completion.chunk","choices":[{"delta":{"role":"assistant"},"index":0}]}

data: {"id":"chatcmpl-...","object":"chat.completion.chunk","choices":[{"delta":{"content":"Стылый"},"index":0}]}

data: {"id":"chatcmpl-...","object":"chat.completion.chunk","choices":[{"delta":{"content":" рассвет"},"index":0}]}

data: {"id":"chatcmpl-...","object":"chat.completion.chunk","choices":[{"delta":{},"finish_reason":"stop","index":0}]}

data: [DONE]

Три маркера, которые нужно обрабатывать любому клиенту: первый чанк задаёт role, промежуточные несут delta.content, а признак конца — finish_reason и специальная строка data: [DONE], которую нельзя парсить как JSON.

JavaScript: fetch + ReadableStream

В браузере EventSource не подойдёт — он умеет только GET, а нам нужен POST с телом. Поэтому стандартный рецепт — fetch, чтение тела через ReadableStream и построчный разбор data:-строк:

const resp = await fetch("https://api.aiin.by/v1/chat/completions", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${AIIN_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    model: "openai/gpt-4o-mini",
    messages: [{ role: "user", content: "Привет!" }],
    stream: true,
  }),
});

const reader = resp.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  buffer += decoder.decode(value, { stream: true });

  const lines = buffer.split("\n");
  buffer = lines.pop(); // последняя строка может быть недописана

  for (const line of lines) {
    if (!line.startsWith("data: ")) continue;
    const payload = line.slice(6).trim();
    if (payload === "[DONE]") return;

    const chunk = JSON.parse(payload);
    const delta = chunk.choices?.[0]?.delta?.content;
    if (delta) appendToUI(delta); // печатаем токен
  }
}

Критическая деталь — buffer: сетевой пакет может разрезать событие посередине JSON, поэтому неполную последнюю строку нельзя парсить сразу — её дописывают следующим read(). В Node.js тот же код работает на нативном fetch (версии 18+).

Python: SDK делает всё за вас

Если вы на Python, ручной разбор SSE не нужен — официальный SDK OpenAI сам превращает поток в итератор чанков. Достаточно передать stream=True:

import os
from openai import OpenAI

client = OpenAI(
    base_url="https://api.aiin.by/v1",
    api_key=os.environ["AIIN_API_KEY"],
)

stream = client.chat.completions.create(
    model="openai/gpt-4o-mini",
    messages=[{"role": "user", "content": "Объясни TTFT"}],
    stream=True,
)

for chunk in stream:
    delta = chunk.choices[0].delta.content
    if delta:
        print(delta, end="", flush=True)

flush=True здесь так же важен, как -N в curl: без него stdout отдаст текст только по завершении цикла, и вся «живая печать» пропадёт. Для async-кода используйте AsyncOpenAI и async for chunk in stream — интерфейс симметричен.

UX-метрики: TTFT против total time

Стриминг не ускоряет модель — он ускоряет восприятие. Поэтому оценивать его нужно правильными метриками:

МетрикаЧто измеряетПочему важна
TTFT (time-to-first-token)время от запроса до первого чанкапорог «живости»: до 500 мс система ощущается мгновенной
Tokens/sскорость прихода дельт после TTFTдолжна опережать скорость чтения человека (≈10–15 слов/с)
Total timeполное время генерациидля бюджета и SLA, но для UX вторична

Практический вывод: ответ на 2000 токенов за 40 секунд со стримингом воспринимается как быстрый, потому что пользователь уже на 2-й секунде читает первый абзац. Тот же ответ без стриминга — 40 секунд «мёртвого» интерфейса. Если оптимизируете UX, вкладывайтесь в TTFT (короче промпт, меньше max_tokens, модель побыстрее), а не в гонку за total time.

Подводные камни продакшна

Отдельно: не забывайте о поведении пользователя. Если человек может прервать генерацию (кнопка «Стоп»), корректно закрывайте соединение — AbortController в JS, stream.close() в Python, — иначе модель продолжит жечь токены впустую.

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

Работает ли стриминг для image-моделей?

Нет. Генерация изображений — атомарная операция: картинка готова целиком или не готова вовсе. SSE-стриминг применим только к текстовым endpoint'ам (/v1/chat/completions).

Нужен ли особый SDK для стриминга?

Нет. Официальный openai SDK для Python и Node умеет stream=True/stream: true из коробки, а в браузере достаточно нативного fetch с ReadableStream — отдельных библиотек не требуется.

Тарифицируется ли стриминг иначе, чем обычный запрос?

Нет. AIIN списывает баланс в BYN только за фактические токены; сам режим stream:true ничего не стоит — считаются те же prompt и completion токены.

Что делать, если соединение оборвалось посреди ответа?

Догенерировать нельзя: стрим не восстанавливается. Стандартная практика — показать полученную часть пользователю и повторить запрос целиком, желательно с экспоненциальным backoff.

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

Проверьте стриминг в деле

Ключ AIIN, один endpoint и нативный SSE для всех моделей каталога — без VPN, оплата балансом в BYN.

Открыть кабинет