Streaming (SSE)
Токены приходят по мере генерации, а не одним куском в конце. Пользователь видит первое слово через доли секунды.
Как включить
Добавьте "stream": true в тело запроса к /chat/completions. Ответ
придёт с Content-Type: text/event-stream — это server-sent events, обычный HTTP-ответ с
порционной передачей.
- Параметр
- "stream": true
- Content-Type ответа
- text/event-stream
- Формат события
- Строка с префиксом
data:, события разделены пустой строкой - Терминатор
- data: [DONE]
- Usage в потоке
- "stream_options": { "include_usage": true }
- В каждом чанке лежит
delta, а неmessage: приходит кусочек текста, который нужно дописать к уже собранному. - Последний содержательный чанк несёт
finish_reason, аdeltaв нём пустая. - Строка
data: [DONE]— не JSON. Её нельзя отдавать в парсер, это сигнал завершения. - Строки, начинающиеся с двоеточия, — служебные комментарии протокола SSE. Просто игнорируйте их.
curl -N https://api.tokendock.cloud/v1/chat/completions \
-H "Authorization: Bearer $TOKENDOCK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "openai/gpt-4.1-mini",
"messages": [
{ "role": "user", "content": "Объясни, что такое SSE, в трёх предложениях." }
],
"stream": true,
"stream_options": { "include_usage": true }
}'
Флаг -N отключает буферизацию в самом curl — без него вывод в терминале появится
целиком в конце, хотя данные шли потоком.
Как выглядит поток
Сырые события одного ответа. Первый чанк объявляет роль, дальше идут куски текста, затем чанк с причиной
остановки, затем чанк с usage и терминатор.
data: {"id":"chatcmpl-td-9f2c41ba7e0d","object":"chat.completion.chunk","created":1787046060,"model":"openai/gpt-4.1-mini","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}
data: {"id":"chatcmpl-td-9f2c41ba7e0d","object":"chat.completion.chunk","created":1787046060,"model":"openai/gpt-4.1-mini","choices":[{"index":0,"delta":{"content":"SSE"},"finish_reason":null}]}
data: {"id":"chatcmpl-td-9f2c41ba7e0d","object":"chat.completion.chunk","created":1787046060,"model":"openai/gpt-4.1-mini","choices":[{"index":0,"delta":{"content":" — это"},"finish_reason":null}]}
data: {"id":"chatcmpl-td-9f2c41ba7e0d","object":"chat.completion.chunk","created":1787046060,"model":"openai/gpt-4.1-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
data: {"id":"chatcmpl-td-9f2c41ba7e0d","object":"chat.completion.chunk","created":1787046060,"model":"openai/gpt-4.1-mini","choices":[],"usage":{"prompt_tokens":21,"completion_tokens":58,"total_tokens":79}}
data: [DONE]
Полный текст ответа — это конкатенация всех delta.content по порядку. Границы чанков
произвольны: слово может приехать по частям, а знак препинания — отдельным событием.
Разбор потока в коде
Официальные SDK разбирают SSE сами — достаточно пройти циклом по объекту потока. Ручной парсер нужен только там, где вы работаете с сырым HTTP.
import os
from openai import OpenAI
client = OpenAI(
base_url="https://api.tokendock.cloud/v1",
api_key=os.environ["TOKENDOCK_API_KEY"],
)
stream = client.chat.completions.create(
model="openai/gpt-4.1-mini",
messages=[
{"role": "user", "content": "Объясни, что такое SSE, в трёх предложениях."},
],
stream=True,
stream_options={"include_usage": True},
)
usage = None
for chunk in stream:
if chunk.usage is not None:
usage = chunk.usage
if not chunk.choices:
continue
delta = chunk.choices[0].delta.content
if delta:
print(delta, end="", flush=True)
print()
print(usage) import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://api.tokendock.cloud/v1",
apiKey: process.env.TOKENDOCK_API_KEY,
});
const stream = await client.chat.completions.create({
model: "openai/gpt-4.1-mini",
messages: [
{ role: "user", content: "Объясни, что такое SSE, в трёх предложениях." },
],
stream: true,
stream_options: { include_usage: 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", chunk.usage);
} Не обращайтесь к API напрямую из браузера: ключ в коде фронтенда виден любому пользователю. Держите ключ на своём бэкенде и проксируйте поток клиенту — пример ниже разбирает уже ваш собственный эндпоинт.
// Запрос уходит на ваш бэкенд, а тот проксирует его в TokenDock.
// Ключ в браузер не попадает никогда.
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt: "Объясни, что такое SSE." }),
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Событие SSE заканчивается пустой строкой; хвост буфера может быть неполным.
const events = buffer.split("\n\n");
buffer = events.pop() ?? "";
for (const event of events) {
const line = event.split("\n").find((row) => row.startsWith("data: "));
if (!line) continue;
const payload = line.slice(6);
if (payload === "[DONE]") return;
const chunk = JSON.parse(payload);
const delta = chunk.choices[0]?.delta?.content;
if (delta) appendToUi(delta);
}
} Ключевая деталь ручного разбора — буфер. Сетевой пакет режет поток в произвольном месте, поэтому накапливайте данные и отделяйте только целые события, а неполный хвост оставляйте до следующей итерации.
TTFT
TTFT — time to first token, время от отправки запроса до первого чанка с текстом. Именно его пользователь воспринимает как скорость продукта: дальше текст идёт со скоростью генерации, и ждать уже не приходится.
- Модель важнее настроек запроса. Лёгкие модели отдают первый токен заметно быстрее флагманов — если задержка критична, начните с выбора модели.
- Короткий промпт — меньший TTFT. Модель сначала читает вход целиком, поэтому история диалога на десятки тысяч токенов заметно сдвигает первый чанк.
- Reasoning-модели думают до первого токена. У них пауза перед потоком — норма, а не обрыв: рассуждение генерируется раньше видимого ответа.
- Загрузка апстрима. В пик очередь на стороне провайдера добавляет ожидание, поэтому одна и та же модель отвечает по-разному в разное время.
Мерьте TTFT у себя — засеките время между отправкой запроса и первым чанком, где
delta.content непустой. Смотрите медиану и p95 на своём трафике, а не один синтетический
запрос. Общее время ответа считайте отдельно: оно зависит ещё и от длины генерации.
Обрыв соединения и таймауты
Поток — это одно долгое HTTP-соединение. Оно может оборваться на любой секунде: пользователь закрыл вкладку, мобильная сеть переключилась, сработал таймаут прокси.
- Таймаут по паузе, а не по общей длительности. Ответ на 4 000 токенов идёт десятки секунд — это норма. Правильный сторож считает паузу между чанками: 30–60 секунд тишины означают проблему.
- Клиент закрыл соединение — генерация останавливается. Шлюз прекращает запрос к провайдеру, поток дальше не идёт.
- Тарифицируется то, что успело сгенерироваться. При обрыве чанк с
usageдо вас не доходит, но расход по запросу считается и попадает в историю расходов в консоли. - Ретрай — только если не пришло ни одного токена. Повтор после половины ответа даст дубль текста и двойное списание. Собранный кусок лучше сохранить и продолжить новым запросом.
- Отменяйте запрос явно. В браузере и Node.js — через
AbortController, в Python — закрытием объекта потока. Так генерация останавливается сразу, а не после полного ответа.
Частые ошибки
Почти все жалобы на «streaming не работает» сводятся к четырём причинам — и три из них лежат не на стороне API.
- Прокси буферизует ответ. nginx по умолчанию копит данные: нужен
proxy_buffering offи заголовокX-Accel-Buffering: no. Тот же эффект дают CDN и корпоративные фильтры — текст приходит целиком в конце. - Нет flush на вашем сервере. Если вы проксируете поток пользователю, отключите сжатие ответа и сбрасывайте буфер после каждого чанка — иначе фреймворк соберёт всё сам.
- Разбор по строкам вместо событий. Событие заканчивается пустой строкой, а не переводом строки. Парсер, который читает построчно без буфера, ломается на разрезанном JSON.
- Не обработан
[DONE]. Попытка распарсить его как JSON роняет обработчик на последнем событии — ответ при этом выглядит «почти полным».
Быстрая проверка, на чьей стороне проблема: повторите запрос через curl -N с той же
машины. Если в терминале текст идёт постепенно, а в приложении приходит одним куском — дело в вашем стеке.