# 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 `` 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 | — | | La cámara capturaba a 1280x720 | 7,8 s por imagen | 2,9 s a 640x480 | **2,7×** | | Una herramienta se ejecutaba en silencio | ~8,4 s hasta oír algo | 4,1 s con acuse hablado | **2×** | 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\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\n\n\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. ## 4. La cámara: la resolución manda en la latencia El servidor ya carga el proyector multimodal, así que el mismo modelo que conversa describe lo que capta la cámara. Lo que cuesta es la imagen: cada píxel se convierte en tokens que el modelo tiene que procesar. Misma escena, misma pregunta, sólo cambia la resolución de captura: | Resolución | Tamaño | Respuesta del modelo | Calidad | |---|---|---|---| | 320x240 | 3 KB | **1,33 s** | «Una pared blanca con un interruptor» | | 640x480 | 9 KB | **2,88 s** | «Una pared blanca y un interruptor blanco en la pared izquierda» | | 960x540 | 15 KB | 5,23 s | describe además el marco de la puerta | | 1280x720 | 24 KB | 7,81 s | equivalente a 960x540 | 640x480 es el punto de equilibrio: sigue distinguiendo objetos y colores, y cuesta la tercera parte que la resolución máxima. Por encima de 960x540 el tiempo se duplica sin que la descripción mejore. La captura en sí es barata: **0,45 s** con ffmpeg, y descartar unos fotogramas para que la exposición automática se asiente sale prácticamente gratis (0,53 s sin descartar ninguno frente a 0,45 s descartando tres; el brillo medio baja de 176 a 174 mientras el sensor se ajusta). ## 4 bis. Los tres caminos a DuckDuckGo, y cuál funciona Buscar sin clave es posible, pero no por donde parece. Comprobado aquí: | Camino | Resultado | |---|---| | `curl` a `html.duckduckgo.com/html/` | **HTTP 202** con página anti-bot (`anomaly.js`), cero resultados | | API sin clave `api.duckduckgo.com` (Instant Answer) | **vacío** para «capital de Australia» y para «qué tiempo hace en Buenos Aires» | | Librería `ddgs` (la que usa `duckduckgo-mcp`) | **funciona**, 1,5–5,8 s | La librería rota buscadores y cabeceras, y por eso pasa donde curl no. Comparado con Tavily sobre las mismas preguntas: | | «capital de Australia» | «tiempo hoy en Buenos Aires» | Forma del resultado | |---|---|---|---| | tavily | **0,54 s** | 2,88 s | respuesta ya redactada | | ddgs | 2,24 s | 3,59 s | fragmentos de tres páginas | Lo que decide no es el tiempo sino la forma. Con Tavily el asistente lee la respuesta; con ddgs el modelo tiene que resumir los fragmentos, y con 2B eso sale bien casi siempre —en el pipeline completo devolvió «Hoy en Buenos Aires hace soleado esta mañana y por la tarde tendremos nubes con temperaturas alrededor de 9°C», correcto— pero es una oportunidad más de equivocarse. ## 5. Con tres herramientas el modelo elige bien, pero busca de más Con `hora_actual`, `buscar_en_internet` y `mirar_por_la_camara` declaradas a la vez, sobre 11 preguntas y dos intentos cada una: | Tipo de pregunta | Acierto | |---|---| | «¿Qué hora es?», «¿Qué día es hoy?» | 4 / 4 | | «¿Qué tiempo hace?», «¿A cuánto está el dólar?», «Busca noticias» | 6 / 6 | | «¿Qué ves?», «¿De qué color es mi camiseta?», «Mira por la cámara» | 5 / 6 | | Sin herramienta: «¿Por qué el cielo es azul?», «Cuéntame un chiste», «¿Cuánto es 15 por 4?» | 1 / 6 | **Elegir entre las tres se le da bien (15/16).** Lo que falla es abstenerse: busca en internet cosas que ya sabe. Cuesta unos 2,5 s de más, pero la respuesta sigue siendo correcta, así que es un problema de latencia y no de exactitud. Intentar corregirlo con instrucciones no funcionó, coherente con lo del apartado 3: | Guía de herramientas | Aciertos | Falsos positivos | |---|---|---| | Sola | **16 / 22** | 5 | | + «No uses ninguna herramienta para conocimiento general» | 16 / 22 | 6 | | + «Las herramientas son sólo para datos que cambian» | 13 / 22 | 6 | Añadir la prohibición no reduce los falsos positivos y sí empeora los aciertos. Se queda la guía sola. ## 6. Sin acuse hablado, una herramienta son seis segundos de silencio Una búsqueda tarda unos 2,4 s y la cámara unos 4,5, y a eso hay que sumarle las dos pasadas del modelo. El turno entero sale por encima de los 7 s, y sin nada que oír se lee como que el asistente se ha colgado. Por eso `Tool::acknowledgement` devuelve una frase que se pronuncia **antes** de ejecutar —«Déjame que lo busque», «Voy a mirar»—. Medido en turnos reales: | | Primer audio | |---|---| | Turno con búsqueda, sin acuse | ~8,4 s (estimado: suma de las dos pasadas) | | Turno con búsqueda, con acuse | **4,1 s** | | Turno con cámara, con acuse | **3,2 s** | El turno sigue durando lo mismo; lo que cambia es cuándo empieza a oírse algo, que es lo único que percibe quien pregunta. ## 7. El resultado de la herramienta hay que mandarlo usar Con la instrucción de voz a secas en la pasada de redacción, el modelo anunciaba lo que acababa de hacer en vez de contar lo que averiguó: > He tomado una foto de la cámara para mostrarte lo que hay delante. Ahora > puedo responderte sobre el objeto o color, pero si quieres saber más > información actualizada como precios, noticias o datos específicos, ¡puedo > buscarlo por internet! El resultado —«Un hombre con gorro sostiene un teléfono»— estaba en la conversación y lo ignoró. Con `general.tool_result_prompt` añadido a esa pasada: > Se ve un hombre con una chaqueta de peluche y el capuchón puesto, > sosteniendo un teléfono en su mano y mirando directamente a la cámara. Aquí sí se puede añadir estilo sin romper nada, al contrario que en el apartado 3: la llamada ya ocurrió, así que no queda nada que estropear. --- ## 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. Turnos con las herramientas nuevas, también medidos de punta a punta: | Turno | ASR | LLM 1ª | Herramienta | LLM 2ª | Primer audio | |---|---|---|---|---|---| | «¿Qué tiempo hace en Buenos Aires?» | 194 ms | 2243 ms | 2132 ms (búsqueda) | 4079 ms | **4122 ms** | | «¿Qué ves por la cámara?» | 185 ms | 1649 ms | 5229 ms (captura + visión) | 1602 ms | **3153 ms** | El primer audio llega mucho antes que el final del turno porque suena el acuse mientras la herramienta trabaja. ## 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 # Qué busca y qué mira RUST_LOG=info,herramientas=info,camara=info cargo run --release -- run # Comprobar de nuevo las cifras de arriba scripts/servidores.sh arrancar cargo test --release -p asist-app --test integracion -- --nocapture ```