Потоковая передача SSE Server-Sent Events LLM API

Потоковая передача ответов LLM через Server-Sent Events

Как работает потоковая передача токенов LLM поверх SSE: формат text/event-stream, разбор строк data и маркера [DONE], почему fetch лучше EventSource и как отменять поток через AbortController.

AveMujica API 7 мин чтения

API языковых моделей передают вывод потоком, токен за токеном, с помощью Server-Sent Events (SSE) — однонаправленного текстового протокола, где сервер отправляет данные text/event-stream, а клиент читает их по мере поступления. Когда вы устанавливаете stream: true, ответ — это не единое тело JSON, а последовательность мелких событий, каждое из которых является строкой data: с фрагментом JSON, разделённых пустыми строками и (для Chat Completions) завершающихся буквальным data: [DONE]. Чтобы отображать токены по мере их генерации, вы разбираете этот поток инкрементально, а не ждёте ответ целиком. В этой статье объясняется формат «на проводе», почему для вызовов LLM следует использовать fetch вместо браузерного EventSource и как аккуратно отменять поток с помощью AbortController.

Потоковая передача стоит дополнительных усилий на разбор, потому что она резко сокращает воспринимаемую задержку. Пользователь ощущает именно время до первого токена (time-to-first-token); потоковый ответ начинает появляться уже через несколько сотен миллисекунд, а не после полной генерации. Плата за это в том, что теперь ваш клиент владеет парсером и путём отмены.

Формат SSE «на проводе», точно

SSE определяется небольшим набором правил. Сервер отвечает с Content-Type: text/event-stream, тело — это текст в UTF-8, а сообщения разделяются парой переводов строки (\n\n). Каждое сообщение состоит из одной или нескольких строк field: value. Определены всего четыре поля:

ПолеЗначение
dataПолезная нагрузка. Идущие подряд строки data: в одном сообщении объединяются с переводом строки между ними.
eventИмя типа события; направляется соответствующему обработчику. Если опущено — это событие message по умолчанию.
idЗадаёт «последний идентификатор события» клиента, возвращаемый при переподключении через заголовок Last-Event-ID.
retryЗадержка переподключения в миллисекундах.

Строка, начинающаяся с двоеточия, — это комментарий, и она игнорируется: провайдеры используют такие строки как keep-alive-пинги, чтобы простаивающие прокси не закрывали соединение. Сырой поток Chat Completions выглядит на проводе так:

data: {"id":"chatcmpl-x","choices":[{"delta":{"role":"assistant"}}]}

data: {"id":"chatcmpl-x","choices":[{"delta":{"content":"Hello"}}]}

data: {"id":"chatcmpl-x","choices":[{"delta":{"content":" world"}}]}

data: [DONE]

Каждый чанк — это объект chat.completion.chunk; текст находится в choices[0].delta.content, и вы накапливаете дельты, чтобы собрать сообщение. Поток завершается маркером data: [DONE], который не является JSON: вы должны проверить его как буквальную строку прежде, чем пытаться разобрать, иначе ваш декодер JSON выбросит исключение на последней строке.

Два словаря событий: Chat Completions и Responses

Транспорт идентичен, но события различаются в зависимости от эндпоинта. Chat Completions выдаёт дельты chat.completion.chunk и маркер [DONE]. Responses API выдаёт семантические события с явными именами — response.created, response.output_text.delta, response.completed и другие, — где каждое событие сообщает, чем оно является. Семантические события легче маршрутизировать: вместо того чтобы разбирать форму чанка, вы делаете ветвление по имени события. Если вы выбираете между эндпоинтами, см. Responses API против Chat Completions; чтобы перенести существующий потоковый код, см. миграцию с Chat Completions на Responses API.

Чтобы получить итоговый подсчёт использования в потоке Chat Completions, отправьте stream_options: {"include_usage": true}; провайдер добавит финальный чанк, чей объект usage содержит число токенов, которое можно переслать в журналы использования вашего шлюза для учёта.

Почему не EventSource?

В браузере есть встроенный SSE-клиент EventSource, и это неверный инструмент для вызовов LLM по двум конкретным причинам. Во-первых, EventSource выполняет только запросы GET, а вызовы LLM — это POST с телом JSON. Во-вторых, EventSource не может задавать пользовательские заголовки, поэтому вы не сможете отправить Authorization: Bearer .... Оба ограничения структурны, это не настройки, которые можно переключить.

