aboutsummaryrefslogtreecommitdiffstats
path: root/docs/RENDIMIENTO.md
diff options
context:
space:
mode:
authorelvis <elvis@claros.ar>2026-09-06 19:21:16 -0300
committerelvis <elvis@claros.ar>2026-09-06 19:23:37 -0300
commitf8f98e83481e7376a235fb305a095552433f79ab (patch)
tree6d0ecddb26bef8430a086431de40f8ce9b1039e6 /docs/RENDIMIENTO.md
downloadasist-p-f8f98e83481e7376a235fb305a095552433f79ab.tar.gz
asist-p-f8f98e83481e7376a235fb305a095552433f79ab.zip
Local voice assistant on top of Canary, llama.cpp and qwentts
Pipeline en Rust de hilos y canales que une los tres motores: el micrófono alimenta un segmentador con VAD, las intervenciones cerradas van al reconocedor, la transcripción al modelo y cada frase que este cierra sale hacia el sintetizador sin esperar al resto de la respuesta. Dos reglas sostienen el diseño: ninguna etapa bloquea a la anterior —quien va sobrado descarta trabajo en lugar de acumular retraso— y todo lo que viaja por los canales lleva el turno al que pertenece, así que interrumpir es subir el contador y levantar dos banderas de cancelación. Seis crates: core (configuración, eventos, HTTP, telemetría, herramientas), audio (cpal, VAD, anillo de reproducción), asr, llm, tts y app (supervisor de procesos y orquestador). Los motores van como submódulos fijados a un commit, con los cambios locales en vendor/patches. Midiendo el pipeline aparecieron tres cuellos de botella de configuración que valieron más que cualquier cambio de código, todos documentados en docs/RENDIMIENTO.md: - tts-server decodificaba el audio en bloques de 24 s, de modo que el modo «streaming» llegaba de una pieza: 4948 ms -> 585 ms hasta el primer audio. - La plantilla de chat del modelo abre <think> y no lo cierra nunca, sin variable que lo apague: 8630 ms -> 413 ms hasta el primer token, con una copia de la plantilla que deja el bloque cerrado de entrada. - Cualquier indicación de estilo junto a la guía de herramientas hace que este modelo de 2B deje de llamarlas y se invente el dato (8/8 aciertos con la guía sola, 0/8 con la persona de asistente de voz). El turno alterna ahora entre dos instrucciones de sistema. La ejecución de órdenes del sistema queda implementada y apagada, tras cuatro barreras: lista blanca sobre el ejecutable, rutas rechazadas, sin shell que interprete metacaracteres y plazo máximo. 68 pruebas unitarias sin modelos, más seis de integración que se saltan solas si no hay servidores y se turnan la GPU: en paralelo, los dos servidores no caben en 4 GB y miden contención en vez de latencia. Claude-Session: https://claude.ai/code/session_01FNxz5cSdQSscJH9H7b8uGU
Diffstat (limited to 'docs/RENDIMIENTO.md')
-rw-r--r--docs/RENDIMIENTO.md216
1 files changed, 216 insertions, 0 deletions
diff --git a/docs/RENDIMIENTO.md b/docs/RENDIMIENTO.md
new file mode 100644
index 0000000..543ee62
--- /dev/null
+++ b/docs/RENDIMIENTO.md
@@ -0,0 +1,216 @@
+# Rendimiento: dónde se va el tiempo
+
+Todo lo que hay aquí está medido en esta máquina, no estimado. Las cifras
+importan menos que el método: cada ajuste del proyecto sale de una medición
+concreta, y el binario sigue midiendo en cada turno para que se note si algo
+se tuerce.
+
+**Máquina:** Intel de 12 hilos · 31 GB de RAM · NVIDIA GTX 1050 de 4 GB ·
+Linux 7.2 (CachyOS) · PipeWire.
+
+---
+
+## Resumen: los tres hallazgos que más cambiaron el resultado
+
+| Hallazgo | Antes | Después | Factor |
+|---|---|---|---|
+| El TTS decodificaba el audio en bloques de 24 s | 4948 ms al primer audio | 585 ms | **8,5×** |
+| La plantilla del LLM abría `<think>` y no lo cerraba | 8630 ms al primer token | 413 ms | **21×** |
+| El prompt de estilo anulaba las llamadas a herramientas | 0 de 8 aciertos | 8 de 8 | — |
+
+Los tres son de configuración, no de código. Ninguno da error: el sistema
+funciona, sólo que despacio o mal, que es lo que los hace difíciles de ver.
+
+---
+
+## 1. El TTS no devolvía nada hasta terminar la frase
+
+`tts-server` acepta `--codec-chunk-dur`, los segundos de audio que el codec
+acumula antes de decodificar un bloque. **De fábrica son 24 s.** Como casi
+ninguna frase dura eso, en la práctica el servidor generaba la frase entera y
+sólo entonces la decodificaba y la enviaba: el modo `pcm` decía ser un flujo,
+pero llegaba de una pieza.
+
+Misma frase, mismo modelo, sólo cambia ese parámetro:
+
+| `--codec-chunk-dur` | Primer audio | Total | RTF |
+|---|---|---|---|
+| 24.0 (de fábrica) | 4948 ms | 7,82 s | 2,51 |
+| 1.0 | **585 ms** | 3,56 s | 1,11 |
+
+El total también baja porque la decodificación se solapa con la generación en
+lugar de ir detrás.
+
+**Aplicado en:** `config.supervisor.tts.codec_chunk_dur = 1.0`.
+**Lo vigila:** `una_sintesis_empieza_a_sonar_antes_de_terminar`, que falla si
+el audio llega en un solo bloque.
+
+## 2. El modelo razonaba en voz baja durante ocho segundos
+
+La plantilla de chat que Qwen3.5-2B trae en el GGUF termina así:
+
+```jinja
+{{- '<|im_start|>assistant\n<think>\n' }}
+```
+
+Abre el bloque de razonamiento y no lo cierra nunca, y no hay ninguna variable
+`enable_thinking` que lo apague. El modelo gastaba entre 120 y 200 tokens
+pensando antes de emitir una sola palabra audible. Desde fuera parecía que el
+asistente se había colgado.
+
+Lo que **no** funcionó, y conviene saberlo para no repetirlo:
+
+- `"chat_template_kwargs": {"enable_thinking": false}` — la plantilla no lee
+ esa variable.
+- `"reasoning_effort": "none"`, `"reasoning_budget": 0` en el cuerpo de la
+ petición — no se aceptan por petición.
+- `--reasoning-budget 0` como argumento del servidor — no llegó a surtir
+ efecto con esta plantilla.
+
+Lo que sí: copiar la plantilla y dejar el bloque cerrado de entrada
+(`config/qwen35-no-think.jinja`), pasándola con `--chat-template-file`.
+
+```jinja
+{{- '<|im_start|>assistant\n<think>\n\n</think>\n\n' }}
+```
+
+| | Primer token | Tokens de razonamiento |
+|---|---|---|
+| Plantilla del modelo | 8630 ms | 147–200 |
+| Plantilla con el bloque cerrado | **413–573 ms** | 0 |
+
+La velocidad de generación no cambió (18,4 tokens/s); lo que cambió es en qué
+se gastaban. Ojo con una trampa al medir: contando sólo los `delta.content`
+parecía que el modelo iba a 1,1 tokens/s, cuando de verdad iba a 18,4 y el
+resto llegaba en `delta.reasoning_content`.
+
+**Aplicado en:** `config.supervisor.llama.chat_template`.
+**Lo vigila:** `el_llm_responde_en_streaming_sin_razonar_en_voz_alta`, que
+exige el primer token en menos de 3 s.
+
+## 3. El prompt de estilo impedía usar las herramientas
+
+Preguntándole la hora con la herramienta `hora_actual` declarada, el modelo se
+inventaba la respuesta («las 12:00», «24 de mayo de 2024») en vez de llamarla.
+No era un fallo del formato de herramientas —sin instrucción de sistema, la
+llamada salía bien—, sino del prompt.
+
+Aciertos sobre 8 intentos, misma herramienta, misma pregunta:
+
+| Instrucción de sistema | Llama a la herramienta |
+|---|---|
+| Sólo la guía de herramientas | **8 / 8** |
+| Guía + «Responde breve.» | 1 / 8 |
+| Guía + «Responde en una o dos frases de texto plano» | 0 / 8 |
+| Guía + «Tu respuesta se leerá en voz alta» | 0 / 8 |
+| Guía + persona de asistente de voz | 0 / 8 |
+| Estilo delante, guía al final | 0 / 8 |
+| Estilo en el turno de usuario, guía en el sistema | 0 / 8 |
+
+Dos palabras de estilo bastan para pasar de 8/8 a 1/8, en cualquier posición.
+Es una fragilidad de este modelo de 2B, no algo general.
+
+**Solución:** el turno usa dos instrucciones de sistema. La pasada en que el
+modelo *decide* si actuar lleva la guía de herramientas a solas; en cuanto hay
+resultados, se cambia a la instrucción de voz para redactar lo que se
+pronuncia. El historial sobrevive al cambio (`Conversation::set_system`).
+
+**El precio:** una respuesta que no usa herramienta se redacta sin la guía de
+estilo. Se compensa en parte porque `clean_for_speech` quita el markdown antes
+de hablar, y `max_tokens` acota la longitud. Con
+`tools.dedicated_prompt = false` se invierte la prioridad.
+
+---
+
+## Un turno real, medido de punta a punta
+
+Audio inyectado por un dispositivo virtual, «Hola. ¿Me puedes decir qué hora
+es?», 2,5 s de voz:
+
+```
+turno #1 — desglose de latencia
+ asr.final 200 ms
+ llm.primer_token 1660 ms <== cuello de botella
+ llm.resto 1362 ms
+ herramientas 1 ms
+ asr.rtf 0.08 x tiempo real (2.5 s de voz)
+ total del turno 5442 ms
+```
+
+- **ASR: 200 ms para 2,5 s de voz (RTF 0,08).** Canary-180m en CPU va muy
+ sobrado. No es el problema.
+- **LLM: 1660 ms al primer token.** Ahora es el cuello de botella. Es más que
+ los 413 ms medidos con el servidor en reposo, y la diferencia es contención:
+ el ASR acaba de decodificar y el TTS está a punto de arrancar.
+- **Herramientas: 1 ms.** Irrelevante.
+- **Latencia percibida: 3213 ms sin herramienta, 6320 ms con ella.** La
+ diferencia es la segunda pasada del modelo, que sólo ocurre si llama.
+
+## La GPU es de 4 GB y no caben los dos
+
+`tts-server` deja residentes unos 3,2 GB (1765 MB del hablante, 177 del
+predictor, 896 de caché KV y el codec), y `llama-server` quiere los suyos.
+Medido: **4020 MiB usados de 4096**.
+
+Se nota cuando ambos trabajan a la vez. Las pruebas de integración lo
+enseñaron sin querer: en paralelo, el primer token del LLM pasaba de ~0,5 s a
+**5,0 s**, y la prueba fallaba. Por eso se turnan mediante un mutex
+(`en_exclusiva()`), y por eso el ASR corre en CPU aunque haya CUDA disponible:
+disputar esa memoria sale más caro que decodificar en el procesador.
+
+El pipeline de verdad sufre menos porque las etapas están naturalmente
+escalonadas —la síntesis no arranca hasta que el modelo cierra su primera
+frase—, pero el solape existe y es la razón principal de que el primer token
+tarde 1,6 s en un turno real y 0,4 s en un banco de pruebas.
+
+Ajustes derivados:
+
+- `--ctx-size 8192` en vez del contexto completo del modelo (262144). Con
+ `--ctx-size 0` y 4 ranuras, la caché KV reservada es enorme; para un
+ asistente de voz, 8192 sobran.
+- `--parallel 2` en vez de 4.
+- `asr.execution_provider = "cpu"`.
+
+## El TTS va justo de tiempo real
+
+Los turnos medidos dan **RTF entre 1,09 y 1,42**: la síntesis genera audio algo
+más despacio de lo que se reproduce. El anillo de reproducción absorbe los
+baches cortos, pero en una respuesta larga la cola se vacía y la voz se
+entrecorta. El binario lo avisa:
+
+```
+WARN tts: la síntesis va por detrás del tiempo real; la voz se cortará a trozos rtf=1.42
+```
+
+Es la limitación de fondo que queda. Lo que ya está hecho para mitigarla:
+
+- **Trocear por frases.** La primera sale con un umbral más bajo (12
+ caracteres) que las siguientes (40): el arranque manda en la latencia
+ percibida, pero una vez que la voz suena, las frases largas se entonan
+ mejor.
+- **Precalentar al arrancar.** La primera síntesis del proceso cuesta unos
+ 3,5 s más que el resto —construir los grafos—, y se pagan antes de que haya
+ nadie escuchando (`tts.warmup`, medido: 1069–1530 ms).
+- **`max_tokens = 300`.** Una respuesta hablada larga cansa, y además es lo
+ que más TTS cuesta.
+
+Si hiciera falta más margen, el siguiente paso sería el modelo hablante de
+0,6B en vez del de 1,7B: hay uno en `vendor/qwentts.cpp/models/`.
+
+## Qué mide el binario y cómo verlo
+
+`asist-core::telemetry` cronometra cada etapa por turno y señala la más lenta.
+Se imprime al terminar cada respuesta si `general.report_latency = true`, y al
+cerrar sale la media de la sesión.
+
+```bash
+# El desglose por turno y poco más
+RUST_LOG=info,latencia=info cargo run --release -- run
+
+# Cada ventana del ASR, cada síntesis, cada frase
+RUST_LOG=info,asr=debug,tts=debug cargo run --release -- run
+
+# Comprobar de nuevo las cifras de arriba
+scripts/servidores.sh arrancar
+cargo test --release -p asist-app --test integracion -- --nocapture
+```