Все статьи

Стриминг LLM API у разных провайдеров: SSE-форматы, буферизация токенов и что ломается при переключении

Практическое руководство по стримингу LLM API через OpenAI, Anthropic, DashScope и DeepSeek: форматы SSE-событий, буферизация токенов, обработка ошибок в потоке, стриминг tool-вызовов и нормализация на уровне gateway.

· TheRouter

Стриминг LLM API у разных провайдеров: SSE-форматы, буферизация токенов и что ломается при переключении

Все крупные LLM API поддерживают стриминг через Server-Sent Events (SSE). Устанавливаете stream: true — и токены приходят инкрементально, а не одним финальным ответом. В теории просто. На практике OpenAI, Anthropic, DashScope и DeepSeek реализуют SSE достаточно по-разному, чтобы переключение провайдера — или маршрутизация через gateway — сломало ваш стриминг-клиент неочевидным образом.

Это руководство документирует конкретные различия. Мы протестировали стриминг-вывод каждого провайдера и зафиксировали, как выглядит проводной протокол: типы событий, формы чанков, сигналы завершения, поведение при ошибках в потоке и сериализация tool-вызовов. Если вы используете мульти-провайдерную архитектуру или рассматриваете её, это справочник, который мы хотели бы иметь, когда строили собственный слой нормализации стриминга.

OpenAI-совместимость означает, что провайдер предоставляет endpoint chat-completions, чей контракт запроса и ответа достаточно близок к API OpenAI, чтобы немодифицированный вызов OpenAI SDK работал после замены трёх значений: API key, base URL, название модели. Минимальная поверхность на практике —POST /v1/chat/completions с messages, model и потоковым ответом в форме OpenAI.

Как SSE-стриминг выглядит на практике

Все четыре провайдера возвращают Content-Type: text/event-stream и отправляют чанки, разделённые переносами строк. На этом сходство заканчивается.

Формат OpenAI Chat Completions

OpenAI Chat Completions стриминг отправляет строки data: без поля event:. Каждый чанк — JSON-объект с object: "chat.completion.chunk" и массивом choices, содержащим объект delta. Delta содержит role, content, tool_calls или refusal — только инкрементальную часть, не полное сообщение. Источник: Руководство OpenAI по стримингу, получено 2026-07-31.

data: {"id":"chatcmpl-abc","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}

data: {"id":"chatcmpl-abc","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Привет"},"finish_reason":null}]}

data: {"id":"chatcmpl-abc","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}

data: [DONE]

Ключевые детали:

  • Сигнал завершения: строка data: [DONE] означает конец потока. Это не валидный JSON.
  • Usage: включается только при передаче stream_options: {"include_usage": true}. Чанк с usage имеет пустой массив choices.
  • Rate-limit заголовки: отправляются в HTTP-заголовках ответа (x-ratelimit-limit-requests, x-ratelimit-remaining-tokens и т.д.), а не в потоке. Источник: Исследование стриминга LLM API Саймона Уиллисона, получено 2026-07-31.

Более новый Responses API от OpenAI использует типизированные семантические события (response.created, response.output_text.delta, response.completed) вместо формата чанков Chat Completions, но Chat Completions остаётся форматом, который эмулируют OpenAI-совместимые провайдеры.

Формат Anthropic Messages

Anthropic использует в SSE оба поля — event: и data: — ключевое отличие от OpenAI. Поток следует жизненному циклу: message_startcontent_block_startcontent_block_delta (повторяется) → content_block_stopmessage_deltamessage_stop. Источник: Документация Anthropic по стримингу, получено 2026-07-31.

event: message_start
data: {"type":"message_start","message":{"id":"msg_01X","role":"assistant","content":[],"usage":{"input_tokens":25,"output_tokens":1}}}

event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}

event: ping
data: {"type":"ping"}

event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Привет"}}

event: content_block_stop
data: {"type":"content_block_stop","index":0}

event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":15}}

event: message_stop
data: {"type":"message_stop"}

Ключевые детали:

  • Нет [DONE]: Anthropic завершает поток событием event: message_stop. Нет сигнала data: [DONE].
  • Ping-события: Anthropic отправляет event: ping для поддержания соединения. Клиент, ожидающий только строки data:, сломается.
  • Usage в двух местах: входные токены — в message_start; выходные — в message_delta.
  • Индексация блоков контента: поле index поддерживает несколько блоков контента (text, tool_use, thinking) в одном ответе. OpenAI использует choices[0].index для похожей, но структурно другой цели.
  • Rate-limit заголовки: используют префикс anthropic-ratelimit-*, а не x-ratelimit-*.

