Библиотека · Поддержка клиентов и голосовые агенты

Real-time AI — стриминг, WebRTC и разговорные агенты с минимальной задержкой

Инженер60 минОбновлено: октябрь 2026
64 из 105 в библиотеке

Модуль: Voice & Real-Time AI | Время: ~25 мин теории + 35 мин практики


Суть урока

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

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():

python
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 напрямую:

python
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":
            break

Server-Sent Events: стриминг в браузер

SSE — самый простой способ донести streaming-ответ до браузера. Это обычный HTTP GET, который сервер держит открытым и периодически пишет в него данные в формате data: ...\n\n.

🎨 Образ: SSE — это как радио. Вы настраиваетесь на волну (открываете соединение) и слушаете. Вещание одностороннее — вы не можете ответить передатчику, только принять сигнал.

FastAPI с Anthropic SDK:

python
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:

javascript
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: клиент может отправлять сообщения в любой момент, сервер — отвечать стримом. Это фундамент для чат-интерфейсов.

python
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:

python
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 и голосовые агенты

🎨 Образ: Если SSE — это радио, а WebSocket — телефонная линия, то 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:

python
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 нет — модель обрабатывает голос нативно. Названия событий и поля сессии менялись при выходе из беты, поэтому старые примеры из блогов могут не работать:

python
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 падает:

python
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 каждое законченное предложение:

python
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: Установка зависимостей

bash
pip install fastapi uvicorn anthropic websockets python-dotenv

Шаг 2: Backend — FastAPI WebSocket сервер

Создайте файл server.py:

python
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: Запуск сервера

bash
# Убедитесь что ANTHROPIC_API_KEY в .env файле
uvicorn server:app --reload --port 8000

Откройте http://localhost:8000 в браузере. Вы увидите интерфейс чата с мгновенным стримингом.

Шаг 4: Замер TTFT в коде

Добавьте в WebSocket-обработчик измерение задержки:

python
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-эндпоинт для сравнения

python
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 и умная эскалация

Отметка хранится только в этом браузере и никуда не отправляется. Мой прогресс