Стриминг ответов
Когда включать `stream: true`, как корректно собирать ответ модели и показывать прогресс пользователю.
При stream: true ответ модели приходит по частям через Server-Sent Events: вы видите текст по мере того, как он генерируется. Это даёт ощущение «живого» интерфейса: пользователь сразу видит первые слова и не ждёт полной завершённости запроса.
Технический формат SSE-стрима (chunk-структура, [DONE] маркер, заголовки) — на странице POST /v1/chat/completions: Стриминг. Этот гид — про use-cases и практику использования в клиенте.
Когда включать стриминг
Подходящие сценарии:
- Чат-интерфейсы — пользователь видит ответ по мере появления, как в ChatGPT.
- Длинные ответы (reasoning-модели, агенты) — без стрима пользователь смотрит 10–30 секунд в крутилку.
- Прогресс-индикация — показать, что модель уже отвечает (даже если первые слова — это reasoning-токены).
Не нужен:
- Однострочные ответы — классификация, экстракция данных, structured output. Накладные расходы на SSE-парсинг не оправданы.
- Batch-сценарии — обработка тысяч запросов в parallelизме, где UI не показывается.
- Когда нужен сразу полный JSON для парсинга — собирать stream и парсить «накопленный буфер» работает, но проще получить готовый ответ одним запросом.
Python (синхронный)
OpenAI SDK скрывает работу с SSE — итерация по объекту Stream отдаёт уже распарсенные чанки.
from openai import OpenAI
client = OpenAI(
base_url="https://api.hubris.pw/v1",
api_key="sk-gw-...",
)
stream = client.chat.completions.create(
model="anthropic/claude-haiku-4.5",
messages=[{"role": "user", "content": "Расскажи о трёх океанах планеты."}],
stream=True,
)
for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content:
print(chunk.choices[0].delta.content, end="", flush=True)
# В последнем чанке Hubris отдаёт usage с итоговыми токенами:
# (доступен через stream.response.usage после завершения итерации)Итерация автоматически останавливается на [DONE]. SDK обрабатывает чанк-буфер за вас — рисковать с ручным парсингом не нужно.
Python (асинхронный)
from openai import AsyncOpenAI
client = AsyncOpenAI(
base_url="https://api.hubris.pw/v1",
api_key="sk-gw-...",
)
async def stream_answer(prompt: str):
stream = await client.chat.completions.create(
model="anthropic/claude-haiku-4.5",
messages=[{"role": "user", "content": prompt}],
stream=True,
)
async for chunk in stream:
delta = chunk.choices[0].delta.content if chunk.choices else None
if delta:
yield deltaУдобно для FastAPI / aiohttp-эндпоинтов, которые транслируют ответ дальше клиенту.
Node.js / TypeScript
import OpenAI from 'openai';
const client = new OpenAI({
baseURL: 'https://api.hubris.pw/v1',
apiKey: 'sk-gw-...',
});
const stream = await client.chat.completions.create({
model: 'anthropic/claude-haiku-4.5',
messages: [{ role: 'user', content: 'Опиши закат.' }],
stream: true,
});
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content;
if (delta) process.stdout.write(delta);
}for await...of итерирует асинхронно — каждый цикл это уже распарсенный chunk.
Vercel AI SDK
Если вы пишете на Next.js и используете Vercel AI SDK — он работает с Hubris из коробки:
import { openai } from '@ai-sdk/openai';
import { streamText } from 'ai';
const result = await streamText({
model: openai.chat('anthropic/claude-haiku-4.5'),
messages: [{ role: 'user', content: 'Hello' }],
});
return result.toAIStreamResponse();Конфигурация base URL — через переменную окружения OPENAI_BASE_URL=https://api.hubris.pw/v1, либо явно в openai({ baseURL: ... }). См. гид Vercel AI SDK.
Браузер (fetch + ReadableStream)
Если SDK для вас избыточен — можно стримить голыми руками:
const resp = await fetch('https://api.hubris.pw/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': 'Bearer sk-gw-...',
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'anthropic/claude-haiku-4.5',
messages: [{ role: 'user', content: 'Привет' }],
stream: true,
}),
});
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Делим буфер по \n\n — каждый блок это SSE-событие
const events = buffer.split('\n\n');
buffer = events.pop() ?? ''; // последний может быть неполным — оставляем
for (const event of events) {
if (!event.startsWith('data: ')) continue;
const payload = event.slice('data: '.length);
if (payload === '[DONE]') return;
const chunk = JSON.parse(payload);
const delta = chunk.choices[0]?.delta?.content;
if (delta) appendToUI(delta);
}
}Ключевые правила:
- Буфер обязателен. TCP может разбить SSE-блок на середине — не делайте
JSON.parseсразу. [DONE]не парсить как JSON. Это литерал-маркер.- Используйте
decoder.decode(value, { stream: true })— чтобы UTF-8 multi-byte символы не ломались на границе чанков.
Cancellation (отмена запроса)
Стандартный AbortController работает:
const ac = new AbortController();
const stream = await client.chat.completions.create(
{
model: 'anthropic/claude-haiku-4.5',
messages: [{ role: 'user', content: '...' }],
stream: true,
},
{ signal: ac.signal },
);
// Через 5 секунд отменяем
setTimeout(() => ac.abort(), 5000);
try {
for await (const chunk of stream) {
// ...
}
} catch (e) {
if (e.name === 'AbortError') {
console.log('Запрос отменён пользователем');
}
}Важно: Hubris продолжает читать ответ модели до конца, даже если клиент закрыл соединение. Это защита от «бесплатных токенов». Иными словами, при отмене вы получите полное списание стоимости — токены уже сгенерированы апстримом, мы просто не доставили их вам.
Прогресс в UI: типичные паттерны
Типографический эффект (как в ChatGPT)
let buffer = '';
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content ?? '';
buffer += delta;
setMessage(buffer); // React state setter
}Индикатор «модель думает»
Reasoning-модели могут несколько секунд молчать перед первым content-чанком (см. Reasoning токены). Покажите spinner, пока delta.content не пришёл хотя бы один раз:
let firstChunk = true;
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content;
if (!delta) continue;
if (firstChunk) {
hideSpinner();
firstChunk = false;
}
appendText(delta);
}Кнопка «Stop» с откатом UI
При отмене иногда нужно показать частичный ответ + label «прервано». Сохраняйте накопленный буфер в state и при AbortError дополняйте его пометкой — не очищайте.
Tool calls и стриминг
Если модель вызывает функцию, delta.tool_calls[] приходит частями (имя функции и аргументы могут разорваться по чанкам). Собирайте по index. Подробности — в гиде Вызов инструментов.
Биллинг
Стриминг не меняет тарификацию: списываем по тем же prompt + completion токенам, что и в обычном (не-стрим) запросе. Hubris всегда добавляет stream_options: { include_usage: true } к стрим-запросам, поэтому в последнем чанке вы видите итоговые токены.
Дисконнект клиента до завершения стрима — полное списание (см. выше). Это не баг, это анти-абьюз.
Что важно знать
- OpenAI SDK почти всегда правильный выбор. Ручной SSE-парсинг — только если есть конкретная причина (другой стек, экстремальная оптимизация).
- Чанк-границы непредсказуемы. Не пытайтесь парсить
delta.contentкак JSON — это просто кусок строки. - Headers commit before bytes. Если стрим уже начался — мы уже отдали
200 OKи SSE-заголовки. Ошибки ПОСЛЕ старта стрима не возвращаются как HTTP 5xx — клиент видит обрыв илиfinish_reason: "stop"на пустом delta.
Что дальше
- POST /v1/chat/completions: Стриминг — технический формат чанков.
- Вызов инструментов — стрим с tool calls.
- Reasoning токены — почему первый чанк может прийти не сразу.
- Vercel AI SDK — React/Next-стрим из коробки.
Обновлено: