SSE-стриминг в LLM API: как ускорить интерфейс в разы
Представьте чат-бота, который отвечает без стриминга: пользователь нажал 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.
Подводные камни продакшна
- Буферизация прокси. Промежуточные Nginx/CDN с включённым
proxy_bufferingсобирают поток в один кусок и убивают эффект стриминга. Ответ нужно проксировать без буферизации; AIIN отдаёт чанки напрямую, но проверьте свой edge-слой, если он есть. - Обрывы соединения. Долгий стрим — это долго открытый TCP. Мобильные сети и корпоративные файрволы любят рубить «висящие» соединения: ловите ошибку чтения, показывайте полученную часть и предлагайте повтор.
- Где usage? В стрим-режиме поле
usageв промежуточных чанках отсутствует. Чтобы получить статистику токенов, передайте"stream_options": {"include_usage": true}— тогда счётчики придут в финальном чанке перед[DONE].
Отдельно: не забывайте о поведении пользователя. Если человек может прервать генерацию (кнопка «Стоп»), корректно закрывайте соединение — 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.
Открыть кабинет