DashScope (OpenAI-совместимый режим)

DashScope предоставляет OpenAI-совместимый endpoint по адресу https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions. При установке stream: true формат ответа повторяет чанки OpenAI Chat Completions: строки data: с объектами chat.completion.chunk, завершение — data: [DONE]. Источник: Alibaba Cloud Model Studio — OpenAI-совместимость, получено 2026-07-31.

Различия тонкие:

  • Заголовок аутентификации: OpenAI-совместимый режим DashScope использует Authorization: Bearer <api-key>, идентично OpenAI.
  • Токены мышления/рассуждения: при вызове моделей Qwen с включённым мышлением (например, qwen3.7-max с enable_thinking: true) токены рассуждения стримятся отдельно. DashScope оборачивает их в ту же структуру delta, но с полем reasoning_content рядом с content. Этого поля нет в спецификации OpenAI.
  • Размер чанков: в наших тестах DashScope отправляет чуть больше токенов на SSE-чанк, чем OpenAI — обычно 2–4 токена на чанк против 1 токена на чанк у OpenAI для текстовых моделей. Это означает меньше HTTP-фреймов для того же объёма вывода, что может влиять на восприятие time-to-first-token в UI.

Формат DeepSeek

API DeepSeek явно совместим с OpenAI, а также поддерживает endpoint в формате Anthropic по адресу https://api.deepseek.com/anthropic. Для OpenAI-совместимого пути стриминг следует тому же паттерну data: + chat.completion.chunk + data: [DONE]. Источник: Документация DeepSeek API, получено 2026-07-31.

Замеченные особенности:

  • Контент рассуждений: как и DashScope, DeepSeek стримит токены рассуждений через поле reasoning_content в delta при включённом режиме мышления. Это то же расширение, что и у DashScope, его нет в спецификации OpenAI.
  • Поддержка двух форматов: DeepSeek — единственный first-party провайдер, нативно поддерживающий оба формата SSE (OpenAI и Anthropic) из одного API. Путь /anthropic возвращает события жизненного цикла в стиле Anthropic.
  • Индикаторы cache-попаданий: DeepSeek может включать cache_creation_input_tokens и cache_read_input_tokens в чанк usage, аналогично полям prompt caching у Anthropic.

Различия буферизации токенов

Провайдеры не сбрасывают токены с одинаковой скоростью. Это важно для пользовательского опыта — UI чата, рендерящий посимвольно, выглядит отзывчивым, но UI, получающий 3 секунды тишины, а потом 50 токенов разом, кажется зависшим.

ПровайдерТипично токенов на чанкПоведение TTFTПримечания
OpenAI1 токенБыстрый TTFT, стабильная подачаСамый консистентный потокенный стриминг
Anthropic1–3 токенаБыстрый TTFT, иногда пакетамиPing-события заполняют тишину
DashScope2–4 токенаУмеренный TTFT, крупные пакетыМодели Qwen группируют агрессивнее
DeepSeek1–2 токенаВарьируется, зависит от моделиReasoning-модели: долгий TTFT, затем быстрый вывод

Для reasoning-моделей у всех провайдеров ожидайте длительную паузу перед первым видимым токеном контента — модель сначала генерирует цепочку рассуждений. Некоторые провайдеры стримят токены рассуждений (DashScope, DeepSeek через reasoning_content); другие скрывают их до начала финального ответа.

Обработка ошибок в потоке

Когда что-то идёт не так после установления SSE-соединения и начала потока токенов, каждый провайдер обрабатывает это по-разному. Это самая сложная часть мульти-провайдерного стриминга — ваш HTTP-код ответа уже 200.

OpenAI

OpenAI отправляет финальный чанк с полем error или резко закрывает соединение. В SSE-потоке Chat Completions нет стандартного типа события ошибки. Если модель срабатывает на фильтр контента в процессе генерации, последний delta может содержать finish_reason: "content_filter" вместо "stop". Если сервер падает, TCP-соединение обрывается без data: [DONE] — клиент должен обрабатывать неполные потоки.

Anthropic

