1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
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
```
|