Streaming real: token a token sin esperar
Esperar que el modelo genere la respuesta entera antes de mostrártela hace que parezca lento aunque no lo sea. Acá explicamos cómo implementamos streaming de verdad en Aleph.
Hay algo psicológicamente importante en ver que el texto aparece mientras el modelo "piensa". No es sólo cosmético: cambia completamente la percepción de velocidad. Un modelo que tarda 8 segundos en generar 200 tokens "de golpe" se siente lento. El mismo modelo mostrando esos tokens uno a uno se siente ágil.
Los servicios en la nube lo aprendieron hace años. Nosotros lo implementamos desde el día uno.
Cómo funciona por debajo
llama-server expone un endpoint POST /v1/chat/completions con soporte para stream: true. Cuando está activado, en lugar de responder con un JSON completo al final, devuelve una secuencia de eventos SSE (Server-Sent Events). Cada evento tiene un delta con el token generado:
data: {"choices":[{"delta":{"content":"Hola"},"finish_reason":null}]}
data: {"choices":[{"delta":{"content":","},"finish_reason":null}]}
data: {"choices":[{"delta":{"content":" ¿en"},"finish_reason":null}]}
...
data: [DONE]
El backend de Aleph (Rust) abre esa conexión HTTP y procesa el stream línea a línea. Cada vez que llega un token, emite un evento Tauri al frontend:
// Rust — cada token genera un emit
app_handle.emit("chat://token", TokenPayload { session_id, token });
Y cuando el stream termina (o hay un error), emite el evento final:
app_handle.emit("chat://done", DonePayload { session_id, full_text, error });
El flujo completo
La cancelación
Una cosa que no queríamos ignorar: si el modelo está generando y el usuario hace clic en "Detener", la respuesta debería cortarse inmediatamente, no en 30 segundos cuando termine. Para esto usamos tokio_util::sync::CancellationToken.
Cada sesión de chat tiene su propio token de cancelación en el AppState. Cuando el usuario envía la señal de stop, cancelamos el token. La task de Rust que está leyendo el stream de llama-server detecta eso (con tokio::select!) y cierra la conexión.
Cancelar a la mitad genera un mensaje de "generación cancelada" en la UI. El texto parcial se mantiene. No se pierde nada.
Por qué no usamos WebSockets
Podríamos haberlo hecho con WebSockets o con polling. Los eventos de Tauri son la opción más natural en este contexto: el backend ya vive en Rust dentro del proceso Tauri, los eventos son eficientes y bidireccionales sin la complejidad de mantener un socket. SSE en llama-server + eventos Tauri fue la combinación que resultó más limpia.
El resultado: el texto aparece a medida que se genera, sin ningún delay artificial, y si querés parar, para de verdad.