Потоковая передача ответов LLM через Server-Sent Events
Как работает потоковая передача токенов LLM поверх SSE: формат text/event-stream, разбор строк data и маркера [DONE], почему fetch лучше EventSource и как отменять поток через AbortController.
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 — с собственными данными использования или счётом провайдера, а не полагайтесь только на локальное число токенов.