Anthropic может отправить event: error с payload data: {"type":"error","error":{"type":"overloaded_error","message":"..."}}. Ошибка приходит как стандартное SSE-событие, поэтому клиенты, слушающие типы event:, могут чисто её перехватить. Затем соединение закрывается. Источник: Документация Anthropic по ошибкам API, получено 2026-07-31.

DashScope

OpenAI-совместимый стриминг DashScope следует паттерну OpenAI: ошибки в потоке редки, проявляются как обрыв соединения или повреждённые чанки. Нативный API DashScope имеет более структурированные события ошибок, но совместимый режим жертвует этим ради совместимости с OpenAI на проводном уровне.

DeepSeek

OpenAI-совместимый путь DeepSeek следует поведению ошибок OpenAI. Anthropic-совместимый путь следует паттерну событий ошибок Anthropic.

Правило для production: всегда реализуйте timeout и детектор неполного потока. Если вы получаете чанки, но не видите сигнал завершения (data: [DONE] или event: message_stop) в течение timeout, считайте ответ неудачным и логируйте частичный вывод для отладки.

Стриминг tool-вызовов: самая сложная задача нормализации

Стриминг tool-вызовов означает получение имени функции и JSON-аргументов посимвольно. Это уже непросто с одним провайдером. Между провайдерами различия множатся.

OpenAI

Tool-вызовы стримятся через delta.tool_calls[i].function.name (отправляется один раз) и delta.tool_calls[i].function.arguments (инкрементальные строковые фрагменты). Необходимо конкатенировать фрагменты arguments и парсить полный JSON только после finish_reason: "tool_calls". Несколько tool-вызовов могут чередоваться по индексу.

{"delta":{"tool_calls":[{"index":0,"id":"call_abc","type":"function","function":{"name":"get_weather","arguments":""}}]}}
{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"lo"}}]}}
{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"cation\":"}}]}}
{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"Paris\"}"}}]}}

Anthropic

Anthropic стримит tool-вызовы как блок контента с type: "tool_use". Событие content_block_start несёт {"type":"tool_use","id":"toolu_abc","name":"get_weather","input":{}}, затем события content_block_delta несут фрагменты {"type":"input_json_delta","partial_json":"..."}. Паттерн конкатенации JSON тот же, но оболочка полностью отличается от OpenAI.

DashScope и DeepSeek

Оба следуют формату стриминга tool-вызовов OpenAI при использовании OpenAI-совместимого endpoint. Anthropic-совместимый endpoint DeepSeek следует формату стриминга tool-use от Anthropic. DashScope не предоставляет Anthropic-совместимый endpoint.

Задача gateway: стриминг-gateway, принимающий любой upstream-формат и выдающий консистентный downstream-формат, должен поддерживать состояние для каждого соединения: какие блоки контента открыты, какие аргументы tool-вызовов собираются, какова семантика завершения исходного провайдера. Проект LLM-Rosetta (Аргоннская национальная лаборатория, апрель 2026) формализовал это как схему из 10 типов потоковых событий с управлением состоянием. Источник: Статья LLM-Rosetta, получено 2026-07-31.

Content-Type и жизненный цикл соединения

ПровайдерЗаголовок Content-TypeKeep-aliveСигнал закрытия
OpenAItext/event-stream; charset=utf-8Управляется серверомdata: [DONE]
Anthropictext/event-stream; charset=utf-8Управляется сервером + pingevent: message_stop
DashScopetext/event-stream; charset=utf-8Управляется серверомdata: [DONE]
DeepSeek (OpenAI)text/event-stream; charset=utf-8Управляется серверомdata: [DONE]
DeepSeek (Anthropic)text/event-stream; charset=utf-8Управляется серверомevent: message_stop

Все провайдеры используют chunked transfer encoding HTTP/1.1 или data frames HTTP/2. SSE-соединение — это долгоживущий HTTP-ответ, а не WebSocket. Responses API от OpenAI также предлагает WebSocket-режим для постоянных соединений, но это отдельный транспорт.

Браузерный EventSource API не может потреблять ни один из этих endpoint, потому что EventSource поддерживает только GET-запросы; LLM API требуют POST. Используйте fetch() с потоковым body reader или библиотеку вроде @microsoft/fetch-event-source.

Gateway-стриминг: нормализация разнородных форматов

