Потоковые ответы LLM: как читать SSE и не потерять окончание
Как устроен streaming LLM-ответов: события SSE, границы сетевых чанков, кириллица, финальные данные об использовании и поведение при обрыве.

Потоковый ответ позволяет показать текст до окончания генерации, но сетевой chunk не равен готовому событию. Собирайте SSE с буфером и потоковым декодированием, а запрос считайте завершённым только после финальных данных и корректного маркера конца.
Поток меняет восприятие задержки
Когда ответ приходит по частям, пользователь видит начало текста раньше полного завершения. Это особенно заметно в длинных объяснениях. Но поток усложняет серверный и браузерный код: соединение может оборваться на середине слова, а финальные данные о расходе прийти после всего текста. Нельзя считать успешным запрос только потому, что на экране появились первые символы.
Для чата отделите состояние «идёт генерация» от «ответ готов». При обрыве сохраните видимый черновик с пометкой о неполноте и дайте приложению восстановить исходный запрос. Не отправляйте новый платный вызов автоматически с другим идентификатором. Это правило особенно важно, когда пользователь уже прочитал часть полезного ответа.
Здесь разбираем, как спроектировать поведение приложения и выбрать проверки. Для воспроизводимого примера в Kvantora ниже есть отдельная короткая практическая инструкция в связанных материалах.
Событие SSE собирается из строк
HTTP передаёт байты произвольными кусками. Один вызов чтения может закончиться посреди русского символа, строки JSON или пустой строки, которая отделяет SSE-события. Используйте TextDecoder в потоковом режиме, храните остаток между чтениями и разбирайте событие только после полного разделителя. JSON.parse на каждом пришедшем сетевом chunk будет случайно работать на коротком тесте и ломаться в сети.
SSE допускает несколько строк data внутри одного события. Парсер должен собрать их по правилам формата. Проверяйте LF и CRLF, пустые строки и конец соединения без завершающего маркера. У Kvantora есть скачиваемый пример streaming.mjs с проверяемым парсером; он полезнее самописного разделения строки по двойному переносу без тестов.
Минимальный сценарий без платного вызова
Пример ниже показывает чтение уже полученного Response. Сам POST, ключ, модель и сохранение Idempotency-Key должны быть организованы отдельно по быстрому старту. Смысл кода: не показать всю интеграцию в пяти строках, а обозначить границу: парсер получает поток и сообщает текстовые delta, затем возвращает итоговое использование.
В документации Kvantora примерный парсер требует успешный text/event-stream, итоговые usage, billing.status=settled и маркер [DONE]. Если эти данные не пришли, приложение должно отметить неполный поток. Не дорисовывайте расход из числа выведенных символов.
import { readTextStream } from './streaming.mjs';
const result = await readTextStream(response, part => process.stdout.write(part));
console.log('Итог:', result.usage.cost_microrub);Интерфейс не должен принимать фрагмент за факт
Пока ответ набирается, модель может изменить продолжение или оборвать мысль. Не запускайте парсер JSON и бизнес-действие после каждого фрагмента. Для текста можно показывать черновик, но сохранение структурированных данных выполняйте после полного ответа и проверки схемы. Если поток содержит вызов инструмента, его обработка требует отдельного состояния и проверки полномочий.
В браузере ограничьте частоту обновлений экрана, чтобы каждое слово не вызывало тяжёлый рендер всей истории диалога. При отмене пользователем различайте прекращение показа и фактический исход серверного запроса. Закрытая вкладка не доказывает, что поставщик не завершил работу.
Прокси не должен ломать событие
Если поток проходит через сервер приложения, не превращайте его в массив готовых строк после полного чтения upstream. Тогда пользователь снова ждёт весь ответ и смысл streaming теряется. Передавайте события по мере поступления, сохраняя границы протокола и контролируя закрытие соединения. Но не пытайтесь пересылать произвольные байты, если нужно скрыть внутренние события и секреты: разберите их на сервере и выдайте свой безопасный формат.
Отдельно проверьте обратное давление: медленный клиент не должен бесконечно накапливать данные в памяти сервера. В тесте отправляйте много коротких событий, замедлите чтение и посмотрите поведение очереди. Это уже инженерная проверка конкретной реализации, а не обещание SSE как формата. Для коротких ответов такой слой может быть избыточен.
Что делать при обрыве
Запишите, успел ли сервер подтвердить приём запроса, какие события получены и был ли финальный расчёт. Если итог неизвестен, сохраните исходные тело и ID. Восстановление должно спросить о состоянии именно этой операции по контракту сервиса. Новый запрос может дать похожий текст, но это отдельная работа и отдельный расход.
Тестируйте обрыв в четырёх местах: до первого байта, посреди UTF-8 символа, после текста до итогового usage и после финального события. На локальном синтетическом сервере такие случаи воспроизводятся без расхода. Для каждого заранее задайте ожидаемое состояние UI.
Показывайте пользователю признак неполноты, если поток остановился раньше финального события. Иначе скопированный им текст будет выглядеть законченным, хотя модель могла не успеть вывести оговорку или последнее условие. В интерфейсе можно оставить полученный фрагмент для чтения, но запретить действия, которые требуют подтверждённого полного ответа.
Когда поток вообще не нужен
Если задача состоит в извлечении небольшого JSON, поток может не ускорить полезный результат: объект всё равно нужно целиком проверить. Для коротких фоновых операций обычный ответ проще. Поток оправдан, когда ранний текст помогает пользователю и команда готова поддерживать его сетевой протокол. Это продуктовый выбор, а не обязательный флажок API.
После подключения сравните долю неполных потоков и время до первого полезного фрагмента. Сохраняйте итоговую задержку отдельно: быстрый первый токен при очень долгом окончании не решает каждую задачу. Разбор ошибок и расходов остаётся таким же обязательным, как при обычном ответе.






