Ошибка стриминга LLM API: причины и исправления

Ошибка стриминга LLM API: причины и исправления

Сообщение error: llm api error: an error occurred during streaming почти всегда вызвано одним из шести источников: сбой провайдера в середине потока, отправленный как SSE-событие после того, как соединение уже вернуло 200; таймаут чтения на стороне клиента или шлюза; превышение лимита запросов (429) в процессе генерации; битый или неожиданный чанк, ломающий ваш SSE-парсер; разрыв сетевого соединения; и проблема с авторизацией/квотой, которая проявляется только после открытия потока. Проверяйте их в таком порядке — большинство сбоев стриминга попадают в первые три.

Сопоставьте свои наблюдения с причиной, прежде чем читать дальше:

Что вы наблюдаете Перейдите к
Поток начинается, приходят несколько токенов, затем обрывается без изменения HTTP-статуса Причина 1
Поток зависает на 10+ секунд без новых токенов, затем клиент выдает таймаут Причина 2
Ошибка возникает чаще всего во время пиков трафика или сразу после серии запросов Причина 3
Исключение упоминает KeyError, JSONDecodeError или ошибку парсинга, а не сетевую проблему Причина 4
Ошибка общая (APIConnectionError, ECONNRESET) и возникает непостоянно, в том числе на стабильных сетях Причина 5
Ошибка возникает с первого же запроса после смены ключа, достижения лимита расходов или переключения окружения Причина 6

Полный диагностический чеклист ниже повторяет это с указанием вероятной причины в каждой строке.

Ключевые выводы

  • Ошибка стриминга может прийти как SSE-событие error после того, как HTTP-статус уже вернул 200, поэтому логика повторных попыток, основанная только на статус-коде, её пропускает.
  • API Messages от Anthropic сообщает о перегрузках в середине потока как event: error с "type": "overloaded_error" внутри тела потока, а не как новый HTTP 529.
  • OpenRouter документирует тот же шаблон для всех провайдеров: после отправки первого токена сбой приходит как chat.completion.chunk с полем error верхнего уровня и finish_reason: "error".
  • Клиентские библиотеки, которые предполагают, что каждый стриминг-чанк имеет успешную форму, упадут с вводящей в заблуждение ошибкой (APIConnectionError вместо реальной причины), когда провайдер отправляет структурированную ошибку в середине потока.
  • Большинство исправлений одинаковы независимо от провайдера: парсить тело потока на предмет событий ошибок, установить отдельный таймаут простоя на уровне токенов (отдельно от таймаута соединения) и использовать экспоненциальную задержку, привязанную к типу ошибки, а не только к HTTP-статусу.

Диагностический чеклист: сначала найдите первопричину

Пройдитесь по этому списку, прежде чем менять код. Каждая строка сопоставляет наблюдаемый симптом с разделом, в котором описано исправление.

Что вы наблюдаете Вероятная причина Перейдите к
Поток начинается, приходят несколько токенов, затем обрывается без изменения HTTP-статуса Ошибка провайдера в середине потока (перегрузка, фильтр контента, сбой) Причина 1
Поток зависает на 10+ секунд без новых токенов, затем клиент выдает таймаут Таймаут чтения или зависание потока Причина 2
Ошибка возникает чаще всего во время пиков трафика или сразу после серии запросов Превышение лимита запросов в середине потока Причина 3
Исключение упоминает KeyError, JSONDecodeError или ошибку парсинга, а не сетевую проблему Битый или неожиданный чанк Причина 4
Ошибка общая (APIConnectionError, ECONNRESET) и возникает непостоянно, в том числе на стабильных сетях Разрыв соединения или маскированная ошибка провайдера Причина 5 и Причина 4
Ошибка возникает с первого же запроса после смены ключа, достижения лимита расходов или переключения окружения Сбой авторизации или квоты Причина 6

