Lo esencial
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:
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:
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: 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.
FastAPI con el SDK de Anthropic:
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:
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.
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:
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
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:
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:
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:
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:
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:
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
pip install fastapi uvicorn anthropic websockets python-dotenvPaso 2: Backend, un servidor WebSocket con FastAPI
Crea el archivo 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 = """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
# Asegúrate de que ANTHROPIC_API_KEY esté en el archivo .env
uvicorn server:app --reload --port 8000Abre 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:
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
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