Потоковая передача ответа: когда нужен stream: true

stream: true не для каждого случая. Разбираем, как читать поток событий, где он реально нужен и какие ошибки чаще всего в нём прячутся.

4 мин · · Обновлено · разработка · стриминг

stream: true добавляют в запрос почти рефлекторно — «модели же стримят токены, значит, надо стримить». Но потоковый ответ — это дополнительная сложность на стороне клиента: разбор SSE-событий, накопление частичных чанков, обработка обрыва посреди потока. Если фронтенд не показывает текст по мере генерации, а просто ждёт полный ответ, стриминг только добавляет код без выгоды.

Что такое стриминг на практике

Без stream: true сервер отвечает одним JSON-объектом после того, как модель полностью сгенерировала ответ. С stream: true сервер отправляет ответ по частям через Server-Sent Events (SSE) — по мере того как модель генерирует токены, а не после завершения.

Обычный запрос:

bash
curl https://api.zukko.pro/v1/chat/completions \
  -H "Authorization: Bearer fetch_ваш_ключ" \
  -H "Content-Type: application/json" \
  -d '{"model":"claude-sonnet-5","messages":[{"role":"user","content":"Напиши короткое стихотворение"}]}'

Потоковый запрос — то же тело плюс "stream": true:

bash
curl https://api.zukko.pro/v1/chat/completions \
  -H "Authorization: Bearer fetch_ваш_ключ" \
  -H "Content-Type: application/json" \
  -d '{"model":"claude-sonnet-5","stream":true,"messages":[{"role":"user","content":"Напиши короткое стихотворение"}]}' \
  --no-buffer

Флаг --no-buffer в curl важен для наблюдения за потоком в реальном времени — иначе терминал может буферизовать вывод и показать всё разом.

Формат событий: OpenAI-стиль

В /v1/chat/completions каждое событие — это строка data: {...}, заканчивающаяся data: [DONE]:

data: {"choices":[{"delta":{"content":"Ветер"}}]} data: {"choices":[{"delta":{"content":" гонит"}}]} data: {"choices":[{"delta":{"content":" листья"}}]} data: [DONE]

Клиент должен накапливать delta.content из каждого события в единый текст:

bash
import requests

resp = requests.post(
    "https://api.zukko.pro/v1/chat/completions",
    headers={"Authorization": "Bearer fetch_ваш_ключ"},
    json={"model": "claude-sonnet-5", "stream": True,
          "messages": [{"role": "user", "content": "Напиши короткое стихотворение"}]},
    stream=True,
)

full_text = ""
for line in resp.iter_lines():
    if not line or not line.startswith(b"data: "):
        continue
    payload = line[len(b"data: "):]
    if payload == b"[DONE]":
        break
    import json
    chunk = json.loads(payload)
    delta = chunk["choices"][0]["delta"].get("content", "")
    full_text += delta
    print(delta, end="", flush=True)

Формат событий: Anthropic-стиль

В /v1/messages события структурированы иначе — с типами message_start, content_block_delta, message_stop и другими:

event: message_start data: {"type":"message_start","message":{...}} event: content_block_delta data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Ветер"}} event: content_block_delta data: {"type":"content_block_delta","delta":{"type":"text_delta","text":" гонит"}} event: message_stop data: {"type":"message_stop"}

Обязательный заголовок anthropic-version: 2023-06-01 нужен и в потоковом режиме — без него запрос вернёт ошибку ещё до начала стрима:

bash
curl https://api.zukko.pro/v1/messages \
  -H "Authorization: Bearer fetch_ваш_ключ" \
  -H "anthropic-version: 2023-06-01" \
  -H "Content-Type: application/json" \
  -d '{"model":"claude-sonnet-5","max_tokens":300,"stream":true,"messages":[{"role":"user","content":"Напиши короткое стихотворение"}]}' \
  --no-buffer

Код на стороне клиента при этом должен фильтровать по type внутри data, а не только по наличию content_block_delta — интересен именно text_delta, остальные типы событий несут служебную информацию (начало блока, статистику использования токенов, окончание сообщения).

Когда стриминг действительно нужен

Чат-интерфейс с видимым набором текста. Если пользователь видит, как ответ печатается по мере генерации — классический UX чат-ботов — стриминг обязателен, иначе пользователь будет смотреть на пустой экран несколько секунд при длинном ответе.

Длинные ответы с большим max_tokens. Чем длиннее ожидаемый ответ, тем больше времени модель тратит на полную генерацию. Без стриминга клиент просто ждёт HTTP-ответ целиком — при max_tokens в несколько тысяч это может быть заметная пауза без обратной связи.

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

Когда стриминг не нужен

Серверная обработка без UI. Если ответ модели используется программно — парсится, кладётся в базу, передаётся дальше в пайплайн — пользователь не видит процесс генерации, и стриминг только усложняет код парсинга без всякой пользы.

Короткие ответы. Классификация в одно слово, оценка от 1 до 10, структурированный JSON на пару полей — генерация занимает доли секунды, разница между стримингом и обычным ответом не заметна.

Строгий JSON-парсинг. Если ответ должен быть валидным JSON, который парсится целиком после получения, накопление по кусочкам не даёт преимущества — парсер всё равно ждёт полный объект, прежде чем сможет его разобрать.

Обрыв потока — частая путаница с лимитами

Если поток обрывается на середине без явной ошибки, это почти всегда таймаут клиента или сети, а не лимит по токенам — стоит проверить max_tokens в запросе и таймаут HTTP-клиента (у многих HTTP-библиотек по умолчанию стоит таймаут в 30–60 секунд, которого не хватает на длинную генерацию). Учтите, что обрыв на стороне клиента не отменяет списание: шлюз всё равно дочитывает ответ провайдера и списывает всю генерацию. Проверить, сколько стоил оборванный запрос, можно в кабинете или через GET /v1/usage — там появится запись с model, input_tokens, output_tokens и cost_cents.

Заголовки в потоковом ответе

Заголовков стоимости X-Zukko-Cost-Micros и X-Fetch-Balance-Cents у потоковых ответов нет — они приходят только в обычных JSON-ответах (stream: false). Точная стоимость фиксируется только после завершения генерации, а заголовки потока отправляются раньше. Для потоковых запросов расход смотрят в кабинете («Использование»), через GET /v1/usage (история общая на аккаунт) или по балансу после запроса:

bash
resp = requests.post(url, headers=headers, json=body, stream=True)
for line in resp.iter_lines():
    ...  # читаем поток до конца

balance = requests.get("https://api.zukko.pro/v1/balance", headers=headers).json()
print("balance after, cents:", balance["balance_cents"])

Если для каждого запроса нужна точная стоимость прямо в ответе, используйте обычный (нестриминговый) режим и читайте X-Zukko-Cost-Micros (микродоллары, 1 000 000 = $1).

Итог

stream: true стоит включать там, где есть живой интерфейс, показывающий текст по мере генерации, или где ответ достаточно длинный, чтобы пауза без обратной связи была заметна. Для серверной обработки, коротких ответов и строгого JSON — обычный запрос проще в реализации и не теряет ничего по функциональности.

zukko

Платите в 25×
меньше

за Claude Code & ChatGPT

Один ключ fetch_* и base URL api.zukko.pro — в Cursor, Claude Code, Codex и любом SDK.