Правильный подход — fetch с читаемым потоком и самостоятельный разбор SSE. Это даёт вам POST, заголовки и полный контроль над отменой:

const res = await fetch("https://your-gateway.example.com/v1/chat/completions", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: "Bearer AVE_GATEWAY_API_KEY",
  },
  body: JSON.stringify({ model: "gpt-5.6", stream: true,
    messages: [{ role: "user", content: "Stream a haiku about latency." }] }),
});

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

while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  // Normalize CRLF so the blank-line separator matches regardless of line endings.
  buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");

  const events = buffer.split("\n\n");     // messages end at a blank line
  buffer = events.pop();                    // keep the incomplete trailing chunk

  for (const evt of events) {
    // One event can hold several data: lines; join them per the SSE spec.
    const data = evt
      .split("\n")
      .filter((l) => l.startsWith("data:"))
      .map((l) => l.slice(5).replace(/^ /, ""))
      .join("\n");
    if (!data) continue;
    if (data === "[DONE]") return;          // sentinel is not JSON
    const json = JSON.parse(data);
    const token = json.choices?.[0]?.delta?.content;
    if (token) appendToken(token);          // browser: write to a DOM text node
  }
}

appendToken — это ваш приёмник вывода: в браузере добавляйте токен в текстовый узел или состояние React, а в Node вместо этого пишите в process.stdout. Две детали разбора критичны, хотя их легко пропустить. Сначала нормализуйте \r\n в \n: некоторые посредники выдают CRLF, и тогда наивное разделение по \n\n не замечает границу \r\n\r\n. Кроме того, объединяйте все строки data: события, а не читайте только первую. Спецификация SSE соединяет последовательные строки data: переводом строки, поэтому многострочная полезная нагрузка повреждается, если оставить лишь одну строку.

Буферизация важна: TCP не соблюдает границы сообщений, поэтому один read() может доставить половину события или сразу несколько событий. Разбивайте по разделителю из пустой строки и переносите неполный хвост к следующему чтению. Пропуск этого шага — самая частая ошибка потоковой передачи: код работает при тестировании и портит данные под нагрузкой.

Отмена потока с помощью AbortController

Пользователи бросают запросы: уходят со страницы, редактируют промпт или останавливают вышедшую из-под контроля генерацию. Как только они это делают, вам следует прекратить тарификацию токенов. AbortController — это механизм для этого. Создайте контроллер, передайте его signal в fetch и вызовите abort(), чтобы разорвать соединение:

const controller = new AbortController();
const res = await fetch(url, { method: "POST", signal: controller.signal, /* ... */ });

// stopButton.onclick:
controller.abort();   // fetch promise rejects with a DOMException named "AbortError"

Когда вы вызываете abort(), выполняющийся fetch отклоняется с DOMException, чьё name равно AbortError, поэтому отличайте намеренную отмену от настоящего сбоя, проверяя err.name === "AbortError". Для жёсткого тайм-аута без создания собственного контроллера AbortSignal.timeout(ms) возвращает сигнал, который автоматически прерывается через заданное число миллисекунд, — сработавший тайм-аут отклоняется с TimeoutError, а не с AbortError. Своевременное прерывание — это прямой контроль расходов, что связано со снижением затрат на API LLM.

Переподключение и его пределы для потоков LLM

В SSE встроено переподключение: поля id и retry позволяют браузеру возобновить работу после разрыва соединения, повторно отправив Last-Event-ID. Это мощно для лент событий, но лишь частично применимо к генерации LLM, потому что возобновлённое соединение не может «разтарифицировать» уже произведённые токены, а большинство потоков моделей не рассчитаны на воспроизведение с произвольного смещения. Для трафика LLM рассматривайте прерванный поток как неполный результат и генерируйте заново — согласовывая это с идемпотентностью, чтобы с вас не списали дважды, как описано в повторах и идемпотентности API LLM. Одно замечание об инфраструктуре: SSE поверх HTTP/1.1 ограничен шестью одновременными соединениями на домен, поэтому потоковая передача множества параллельных генераций из одного браузерного источника требует HTTP/2, который шлюз обычно терминирует за вас.

