Latencia y streaming: respuestas LLM en tiempo real
El LLM tarda 2-5s en responder. Cómo evitar que tu juego parezca colgado: streaming token a token, voice barks de transición y skeleton UI.
Publicado: · Por Juanjo "Banyo" López
0. Introducción
El jugador hace click en un NPC, escribe una pregunta, pulsa enter… y se queda mirando una pantalla quieta tres segundos. No hay animación, no hay sonido, no hay nada. Cuando por fin aparece la respuesta, la conexión emocional ya se rompió. Sintió el motor, no al personaje.
Las APIs comerciales devuelven la respuesta cuando termina toda la generación. Un modelo medio tarda 2-5 segundos. Un modelo top entre 4 y 10. Tu NPC no responde lento porque tu código sea malo: responde lento porque así funciona la inferencia. Lo que sí puedes controlar es lo que el jugador percibe mientras tanto.
Este tutorial cubre las dos piezas que arreglan el problema: streaming (mostrar tokens según llegan) y los UX patterns que enmascaran los segundos restantes.
En este tutorial vas a ver:
- Qué es TTFT, por qué importa más que el tiempo total y cómo medirlo.
- Cómo se consume un stream de SSE desde Unity / C# y cómo cancelar a mitad.
- Los cuatro trucos de UX que hacen que 4 segundos se sientan como uno.
1. Demo
2. Concepto y arquitectura
Streaming en LLMs es recibir la respuesta token a token a medida que se genera, en vez de esperar a que termine. Reduce el tiempo percibido aunque el total sea igual. La pieza crítica no es el streaming en sí: es lo que muestras al jugador mientras llega.
Todo lo demás de esta sección son detalles para implementar bien esa idea: cómo se mide la latencia real, cómo funciona el protocolo, qué patrones de UX disimulan los huecos y cuándo cancelar para no malgastar tokens.
2.1 TTFT vs tiempo total: ¿qué métrica importa de verdad?
Hay dos números que definen la latencia de un LLM:
- TTFT (Time To First Token): cuánto tarda en aparecer el primer token desde que enviaste la request.
- Tiempo total: cuánto tarda en terminar toda la respuesta.
Cuando NO usas streaming, el jugador espera el tiempo total entero antes de ver nada. Cuando SÍ lo usas, el jugador espera solo el TTFT antes de empezar a leer. La diferencia es brutal: un modelo top puede tener 4 segundos de tiempo total pero 600 ms de TTFT. Sin streaming son 4 segundos de pantalla muerta. Con streaming, 600 ms y el jugador ya está leyendo.
Un patrón típico de timeline:
Mismo tiempo total (3200 ms). Sin streaming, el jugador mira una pantalla muerta hasta el final. Con streaming, empieza a leer en cuanto llega el TTFT.
El tiempo total no cambia. Lo que cambia es cuándo empieza la lectura. Y leer es una tarea activa: el cerebro deja de notar la espera.
2.2 ¿Cómo funciona streaming técnicamente?
La API expone el endpoint con un flag stream: true. En vez de devolver un JSON al final, mantiene la conexión HTTP abierta y va escribiendo eventos a medida que el modelo genera tokens. El formato estándar es SSE (Server-Sent Events): líneas con prefijo data:, separadas por dos saltos de línea.
data: {"choices":[{"delta":{"content":"Hola"}}]}
data: {"choices":[{"delta":{"content":", forastero"}}]}
data: {"choices":[{"delta":{"content":". ¿Qué buscas?"}}]}
data: [DONE]
Cada chunk sale del servidor en cuanto el modelo produce el delta. El cliente los pinta a medida que llegan, sin esperar al [DONE].
Tu cliente lee la respuesta como un stream (no como un string), parsea cada línea data:, extrae el delta.content y lo concatena. Cuando llega data: [DONE], cierras. El delta es un fragmento (pueden ser caracteres sueltos, palabras o varias palabras según el tokenizer).
Detrás de SSE va un HTTP normal con Transfer-Encoding: chunked. Lo único especial es que no esperas a Content-Length: lees mientras vengan bytes.
2.3 ¿Cuánto tarda cada componente?
Valores orientativos para 2026 con un modelo “medio” (Sonnet-tier, GPT-4o):
| Fase | Tiempo típico |
|---|---|
| TTFT (modelo medio, cloud) | 400-1200 ms |
| TTFT (modelo top, cloud) | 600-2000 ms |
| Throughput | 30-80 tokens/s |
| Respuesta de 80 tokens | 1-3 s |
| Respuesta de 300 tokens | 4-10 s |
| Cancelación de stream | instantánea (cierras el socket) |
Dos lecturas importantes. Primero: el TTFT es relativamente estable; lo que más varía es el tiempo total, y eso depende de cuántos tokens generes (palanca §2.7). Segundo: con throughput de 40 t/s, cada palabra tarda ~50-100 ms en aparecer. Eso es exactamente la velocidad a la que un humano lee. Pintar la respuesta token a token no se siente lento: se siente vivo.
2.4 ¿Qué UX patterns enmascaran la espera?
El TTFT siempre será > 0. Aunque sea 400 ms, sin nada en pantalla esos 400 ms son raros. Cuatro trucos que se combinan:
- Voice bark de transición. Cuando el jugador termina su mensaje, suelta de inmediato un audio corto del NPC (“Hmm…”, “piensa”, un suspiro, un typing sound de máquina de escribir). El sonido llega en ms y le dice al cerebro “te escuché, estoy procesando”.
- Animación de pensar. Activa un estado de animación
thinkingen el NPC: que se rasque la barbilla, mire arriba, mueva los labios sin hablar. Si tu NPC es 2D, una burbuja...parpadeante. El movimiento es la diferencia entre “vivo” y “colgado”. - Skeleton del bubble de diálogo. Aparece la burbuja de texto vacía o con un placeholder gris en cuanto el jugador envía. Avisa “aquí va a salir la respuesta”, reserva el espacio en pantalla y elimina el reflow cuando llegue el texto.
- Reveal token a token con caret. Cuando empiezan a llegar tokens, los pintas con un caret parpadeante al final. El cerebro lee “está escribiendo ahora mismo” — y el caret cubre el hueco si entre token y token hay un microbache.
Los cuatro son acumulativos. Sin ellos, streaming sigue siendo mejor que no streaming, pero se siente seco. Con ellos, los 600 ms de TTFT desaparecen.
2.5 ¿Cuándo cancelar la generación?
Si el jugador interrumpe — cierra el diálogo, hace click en otra opción, cambia de NPC — cancela el stream. Tres razones:
- Costo: las APIs cobran por tokens generados. Si el jugador no va a leer la respuesta, no la pagues.
- Recursos del servidor (local): en Ollama, una generación abandonada bloquea la GPU para la siguiente.
- Responsividad: si no cancelas y arranca otra request, las respuestas se solapan y aparecen tarde y desordenadas.
Cancelar es trivial cuando el stream está abierto: cierras el socket o pasas un CancellationToken cancelado. La API lo detecta y deja de generar. En C# es literalmente cts.Cancel().
2.6 Streaming local (Ollama) vs cloud: ¿cuál tiene mejor feeling?
No siempre el local gana. Cloud paga red (RTT al servidor) y scheduling (tu request en cola entre otras). Local paga inferencia pura sobre el hardware del jugador. En una máquina modesta sin GPU dedicada, el TTFT de un modelo 7B corriendo en Ollama puede ser PEOR que el de una API cloud — y el throughput suele ser mucho peor.
| Escenario | TTFT típico | Throughput |
|---|---|---|
| Cloud, conexión decente | 400-1200 ms | 30-80 t/s |
| Ollama, GPU dedicada moderna | 100-300 ms | 50-150 t/s |
| Ollama, CPU o GPU integrada | 1-4 s | 5-20 t/s |
La conclusión incómoda: el hardware del jugador define el feeling. Si shippeas con LLM local, considera detectar hardware y degradar (modelo más pequeño en máquinas modestas) o caer a cloud como fallback.
2.7 ¿Qué cambia en el prompt si quieres respuestas cortas?
Brevedad es una palanca de latencia. Si el modelo genera 60 tokens en vez de 200, terminas tres veces antes. La forma directa: instruir al modelo.
Añade al system prompt algo como:
Responde en máximo 2 frases. Sé directo. Si necesitas más, pregunta.
También baja el max_tokens a un valor coherente con esa instrucción (120-160 deja margen). Combinado, el tiempo total se desploma sin tocar el modelo ni la infraestructura. Para NPCs conversacionales esto casi siempre es lo que quieres: nadie en una taberna habla en párrafos.
Como bonus: respuestas cortas también cuestan menos. Output suele ser 3-5× más caro que input.
2.8 El pitfall del JSON parsing en streaming
Si tu LLM debe devolver JSON estructurado — function calling, datos de inventario, decisiones del NPC con campos — el streaming se complica. No puedes parsear JSON parcial: {"action": "att no es JSON válido. Tienes tres opciones realistas:
- Esperar al final. Activas streaming pero ignoras los deltas y procesas solo cuando llega
[DONE]. Pierdes el beneficio percibido. - Streaming parcial con tolerancia. Algunos providers (OpenAI, Anthropic) emiten function calls como un stream de pares clave-valor que sí puedes consumir parcialmente; requiere un parser stream-aware.
- Separar canales. El NPC genera texto plano en streaming (para mostrar) y, al terminar, una segunda llamada barata extrae estructura. Dos requests, pero el jugador ve la respuesta de inmediato.
Para diálogos de NPC en texto plano, streaming es trivial y siempre compensa. Para function calling, decide caso por caso.
3. Pseudocódigo
function streamChat(client: LlmClient, messages: List<Message>,
onToken: Func<String>, cancel: CancellationToken) -> String
full = ""
request = buildRequest(messages, stream=true)
response = client.openStream(request)
for each chunk in response.events()
if cancel.isRequested
response.close()
return full
# cada chunk es una línea "data: {json}" o "data: [DONE]"
if chunk == "[DONE]"
break
token = parseDelta(chunk)
if token != null and token.length > 0
full += token
onToken(token)
return full
function parseDelta(line: String) -> String
json = jsonParse(stripPrefix(line, "data: "))
return json.choices[0].delta.content # null si es un keep-alive
Tres ideas: la respuesta llega como un flujo de líneas, el onToken es el callback que tu UI usa para pintar incrementalmente, y cancel permite abortar limpio sin abandonar sockets abiertos.
4. Implementación en Unity / C#
Cliente de streaming con HttpClient, HttpCompletionOption.ResponseHeadersRead para no esperar al body completo, y CancellationToken para abortar. Patrón válido para OpenAI, Anthropic y endpoints compatibles.
using System;
using System.IO;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
public class StreamingLlmClient : MonoBehaviour {
static readonly HttpClient http = new();
CancellationTokenSource cts;
public async Task<string> StreamChat(
string body, Action<string> onToken
) {
cts?.Cancel(); // cancela un stream previo si lo hay
cts = new CancellationTokenSource();
var token = cts.Token;
var req = new HttpRequestMessage(HttpMethod.Post,
"https://api.openai.com/v1/chat/completions");
req.Headers.Add("Authorization",
$"Bearer {Environment.GetEnvironmentVariable("OPENAI_API_KEY")}");
req.Content = new StringContent(body, Encoding.UTF8, "application/json");
// CLAVE: ResponseHeadersRead devuelve apenas llegan los headers,
// sin esperar al body completo. Sin esto, no hay streaming real.
using var resp = await http.SendAsync(
req, HttpCompletionOption.ResponseHeadersRead, token);
resp.EnsureSuccessStatusCode();
using var stream = await resp.Content.ReadAsStreamAsync(token);
using var reader = new StreamReader(stream);
var full = new StringBuilder();
string line;
while ((line = await reader.ReadLineAsync()) != null) {
if (token.IsCancellationRequested) break;
if (string.IsNullOrEmpty(line) || !line.StartsWith("data:")) continue;
var payload = line.Substring(5).Trim();
if (payload == "[DONE]") break;
var delta = ExtractDelta(payload); // helper: choices[0].delta.content
if (!string.IsNullOrEmpty(delta)) {
full.Append(delta);
onToken(delta); // tu UI pinta el token aquí
}
}
return full.ToString();
}
public void CancelStream() => cts?.Cancel();
void OnDisable() => cts?.Cancel();
string ExtractDelta(string json) {
// En producción: Newtonsoft.Json o System.Text.Json.
// JsonUtility de Unity no maneja bien estructuras anidadas.
return "";
}
}
5. En otros engines
- Godot:
HTTPClient(noHTTPRequest, que es de tirón único) conread_response_body_chunk()en un loop. Si usas C# en Godot, el snippet de arriba funciona casi sin tocar. - Unreal:
FHttpModule+IHttpRequest::OnRequestProgresspara recibir chunks parciales. Más verboso; muchos equipos prefieren unUWebSocketo un plugin SSE dedicado para evitar parsear manual. - JavaScript / web:
fetch()conresponse.body.getReader()para SSE manual, oEventSourcepara SSE puro (peroEventSourcesolo soporta GET, así que para LLMs casi siempre vas confetch+ reader).
La lógica de UX (bark, skeleton, caret, cancel) es idéntica en todos. Solo cambia el cliente HTTP.
6. Quiz
Pon a prueba lo que entendiste
Responde una por una. La explicación aparece al elegir, correcta o no.
El jugador empieza a leer la respuesta y el NPC corta a mitad de frase. ¿Qué pasó probablemente?
Tu modelo tiene throughput de 30 t/s y TTFT de 500 ms, pero los jugadores siguen quejándose de que se siente lento. ¿Qué pruebas primero?
¿Por qué streaming NO reduce la latencia total de la respuesta?
Quieres que tu NPC devuelva JSON con un campo 'mood' y otro 'reply'. Activas streaming y la UI se rompe. ¿Por qué?
Tu NPC con LLM corriendo en Ollama local se siente más lento en una máquina sin GPU dedicada que tu versión cloud. ¿Tiene sentido?