LLMTOKENAPI

SSE streaming в LLM API: надёжный клиент

Разбираем SSE streaming в LLM API: как читать поток через fetch, буферизовать события, показывать части ответа и обрабатывать отмену и ошибки.

2 сентября 2026 г.4 мин чтенияОбновлено 2 сентября 2026 г.
Последовательные пакеты данных собираются в готовый ответ на стороне клиента

SSE streaming в LLM API позволяет показывать ответ по мере генерации: клиент отправляет обычный POST-запрос с stream: true, читает поток text/event-stream и последовательно применяет события. Надёжная реализация должна буферизовать неполные сетевые фрагменты, различать типы событий и корректно завершать или отменять запрос.

Что меняется при stream true

Без streaming приложение ждёт полный JSON, а затем показывает результат. С stream: true сервер начинает отдавать Server-Sent Events, когда появляются части ответа. Пользователь раньше видит первый текст, а интерфейс может показать состояние генерации, вызов инструмента и финальное завершение.

В LLMTOKENAPI streaming поддерживают Responses API и Chat Completions. Для новых интеграций удобно использовать POST /v1/responses; сравнительный разбор протоколов есть в статье «Responses API или Chat Completions».

SSE остаётся однонаправленным потоком от сервера к клиенту. По стандарту WHATWG события кодируются в UTF-8, состоят из полей вроде event и data и разделяются пустой строкой.

Почему одного split по чанку недостаточно

Фрагмент, который вернул ReadableStream, не равен одному SSE-событию. Сеть может разрезать JSON посередине символа или объединить несколько событий в один chunk. Поэтому нельзя сразу делать JSON.parse(decoder.decode(value)).

Нужен строковый буфер. Добавляйте в него декодированные байты, извлекайте только блоки, завершённые двойным переводом строки, а хвост оставляйте до следующего чтения. Используйте TextDecoder с { stream: true }, чтобы многобайтовый UTF-8 символ не повредился на границе чанков.

Если событие содержит несколько строк data:, объедините их через перевод строки. Строка event: задаёт тип события. Комментарии, начинающиеся с двоеточия, не содержат пользовательских данных и могут использоваться как heartbeat.

Отправьте потоковый запрос через fetch

Нативный EventSource удобен для простого GET-потока, но запрос к LLM требует POST, JSON-тело и заголовок Authorization. Поэтому в серверном Node.js практичнее использовать fetch и читать response.body. В браузерном приложении рабочий API-ключ должен оставаться на вашем backend, а frontend — обращаться к собственному защищённому маршруту.

const controller = new AbortController();

const response = await fetch("https://api.llmtokenapi.ru/v1/responses", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.LLMTOKENAPI_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    model: "gpt-5.4-mini",
    input: "Объясни пользу потокового ответа в трёх пунктах",
    stream: true,
  }),
  signal: controller.signal,
});

if (!response.ok || !response.body) {
  throw new Error(`LLM request failed: ${response.status}`);
}

До чтения потока проверяйте HTTP-статус. Ошибка авторизации или баланса обычно приходит как обычный JSON и не должна попадать в SSE-парсер.

Разбирайте события через буфер

Минимальный парсер может выглядеть так:

const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";

while (true) {
  const { value, done } = await reader.read();
  buffer += decoder.decode(value ?? new Uint8Array(), { stream: !done });

  const blocks = buffer.split(/\r?\n\r?\n/);
  buffer = blocks.pop() ?? "";

  for (const block of blocks) {
    const lines = block.split(/\r?\n/);
    const event = lines.find((line) => line.startsWith("event:"))
      ?.slice(6).trim();
    const data = lines
      .filter((line) => line.startsWith("data:"))
      .map((line) => line.slice(5).trimStart())
      .join("\n");

    if (!data || data === "[DONE]") continue;
    const payload = JSON.parse(data);

    if (event === "response.output_text.delta") {
      renderDelta(payload.delta);
    }
    if (event === "response.completed") {
      saveUsage(payload.response.usage);
    }
  }

  if (done) break;
}

В Responses API текст приходит в событиях response.output_text.delta, а финальное состояние — в response.completed. Chat Completions использует другой JSON внутри data: и завершает поток маркером [DONE]. Парсеру полезно явно знать выбранный протокол, а не угадывать его по случайному полю.

Обновляйте интерфейс без рывков

Не запускайте тяжёлый render на каждый короткий delta. Добавляйте текст во внутреннюю строку и обновляйте DOM или состояние с небольшой кадровой группировкой. Так интерфейс остаётся плавным, особенно если сервер присылает много мелких событий.

До первого текстового фрагмента покажите состояние «генерация началась». После response.completed зафиксируйте итоговый текст и usage. Если поток оборвался, пометьте ответ как незавершённый; нельзя выдавать частичный текст за проверенный полный результат.

Для Markdown безопаснее во время потока показывать обычный текст либо аккуратно перерисовывать накопленную строку. Незакрытый кодовый блок или ссылка в промежуточном состоянии иначе создают визуальные скачки.

Отмена, timeout и повтор

Свяжите кнопку «Остановить» с AbortController.abort(). Добавьте конечный общий timeout и очистите его после завершения. На серверной стороне отмена чтения должна закрывать соединение; на клиенте — переводить сообщение в понятное состояние, а не оставлять вечный индикатор.

Автоматический retry безопасен только до получения первого содержательного фрагмента. После частичного вывода повторный запрос может создать второй, отличающийся ответ и двойное списание. Если соединение оборвалось после начала текста, предложите пользователю повторить запрос явно или начните новую попытку с отдельным идентификатором.

Логируйте HTTP-статус, время до первого события, общее время, факт отмены и requestId, но не API-ключ и не полный пользовательский текст. Эти метрики позволяют отличить медленную модель от буферизации прокси или ошибки клиентского парсера.

Чек-лист production-готовности

Проверьте поток на кириллице и emoji, искусственно разделите одно событие между несколькими chunks и объедините несколько событий в одном чтении. Протестируйте обычное завершение, [DONE] для Chat Completions, именованное response.completed для Responses, отмену пользователем, timeout и ошибку до первого байта.

Корректный SSE-клиент не считает chunk событием, не теряет хвост буфера, не повторяет запрос после частичного ответа и всегда отличает завершённый текст от оборванного. Именно эти свойства важнее визуального эффекта «печатающегося» ответа.

Два потока протоколов сходятся в едином шлюзе API

Responses API или Chat Completions: что выбрать

Сравниваем два протокола LLM API по структуре запроса, потоковой выдаче, инструментам и миграции без привязки продуктовой логики к одному формату.