Biblioteca · Atención al cliente y agentes de voz

IA en tiempo real: streaming, WebRTC y agentes de conversación con latencia mínima

Ingeniero60 minActualizado: octubre de 2026
64 de 105 en la biblioteca

Módulo: Voice & Real-Time AI | Tiempo: ~25 min de teoría + 35 min de práctica


Lo esencial

🎨 Imagínalo así: piensa en la diferencia entre una carta y una llamada telefónica. Con la carta escribes todo, cierras el sobre, lo llevas al correo y esperas varios días. La otra persona lo recibe todo de una vez, en un solo bloque. En la llamada, las palabras vuelan al instante: oyes la respiración, las pausas, la entonación. La misma información, pero una experiencia completamente distinta.

Una solicitud batch a un LLM es la carta: Claude piensa, arma toda la respuesta y solo entonces la envía. El streaming en tiempo real es la llamada: las primeras palabras aparecen en pantalla muy rápido, mientras Claude todavía "piensa" el final de la oración.

La diferencia en lo que siente el usuario se nota. En esta lección veremos cómo construir una arquitectura de streaming, desde un endpoint SSE sencillo hasta un agente de voz completo con WebRTC.


Conceptos clave

  • Streaming: enviar la respuesta del LLM palabra por palabra, en flujo, conforme se generan los tokens, en lugar de esperar la respuesta completa
  • Server-Sent Events (SSE): un protocolo de un solo sentido: el servidor empuja datos al cliente por una conexión HTTP normal, y el navegador los lee con EventSource
  • WebSocket: un protocolo de dos sentidos con conexión permanente: tanto el cliente como el servidor pueden mandar datos en cualquier momento; ideal para chats y agentes interactivos
  • WebRTC: el estándar para flujos de medios peer-to-peer (audio/video) en el navegador con latencia mínima; la base de los agentes de voz modernos
  • TTFT (Time To First Token): el tiempo desde que envías la solicitud hasta que llega el primer token de la respuesta; una métrica crítica de UX: cuanto menor, mejor
  • TPOT (Time Per Output Token): el tiempo promedio para generar un token; influye en qué tan rápido se llena la pantalla de texto
  • LiveKit: infraestructura WebRTC open source para agentes de voz; sobre ella se construyen productos de voz, y tiene el framework de Python LiveKit Agents
  • OpenAI Realtime API: una API de audio en flujo con comunicación en ambos sentidos; permite construir asistentes de voz sin los pasos intermedios STT → LLM → TTS. El nombre del modelo vigente y los precios están en la documentación de OpenAI (ver también Lo vigente)

Teoría

Por qué el streaming es crítico para la UX

Un usuario que mira una pantalla vacía durante muchos segundos ya piensa que algo se rompió. Un usuario que ve las primeras palabras casi de inmediato y observa cómo el texto "se va escribiendo" está enganchado y percibe el sistema como algo vivo.

La velocidad percibida de un sistema importa tanto como la real. Dos servicios con el mismo tiempo total de respuesta dan experiencias distintas si uno empieza a transmitir de inmediato y el otro se queda callado. En chatbots de soporte y asistentes, esto suele notarse en las opiniones de los usuarios; el efecto en la retención mídelo con tus propios datos.

Claude Streaming API: Python básico

El SDK de Anthropic soporta streaming de fábrica. La clave es usar stream() en lugar del messages.create() normal:

python
import anthropic

client = anthropic.Anthropic()

# Streaming con un context manager
with client.messages.stream(
    model="claude-sonnet-5-5",   # modelos vigentes: página «Lo vigente»
    max_tokens=1024,
    messages=[{"role": "user", "content": "Explica el entrelazamiento cuántico en palabras sencillas"}],
) as stream:
    for text in stream.text_stream:
        print(text, end="", flush=True)

# Obtener el mensaje final después del stream
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}")

El método text_stream es la forma más sencilla: devuelve solo los deltas de texto e ignora los eventos de servicio. Si necesitas control total de los eventos (content_block_start, ping, message_delta), usa stream directamente:

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: streaming al navegador

SSE es la forma más sencilla de llevar una respuesta en streaming al navegador. Es un HTTP GET normal que el servidor mantiene abierto y en el que va escribiendo datos con el formato data: ...\n\n.

🎨 Imagínalo así: SSE es como la radio. Sintonizas la estación (abres la conexión) y escuchas. La transmisión es de un solo sentido: no puedes contestarle al transmisor, solo recibir la señal.

