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

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

Канонический URL: https://llmtokenapi.ru/blog/sse-streaming-v-llm-api
Опубликовано: 2026-09-02T15:11:37.190Z
Автор: Команда LLMTOKENAPI

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»](/blog/responses-api-ili-chat-completions).

SSE остаётся однонаправленным потоком от сервера к клиенту. По [стандарту WHATWG](https://html.spec.whatwg.org/dev/server-sent-events.html) события кодируются в 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 — обращаться к собственному защищённому маршруту.

```javascript
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-парсер.

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

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

```javascript
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 событием, не теряет хвост буфера, не повторяет запрос после частичного ответа и всегда отличает завершённый текст от оборванного. Именно эти свойства важнее визуального эффекта «печатающегося» ответа.