Проксирование потока SSE через сервер

Большинство продакшен-конфигураций не передают поток напрямую из браузера провайдеру — они передают его через собственный бэкенд или шлюз, который держит ключ провайдера и применяет аутентификацию, ограничение частоты и логирование. У проксирования SSE есть несколько требований, которые легко нарушить и получить поток, «зависающий» до готовности всего ответа.

  • Отключите буферизацию ответа. Обратные прокси и фреймворки часто по умолчанию буферизируют ответы, что полностью сводит на нет потоковую передачу. Отправляйте чанки немедленно и, где это уместно, отключайте буферизацию прокси, чтобы ничто не накапливало тело перед пересылкой.
  • Задайте правильные заголовки. Отвечайте с Content-Type: text/event-stream и Cache-Control: no-cache, чтобы посредники не кэшировали и не переформировывали поток.
  • Пересылайте, а не пересериализуйте. Пропускайте чанки провайдера как есть, где это возможно; повторный разбор и повторная отправка каждого токена добавляют задержку и провоцируют ошибки на границах.
  • Распространяйте отмену. Когда клиент отключается, прерывайте и восходящий запрос, иначе вы продолжите платить за токены, которые никто не прочитает. Это серверное зеркало AbortController.

Шлюз — естественное место для всего этого, потому что он уже терминирует клиентские соединения и держит учётные данные. Регистрация каждого провайдера как канала за ним позволяет шлюзу передавать поток из работоспособного апстрима, предъявляя каждому клиенту единую форму SSE, и логировать использование токенов потокового запроса так же, как непотокового. Если вы держите множество провайдеров за одним эндпоинтом, потоковый перевод между их форматами событий — это часть того, что делает за вас OpenAI-совместимый API-шлюз.

Часто задаваемые вопросы

Почему мой парсер JSON выбрасывает исключение на последней строке потока?

Потому что последняя строка потока Chat Completions — это data: [DONE], а [DONE] — буквальный маркер, а не JSON. Проверьте строку [DONE] и остановитесь прежде, чем передавать полезную нагрузку в JSON.parse. Это единственная самая частая ошибка разбора при потоковой передаче.

Можно ли использовать браузерный EventSource для потоковой передачи в стиле OpenAI?

Нет. EventSource работает только с GET и не может задать заголовок Authorization, тогда как вызовы LLM — это аутентифицированные запросы POST. Используйте fetch с res.body.getReader() и разбирайте text/event-stream самостоятельно, как показано в примере выше.

Как остановить поток и перестать платить за токены?

Прикрепите сигнал AbortController к fetch и вызовите controller.abort(). Соединение разрывается, а промис fetch отклоняется с AbortError. Прерывание остановит тарификацию только в том случае, если отмена дойдёт до провайдера. В конфигурации с прокси сервер должен прервать и собственный восходящий вызов, поэтому подключите отмену к элементу «стоп» в интерфейсе и отключениям клиента и распространяйте её по всей цепочке.

Одинаково ли выглядят потоки Chat Completions и Responses на проводе?

Они разделяют транспорт SSE, но не события. Chat Completions отправляет дельты chat.completion.chunk, заканчивающиеся [DONE]; Responses API отправляет именованные семантические события, такие как response.output_text.delta и response.completed. Парсер, написанный для одного, требует доработки для другого.

Подключите отменяемый поток

Возьмите приведённый выше сниппет fetch + reader, направьте его на ваш шлюз с stream: true и отображайте choices[0].delta.content в текстовом узле по мере поступления токенов. Затем добавьте AbortController и кнопку остановки. Отмена не даёт абсолютной гарантии: число выходных токенов, записанное локально для отменённого запроса, должно уменьшиться, но считайте это сигналом, а не доказательством. После отключения апстрим может ещё некоторое время продолжать генерацию или тарификацию, а локальные счётчики могут быть оценочными, если финальная статистика апстрима не пришла. Чтобы подтвердить, что отмена действительно дошла до провайдера, сопоставьте stream_status шлюза — маркер client_gone — с собственными данными использования или счётом провайдера, а не полагайтесь только на локальное число токенов.

Ссылки