FastAPI con el SDK de Anthropic:

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):
    """Generador de tokens para 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:
            # Formato SSE: data: <payload>\n\n
            yield f"data: {json.dumps({'text': text})}\n\n"
    
    # Señal de fin del stream
    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",  # Desactivamos el búfer de nginx
        }
    )

Del lado del navegador, el EventSource nativo:

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');
};

Limitaciones de SSE: solo solicitudes GET (los prompts largos no caben) y no hay soporte nativo para comunicación en ambos sentidos. Para chats interactivos hace falta WebSocket.

WebSocket: stream en ambos sentidos

WebSocket resuelve el problema de SSE: el cliente puede mandar mensajes en cualquier momento y el servidor responde en stream. Es la base de las interfaces de chat.

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:
            # Recibimos el mensaje del usuario
            data = await websocket.receive_text()
            user_message = json.loads(data)
            
            conversation_history.append({
                "role": "user",
                "content": user_message["text"]
            })
            
            # Transmitimos la respuesta de Claude de vuelta por el WebSocket
            # (el cliente síncrono bloquea el ciclo de eventos; en producción usa AsyncAnthropic y async with)
            full_response = ""
            with client.messages.stream(
                model="claude-sonnet-5-5",
                max_tokens=2048,
                system="Eres un asistente útil. Responde en español.",
                messages=conversation_history,
            ) as stream:
                for text in stream.text_stream:
                    full_response += text
                    await websocket.send_json({
                        "type": "delta",
                        "text": text
                    })
            
            # Señal de fin de la respuesta
            await websocket.send_json({"type": "done"})
            
            # Agregamos la respuesta al historial para el siguiente turno
            conversation_history.append({
                "role": "assistant",
                "content": full_response
            })
    
    except WebSocketDisconnect:
        print("Client disconnected")

TTFT y TPOT: las métricas que importan

TTFT (Time To First Token): el tiempo desde que se envía la solicitud hasta que aparece el primer token. Es justo lo que el usuario siente como "la espera antes de la respuesta". Referencias (aproximadas, ajústalas a tu producto):

  • Bien: menos de medio segundo
  • Aceptable: alrededor de un segundo
  • Mal: bastante más de un segundo

TPOT (Time Per Output Token): el tiempo promedio entre tokens. Define qué tan "fluido" aparece el texto en pantalla. Para leer con comodidad, el texto debe aparecer más rápido de lo que la persona lo lee.

Qué influye en el TTFT:

  • Tamaño del modelo: por lo general, los modelos pequeños (Haiku) responden más rápido que los grandes
  • Largo del prompt: prompts de sistema e historiales largos = más lento
  • Carga de la API: horas pico = más latencia
  • Región: más cerca del centro de datos = menos latencia de red
  • Prompt caching: los tokens en caché no se recalculan; con contextos largos, el TTFT baja bastante

Mide estas métricas en producción:

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 y agentes de voz

🎨 Imagínalo así: si SSE es la radio y WebSocket una línea telefónica, WebRTC es el sistema de intercomunicadores de un edificio. Conexión directa, mínimo de intermediarios, la voz llega casi al instante.

WebRTC (Web Real-Time Communication) es el estándar del navegador para transmitir audio y video peer-to-peer. Sobre él funcionan Google Meet, Zoom Web y Discord. Para los agentes de voz con IA, WebRTC permite:

  • Capturar el micrófono del usuario y transmitir el audio
  • Recibir la voz sintetizada del agente
  • Minimizar la latencia (bastante menor que la de las solicitudes HTTP normales)

La arquitectura básica de un agente de voz:

Código
El usuario habla → [Micrófono] → [WebRTC] → [STT: Whisper / Deepgram]
                                                        ↓
                                              [Claude (texto)]
                                                        ↓
                                    [TTS: ElevenLabs / OpenAI TTS]
                                                        ↓
                        [El usuario escucha] ← [WebRTC] ← [Audio]

El problema de esta arquitectura es que la latencia se acumula en cada paso: reconocimiento de voz + primer token de Claude + generación + síntesis de voz. Todo junto fácilmente suma varios segundos, y en una conversación eso es crítico.

LiveKit: infraestructura para voz

LiveKit es un servidor WebRTC y un SDK open source que se encarga de la complejidad de la infraestructura: servidores TURN/STUN, enrutamiento de medios, grabación de sesiones. Sobre LiveKit se construyen productos de voz propios; plataformas listas como Vapi (ver la lección Agentes de voz con IA) resuelven lo mismo como servicio.

El SDK de Python para un agente de voz con 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="""Eres un asistente de voz. 
            Responde en español, breve y al grano.
            Usa imágenes y analogías para explicar."""
        )

server = AgentServer()

@server.rtc_session()
async def entrypoint(ctx: agents.JobContext):
    session = AgentSession(
        stt=openai.STT(language="es"),           # Speech-to-Text
        llm=anthropic.LLM(model="claude-sonnet-5-5"),  # LLM; modelos vigentes: página «Lo vigente»
        tts=openai.TTS(voice="alloy"),           # Text-to-Speech
        vad=silero.VAD.load(),                   # Voice Activity Detection
    )
    
    # La supresión de ruido y otros parámetros de la sala se configuran con room_options
    # (ver la documentación de LiveKit Agents para tu versión)
    await session.start(room=ctx.room, agent=VoiceAssistant())
    
    # Saludo
    await session.generate_reply(
        instructions="Saluda al usuario en español y ofrécele ayuda"
    )

if __name__ == "__main__":
    agents.cli.run_app(server)

Los plugins se instalan aparte (paquetes livekit-agents, livekit-plugins-openai, livekit-plugins-silero, livekit-plugins-anthropic). La API de LiveKit Agents ha cambiado de versión en versión, así que compárala con la documentación.

LiveKit se encarga de todo lo difícil: cancelación de eco, jitter buffer, ocultamiento de pérdida de paquetes. Tú te concentras en la lógica del agente.

OpenAI Realtime API

OpenAI ofrece un enfoque de fondo distinto: su Realtime API funciona por WebSocket (también hay una variante por WebRTC) y permite mandar audio directamente y recibir audio de respuesta. No hay STT/TTS intermedios: el modelo procesa la voz de forma nativa. Los nombres de los eventos y los campos de la sesión cambiaron al salir de la beta, así que los ejemplos viejos de blogs pueden no funcionar:

python
import asyncio
import json
import websockets
import base64

async def realtime_voice_session():
    # El nombre del modelo en la URL es un ejemplo: tómalo de la lista de modelos vigente en la documentación de OpenAI
    url = "wss://api.openai.com/v1/realtime?model=gpt-realtime-2.1"
    headers = {
        "Authorization": f"Bearer {OPENAI_API_KEY}",
        # El encabezado OpenAI-Beta: realtime=v1 no hace falta en la versión actual de la interfaz
    }
    
    async with websockets.connect(url, additional_headers=headers) as ws:
        # Configuramos la sesión. El formato de audio, la voz y la detección del fin del habla
        # se definen en session.audio: los campos exactos están en la referencia de eventos de la Realtime API
        await ws.send(json.dumps({
            "type": "session.update",
            "session": {
                "type": "realtime",
                "instructions": "Eres un asistente de voz. Responde breve.",
            }
        }))
        
        # Escuchamos los eventos
        async for message in ws:
            event = json.loads(message)
            
            if event["type"] == "response.output_audio.delta":
                # Recibimos un pedazo del audio de respuesta
                audio_data = base64.b64decode(event["delta"])
                # Reproducimos el audio...
            
            elif event["type"] == "response.done":
                print("Respuesta terminada")

La ventaja de la Realtime API es el procesamiento nativo de la voz sin la cadena STT → LLM → TTS, por eso la latencia es menor que en la cascada y la conversación suena más natural. La desventaja: solo modelos de OpenAI; Claude no está disponible en este formato (usa LiveKit con Claude para el esquema en cascada).

Optimizar la latencia

Algunas técnicas para minimizar la latencia en producción:

1. Prompt Caching. Si el prompt de sistema es largo, guárdalo en caché (el largo mínimo para la caché depende del modelo; revisa la documentación). Las solicitudes repetidas no recalcularán el prompt y el TTFT baja:

python
messages = client.messages.create(
    model="claude-sonnet-5-5",
    system=[{
        "type": "text",
        "text": long_system_prompt,
        "cache_control": {"type": "ephemeral"}  # Lo guardamos en caché
    }],
    messages=conversation,
    max_tokens=1024,
)

2. Un modelo más chico para la primera respuesta. La estrategia "cascade": empiezas con Haiku (rápido, barato) y cambias a Sonnet para las preguntas complejas.

3. Edge deployment. Cloudflare Workers AI o AWS Lambda@Edge: corre la inferencia más cerca del usuario.

4. Streaming con inicio temprano del TTS. En los agentes de voz no esperes el texto completo: pásale al TTS cada oración terminada:

python
buffer = ""
async for text in stream.text_stream:
    buffer += text
    # Si ya juntamos una oración completa, directo al TTS
    if any(buffer.endswith(p) for p in ['.', '!', '?', '...']):
        await tts_engine.synthesize_and_play(buffer.strip())
        buffer = ""

Un caso real: chat de soporte con streaming

Imagina: una tienda en línea, cientos de consultas al día, clientes que esperan varios minutos a un operador. Implementas un agente de Claude con streaming por WebSocket.

Qué cambia: el usuario ve las primeras palabras de la respuesta casi de inmediato y la espera deja de sentirse. El agente resuelve las preguntas típicas y los operadores solo ven los casos difíciles. Qué parte de las consultas se resolverá sin una persona y cuánto costará cada respuesta depende de tu base de conocimiento y del modelo: mídelo en un piloto y calcula los tokens con los precios de la página Lo vigente.

La decisión de arquitectura clave aquí es WebSocket en lugar de REST. Una conexión permanente por sesión, intercambio en ambos sentidos, sin el costo de abrir una conexión para cada mensaje.


Práctica

Tarea: una API de chat con streaming usando FastAPI y Claude

Meta: construir un servidor WebSocket que transmita las respuestas de Claude en tiempo real, con un cliente HTML sencillo.

Paso 1: Instalar las dependencias

bash
pip install fastapi uvicorn anthropic websockets python-dotenv

Paso 2: Backend, un servidor WebSocket con FastAPI

Crea el archivo 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 = """Eres un asistente de IA útil. Responde en español, 
usa estructura e imágenes para explicar. Sé breve."""

# Un cliente HTML sencillo para probar
HTML = """
<!DOCTYPE html>
<html>
<head><title>Streaming Chat</title></head>
<body>
<h1>Streaming Chat con 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="Escribe un mensaje..." />
    <button onclick="sendMessage()">Enviar</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>Tú:</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"]
            })
            
            # Señal de inicio de la respuesta
            await websocket.send_json({"type": "start"})
            
            full_response = ""
            
            # Transmitimos la respuesta de Claude
            with client.messages.stream(
                model="claude-haiku-4-5",  # Haiku por velocidad (revisa que el modelo siga disponible en la 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
                    })
            
            # Fin de la respuesta
            await websocket.send_json({"type": "done"})
            
            # Lo guardamos en el historial
            history.append({
                "role": "assistant",
                "content": full_response
            })
            
            # Limitamos el historial (los últimos 10 pares)
            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()

