Русский ответ LLM ломается в потоке: декодируем UTF-8 между порциями байтов

Английский ответ в чате выглядит нормально, а русский иногда содержит ромбы с вопросом или падает с UnicodeDecodeError. Ошибка возникает нерегулярно: повторный запрос с тем же текстом проходит. Если приложение читает сырой HTTP-поток, причина может быть в границе сетевой порции. Один символ кириллицы занимает несколько байтов UTF-8, и эти байты не обязаны приходить вместе.

Например, слово «Да» кодируется четырьмя байтами. Сеть может доставить первый байт буквы «Д» отдельно от второго. Вызов decode для каждой порции рассматривает её как законченный документ. Первая порция оказывается некорректной, хотя весь ответ сервера правильный. Замена ошибок через errors="replace" убирает исключение, но портит исходный текст.

Уточните, на каком уровне вы читаете поток

Если SDK уже выдаёт готовые строки событий, повторно декодировать их не нужно. Исправление требуется там, где собственный транспорт получает bytes: в прокси, адаптере для локальной модели или обработчике сырых сетевых данных. Сначала посмотрите тип объекта и место первого преобразования bytes в str. Нельзя искать эту ошибку в промпте, пока не установлен путь байтов до текста.

У потока есть несколько границ: сетевая порция, символ UTF-8, строка события и JSON внутри события. Они не совпадают. Декодер отвечает за символы; затем отдельный буфер собирает строки или события. Передавать каждый кусок текста в json.loads тоже неверно: корректный символ ещё не означает, что JSON-объект завершён.

В примере ниже имитируются маленькие сетевые порции. Вход — русский ответ модели на вопрос «Подтверди получение заявки одним коротким предложением». Саму модель вызывать не требуется: мы проверяем сохранность заранее известного ответа при разрезании его байтов. Декодер создаётся один раз на поток и сохраняет неполную последовательность между вызовами.

import codecs
text = "Да, принято."
raw = text.encode("utf-8")
def decode_parts(parts):
    decoder = codecs.getincrementaldecoder("utf-8")(errors="strict")
    result = [decoder.decode(part, final=False) for part in parts]
    result.append(decoder.decode(b"", final=True))
    return "".join(result)
assert decode_parts([raw[i:i+1] for i in range(len(raw))]) == text
assert decode_parts([raw[:1], raw[1:5], raw[5:]]) == text
for boundary in range(len(raw) + 1):
    assert decode_parts([raw[:boundary], raw[boundary:]]) == text
print(decode_parts([raw[i:i+1] for i in range(len(raw))]))
try:
    decode_parts(["Я".encode("utf-8")[:1]])
except UnicodeDecodeError:
    print("truncated stream rejected")
else:
    raise AssertionError("truncated stream accepted")

Обе схемы разбиения возвращают исходное «Да, принято.». Первая намеренно подаёт каждый байт отдельно, вторая использует неравномерные порции. В обоих случаях завершающий вызов с final=True успешно проверяет остаток. Последняя проверка передаёт только первый байт буквы «Я» и получает UnicodeDecodeError при завершении. Это отличает задержанный символ от действительно оборванного ответа.

Почему завершение нельзя пропустить

Пока соединение открыто, декодер вправе ждать следующий байт. Когда транспорт сообщил конец, ждать уже нечего. Если забыть final=True, неполная буква останется в буфере, а приложение объявит укороченный ответ успешно завершённым. Для чата это потеря текста; для структурированного ответа — возможная потеря значения или закрывающей части данных.

При ошибке завершения сохраните технический статус «повреждённый либо оборванный поток». Не записывайте частичный ответ как окончательный. Если интерфейс уже показал текст, отметьте незавершённость и предложите повтор согласно политике приложения. Вызовы инструментов должны ждать полного разбора события и проверки аргументов, поэтому незаконченный поток не должен запускать действие.

Не используйте общий декодер для нескольких параллельных ответов. Его буфер относится к одному потоку байтов. При отмене запроса объект освобождается вместе с этим запросом; новый ответ получает новый декодер. Иначе первый байт одного ответа может соединиться с продолжением другого, создав трудно воспроизводимую ошибку.

Что добавить в проверку адаптера

Разрежьте один короткий ответ во всех возможных позициях и сравните восстановленный текст с исходным. Затем проверьте многобайтовые символы, переносы строк и обрыв последнего символа. Для SSE поверх декодера потребуется отдельная проверка сборки события из нескольких строк. IncrementalDecoder в Python решает именно задачу преобразования последовательности байтов с сохранением состояния.

В платном курсе «LLMOps & Evaluation: из песочницы в продакшн» вы будете собирать тестовые наборы, проверять пограничные случаи и готовить эксплуатационные отчёты. Этот пример можно превратить в проверку транспортного адаптера, чтобы ошибки доставки не смешивались с качеством модели. Откройте программу курса и постройте контур, который различает неверный ответ и повреждённый поток.

Продолжить на практике

  • LLMOps & Evaluation: из песочницы в продакшн ↗

    Практический курс по LLMOps. Соберёте продакшн-контур для LLM: eval-фреймворк (LangSmith/Ragas/OpenAI Evals), quality-гейты в CI/CD, алёрты на дрейф, монитринг скорости и стоимости, управление промпт-версиями и безопасность. Плюс профессиональные блоки: synthetic data для тестов, LLM-based judges, отчётность для релиз-комитета и runbook’и инцидентов.