LabHub
开始
学习 学习路径 课程

语音 AI 智能体 — 会听、会查、会说的流水线

开口的时刻 — SSE 流式·首 token 延迟·KV 缓存

在 LabHub 中继续学习

一句话总结

语音助手开口的时刻,不是 LLM 把答案写完的时刻,而是写完第一句话的时刻。所以不把响应作为一整块来接收,而是在 token 产生时随到随收(SSE 流式)。到第一个 token 为止的时间(TTFT),大部分是计算提示词的时间(prefill),并且与提示词长度成正比。如果把相同的前半部分再发送一遍,服务器会复用 KV 缓存,几乎消除这段时间——不过,哪怕最前面一个字符改变,后面的全部也要重新计算。

为什么需要它

LabHub 口语练习的一轮对话是 ASR 0.4 秒 + 模型 0.6 秒 + TTS 2.3 秒 ≈ 3.3 秒(2026-09-23 实测,backend/app/lang_talk.py)。因为是接收完模型的回答之后再交给 TTS 的串行结构,回答变长,耗时就原样增加——所以那个文件把回答按句数限定,超出就在代码里截断(“模型兴奋起来写出五句话,就要 12 秒”)。如果以流式接收,在第一句话结束的那一刻就能开始 TTS。本模块要测量的,就是这个“第一句话”什么时候到,以及什么会让它变晚。

工作原理

llama-server 与 SSE。llama.cpp 的 llama-server 会开放与 OpenAI 形态相同的 /v1/chat/completions。以 "stream": true 发送,响应就以 Server-Sent Events 的形式到来——data: {JSON} 行与空行的重复,每个片段的 choices[0].delta.content 中有新的文字,最后是 data: [DONE]。第一个片段通常只有角色(role),内容是空的。如果把它当作第一个 token,TTFT 就会比实际短。最后一个片段会附带服务器测得的 timings(prompt_n · prompt_ms · predicted_n ……)。

TTFT = 等待 + prefill + 第一个 token。模型先一次性计算整个提示词,生成每一层的 K·V(prefill),然后一个 token 一个 token 地写(decode)。prefill 与提示词的 token 数成正比。把这个镜像中的 Qwen2.5-0.5B-Instruct Q4_0 放进 CPU 2 核的 Pod(nuc1 实测),35 个 token 的提示词,第一个 token 是 127 ms,258 个 token 则是 801 ms,之后的生成速度约为每秒 50 个 token。这意味着,一旦通过 RAG 附上几份文档,第一个 token 就会晚上好几倍。

KV 缓存与前缀一致。服务器为每个 slot(对话位)保存着上一次请求的 K·V。如果新请求的前半部分与它相同,就会跳过相同的部分,只计算新的 token(cache_prompt)。把同样的 258 个 token 的提示词发送第二次,prompt_n 变成 1,第一个 token 缩短到 24 ms(nuc1 实测)。但是比较是从前往后的。如果在系统提示词最前面放上“当前时间:14:05”这样会变化的一行,每次请求都会从头重新计算。会变的放后面,固定的放前面。

这个镜像的服务器是用 --cache-ram 0 启动的。最新的 llama-server 会把过去提示词的 KV 汇集到主机内存中,等相似的请求到来时再恢复,但默认上限是 8,192 MiB,可能超过 Pod 的限制(2 GiB),而且如果开着,即使清空了 slot,旧的提示词也会被恢复,使缓存实验变得模糊(实测:连最先发送的提示词,prompt_n 也是 1)。在生产环境中,对话有多个时,这是有用的功能。

第一句话与 max_tokens。在语音中,长回答就是长等待。要单独测量第一句话结束的时刻——句号、问号后面出现空格的那一刻——并用 max_tokens 为回答设置上限。要等到空格的原因,是为了不在“3.5”或“a.m.”处截断(在模块 7 中会进一步讨论)。

打断 = 断开连接。用户一旦打断,剩下的生成就是可以丢弃的了。关闭正在读取流的 HTTP 连接,llama-server 就会取消那个任务(日志中会出现 cancel task)。如果只停止读取而仍然占着连接,服务器就会一直生成到结束,并占用 CPU——在与 TTS 共用 2 核的 Pod 里,下一轮就会相应变慢。

在现场相遇的样子

小模型不太听指令。对这个镜像中的 0.5B 模型说“恰好三句话”,它常常只回答一句话,或者给出编号列表。所以运维的一方不会只相信模型的话,而是用代码限定长度——max_tokens、按句数截断、把第一句话切短先朗读(模块 7)。LabHub 口语练习把句数写死在指令中、超出就在代码里截断,也是同样的原因。

下一项实验要做什么

用 voice-llm up 启动服务器,并保存 /health 和 /props。制作逐行读取 SSE 的 sse.py,记录各片段的时刻,在关闭缓存的情况下测量五次第一个 token 的延迟。附上 0、4、12 份文档来测量 prefill,观察缓存复用前半部分的情形,以及最前面一行破坏缓存的情形。测量第一句话结束的时刻,并在 0.5 秒时断开连接,用 /slots 和日志确认服务器是否停止。