Регистрация

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. Просто игнорируйте их.
bash · curl
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 и терминатор.

text · сырые события sse
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.

python · openai
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)
javascript · node.js
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 напрямую из браузера: ключ в коде фронтенда виден любому пользователю. Держите ключ на своём бэкенде и проксируйте поток клиенту — пример ниже разбирает уже ваш собственный эндпоинт.

javascript · браузер, fetch + ReadableStream
// Запрос уходит на ваш бэкенд, а тот проксирует его в 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 с той же машины. Если в терминале текст идёт постепенно, а в приложении приходит одним куском — дело в вашем стеке.