diff options
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/EXTENDER.md | 138 | ||||
| -rw-r--r-- | docs/RENDIMIENTO.md | 216 |
2 files changed, 354 insertions, 0 deletions
diff --git a/docs/EXTENDER.md b/docs/EXTENDER.md new file mode 100644 index 0000000..e45eb16 --- /dev/null +++ b/docs/EXTENDER.md @@ -0,0 +1,138 @@ +# Extender el asistente + +## Añadir una herramienta + +Las herramientas son el punto de extensión pensado para lo que venga después: +ejecutar órdenes, consultar cosas, encender luces. Una herramienta se describe +a sí misma en JSON Schema y sabe ejecutarse; el registro se encarga del resto. + +Implementa `asist_core::tools::Tool`: + +```rust +use asist_core::error::{Error, Result}; +use asist_core::tools::Tool; +use serde_json::{json, Value}; + +pub struct Temperatura; + +impl Tool for Temperatura { + fn name(&self) -> &str { "temperatura_cpu" } + + // La lee el modelo, así que se escribe para él: concreta y en español. + fn description(&self) -> &str { + "Devuelve la temperatura actual de la CPU en grados. Úsala cuando \ + pregunten si el equipo está caliente." + } + + fn parameters(&self) -> Value { + json!({ "type": "object", "properties": {}, "required": [] }) + } + + fn call(&self, _args: &Value) -> Result<String> { + let raw = std::fs::read_to_string("/sys/class/thermal/thermal_zone0/temp") + .map_err(|e| Error::Tool { tool: self.name().into(), message: e.to_string() })?; + let grados = raw.trim().parse::<f32>().unwrap_or(0.0) / 1000.0; + Ok(format!("{grados:.1} grados")) + } + + // Ponlo a true si cambia algo fuera del proceso. + fn is_side_effecting(&self) -> bool { false } +} +``` + +Y regístrala en `ToolRegistry::from_config` (en `crates/asist-core/src/tools.rs`) +o directamente antes de montar el pipeline: + +```rust +let mut tools = ToolRegistry::from_config(&config.tools); +tools.register(std::sync::Arc::new(Temperatura)); +``` + +Tres cosas que conviene saber antes de escribir la primera: + +- **El error nunca tumba el turno.** `dispatch` convierte un fallo en texto y + se lo devuelve al modelo, que lo explica o reintenta. +- **La salida se lee en voz alta.** Devuelve una frase, no una tabla. El + registro recorta a 2000 caracteres, pero mucho antes de eso ya aburre. +- **Con este modelo, más herramientas es peor.** Qwen3.5-2B ya es frágil + eligiendo entre dos; ver el apartado 3 de [RENDIMIENTO.md](RENDIMIENTO.md). + +## Ejecutar órdenes del sistema + +Ya está implementado y apagado (`tools.shell = false`). Encenderlo es un cambio +de postura de seguridad, no una comodidad: lo que llega a esa herramienta +viene, en última instancia, de lo que se oye por el micrófono. + +Las cuatro barreras que tiene, y que conviene no quitar: + +1. **Apagada por defecto**, y la configuración se niega a arrancar con la lista + blanca vacía. +2. **Lista blanca sobre el ejecutable.** Se compara `argv[0]` exacto, y las + rutas se rechazan: `/bin/echo` no cuela aunque `echo` esté permitido. +3. **Sin shell de por medio.** Los argumentos van al `execve` tal cual, así que + `; rm -rf /` es un argumento literal y no otra orden. +4. **Plazo máximo**, tras el cual el proceso se mata. + +```toml +[tools] +shell = true +shell_allowlist = ["date", "uptime", "free", "df", "ls"] +shell_timeout_secs = 10 +shell_dry_run = true # estrena la lista sin ejecutar nada +shell_working_dir = "/home/elvis" +``` + +Empieza con `shell_dry_run = true`: registra lo que el modelo habría ejecutado +sin llegar a hacerlo, que es la forma barata de descubrir qué pide de verdad. + +## Cambiar de motor + +Cada etapa habla con su motor a través de un único tipo, así que sustituir uno +no toca el resto: + +| Etapa | Tipo | Fichero | +|---|---|---| +| Voz a texto | `asist_asr::Recognizer` | `crates/asist-asr/src/lib.rs` | +| Modelo | `asist_llm::LlmClient` | `crates/asist-llm/src/client.rs` | +| Texto a voz | `asist_tts::TtsClient` | `crates/asist-tts/src/lib.rs` | + +El cliente HTTP (`asist_core::http`) es propio y mínimo a propósito: todo es +HTTP plano contra localhost, y lo que ninguna librería genérica da cómodo es +**abortar una respuesta a media descarga**, que es justo lo que sostiene la +interrupción. + +## Añadir una etapa al pipeline + +El orquestador (`crates/asist-app/src/pipeline.rs`) es una cadena de hilos +unidos por canales. Para meter una etapa nueva —clasificar la intención, +filtrar palabrotas, registrar la conversación— añade un hilo con +`spawn_named` y un canal, y respeta las dos reglas que sostienen el conjunto: + +1. **Nada bloquea al que va delante.** Si vas más lento que tu emisor, + descarta trabajo; no acumules retraso. +2. **Comprueba el turno antes de gastar esfuerzo.** `session.is_current(turn)` + dice si lo que estás a punto de hacer sigue interesando a alguien, o si el + usuario ya ha dicho otra cosa. + +## Cambiar la voz + +```bash +# Graba unos segundos de la voz que quieras y transcríbelos a mano. +scripts/clonar-voz.sh grabacion.wav transcripcion.txt mivoz +``` + +Deja `assets/voices/mivoz.{spk,rvq,txt}`. Apunta ahí `[tts.reference]` en la +configuración. Los latentes se extraen una vez; en cada arranque se manda lo ya +extraído y no la grabación. + +También sirven las voces del propio modelo: quita `[tts.reference]` y pon +`tts.voice` con un nombre de `GET /v1/audio/voices`. + +## Ideas que el diseño ya admite + +- **Palabra de activación.** El segmentador ya entrega intervenciones sueltas: + bastaría con filtrar por la transcripción antes de abrir turno. +- **Memoria entre sesiones.** `Conversation` es serializable en la práctica; + guardarla y recargarla al arrancar es un cambio pequeño. +- **Multimodal.** El servidor ya carga `mmproj`, así que el modelo puede ver + imágenes; faltaría meterlas en el mensaje de usuario. 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 +``` |