Если ваши логи показывают только общее имя исключения без сообщения от вышестоящей системы, это само по себе симптом — см. Причина 4 и Причина 5 о том, почему общие обертки скрывают реальную причину.

Причина 1: Ошибка провайдера в середине поотока (перегрузка, фильтр контента, сбой)

Это причин, которая сбивает с толку большую часть людей, потому что запрос выглядл успешным. Как только стриминг-ответ начался, HTTP-статус код и заголовки уже зафиксированы для клиента. Если провайдер затем сталкивается со сбоем — исчерпание емкости, внутренняя ошибка, срабатывание фильтра контента после частичного вывода, или краш процесса модели — он не может изменить HTTP-статус на код ошибки. Сбой должен передать внутри самого потока как специальное событие.

API Messages от Anthropic прямо это документирует: API “может иногда отправлять [ошибки] в потоке событий” и приводит этот пример для состояния перегрузки, возникающей в середине потока:

event: error
data: {"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}}

Это реальный пробел в логике повторных попыток. Если ваш код проверяет только response.status_code, он уже видел 200 до того, как произошла ошибка, поэтому повторная попытка никогда не срабатывает. Отчет об инциденте третьей стороны, описывающий этот точный шаблон, говорит прямо: “Логика повторных попыток, основанная на HTTP-статусе, никогда не срабатывает, потому что статус уже был 200. Результат — молчаливо обрезанный ответ” — и рекомендует парсить тело потока на предмет событий ошибок, а не полагаться только на код статуса.

Документация OpenRouter описывает ту же структурную проблему для всех провайдеров, к которым он маршрутизирует, а не только для одного поставщика. Как только первый токен записан, “HTTP-статус 200 OK и заголовки уже зафиксированы — их нельзя изменить”, поэтому сбой провайдера “должен прибыть в канале как SSE-событие”. Задокументированные причины этого класса ошибок:

  • Отключение провайдера — вышестоящее соединение обрывается после частичного вывода (проблема сети, сбой провайдера, таймаут банансировщика нагрузок)
  • Таймаут провайдера — модель перестает отвечать в середине генерации и срок чтения истекает
  • Превышение лимита токенов во время генерации — модель достигает max_tokens или заполняется контекстное окно во время вывода
  • Фильтр вывода контента — система модерации помечает сгенерированный текст после того, как часть его уже была отправлена в потоке
  • Перегрузка провайдера — вышестоящий сервер возвращает ошибку лимита запросов или емкости после начала стриминга

Полезная нагрузка ошибки в середине потока от OpenRouter содержит ошибку внутри обычного на вид чанка, с finish_reason, который сообщает, что поток завершился ненормально:

{"id":"gen-abc123","object":"chat.completion.chunk","created":1234567890,"model":"openai/gpt-4o","provider":"OpenAI","error":{"code":429,"message":"Rate limit exceeded","metadata":{"error_type":"rate_limit_exceeded"}},"choices":[{"index":0,"delta":{"content":""},"finish_reason":"error"}]}

Как подтвердить, что это ваша причина: запишите в лог сырой SSE-поток (а не только распарсенный текст) для неудачного запроса. Если вы видите событие error или чанк с полем error верхнего уровня где-либо в потоке перед терминальным событием, это оно.

Исправление: рассматривайте finish_reason: "error" и полезные нагрузки event: error как повторяющиеся сбои, а не как успешные завершения с пустым содержанием. Повторяйте с задержкой в зависимости от конкретного типа ошибки внутри полезной нагрузки, а не от HTTP-статуса, так как HTTP-статус уже будет 200.

Причина 2: Таймаут чтения или зависание потока

Зависание отличается от жесткой ошибки: соедиение остается открытым, но новые токены не постуают длительное время. Большинство HTTP-клиенто смешиваю две концпции таймаута — общий таймаут запроса и таймаут на каждое чтение (ожидание). Длинный поток может легитимно превысить короткий общий таймаут при большом ответе, в то время как действительно зависший поток может находиться в пределах общего окна таймаута, если нет проверки ожидания чтения.

OpenAI Python SDK устанавливает общий таймаут запроса по умолчанию 10 минут: “По умолчанию запросы таймаутятся через 10 минут. Вы можете настроить это с помощью опции timeout, которая принимает число с плавающей точкой или объект httpx.Timeout… При таймауте выбрасывается APITimeoutError.” Он также автоматически повторяет некоторые сбои: “Ошибки соединения (например, из-за проблем с сетью), 408 Request Timeout, 409 Conflict, 429 Rate Limit и >=500 Internal ошибки повторяются по умолчанию” дважды, настраивается через max_retries.

Подтверждение: измерьте промежуток между последним полученным токеном и ошибкой. Фиксированная длительность, соответствующая настроенному значению таймаута, указывает на таймаут, а не на сбой на стороне провайдера.

Исправление: установите два таймаута, а не один — таймаут подключения/общий для жизненного цикла запроса и отдельный таймаут ожидания чтения, который срабатывает, если чанк не поступает в течение N секунд. Это позволит отличить “модель медленная” от “поток умер”. 15-30 секунд — разумный таймаут ожидания чтения для чат-завершений; увеличьте его, если у вашей модели есть длинные “молчаливые” фазы размышления перед первым токеном.

Причина 3: Превышение лимита запросов (429) в середине потока

Лимиты запросов обычно отклоняют запрос до его начала. Но некоторые шлюзы применяют лимиты на каждый токен или на окно, поэтому запрос может начать стриминг и быть оборван, как только пересечет бюджет в середине генерации — та же форма ошибки в канале, что и в Причине 1, с кодом 429, приходящим внутри потока, а не в качестве начального статуса ответа.

Подтверждение: проверьте, группируются ли сбои во время всплесков трафика или при достижении определенного порога запросов в минуту. Заголовок Retry-After или эквивалентное поле в полезной нагрузке ошибки подтверждают это.

Исправление: уважайте Retry-After, когда он присутствует, уменьшите конкурентность перед повторной попыткой и используйте экспоненциальную задержку. Частые попадания — сигнал уменьшить количество параллельных потоков или обновить уровень учетной записи, а не агрессивно повторять попытки.

Причина 4: Битый или неожиданный чанк, ломающий парсер

Не каждая “ошибка стриминга” является виной модели. Некоторые полностью на стороне клиента: ваш SSE-парсер предполагает, что каждый чанк имеет фиксированную форму, а полезная нагрузка другой формы — включая легитимную полезную нагрузку ошибки от провайдера — нарушает это предположение и вызывает непохожее на себя исключение.

Задокументированный случай: обработчик стриминга ollama_chat в LiteLLM ожидал, что каждый чанк содержит поле message. Когда Ollama вместо этого вернул структурированную ошибку, например {"error": "error parsing tool call: ..."}, обработчик сделал небезопасный поиск chunk["message"], вызвав KeyError: 'message'. Более широкий обработчик исключений перехватил его и перевыбросил как APIConnectionError — звучащая как сетевая ошибка на самом деле была сбоем парсинга JSON внутри вывода вызова инструмента модели. Отчет об ошибке прямо указывает на последствия: инженеры, отлаживавшие это, “логировали reason=timeout / LLM request timed out для того, что на самом деле было некорректным JSON вызова инструмента”, что привело к “двум дням неправильной диагностики” в поисках сетевых и инфраструктурных причин, которые не были проблемой.

Общий шаблон: провайдер отправляет ошибку в форме, которую ваш код парсинга не ожидает, возникает низкоуровневое исключение (KeyError, TypeError, ошибка декодирования JSON), и блок except, перехватывающий все, оборачивает его в общую ошибку соединения или стриминга, стирающую реальную причину.

Подтверждение: запишите в лог сырой чанк до того, как запустится ваш код обертки исключений. Если в сырой полезной нагрузке есть поле error с реальным сообщением, ваш клиент отбросил полезную информацию на пути к общей ошибке, которую вы видите.

Исправление: проверяйте наличие ключа error перед доступом к ожидаемым полям, таким как message или delta, и обрабатывайте его явно. Сохраняйте сообщение об ошибке от вышестоящей системы и код статуса при повторном выбрасывании, вместо того чтобы сворачивать все сбои в один общий тип исключения.

Причина 5: Разрыв сетевого соединения

Иногда причина действительно в сети: проски или балансировщик нагрузок закрывает донгоживущее соединение после периода бездействия, или сеть клиента меняется в середине запроса. Это напоминает Причину 2, но исправление отличается — самого соединения ужу нет, а не просто оно бездейтвует в пределах вашего собственного окна таймаута.

Подтверждение: ищите ошибки типа сброса соединения (ECONNRESET, Broken pipe, Connection reset by peer) вмсто иключения таймаута на уровне приложения и проверьте, коррдилируются ли сбои с извесным таймаутом бездйствия промежуточного узла (многие балансировщики нагрузки по умолчанию имеют 60 секунд бездействия).**

**Исправление: если вы контролируете прокси или банансировщик нагрузки, уеличте его таймаут ожидания бездействия выше ожидаемой максимальной длительности потока или добавьте keep-alive пинги. Если разрыв вне вашего контроля, рассматривайте его как повторяемый — большинство стриминг-API не поддерживают возобновление частичного потока, поэтому повторная попытка означает начало генерации заново.

Причина 6: Сбой авторизации или квоты, проявлющийся в середине потока

Некоторые шлюзы проверяют авторизацию и биллинг лениво — соединение открывается, затем проверка авторизации или баланса происходит во время генерации первого чанка, и сбой там сообщается как ошибка стриминга, а не как чистый 401/402 в момент запроса.

Подтверждение: проверьте, возникает ли ошибка на каждом запросе с данного ключа, а не время от времени, и началась ли она сразу после смены ключа, биллингового события или изменения окружения (dev-ключ против продакшн-базового URL или наоборот).

Исправление: проверете формат ключа (Authorization: Bearer <key>, а не сырой ключ), подтвердите, что ключ активен, и проверьте баланс акаунта отдельо от отладки на урвне запроса. Каждый запрос, терпящий неудачу одинаково независимо от промпта или модели, указывет на это, а не на предыдущие пять причин.

Шаблон повторныз попыток и задержки, который обрабатывает все шесть причин

Одна единственная стратегия повторных попыток может покрыть Причины 1 через 5, если она проверяет тело потока, а не только HTTP-статус:

import time
import openai

def stream_with_recovery(client, **kwargs):
    max_attempts = 3
    for attempt in range(max_attempts):
        try:
            collected = ""
            stream = client.chat.completions.create(stream=True, **kwargs)
            for chunk in stream:
                choice = chunk.choices[0] if chunk.choices else None
                if choice and getattr(choice, "finish_reason", None) == "error":
                    raise RuntimeError(f"mid-stream error: {chunk}")
                if choice and choice.delta.content:
                    collected += choice.delta.content
            return collected
        except (openai.APITimeoutError, openai.APIConnectionError, openai.RateLimitError) as e:
            if attempt == max_attempts - 1:
                raise
            time.sleep(2 ** attempt)
    return collected

Этот пример использует совместимую с OpenAI форму клиента, которая также работает с конечной точкой чат-завершений, совместимой с OpenAI от Novita AI путем устновки base_url="https://api.novita.ai/openai". Помимо вышеуказанного кода важны три вещи:

  • Проверяйте содержимое чанка на наличие ошибки в канале, прежде чем предполагать нормальное завершение, в соответствии с Причиной 1.
  • Используйте экспоненциальную задержку (2 ** attempt, с ограничением), а не немедленные повторные попытки, особенно для лимита запросов и ошибок перегрузки.
  • Записывайте в лог сырое сообщение об ошибке от вышестоящей системы до того, как обернете его в свой тип исключения, чтобы будущая отладка не повторяла двухдневную неверную диагностику из Причины 4.

Если конкретные провайдер или модель выходят из строя чаще других, маршрутизация этого класса запросов на другую модель является допустимым смягчением, которое стоит иметь на месте до того, как оно понадобится — см. управление мулти-провайдерным LLM-сервисом для достижения целевого времени безотказной работы о том, как определить такую политику отката, вместо импровизации во время инцидента.

Заключение

Диагностика этой ошибки — процесс исключения, а не угадывания: используйте чеклист в начале, чтобы сопоставить свой симптом с одной из шести причин, затем подтвердите его специфическим сигналом, на который указывает каждый раздел — встроенное событие error, длительность зависания, соответствующая вашему таймауту, коррелирующий с всплеском 429, сырая полезная нагрузка, на которой подавился ваш парсер, строка сброса соединения или сбой, повторяющийся на каждом запросе с одного ключа. Причины 1-3 наиболее распространены, и Причины 1, 3 и 5 имеют одно и то же основное исправление: перестаньте доверять HTTP-статусу, как только поток начался, и вместо этого повторяйте попытку с задержкой на основе того, что сообщает сам поток.

Однократное исправление логики повторных попыток, используя шаблон выше, одновременно закрывает первые пять причин. Причина 6 является исключением — никакое количество повторных попыток не исправит недействительный ключ или пустой баланс, поэтому рассматривайте идентичные сбои на каждом запросе как проверку конфигурации, а не сетевую проблему.

Часто задаваемые вопросы

Почему мой запрос к LLM API иногда работает, а иногда выдает ошибку стриминга?

Периодические сбои указывают на причину в середине потока, а не на проблему конфигурации — ошибки конфигурации, такие как неверный ключ или неправильная конечная точка, происходят каждый раз. Проверьте в первую очередь перегрузку провайдера и лимиты запросов, так как оба зависят от трафика.

Ошибка стриаминга — это то же самое, что и таймаут?

Не всегда. Таймаут означает, что в течение заданного окна не поступило ни одного ответа. Ошибка в середине потока означает, что ответ начался, пришло несколько токенов, а затем внутри потока было отправлено событие сбоя. Обработка ошибок должна различать эти два случая, так как исправления различаются.

Почему мое сообщение об ошибке глорит “ошибка соединения”, когда реальная проблема была в другом?

Многие клиентские библиотеки оборачивают неожиданные исключения в общий тип ошибки соединения, когда чанк не соответствует форме, ожидаемой парсером — см. Причину 4 для задокументированного случая, когда ошибка парсинга JSON сообщалась как таймаут соединения в течение двух дней до обнаружения реальной причины.

Могу ли я возобновить поток после ошибки в середине потока вместо того, чтобы начинать заново?

Большинство совместимых с OpenAI и Anthropic стриминг-API не поддерживают возобновление частичного потока с точки сбоя. Повторите полный запрос, отбросив частичный вывод, а не добавляя к нему, чтобы избежать дублирования контента.

Всегда ли следует использовать стринг для вызовов LLM API?

Стриминг позволяет избежать таймаута одного большого запроса, но вводит режим сбоя частичного вывода, описанный в этом руководстве. Для коротких ответов, где не нужно показывать частичный вывод, запрос без стриминга проще в обработке ошибок.

Что означает finish_reason: error в стриминговом ответе?

Это терминальный сигнал, который некоторые шлюзы прикрепляют к последнему чанку потока, частично завершившегося сбоем, в отличие от нормальных значений stop или length. Рассматривайте его как неудачную генерацию, даже если HTTP-статус запроса был 200.

Рекомендуемые статьи