Суть урока
Batch-запрос к LLM — это письмо: Claude думает, формирует весь ответ, и только потом отправляет. Real-time streaming — это звонок: первые слова появляются на экране очень быстро, пока Claude ещё "думает" над концом предложения.
Разница в ощущениях пользователя заметная. В этом уроке мы разберём, как построить streaming-архитектуру от простого SSE-эндпоинта до полноценного голосового агента с WebRTC.
Ключевые концепции
- Streaming — пословная/потоковая передача ответа LLM по мере генерации токенов, вместо ожидания полного ответа
- Server-Sent Events (SSE) — односторонний протокол: сервер пушит данные клиенту через обычное HTTP-соединение, браузер читает через
EventSource - WebSocket — двусторонний протокол с постоянным соединением: и клиент, и сервер могут слать данные в любой момент; идеален для чатов и интерактивных агентов
- WebRTC — стандарт для peer-to-peer медиа-потоков (аудио/видео) в браузере с минимальной задержкой; основа современных голосовых агентов
- TTFT (Time To First Token) — время от отправки запроса до получения первого токена ответа; критическая метрика UX: чем меньше, тем лучше
- TPOT (Time Per Output Token) — среднее время генерации одного токена; влияет на скорость заполнения экрана текстом
- LiveKit — open-source WebRTC инфраструктура для голосовых агентов; на ней строят голосовые продукты, есть Python-фреймворк LiveKit Agents
- OpenAI Realtime API — API для потокового аудио с двусторонней связью; позволяет строить голосовых ассистентов без промежуточных шагов STT → LLM → TTS. Актуальную модель для realtime и её цены смотри в документации и на странице тарифов OpenAI (см. также Актуальное сейчас)
Теория
Почему стриминг критичен для UX
Пользователь, который ждёт много секунд пустого экрана, уже думает, что что-то сломалось. Пользователь, который видит первые слова почти сразу и наблюдает, как текст "печатается" — вовлечён, воспринимает систему как живую.
Воспринимаемая скорость системы важна не меньше реальной. Два сервиса с одинаковым итоговым временем ответа дадут разный пользовательский опыт, если один начинает стримить немедленно, а другой держит паузу. Для чат-ботов поддержки и ассистентов это обычно заметно по отзывам пользователей; эффект на удержание измеряй на своих данных.
Claude Streaming API: базовый Python
Anthropic SDK поддерживает streaming "из коробки". Ключевой параметр — использование stream() вместо обычного messages.create():
import anthropic
client = anthropic.Anthropic()
# Streaming через context manager
with client.messages.stream(
model="claude-sonnet-5-5", # актуальные модели: страница «Актуальное сейчас»
max_tokens=1024,
messages=[{"role": "user", "content": "Объясни квантовую запутанность простыми словами"}],
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)
# Получить финальное сообщение после стрима
final_message = stream.get_final_message()
print(f"\n\nStop reason: {final_message.stop_reason}")
print(f"Input tokens: {final_message.usage.input_tokens}")
print(f"Output tokens: {final_message.usage.output_tokens}")Метод text_stream — самый простой способ: он возвращает только текстовые дельты, игнорируя служебные события. Если нужен полный контроль над событиями (content_block_start, ping, message_delta), используйте stream напрямую:
with client.messages.stream(...) as stream:
for event in stream:
if event.type == "content_block_delta":
if event.delta.type == "text_delta":
yield event.delta.text
elif event.type == "message_stop":
breakServer-Sent Events: стриминг в браузер
SSE — самый простой способ донести streaming-ответ до браузера. Это обычный HTTP GET, который сервер держит открытым и периодически пишет в него данные в формате data: ...\n\n.
FastAPI с Anthropic SDK:
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import anthropic
import json
app = FastAPI()
client = anthropic.Anthropic()
async def generate_stream(prompt: str):
"""Генератор токенов для SSE"""
with client.messages.stream(
model="claude-sonnet-5-5",
max_tokens=1024,
messages=[{"role": "user", "content": prompt}],
) as stream:
for text in stream.text_stream:
# SSE формат: data: <payload>\n\n
yield f"data: {json.dumps({'text': text})}\n\n"
# Сигнал окончания стрима
yield "data: [DONE]\n\n"
@app.get("/stream")
async def stream_response(prompt: str):
return StreamingResponse(
generate_stream(prompt),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no", # Отключаем буферизацию nginx
}
)Браузерная сторона — нативный EventSource:
const eventSource = new EventSource(`/stream?prompt=${encodeURIComponent(userInput)}`);
const output = document.getElementById('output');
eventSource.onmessage = (event) => {
if (event.data === '[DONE]') {
eventSource.close();
return;
}
const { text } = JSON.parse(event.data);
output.textContent += text;
};
eventSource.onerror = () => {
eventSource.close();
console.error('Stream ended or error occurred');
};Ограничения SSE: только GET-запросы (длинные промпты не пролезут), нет нативной поддержки двусторонней коммуникации. Для интерактивных чатов нужен WebSocket.
WebSocket: двусторонний стрим
WebSocket решает проблему SSE: клиент может отправлять сообщения в любой момент, сервер — отвечать стримом. Это фундамент для чат-интерфейсов.
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
import anthropic
import json
app = FastAPI()
client = anthropic.Anthropic()
@app.websocket("/ws/chat")
async def websocket_chat(websocket: WebSocket):
await websocket.accept()
conversation_history = []
try:
while True:
# Получаем сообщение от пользователя
data = await websocket.receive_text()
user_message = json.loads(data)
conversation_history.append({
"role": "user",
"content": user_message["text"]
})
# Стримим ответ Claude обратно в WebSocket
# (синхронный клиент блокирует цикл событий; в продакшне используй AsyncAnthropic и async with)
full_response = ""
with client.messages.stream(
model="claude-sonnet-5-5",
max_tokens=2048,
system="Ты полезный ассистент. Отвечай по-русски.",
messages=conversation_history,
) as stream:
for text in stream.text_stream:
full_response += text
await websocket.send_json({
"type": "delta",
"text": text
})
# Сигнал конца ответа
await websocket.send_json({"type": "done"})
# Добавляем ответ в историю для следующего хода
conversation_history.append({
"role": "assistant",
"content": full_response
})
except WebSocketDisconnect:
print("Client disconnected")TTFT и TPOT: метрики которые важны
TTFT (Time To First Token) — время от момента отправки запроса до появления первого токена. Именно это пользователь ощущает как "задержку перед ответом". Ориентиры (условные, подбери под свой продукт):
- Хорошо: меньше полусекунды
- Приемлемо: около секунды
- Плохо: заметно больше секунды
TPOT (Time Per Output Token) — среднее время между токенами. Определяет, насколько "плавно" текст появляется на экране. Для комфортного чтения текст должен появляться быстрее, чем человек его читает.
Что влияет на TTFT:
- Размер модели — как правило, маленькие модели (Haiku) отвечают быстрее больших
- Длина prompt — длинные системные промпты и история = медленнее
- Нагрузка на API — пиковые часы = выше задержка
- Регион — ближе к дата-центру = меньше latency сети
- Prompt caching — кешированные токены не пересчитываются, для длинных контекстов TTFT заметно падает
Измеряйте эти метрики в production:
import time
start_time = time.time()
first_token_time = None
token_count = 0
with client.messages.stream(...) as stream:
for text in stream.text_stream:
if first_token_time is None:
first_token_time = time.time()
ttft = first_token_time - start_time
print(f"TTFT: {ttft * 1000:.0f}ms")
token_count += 1
total_time = time.time() - start_time
tpot = (total_time - first_token_time) / token_count * 1000
print(f"TPOT: {tpot:.1f}ms/token")WebRTC и голосовые агенты
WebRTC (Web Real-Time Communication) — браузерный стандарт для peer-to-peer передачи аудио и видео. Именно на нём работают Google Meet, Zoom Web, Discord. Для голосовых AI-агентов WebRTC позволяет:
- Захватить микрофон пользователя и стримить аудио
- Получить синтезированный голос агента
- Минимизировать задержку (она существенно ниже, чем у обычных HTTP-запросов)
Базовая архитектура голосового агента:
Пользователь говорит → [Микрофон] → [WebRTC] → [STT: Whisper / Deepgram]
↓
[Claude (текст)]
↓
[TTS: ElevenLabs / OpenAI TTS]
↓
[Пользователь слышит] ← [WebRTC] ← [Аудио]Проблема этой архитектуры — задержка накапливается на каждом шаге: распознавание речи + первый токен Claude + генерация + синтез голоса. Вместе это легко даёт несколько секунд, а для разговора это критично.
LiveKit: инфраструктура для голоса
LiveKit — open-source WebRTC-сервер и SDK, который берёт на себя сложности инфраструктуры: TURN/STUN серверы, медиа-маршрутизация, запись сессий. На LiveKit строят собственные голосовые продукты; готовые платформы вроде Vapi (см. урок Голосовые AI-агенты) решают ту же задачу как сервис.
Python SDK для голосового агента с LiveKit:
from livekit import agents
from livekit.agents import AgentServer, AgentSession, Agent
from livekit.plugins import anthropic, openai, silero
class VoiceAssistant(Agent):
def __init__(self):
super().__init__(
instructions="""Ты голосовой ассистент.
Отвечай по-русски, кратко и по делу.
Используй образы и аналогии для объяснений."""
)
server = AgentServer()
@server.rtc_session()
async def entrypoint(ctx: agents.JobContext):
session = AgentSession(
stt=openai.STT(language="ru"), # Speech-to-Text
llm=anthropic.LLM(model="claude-sonnet-5-5"), # LLM; актуальные модели: страница «Актуальное сейчас»
tts=openai.TTS(voice="alloy"), # Text-to-Speech
vad=silero.VAD.load(), # Voice Activity Detection
)
# Шумоподавление и другие параметры комнаты задаются через room_options
# (см. документацию LiveKit Agents для вашей версии)
await session.start(room=ctx.room, agent=VoiceAssistant())
# Приветствие
await session.generate_reply(
instructions="Поприветствуй пользователя по-русски и предложи помощь"
)
if __name__ == "__main__":
agents.cli.run_app(server)Плагины ставятся отдельно (пакеты livekit-agents, livekit-plugins-openai, livekit-plugins-silero, livekit-plugins-anthropic). API LiveKit Agents менялся от версии к версии, поэтому сверяйся с документацией.
LiveKit обрабатывает всё сложное: echo cancellation, jitter buffer, packet loss concealment. Вы фокусируетесь на логике агента.
OpenAI Realtime API
OpenAI предлагает принципиально другой подход — их Realtime API работает через WebSocket (есть и вариант через WebRTC) и позволяет слать аудио напрямую, получая аудио-ответ. Промежуточных STT/TTS нет — модель обрабатывает голос нативно. Названия событий и поля сессии менялись при выходе из беты, поэтому старые примеры из блогов могут не работать:
import asyncio
import json
import websockets
import base64
async def realtime_voice_session():
# Имя модели в URL — пример: возьми актуальное из списка моделей в документации OpenAI
url = "wss://api.openai.com/v1/realtime?model=gpt-realtime-2.1"
headers = {
"Authorization": f"Bearer {OPENAI_API_KEY}",
# Заголовок OpenAI-Beta: realtime=v1 для текущей версии интерфейса не нужен
}
async with websockets.connect(url, additional_headers=headers) as ws:
# Конфигурируем сессию. Формат аудио, голос и определение конца речи
# задаются в session.audio: точные поля смотри в справочнике событий Realtime API
await ws.send(json.dumps({
"type": "session.update",
"session": {
"type": "realtime",
"instructions": "Ты голосовой ассистент. Отвечай кратко.",
}
}))
# Слушаем события
async for message in ws:
event = json.loads(message)
if event["type"] == "response.output_audio.delta":
# Получаем кусок аудио-ответа
audio_data = base64.b64decode(event["delta"])
# Воспроизводим аудио...
elif event["type"] == "response.done":
print("Ответ завершён")Преимущество Realtime API — нативная обработка голоса без цепочки STT → LLM → TTS, поэтому задержка меньше, чем у каскада, и разговор звучит живее. Недостаток — только модели OpenAI: Claude в этом формате не доступен (используйте LiveKit с Claude для каскадной схемы).
Оптимизация задержки
Несколько техник для минимизации latency в production:
1. Prompt Caching. Если системный промпт длинный, кешируйте его (минимальная длина для кеша зависит от модели, смотри документацию). Повторные запросы не будут пересчитывать промпт, и TTFT падает:
messages = client.messages.create(
model="claude-sonnet-5-5",
system=[{
"type": "text",
"text": long_system_prompt,
"cache_control": {"type": "ephemeral"} # Кешируем
}],
messages=conversation,
max_tokens=1024,
)2. Меньшая модель для первого ответа. Стратегия "cascade": начинаем с Haiku (быстро, дёшево), переключаемся на Sonnet для сложных вопросов.
3. Edge deployment. Cloudflare Workers AI или AWS Lambda@Edge — запускайте инференс ближе к пользователю.
4. Streaming с ранним началом TTS. Для голосовых агентов не ждите полного текста — передавайте в TTS каждое законченное предложение:
buffer = ""
async for text in stream.text_stream:
buffer += text
# Если накопили законченное предложение — сразу в TTS
if any(buffer.endswith(p) for p in ['.', '!', '?', '...']):
await tts_engine.synthesize_and_play(buffer.strip())
buffer = ""Реальный кейс: стриминговый чат поддержки
Представьте: интернет-магазин, сотни обращений в день, клиенты по несколько минут ждут оператора. Внедряете Claude-агента с WebSocket-стримингом.
Что меняется: пользователь видит первые слова ответа почти сразу, ожидание перестаёт ощущаться. Типовые вопросы закрывает агент, операторы видят только сложные случаи. Какая доля обращений закроется без человека и сколько будет стоить ответ, зависит от вашей базы знаний и модели: замерь это на пилоте, а токены посчитай по ценам на странице Актуальное сейчас.
Ключевой архитектурный выбор здесь — WebSocket вместо REST. Одно постоянное соединение на сессию, двусторонний обмен, нет накладных расходов на установку соединения для каждого сообщения.
Практика
Задача: Streaming Chat API с FastAPI и Claude
Цель: построить WebSocket сервер, который стримит ответы Claude в реальном времени, с простым HTML-клиентом.
Шаг 1: Установка зависимостей
pip install fastapi uvicorn anthropic websockets python-dotenvШаг 2: Backend — FastAPI WebSocket сервер
Создайте файл server.py:
import os
import json
import asyncio
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse
import anthropic
from dotenv import load_dotenv
load_dotenv()
app = FastAPI(title="Streaming Chat API")
client = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
SYSTEM_PROMPT = """Ты полезный AI-ассистент. Отвечай по-русски,
используй структуру и образы для объяснений. Будь краток."""
# Простой HTML клиент для тестирования
HTML = """
<!DOCTYPE html>
<html>
<head><title>Streaming Chat</title></head>
<body>
<h1>Streaming Chat с Claude</h1>
<div id="chat" style="height:400px;overflow-y:auto;border:1px solid #ccc;padding:10px;"></div>
<div style="margin-top:10px">
<input id="input" type="text" style="width:80%" placeholder="Введите сообщение..." />
<button onclick="sendMessage()">Отправить</button>
</div>
<script>
const ws = new WebSocket(`ws://${location.host}/ws/chat`);
const chat = document.getElementById('chat');
let currentBubble = null;
ws.onmessage = function(event) {
const data = JSON.parse(event.data);
if (data.type === 'start') {
currentBubble = document.createElement('div');
currentBubble.style.cssText = 'margin:8px 0;padding:8px;background:#e8f4fd;border-radius:8px';
currentBubble.innerHTML = '<strong>Claude:</strong> ';
chat.appendChild(currentBubble);
} else if (data.type === 'delta') {
currentBubble.innerHTML += data.text;
chat.scrollTop = chat.scrollHeight;
} else if (data.type === 'done') {
currentBubble = null;
}
};
function sendMessage() {
const input = document.getElementById('input');
const text = input.value.trim();
if (!text) return;
const userBubble = document.createElement('div');
userBubble.style.cssText = 'margin:8px 0;padding:8px;background:#f0f0f0;border-radius:8px;text-align:right';
userBubble.innerHTML = `<strong>Вы:</strong> ${text}`;
chat.appendChild(userBubble);
ws.send(JSON.stringify({text}));
input.value = '';
}
document.getElementById('input').addEventListener('keypress', (e) => {
if (e.key === 'Enter') sendMessage();
});
</script>
</body>
</html>
"""
@app.get("/")
async def get():
return HTMLResponse(HTML)
@app.websocket("/ws/chat")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
history = []
try:
while True:
data = await websocket.receive_text()
user_msg = json.loads(data)
history.append({
"role": "user",
"content": user_msg["text"]
})
# Сигнал начала ответа
await websocket.send_json({"type": "start"})
full_response = ""
# Стримим ответ Claude
with client.messages.stream(
model="claude-haiku-4-5", # Haiku для скорости (проверь, что модель ещё доступна в API)
max_tokens=1024,
system=SYSTEM_PROMPT,
messages=history,
) as stream:
for text in stream.text_stream:
full_response += text
await websocket.send_json({
"type": "delta",
"text": text
})
# Завершение ответа
await websocket.send_json({"type": "done"})
# Сохраняем в историю
history.append({
"role": "assistant",
"content": full_response
})
# Ограничиваем историю (последние 10 пар)
if len(history) > 20:
history = history[-20:]
except WebSocketDisconnect:
print(f"Client disconnected, history length: {len(history)}")
except Exception as e:
print(f"Error: {e}")
await websocket.close()Шаг 3: Запуск сервера
# Убедитесь что ANTHROPIC_API_KEY в .env файле
uvicorn server:app --reload --port 8000Откройте http://localhost:8000 в браузере. Вы увидите интерфейс чата с мгновенным стримингом.
Шаг 4: Замер TTFT в коде
Добавьте в WebSocket-обработчик измерение задержки:
import time
start = time.time()
first_token = True
token_count = 0
with client.messages.stream(...) as stream:
for text in stream.text_stream:
if first_token:
ttft = (time.time() - start) * 1000
print(f"TTFT: {ttft:.0f}ms")
first_token = False
token_count += 1
# ... отправка клиенту
total = (time.time() - start) * 1000
tpot = total / token_count if token_count > 0 else 0
print(f"TPOT: {tpot:.1f}ms/token | Total tokens: {token_count}")Шаг 5: Добавьте SSE-эндпоинт для сравнения
from fastapi.responses import StreamingResponse
@app.get("/stream")
async def stream_endpoint(prompt: str):
async def generate():
with client.messages.stream(
model="claude-haiku-4-5", # быстрая модель для real-time
max_tokens=512,
messages=[{"role": "user", "content": prompt}],
) as stream:
for text in stream.text_stream:
yield f"data: {json.dumps({'text': text})}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
)Протестируйте: curl "http://localhost:8000/stream?prompt=Что+такое+AI%3F" — вы увидите токены в реальном времени прямо в терминале.
Инструменты и ресурсы
| Инструмент | Назначение | Документация |
|---|---|---|
| Anthropic SDK (Python) | Streaming API с stream() и text_stream |
platform.claude.com/docs/en/build-with-claude/streaming |
| FastAPI | WebSocket и SSE эндпоинты | fastapi.tiangolo.com |
| LiveKit | Open-source WebRTC для голосовых агентов | docs.livekit.io/agents |
| LiveKit Agents (Python) | Фреймворк поверх LiveKit с интеграцией Claude | github.com/livekit/agents |
| OpenAI Realtime API | Нативный голосовой стриминг | developers.openai.com/api/docs/guides/realtime |
| websockets (Python) | WebSocket клиент/сервер | websockets.readthedocs.io |
| Deepgram | Быстрое потоковое распознавание речи, альтернатива Whisper | developers.deepgram.com |
| ElevenLabs | Высококачественный TTS с потоковым выводом | elevenlabs.io/docs |
| uvicorn | ASGI сервер для FastAPI | www.uvicorn.org |
Ключевые выводы
"Стриминг — это не техническая деталь, это UX-решение. Пользователь видит первый токен почти сразу и ощущает систему живой."
"TTFT — ваша главная метрика в real-time AI. Если первый токен приходит слишком поздно, пользователь теряет терпение. Кешируйте промпты, выбирайте быструю модель для первого касания."
"WebSocket для чата, SSE для односторонних обновлений, WebRTC для голоса — не смешивайте эти паттерны. Каждый инструмент решает свою задачу и имеет свою цену за настройку."
Следующий урок
→ Менеджеры чат-ботов — NLP, intent routing и умная эскалация
Отметка хранится только в этом браузере и никуда не отправляется. Мой прогресс