Перейти к содержимомуKumoДокументация
Разделы
На странице
Шлюз

Стриминг

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

Открыть как Markdown

Быстро

Добавьте "stream": true к тому же вызову — и ответ придёт как text/event-stream. На Chat Completions попросите ещё и расход: stream_options.include_usage.

curl -N https://api.kumorouter.com/v1/chat/completions \
  -H "Authorization: Bearer $KUMO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "<model>",
    "max_tokens": 128,
    "stream": true,
    "stream_options": { "include_usage": true },
    "messages": [{ "role": "user", "content": "Напиши хайку про задержку." }]
  }'

Поток без завершающего кадра — это неудавшийся вызов

Отказ, решённый до первого события, приходит обычной JSON-ошибкой со статусом — тем же конвертом, что и у неразбитого на события вызова, и в потоке при этом нет ничего. Сбой после первого события таким быть уже не может: строка статуса ушла раньше, и её не переписать.

Поэтому каждый диалект называет свой завершающий кадр, и целый ответ виден именно по нему.

ДиалектЗавершающий кадрЕсли его не пришло
Chat Completions[DONE]Поток просто кончился. Ответ усечён.
Anthropic Messagesmessage_stopПришло событие error этого протокола, и поток кончился без message_stop.
Responsesresponse.completedПришло response.failed с конвертом ошибки вместо готового тела.

Читайте именно это отсутствие. Клиент, который считает «соединение закрылось» синонимом «ответ дописан», отдаст дальше усечённый текст, и в полученных им байтах ничто не скажет обратного. Поток, кончившийся до завершающего кадра, — неудавшийся вызов, а не короткий.

Практическое правило: держите флаг «завершающий кадр видели» и после выхода из цикла проверяйте его первым, до того как отдать текст дальше.

Что приходит в потоке

Chat Completions

События chat.completion.chunk в порядке прихода. У каждого id, model и object те же, что были бы у унарного ответа, а содержимое лежит в choices[0].delta: role — на первом чанке, дальше content, refusal или tool_calls кусками. finish_reason заявляется один раз, на чанке, который закрывает ответ, и до тех пор он null.

Если запрошен stream_options.include_usage, перед [DONE] придёт чанк расхода: событие с пустым choices, несущее только usage. На то, из чего платформа считает вызов, этот член не влияет — расход собирается всегда, и он решает лишь, покажет ли его ваш собственный поток.

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

data: {"id":"chatcmpl-8f2b7e10c9","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Задержка"},"finish_reason":null}]}

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

data: {"id":"chatcmpl-8f2b7e10c9","object":"chat.completion.chunk","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":18,"total_tokens":30}}

data: [DONE]

Anthropic Messages

Шесть именованных событий по порядку: message_start — открывающий конверт с личностью ответа и входной половиной расхода; content_block_start, content_block_delta, content_block_stop — на каждый блок; message_delta — закрывающие факты, stop_reason и расход целиком; message_stop. Кроме них приходят ping и error.

Дельта блока бывает двух видов: text_delta несёт кусок текста, input_json_delta — кусок аргументов вызова инструмента как частичный JSON в partial_json.

Responses

Собственные именованные события: response.created, response.output_item.added, response.output_text.delta, response.refusal.delta, response.function_call_arguments.delta, response.output_item.done и ровно одно завершающее — response.completed или response.failed. Имя события повторяется в поле event: SSE-кадра, так что ветвиться можно на любом из двух.

Расход приходит на response.completed. response.failed несёт конверт ошибки этого протокола в члене error.

Поток на диалекте Messages

import os
from anthropic import Anthropic

client = Anthropic(
    base_url="https://api.kumorouter.com",
    api_key=os.environ["KUMO_API_KEY"],
)

with client.messages.stream(
    model="<model>",
    max_tokens=256,
    messages=[{"role": "user", "content": "Напиши хайку про задержку."}],
) as stream:
    for text in stream.text_stream:
        print(text, end="", flush=True)

Первый байт решает всё

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

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

Отмена

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

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

Где стриминга нет

  • У эмбеддингов стриминга нет: эта поверхность отвечает векторами целиком.
  • У генерации изображений члена stream нет вовсе.
  • Подсчёт токенов к провайдеру не обращается и потому потока не даёт.
Заметка

Стриминг — это доказанная возможность точной пары модели и канала, а не свойство протокола. Запрос со stream: true не поедет к паре, которая стриминг не доказала: он отвергается до обращения наверх. Что доказано у конкретной модели, видно на её карточке в каталоге.

Что дальше