Когда TheRouter маршрутизирует стриминг-запрос через настроенных провайдеров, gateway должен нормализовать upstream SSE в консистентный downstream-контракт. Мы маршрутизируем OpenAI-совместимые запросы через настроенных провайдеров и поддерживаем маршрутизацию и fallback провайдеров/моделей там, где это поддерживается в production.

Задача нормализации имеет три уровня:

  1. Оболочка событий: преобразование жизненного цикла Anthropic event: + data: в чанки OpenAI-стиля только с data:, или наоборот. Это включает маппинг message_start → первый чанк с role, content_block_deltadelta.content, message_delta → финальный чанк с finish_reason.
  2. Поля-расширения: удалить или сохранить провайдер-специфичные поля вроде reasoning_content, cache_read_input_tokens или расположение usage у Anthropic. Downstream-клиенты не должны ломаться на неожиданных полях, но и не должны полагаться на поля, которые отправляет только один upstream-провайдер.
  3. Семантика завершения: обеспечить, чтобы downstream всегда получал ожидаемый сигнал завершения вне зависимости от upstream. Если upstream — Anthropic, а downstream ожидает формат OpenAI, gateway должен отправить data: [DONE] после обработки event: message_stop.

Для более широкого обзора сравнения провайдеров по ценам, моделям и API см. наше сравнение провайдеров LLM API и руководство по OpenAI-совместимым API-провайдерам.

Пример кода: минимальный кросс-провайдерный стриминг-клиент

Этот Python-клиент обрабатывает и OpenAI-стиль, и Anthropic-стиль SSE-потоков. Он намеренно минимален — production-реализация требует логики повторов, обработки timeout и надлежащего backpressure.

import httpx
import json

def stream_openai_compatible(base_url: str, api_key: str, model: str, messages: list):
    """Стриминг от любого OpenAI-совместимого endpoint (OpenAI, DashScope, DeepSeek)."""
    with httpx.stream(
        "POST",
        f"{base_url}/chat/completions",
        headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
        json={"model": model, "messages": messages, "stream": True,
              "stream_options": {"include_usage": True}},
        timeout=60.0,
    ) as response:
        buffer = ""
        for line in response.iter_lines():
            if not line or line.startswith(":"):
                continue
            if line == "data: [DONE]":
                break
            if line.startswith("data: "):
                chunk = json.loads(line[6:])
                delta = chunk.get("choices", [{}])[0].get("delta", {})
                if content := delta.get("content"):
                    yield content
                if reasoning := delta.get("reasoning_content"):
                    yield f"[thinking] {reasoning}"

def stream_anthropic(api_key: str, model: str, messages: list):
    """Стриминг от нативного API Anthropic."""
    with httpx.stream(
        "POST",
        "https://api.anthropic.com/v1/messages",
        headers={
            "x-api-key": api_key,
            "anthropic-version": "2023-06-01",
            "Content-Type": "application/json",
        },
        json={"model": model, "messages": messages, "stream": True, "max_tokens": 4096},
        timeout=60.0,
    ) as response:
        for line in response.iter_lines():
            if not line:
                continue
            if line.startswith("event: "):
                event_type = line[7:]
                if event_type == "message_stop":
                    break
                continue
            if line.startswith("data: "):
                data = json.loads(line[6:])
                if data.get("type") == "content_block_delta":
                    delta = data.get("delta", {})
                    if delta.get("type") == "text_delta":
                        yield delta.get("text", "")

О том, как работает fallback-маршрутизация, когда стриминг-провайдер отказывает посреди запроса, читайте в нашем руководстве по fallback-маршрутизации LLM API.

Production-подводные камни

Буферизация proxy и CDN

Reverse proxy (nginx, Cloudflare, AWS ALB) могут буферизовать SSE-ответы и доставлять их крупными пакетами вместо потокенной передачи. Это уничтожает стриминг-опыт. Решения:

  • nginx: proxy_buffering off; и заголовок ответа X-Accel-Buffering: no
  • Cloudflare: заголовок cf-no-transform или отключение буферизации ответов в зоне
  • AWS ALB: ALB не поддерживает SSE нативно во всех конфигурациях — рассмотрите NLB или прямое подключение

Настройка timeout

SSE-соединения долгоживущие. Стандартные timeout HTTP-клиентов (30 с) убьют стриминг-запросы с длинными выводами. Разделяйте read timeout (на чанк) и connection timeout:

