Почему неполное финальное событие в стриминге ломает UI?
Неполное финальное событие в стриминге: как сохранить уже полученный текст при пустом output, пропуске дельт, EOF и статусе incomplete.

Потоковый ответ нельзя считать успешным только потому, что клиент дочитал соединение до конца. В LLM-интерфейсе это правило определяет, останется ли у человека уже полученный полезный текст или исчезнет из-за одного сбоя парсера, прокси либо финального кадра.
Самая дорогая ошибка здесь выглядит невинно: код держит текст только в временном буфере, ждёт финальный объект, не получает его, ловит исключение и заменяет весь ответ сообщением «Что-то пошло не так». Пользователь видел несколько абзацев, возможно уже начал их читать, а интерфейс стирает их сам. Это не аккуратность. Это потеря данных, которую разработчик мог предотвратить.
Ниже речь идёт о текстовом UI поверх SSE или похожего однонаправленного потока. Принцип тот же для WebSocket: транспортное окончание, парсинг протокола и смысловое завершение ответа живут на разных уровнях. Пока их смешивают в одну ветку finally, баг будет возвращаться.
Закрытие соединения не означает успешный ответ
EOF говорит, что чтение байтов закончилось. Он не говорит, что модель завершила генерацию, шлюз передал все события, а клиент увидел завершающий статус. Для интерфейса это три разные вещи.
Стандарт HTML для Server-Sent Events прямо задаёт неприятную деталь: если файл заканчивается посреди события до пустой строки, клиент должен отбросить накопленные данные этого незавершённого события. Конец потока сам по себе не запускает доставку последнего SSE-кадра. Это важно, когда прокси оборвал соединение между data: и разделяющим пустым рядом.
Представьте поток, который пришёл так:
event: response.output_text.delta
data: {"response_id":"r_42","seq":17,"delta":"Уже полученный текст"}
event: response.completed
data: {"response_id":"r_42","status":"completed"}
Здесь клиент имеет право перевести ответ в состояние completed только после разбора второго события. Если соединение закрылось сразу после первой дельты, результатом будет не «успех», а «текст получен, но исход не подтверждён». Если оборвалось внутри второй строки data:, браузерный EventSource не должен выдать частично собранный JSON как событие. Это корректное поведение парсера, а не повод удалить первую дельту.
Документация OpenAI для Responses API различает событие response.completed и response.incomplete. Во втором случае объект ответа получает статус incomplete и может содержать incomplete_details.reason, например max_tokens. Этот контракт полезен не потому, что каждый провайдер называет события так же, а потому, что он показывает правильную модель: конечный статус относится к ответу, а не к TCP-соединению.
Сделайте правило явным: только распознанный terminal event со статусом completed подтверждает успешное завершение. EOF, AbortError, сетевой timeout, ошибка JSON-парсинга и закрытый EventSource подтверждают лишь одно: дальше байтов нет.
Храните текст отдельно от статуса генерации
Текст и состояние ответа нельзя держать в одной переменной, которую финальный обработчик либо подтверждает, либо выбрасывает. Текст имеет собственную историю, а итог имеет собственную неопределённость.
Я обычно задаю модели UI такие состояния:
connecting: запрос создан, ни одной принятой дельты нет;streaming: есть хотя бы одна валидная дельта, финальный исход неизвестен;completed: пришёл корректный terminal event с успешным статусом;incomplete: пришёл terminal event, который явно сообщает незавершённость;interrupted: поток закончился или сломался без достоверного terminal event;failed_before_output: ошибка произошла до первой пригодной дельты.
Это не бюрократия в клиенте. Разница между incomplete и interrupted нужна пользователю и инженеру. В первом случае поставщик сообщил, что ответ остановился, например из-за лимита. Во втором вы не знаете, модель остановилась, шлюз упал, сеть пропала или ваш код не разобрал финальное событие.
Минимальная запись состояния может выглядеть так:
type StreamPhase =
| "connecting"
| "streaming"
| "completed"
| "incomplete"
| "interrupted"
| "failed_before_output";
type AnswerState = {
requestId: string;
attemptId: string;
phase: StreamPhase;
text: string;
receivedSeq: number;
terminalReason?: string;
parserError?: string;
};
Поле text меняется только при принятой текстовой дельте. Обработчик статуса не имеет права обнулять его. Он меняет phase, сохраняет причину и решает, какие действия показать рядом с сообщением.
Это различие часто размывают словами «ответ не получен». Оно неверно в двух разных ситуациях. «Не получен целиком» не равно «не получен вообще». В первом случае пользователь уже имеет часть работы модели. Во втором ему нечего показать. Если смешать их, вы испортите и UX, и метрики: доля полностью пустых отказов станет неотличима от доли обрывов после полезного вывода.
Пустой output требует отдельного решения
Пустая строка, отсутствие текстового блока и отсутствие событий вообще не одно и то же. Обработайте каждый вариант отдельно, иначе интерфейс начнёт показывать ложные ошибки или ложные успехи.
Пустой output при completed бывает нормальным. Модель могла вернуть только вызов инструмента, отказ, структурированный элемент, аудио, изображение или служебный результат, который ваш слой рендеринга ещё не умеет показать. В Responses API выход складывается из типизированных элементов, а SDK-удобство вроде output_text агрегирует только текстовые части. Пустое текстовое поле поэтому не доказывает, что в ответе ничего нет.
Сначала классифицируйте содержимое, потом решайте, что выводить:
function classifyFinal(response: {
status: string;
output?: Array<{ type: string; status?: string }>;
}) {
const types = new Set((response.output ?? []).map(item => item.type));
if (response.status === "incomplete") return "incomplete";
if (response.status !== "completed") return "unexpected_terminal";
if (types.has("function_call")) return "tool_call";
if (types.has("refusal")) return "refusal";
if (types.has("message")) return "message";
return "empty_completed";
}
Не подменяйте empty_completed текстом «Модель ничего не ответила», пока не проверили типы элементов. Такой текст полезен только если ваш продукт действительно ожидал текст и договорился об этом с вызывающим кодом. Для внутреннего оркестратора пустой текст после tool call часто означает, что дальше должен работать исполнитель инструмента, а не чатовый рендерер.
Если вы принимаете только текстовый сценарий, формулируйте правило честно: «Ответ завершился без текстового содержимого». Дайте пользователю повторить запрос, но не объявляйте сетевую ошибку. Сетевой сбой и корректный пустой ответ требуют разных расследований.
Пропущенная дельта нельзя лечить склейкой строк
Когда в тексте не хватает фразы, команда часто добавляет дедупликацию вида if (!text.endsWith(delta)) text += delta. Это популярно, потому что быстро убирает видимые повторы после переподключения. Одновременно оно молча удаляет законные повторы, ломает одинаковые окончания и не отвечает на вопрос, пропала ли дельта.
Правильная дедупликация работает по идентичности события, а не по его тексту. Если протокол даёт sequence number, используйте его. Если даёт идентификатор события, сохраняйте его. Если не даёт ни того ни другого, ваш шлюз должен добавить монотонный номер на границе, где он уже получил событие от провайдера.
Вот обработчик с ожидаемым поведением:
type DeltaEvent = {
type: "text.delta";
response_id: string;
seq: number;
delta: string;
};
function acceptDelta(state: AnswerState, event: DeltaEvent): AnswerState {
if (event.response_id !== state.requestId) return state;
if (event.seq <= state.receivedSeq) return state;
if (event.seq > state.receivedSeq + 1) {
return {
...state,
phase: "interrupted",
terminalReason: `gap_before_seq_${event.seq}`
};
}
return {
...state,
phase: "streaming",
receivedSeq: event.seq,
text: state.text + event.delta
};
}
Такой код не пытается угадать пропущенные слова. Он фиксирует разрыв и сохраняет уже принятый текст. После обнаружения gap можно прекратить применение новых дельт к этому ответу, запросить подтверждённый снимок ответа у сервера или показать пользователю частичный результат. Выбор зависит от контракта, но скрывать разрыв нельзя.
Есть неприятный случай: поставщик шлёт дельты без номеров, а ваш браузер переподключается сам. Нативный EventSource умеет повторно подключаться, а стандарт предусматривает Last-Event-ID для продолжения после разрыва. Но это помогает только если сервер назначает событиям id и умеет восстановить последовательность. MDN отдельно показывает, что без поля event сообщения приходят как обычные message, а обработчик ошибок вызывается и при проблемах сети. Не принимайте автоматическое переподключение за гарантию отсутствия потерь.
Если у вас прокси между браузером и LLM API, лучше сделать одно из двух. Либо прокси сам читает upstream до terminal event и отдаёт клиенту собственную нумерованную последовательность. Либо прокси выдаёт попытке уникальный ID, а клиент после переподключения спрашивает накопленный снимок по этому ID. Второй вариант проще отлаживать, потому что браузер не обязан заново собирать историю из повторно доставленных кусков.
Сбой парсера не должен стирать принятые события
Ошибка JSON-парсинга часто происходит не потому, что модель отдала плохой JSON. Её вызывают неверная граница SSE-кадра, буферизация reverse proxy, смешение строк data:, gzip-поток, отмена ReadableStream или клиент, который вызывает JSON.parse для каждого произвольного chunk сети.
Никогда не считайте сетевой chunk событием. HTTP-слой может разделить один UTF-8 символ между кусками, отдать несколько SSE-сообщений одним куском или оставить последнюю половину кадра в буфере. Парсите байты инкрементально через TextDecoder с stream: true, отделяйте кадры пустой строкой и только затем разбирайте поля SSE.
Упрощённый пример для собственного fetch-клиента:
const decoder = new TextDecoder();
let buffer = "";
for await (const chunk of response.body!) {
buffer += decoder.decode(chunk, { stream: true });
let boundary: number;
while ((boundary = buffer.indexOf("\n\n")) !== -1) {
const frame = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
const data = frame
.split(/\r?\n/)
.filter(line => line.startsWith("data:"))
.map(line => line.slice(5).trimStart())
.join("\n");
if (!data || data === "[DONE]") continue;
try {
acceptProtocolEvent(JSON.parse(data));
} catch (error) {
markParserFailure({ frame, error: String(error) });
preserveVisibleText();
stopCurrentAttempt();
break;
}
}
}
Этот пример не заменяет полный SSE-парсер. Он намеренно показывает границу ответственности: сначала кадр, затем JSON. В production-коде учтите \r\n, комментарии, несколько строк data:, поле event, поле id, лимит размера буфера и отмену запроса. Стандарт SSE определяет UTF-8, построчную обработку и пустую строку как сигнал доставить событие.
Критичная деталь находится в обработчике catch. Он должен записать ошибку, перевести попытку в interrupted и оставить state.text как есть. Не вызывайте там общий resetConversationMessage(). Общий сброс удобен, пока в нём не исчезает часть ответа, которую человек успел увидеть.
Статус incomplete не равен ошибке UI
Статус incomplete означает, что генерация не стала завершённым ответом по правилам поставщика. Это может быть лимит вывода, отмена, политика, внутренняя остановка или другой явно названный повод. Интерфейс не должен маскировать такой статус под «готово», но и не должен делать вид, что полученных дельт не существовало.
Для обычного чатового текста используйте простую политику:
- Оставьте на месте все принятые дельты.
- Смените индикатор генерации на «ответ прерван» или на более точную причину, если она безопасна и понятна.
- Покажите действие «Продолжить», только если сервер может создать новую попытку без повторного исполнения побочных действий.
- Покажите действие «Повторить», если новая генерация допустима, но не склеивайте её с прежней автоматически.
- Сохраните исходную попытку в истории с её фактическим статусом.
Слово «прерван» лучше, чем «ошибка», когда текст уже есть. Оно не обещает, что ответ верен или закончен. При этом не ставьте красный баннер поверх каждого обрыва после 99 процентов текста. В чате достаточно небольшой статусной строки под сообщением. Человек должен видеть текст, а не наказание за проблему инфраструктуры.
Для структурированных результатов политика строже. Если вы ожидаете JSON по схеме, недописанный объект нельзя отдавать потребителю даже тогда, когда он синтаксически случайно парсится. В нём может отсутствовать обязательное поле, завершаться массив или обрываться строка. Для SQL, программного кода, аргументов инструментов, юридических форм и медицинских инструкций применяйте правило «полный terminal success плюс валидация содержимого». Частичный текст можно сохранить в журнале или показать как черновик, но не запускать.
Не путайте incomplete с content filtering или отказом. Отказ может быть полностью доставленным ответом с объяснением. Incomplete может содержать полезный нейтральный текст. В продуктовой аналитике это разные классы событий, иначе команда начнёт лечить лимиты генерации настройками модерации, а модерацию повторными запросами.
Финальное событие нужно валидировать так же строго, как дельту
Клиенты часто аккуратно проверяют каждую дельту, а финальному событию верят по одному полю type. Этого мало. Terminal event должен относиться к активному ответу, приходить в допустимом порядке и не противоречить тому, что клиент уже принял.
Минимальная проверка выглядит так:
type TerminalEvent = {
type: "response.completed" | "response.incomplete" | "response.failed";
response: {
id: string;
status: "completed" | "incomplete" | "failed";
incomplete_details?: { reason?: string };
};
seq?: number;
};
function acceptTerminal(state: AnswerState, event: TerminalEvent): AnswerState {
if (event.response.id !== state.requestId) return state;
if (state.phase === "completed" || state.phase === "incomplete") return state;
if (event.seq !== undefined && event.seq < state.receivedSeq) return state;
if (event.response.status === "completed") {
return { ...state, phase: "completed" };
}
if (event.response.status === "incomplete") {
return {
...state,
phase: "incomplete",
terminalReason: event.response.incomplete_details?.reason ?? "unknown"
};
}
return {
...state,
phase: state.text ? "interrupted" : "failed_before_output",
terminalReason: "provider_failed"
};
}
Не допускайте, чтобы поздний completed от старой попытки закрыл новую. Это бывает после отмены, переключения модели или повторной отправки сообщения. requestId должен принадлежать конкретной генерации, а attemptId отличать повторный запуск от исходного пользовательского сообщения.
Есть и обратная ошибка: фронтенд получает terminal event, но серверный агрегатор затем перезаписывает его таймаутом. Terminal state должен быть необратимым. После completed и incomplete не переводите запись в interrupted из-за события закрытия сокета, которое пришло позже. Сокет обязан закрыться после нормального конца, и это не новая бизнес-ошибка.
Повторная попытка может создать два ответа вместо одного
Автоматический retry кажется безопасным, когда генерация оборвалась. Он безопасен только для чистой генерации, где повтор не запускает инструменты, не создаёт запись в CRM, не отправляет письмо и не меняет состояние внешней системы.
Если ответ мог вызвать инструмент, сначала выясните, на какой стадии остановилась попытка. Возможны четыре разных исхода:
- инструмент не был запрошен;
- модель запросила инструмент, но исполнитель не начал работу;
- исполнитель завершил работу, но результат не дошёл до модели;
- модель получила результат и начала писать текст пользователю.
Повторить один и тот же запрос во втором и третьем случаях нельзя без идемпотентного ключа на стороне инструмента. Иначе «создай счёт» станет двумя счетами. В streaming-архитектуре это случается чаще, чем кажется: UI видит обрыв, отправляет retry, а серверная задача ещё живёт и успевает завершить исходную операцию.
Для продолжения текста не просите модель «напиши ответ заново» и не склеивайте две генерации без отметки границы. Передайте ей подтверждённый видимый фрагмент и задайте узкую инструкцию: продолжить после последнего предложения, не повторять уже выведенный текст. Затем покажите новую часть отдельным блоком до тех пор, пока сервер не подтвердит, что она относится к той же логической цепочке.
Если ваша архитектура использует единый OpenAI-совместимый endpoint, AI Router может оставить существующий SDK и обработчик потока без переписывания вызова. Но семантику completed, incomplete и transport failure всё равно должен определять ваш клиентский контракт, а не смена base_url.
Чеклист поведения клиента при плохом финале
Этот чеклист стоит положить рядом с кодом рендеринга и превратить в тесты. Он задаёт не внешний вид ошибки, а сохранность состояния.
Когда текст уже есть
- При валидной дельте немедленно добавляйте её в постоянное состояние сообщения.
- При EOF без terminal event оставляйте текст и ставьте
interrupted. - При
response.incompleteоставляйте текст и записывайте причину. - При ошибке парсера после принятой дельты прекращайте попытку, но не очищайте текст.
- При gap в sequence number фиксируйте разрыв и не выдавайте ответ за законченный.
Когда текста нет
- При
completedбез текстовых элементов проверяйте типы output, а не подменяйте результат ошибкой сети. - При
incompleteдо первой дельты показывайте, что генерация прервалась до вывода. - При parser error до первой дельты показывайте техническую ошибку и кнопку повторить.
- При отмене пользователем сохраняйте статус
cancelled, если протокол его даёт, и не называйте это сбоем модели. - При неизвестном terminal event сохраняйте сырое имя типа в диагностике и не помечайте ответ как completed.
Проверьте, что UI не заменяет сообщение целиком при переходе между этими состояниями. В React и похожих системах это означает стабильный идентификатор сообщения и обновление отдельных полей. Если при interrupted вы создаёте новый error bubble вместо обновления текущего assistant message, пользователь увидит два противоречивых объекта: текст без статуса и ошибку без текста.
Тестируйте поток как журнал событий, а не как строку
Тест, который подаёт «Hello» и затем completed, почти ничего не проверяет. Он не ловит ошибки, из-за которых интерфейс теряет данные в реальной сети.
Соберите фикстуры из последовательностей событий. Каждая фикстура должна проверять финальный текст, фазу, причину и доступные действия. Полезны как минимум эти сценарии:
const cases = [
{
name: "нормальное завершение",
events: [delta(1, "Первый абзац."), done(2)],
expect: { text: "Первый абзац.", phase: "completed" }
},
{
name: "обрыв после текста",
events: [delta(1, "Первый абзац."), eof()],
expect: { text: "Первый абзац.", phase: "interrupted" }
},
{
name: "incomplete после текста",
events: [delta(1, "Первый абзац."), incomplete(2, "max_tokens")],
expect: { text: "Первый абзац.", phase: "incomplete" }
},
{
name: "разрыв последовательности",
events: [delta(1, "Первая часть "), delta(3, "третья часть")],
expect: { text: "Первая часть ", phase: "interrupted" }
},
{
name: "незакрытый SSE кадр",
events: [raw("data: {\\\"type\\\":\\\"text.delta\\\""), eof()],
expect: { text: "", phase: "failed_before_output" }
}
];
Последняя фикстура особенно полезна для проверки собственного SSE-парсера. По правилам стандарта такой кадр не должен попасть в обработчик событий. Если ваш тест получает частичный JSON, вы парсите не SSE, а удобную для демо подстроку.
Отдельно тестируйте отмену во время активной генерации. После пользовательской отмены поздние дельты и terminal event старой попытки не должны менять новое сообщение. Это проверяется не таймерами, а намеренной доставкой событий в неправильном порядке.
В наблюдаемости записывайте response ID, attempt ID, тип terminal event, номер последней дельты, причину incomplete, число символов, время до первой дельты и время от последней дельты до конца потока. Полные prompt и output не отправляйте в технические логи по умолчанию. Для банков, healthcare и других регулируемых сценариев журнал без маскирования быстро нарушает собственные правила хранения данных.
Сделайте ещё одну метрику: долю interrupted с непустым текстом. Она показывает ущерб пользователю лучше, чем общий процент HTTP-ошибок. Второй полезный срез, доля completed с пустым ожидаемым текстом, быстро обнаруживает поломку маршрутизации типов output или новый формат ответа провайдера.
Потоковый UI не обязан обещать пользователю, что каждый ответ будет завершён. Он обязан не выбрасывать подтверждённые данные из-за того, что финальное подтверждение не дошло. Сохраните дельты отдельно, требуйте явный terminal status для успеха и относитесь к незавершённости как к состоянию сообщения. Тогда один плохой кадр не превратит несколько хороших абзацев в пустое место.
Часто задаваемые вопросы
Нужно ли очищать текст, если стрим закрылся без ошибки HTTP?
Потому что закрытое HTTP-соединение сообщает только о транспорте. Оно не доказывает, что поставщик отправил финальный доменный статус, а клиент получил и разобрал его. Если UI очищает буфер при любом завершении чтения, он сам уничтожает корректные дельты.
Можно ли показывать пользователю текст из незавершённого ответа?
Нет, если дельта уже прошла валидацию и была привязана к текущему запросу. Сохраните её как черновой результат, покажите понятный статус и дайте пользователю продолжить, повторить или скопировать текст. Исключение составляют ответы, которые нельзя безопасно показывать частично, например незавершённая команда на перевод денег.
Что означает пустой output в потоковом ответе LLM?
Пустой output не означает ошибку сам по себе. Модель могла вызвать инструмент, вернуть отказ, послать служебное событие или не успеть выдать первую текстовую дельту. Обрабатывайте отсутствие текста как отдельное состояние, а не как пустую строку, которую надо безусловно вывести в чат.
Как понять, что в стриме пропущена дельта?
Пропущенная дельта часто выглядит как неполный текст, но причина может быть в повторном подключении, дедупликации по плохому ключу, ошибке декодера или в том, что прокси разрезал SSE-кадр. Нужны sequence number, идентификатор ответа и журнал принятых событий. По одному виду текста причину не установить.
Считается ли EOF успешным завершением SSE-стрима?
Нет. SSE задаёт правила упаковки событий, но не определяет, какое событие означает успешное завершение конкретного LLM-протокола. Клиент должен ждать явный terminal event или финальный объект со статусом completed, если именно это предусмотрено контрактом поставщика.
Можно ли автоматически повторять незавершённый стрим?
Повторяйте запрос только при идемпотентном действии и при понятном способе связать новую попытку со старой. Для генерации текста лучше предлагать «продолжить с этого места» либо запускать новый ответ с уже полученным фрагментом в контексте. Автоматический повтор легко даёт дубли, две операции инструмента или лишние расходы.
Как интерфейс должен показывать incomplete status?
Для обычного текста достаточно показать пометку «ответ прерван» рядом с сохранённым фрагментом. Для JSON, SQL, кода, аргументов инструмента и любых исполняемых данных частичный результат нельзя считать готовым. Храните его для диагностики, но не передавайте дальше без отдельной проверки.
Нужно ли дедуплицировать события LLM-стрима?
Да, но только с ограничением по времени, идентификатору запроса и номеру последовательности. Повторная доставка допустима, а слепое склеивание одинаковых строк нет. Состояние должно принимать события одного активного ответа и игнорировать всё, что пришло после отмены или от прежней попытки.
Какие метрики и логи нужны для незавершённых стримов?
Обычно достаточно response ID, attempt ID, sequence number, типа события, времени первой и последней дельты, длины сохранённого текста, финального статуса и причины incomplete. Не пишите в обычные логи полный пользовательский prompt и ответ без правил маскирования. Они быстро превращаются в склад персональных данных.
Какие тесты нужны для клиента потоковых ответов?
Проверьте пять случаев: нормальное completed, empty output с completed, дельты без terminal event, terminal incomplete после нескольких дельт и обрыв в середине SSE-кадра. Последний случай особенно нужен: по стандарту SSE браузер не должен доставлять незаконченный кадр как событие. Тестируйте и браузерный путь, и серверный прокси, если он есть.