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



