ТЕХНОЛОГИИ

SSE и UTF-8: сетевой chunk не равен JSON-событию

В этом материале
ГЛАВНОЕ ЗА МИНУТУ

Сначала bytes декодируются с сохранением состояния, затем выделяются SSE-события, и только после этого разбирается JSON payload.

Потоковый ответ может быть корректным на сервере и ломаться у клиента из-за неправильной границы обработки. Один сетевой read содержит половину символа, несколько SSE-событий или часть строки data. Если каждый полученный chunk сразу превращать в отдельный JSON, ошибки будут зависеть от случайной нарезки сети. Надёжная проверка строит несколько последовательных уровней и сохраняет состояние между вызовами чтения.

Отделите bytes от текста

Стандарт SSE задаёт UTF-8 для event stream. Это не означает, что любой сетевой фрагмент содержит целое число символов. Многобайтовая последовательность может закончиться в следующем read, и декодер должен сохранить её незавершённый остаток до получения продолжения.

TextDecoder.decode описывает streaming-режим, в котором дополнительные данные ожидаются в следующих вызовах. Завершение декодирования также является отдельным шагом. Если каждый раз создавать новый decoder, его состояние теряется, даже когда исходные bytes полностью корректны и в правильном порядке дошли до клиента.

Добавьте framing после декодера

Текст поступает в SSE parser, который собирает строки и события по правилам формата. Только готовый data payload передаётся JSON parser. Между этими слоями не следует делать допущение «один read — одно событие» или «одна строка — один полный объект».

Поток может содержать comments и несколько data-строк одного события. Их обработка определяется форматом, а не удобством регулярного выражения. При создании своего parser сохраните явное состояние незавершённой строки и текущего события, ограничив объём накопления для некорректного входа.

Составьте fixture с Unicode

Возьмите короткое синтетическое событие с латинским текстом, кириллическим символом и emoji. Сохраните исходные bytes и ожидаемый итоговый текст. Затем разрежьте поток в нескольких местах, включая границу внутри многобайтового символа, внутри data-префикса и между двумя соседними событиями.

Каждая нарезка должна давать одинаковую последовательность прикладных событий. Это проверяет независимость результата от транспорта. Для начала достаточно ограниченного набора cuts; полный перебор всех вариантов не нужен, если он резко увеличивает время проверки и не помогает найти конкретную ошибку реализации.

Не теряйте несколько событий

MDN о Streams API объясняет, что chunks имеют разные размеры и находятся в очередях чтения. Поэтому один крупный read может содержать сразу несколько готовых событий. Обработчик должен извлечь каждое из них, а остаток сохранить для следующего чтения.

Проверьте счётчик событий, порядок и итоговую конкатенацию content. Успешный последний JSON не подтверждает, что предыдущие события не были потеряны. Отдельно добавьте пустой read, обычное закрытие и обрыв с незавершённой строкой, сохранив различие между этими исходами.

Ограничьте вывод транспортным слоем

Прошедший fixture подтверждает decoding и framing на выбранном наборе. Он не проверяет правильность tool arguments, смысл finish_reason или качество генерации. Эти уровни должны получать уже корректно выделенные события и иметь собственные ожидания, чтобы одна ошибка не маскировалась другой.

Практика инкрементального чтения описана в клиенте OpenRouter, а первый серверный пример — в streaming vLLM. Границы bytes, текста, SSE и JSON остаются разными независимо от выбранного движка или маршрутизатора.

← Все материалы