Paso 3: Arrancar el servidor

bash
# Asegúrate de que ANTHROPIC_API_KEY esté en el archivo .env
uvicorn server:app --reload --port 8000

Abre http://localhost:8000 en el navegador. Verás la interfaz del chat con streaming instantáneo.

Paso 4: Medir el TTFT en el código

Agrega la medición de latencia al manejador del 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
        # ... envío al cliente

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}")

Paso 5: Agrega un endpoint SSE para comparar

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",  # modelo rápido para tiempo real
            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"}
    )

Pruébalo: curl "http://localhost:8000/stream?prompt=%C2%BFQu%C3%A9+es+la+IA%3F"; verás los tokens en tiempo real directo en la terminal.


Herramientas y recursos

Herramienta Para qué Documentación
Anthropic SDK (Python) Streaming API con stream() y text_stream platform.claude.com/docs/en/build-with-claude/streaming
FastAPI Endpoints WebSocket y SSE fastapi.tiangolo.com
LiveKit WebRTC open source para agentes de voz docs.livekit.io/agents
LiveKit Agents (Python) Framework sobre LiveKit con integración de Claude github.com/livekit/agents
OpenAI Realtime API Streaming de voz nativo developers.openai.com/api/docs/guides/realtime
websockets (Python) Cliente/servidor WebSocket websockets.readthedocs.io
Deepgram Reconocimiento de voz en flujo rápido, alternativa a Whisper developers.deepgram.com
ElevenLabs TTS de alta calidad con salida en flujo elevenlabs.io/docs
uvicorn Servidor ASGI para FastAPI www.uvicorn.org

Conclusiones clave

"El streaming no es un detalle técnico, es una decisión de UX. El usuario ve el primer token casi de inmediato y siente el sistema vivo."

"El TTFT es tu métrica principal en IA en tiempo real. Si el primer token llega demasiado tarde, el usuario pierde la paciencia. Guarda los prompts en caché y elige un modelo rápido para el primer contacto."

"WebSocket para el chat, SSE para actualizaciones de un solo sentido, WebRTC para la voz: no mezcles estos patrones. Cada herramienta resuelve su propia tarea y tiene su propio costo de configuración."


Siguiente lección

→ Administradores de chatbots: NLP, enrutamiento por intención y escalamiento inteligente

La marca se guarda solo en este navegador y no se envía a ningún sitio. Mi progreso