# httpx: общий timeout vs read timeout
timeout = httpx.Timeout(connect=10.0, read=120.0, write=10.0, pool=10.0)

Backpressure

Если ваш клиент обрабатывает токены медленнее, чем сервер их отправляет, TCP receive buffer заполняется и сервер в конечном счёте блокируется на send. Для большинства LLM API это не практическая проблема — генерация токенов медленнее сетевой передачи — но batch-стриминг или кэшированные ответы могут перегрузить медленных клиентов.

Keep-alive и переподключение

SSE-соединения могут обрываться незаметно из-за сетевых проблем. Ping-события Anthropic помогают быстро обнаружить мёртвое соединение. Для OpenAI-стиля потоков без ping реализуйте read timeout: если данные не приходят в течение N секунд и поток не завершён, переподключайтесь. Учтите, что LLM API обычно не поддерживают возобновление потока с точки обрыва — обрыв соединения означает новый запрос.

Чек-лист: подключение нового стриминг-endpoint провайдера

При интеграции стриминг-API нового LLM-провайдера проверьте каждый пункт:

  1. Формат событий: провайдер использует только data: (стиль OpenAI) или event: + data: (стиль Anthropic)?
  2. Сигнал завершения: data: [DONE], event: message_stop или что-то другое?
  3. Путь извлечения контента: где text delta? choices[0].delta.content? delta.text_delta.text? Что-то провайдер-специфичное?
  4. Стриминг tool-вызовов: следует паттерну OpenAI tool_calls[i].function.arguments или Anthropic input_json_delta?
  5. Отчёт об usage: usage включён в поток? Только по запросу? В первом чанке, последнем или обоих?
  6. Поля-расширения: провайдер добавляет нестандартные поля вроде reasoning_content, cache_read_input_tokens или custom metadata?
  7. Сигнализация ошибок: как провайдер сигнализирует об ошибках в потоке? Структурированное событие? Обрыв соединения? Повреждённый чанк?
  8. Механизм keep-alive: провайдер отправляет ping-события или соединение молчит между генерациями токенов?
  9. Размер чанков: провайдер отправляет отдельные токены или пакетами? Это влияет на воспринимаемую задержку.

О различиях ошибок и кодов статуса у разных провайдеров в не-стриминг-контексте читайте в наших сравнении rate limit и сравнении провайдеров.

FAQ

Можно ли использовать браузерный EventSource API для потребления стриминг-endpoint LLM?

Нет. EventSource API поддерживает только GET-запросы. Все стриминг API для LLM требуют POST. Используйте fetch() с ReadableStream reader или библиотеку вроде @microsoft/fetch-event-source. Подробности интеграции провайдеров — в нашем руководстве OpenAI-совместимые API-провайдеры.

Все OpenAI-совместимые провайдеры стримят идентично OpenAI?

По большей части — да, для базового пути текстового стриминга. Формат строк data: и завершение data: [DONE] согласованы. Различия проявляются в полях-расширениях (reasoning_content у DashScope и DeepSeek), поведении отчётов об usage и размерах чанков. Стриминг tool-вызовов — область с наиболее тонкими различиями.

Что случится, если переключиться с OpenAI на Anthropic посреди проекта?

Ваш стриминг-клиент сломается. Anthropic использует полностью иной жизненный цикл событий (message_start / content_block_delta / message_stop) с именованными типами event:. Вам нужна либо новая реализация клиента, либо gateway, нормализующий формат Anthropic в OpenAI-совместимые чанки.

Как обрабатывать reasoning-токены в стриминге?

DashScope и DeepSeek стримят reasoning-токены через поле reasoning_content в объекте delta. Anthropic стримит extended thinking как отдельный блок контента с type: "thinking". OpenAI не стримит reasoning-токены в Chat Completions — они потребляются внутренне. Ваш клиент должен корректно обрабатывать поле reasoning_content (игнорировать, если неожиданное; отображать, если UI это поддерживает).

Существует ли стандарт кросс-провайдерной нормализации стриминга?

Проект LLM-Rosetta (апрель 2026) предложил hub-and-spoke IR с 10 типами потоковых событий как формальный подход. На практике большинство gateway и SDK (LiteLLM, Portkey, TheRouter) реализуют собственные слои нормализации. Формат чанков OpenAI Chat Completions — де-факто стандарт, на который ориентируются совместимые провайдеры.

Поддержка