Почему нельзя парсить LLM-стрим по сетевым чанкам: SSE на Python и тест на каждый байт
Потоковый ответ может начаться нормально и закончиться полусловом. Разберу на воспроизводимом примере, где заканчивается сетевой чанк, начинается SSE-событие и почему ни одно из них ещё не означает «ответ готов». Ниже — парсер на стандартной библиотеке Python и тесты без платных запросов к модели.
Не проблема модели, а ошибка на границе слоёв
Представим небольшой backend: он получает поток от LLM-провайдера и передаёт текст в интерфейс. В тесте всё работает, а после прокси или изменения размера буфера внезапно появляются ошибки JSON и пропавшие фрагменты.
Подозрительное место — json.loads(chunk) внутри цикла чтения HTTP. У сетевого фрагмента нет обязанности совпадать с сообщением приложения: в нём может лежать половина строки, несколько событий или часть многобайтного символа. Для разбора возьму синтетический поток: ошибка воспроизводится без API-ключа и не зависит от выбранной модели.
Я разделяю четыре уровня: байты транспорта → строки UTF-8 → события SSE → состояние ответа конкретного API. Если смешать их в одном цикле, ошибка парсинга легко превращается в «провайдер нестабилен».
Минимальный контракт до кода
Для SSE событие отделяется пустой строкой. Несколько полей data объединяются через перевод строки; комментарии с двоеточия не являются текстом ответа. Переносы могут быть LF, CRLF или CR, включая разрыв CRLF между двумя чтениями.
Ключевая деталь: EOF не заменяет пустую строку. Незавершённое событие надо отбросить, даже если его data выглядит как корректный JSON. Эти правила сверены с HTML Standard, разделы 9.2.5–9.2.6.
Ниже — ограниченный парсер data/event для backend-клиента. Это не полная реализация браузерного EventSource: здесь нет id, retry, автоматического reconnect и Last-Event-ID. Невалидный UTF-8 намеренно вызывает ошибку, а не заменяется символом �. Это более строгая политика клиента, не попытка побайтно повторить браузер.
Парсер: сначала восстановить событие, потом читать JSON
Сохраните код как sse_parser.py. На вход поступает итератор bytes уже после HTTP-декодирования; на выходе — пары (тип события, данные). Если SDK уже выдаёт типизированные события, этот слой писать заново не нужно.
Важны не столько строки кода, сколько ограничения. max_line и max_event считаются в символах после декодирования; CRLF считается одним разделителем. max_total — в байтах входного потока. Значения в примере — учебные настройки, не универсальные лимиты для любого провайдера.
Бюджет события учитывает и комментарии до пустой строки. Общий лимит защищает от бесконечной последовательности коротких heartbeat. Но он не ограничивает время ожидания: read timeout, общий абсолютный deadline и закрытие ответа при отмене должен обеспечить HTTP-слой. Размер одного чтения там тоже нужно ограничить — парсер не отменит уже выделенную память.
Тест, который полезнее одного успешного запроса
Один и тот же корректный поток должен давать одинаковые события независимо от разбиения на чанки. Проверим все позиции разреза, а затем подадим поток по одному байту. В строке намеренно есть кириллица, иероглиф и emoji.
Запустите следующий код рядом с sse_parser.py:
Для статьи я также прогнал расширенный набор из 9 тестов: BOM, три варианта переноса, пустое data, неизвестные поля, незавершённый хвост, повреждённый UTF-8, ограничения размеров и пустые чанки. В тестах корректных потоков проверяются все одиночные разрезы и случайные многократные разбиения с фиксированным seed. Все 9 прошли локально; это проверка примера, не сертификация интеграции с каждым API.
Событие прочитано — ответ ещё не завершён
Парсер не знает, что у провайдера означает успешное завершение. [DONE] — не специальное слово стандарта SSE. Названия terminal-событий, ошибки внутри потока, причины остановки и финальная статистика расходов относятся к контракту конкретного API.
Поэтому поверх парсера нужен отдельный адаптер. Сначала он разбирает payload и проверяет тип события. Затем накапливает допустимые дельты. Статус completed выставляет только после предусмотренного контрактом успешного финала и нужных проверок результата.
Если соединение закончилось без такого финала, я бы оставлял состояние incomplete, а уже показанный текст помечал частичным. Такой ответ нельзя незаметно положить в кэш как полный. Не надо и склеивать его с новой генерацией: повтор — отдельная попытка, а не гарантированное продолжение.
Даже финальный маркер не делает результат автоматически пригодным для бизнеса: ограничение длины, отказ модели и собранные аргументы инструмента требуют своей обработки. И наоборот, ошибка транспорта после уже подтверждённого финала не обязана отменять завершённый результат — это решает адаптер по контракту, а не сам факт EOF.
Когда свой парсер оправдан
Для обычного клиента я сначала выберу поддерживаемый SDK или проверенную SSE-библиотеку. Свой код имеет смысл, если вы строите gateway, исследуете несовместимость прокси или должны одинаково тестировать несколько транспортных адаптеров.
Цена собственного слоя — не только поддержка синтаксиса. Придётся тестировать отмену, backpressure, disconnect клиента, лимиты и обновления провайдерских событий. Синхронный пример выше проверяет framing; он не является готовым асинхронным gateway и не должен блокировать event loop.
Практический минимум перед релизом: тестировать произвольные границы байтов; отдельно учитывать stream_started и stream_completed; измерять incomplete_rate; не путать heartbeat с прогрессом генерации; не логировать целиком приватные ответы ради диагностики.
Мой вывод: поток нельзя считать успешным только потому, что он открылся и показал несколько слов. Надёжность здесь начинается с двух независимых проверок: «событие собрано правильно» и «задача действительно завершена».
Вы доверяете обработку стрима SDK или держите собственный gateway? Как проверяете обрыв после первого текста, но до финального события?
Техническая основа: HTML Standard, Server-sent events, §9.2.5–9.2.6 https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream
Python codecs.iterdecode https://docs.python.org/3/library/codecs.html#codecs.iterdecode