如何工程化降低语音交互 AI 设备的延迟
约 6326 字大约 21 分钟
2026-06-01
引言:语音延迟不是模型问题,而是系统工程问题
很多团队第一次做语音交互 AI 设备时,会本能地把延迟问题丢给“换一个更快的模型”。但真正落地后会发现:模型再快,只要链路其他环节在排队,端到端延迟依然下不来。
语音延迟和文本延迟的体感完全不同。文本对话只要首字够快,后面流式滚出来,用户就觉得不慢;语音对话却是一个完全沉默的等待窗口——用户说完后,到听见 AI 回复第一句话之前,系统在做语音识别、模型推理、语音合成、音频下发,但用户什么都看不见。这个窗口越长、波动越大,体验就越差。
所以语音延迟治理的第一原则,不是让整段回复变短,而是让首句语音尽快、尽量稳定地出现。比平均延迟更重要的,是延迟的方差和失败率——平均 800ms 但偶尔 5 秒的体感,比平均 1.5 秒但基本在 1~2 秒之间波动要差得多。
这篇文章不讲源码,而是想把联犀在语音交互设备上走过的完整路径沉淀下来:
测量 → 设计 → 落地 → 验证 → 权衡希望给正在做类似系统的工程师一条可复用的工程化思路。
第一步:把延迟拆成可观测的段
在动手优化之前,必须先能观测。否则团队只能笼统地说“AI 慢”,却找不到真正的瓶颈。
一条语音对话从用户张嘴到听到回复,大致经过下面几个阶段:
用户说话
→ 麦克风采集音频
→ VAD 判断“开始说话了 / 说完了”
→ ASR 把音频转成文字
→ LLM 理解意图并生成回复文本
→ TTS 把文本转成语音
→ 音频推回设备播放这几个环节各有分工:
| 组件 | 职责 |
|---|---|
| VAD | Voice Activity Detection,判断语音边界,决定 ASR 何时启动、何时收口,同时负责打断检测 |
| ASR | Automatic Speech Recognition,把音频流变成文字 |
| LLM | 大语言模型,理解、推理、调用工具、生成回复 |
| TTS | Text-to-Speech,把回复文本合成语音 |
还有两个常被提及的指标:
| 指标 | 含义 |
|---|---|
| TTFT | Time To First Token,LLM 收到输入后输出第一个文字 token 的时间 |
| 首帧延迟 | TTS 收到文本后输出第一帧音频的时间 |
这里有一个容易忽视的前提:为什么必须先做 VAD,而不是直接把音频流送给 ASR?
因为 ASR 只负责“把音频转成文字”,它不知道“这段话从哪里开始、到哪里结束”。如果没有 VAD,环境噪音、沉默、翻书声都会被送给 ASR,既浪费带宽和计算,又会让识别边界变得模糊。更关键的是,AI 回复期间需要靠 VAD 检测用户是否插入新语音来触发打断。所以 VAD 不是“可有可无的优化项”,而是定义语音边界、支撑打断感知的基础设施。
联犀把端到端延迟拆成 4 段:
| 指标 | 含义 |
|---|---|
end→stt | 用户说完 → ASR 识别完成 |
stt→created | ASR 完成 → LLM 首 token 到达 |
created→audio | LLM 首 token → TTS 首帧音频 |
end→audio | 用户说完 → 听到 AI 第一句语音 |
拆开后,问题就变成:是 ASR 收口慢?LLM 首 token 慢?还是 TTS 首帧慢? 每一段都有独立的优化入口,也让团队能针对性地做 A/B 验证。
第二步:设计终局架构,减少层间等待
最朴素的实现方式是“分段接力”:VAD 把整段音频交给 ASR,ASR 把整段文字交给 LLM,LLM 把完整回复交给 TTS,TTS 合成完再一次性推给设备。这种方式简单,但每一层都在等上一层完全结束,延迟自然叠加。
联犀最终采用的架构更像一条流水线车间:
音频流
→ VAD 逐帧判断“是不是语音”
→ ASR 边收边识别
→ LLM 边生成 token 边输出
→ TTS 收到 token 就开始合成
→ 首帧音频立刻推给设备每一层都不等上一层完成,只要自己的输入足够启动,就立刻开始工作。这个架构的终局形态不是把某个模块加速到极致,而是让模块之间的等待尽可能少。
下面这张表是全文的总览。你可以把它当作目录,也可以当作结论:
| 瓶颈段 | 典型问题 | 终局思路 | 测试效果 |
|---|---|---|---|
end→stt | ASR 建连慢、首帧音频丢帧 | eager 启动 + 音频缓冲 + 热连接复用 | ~568ms |
stt→created | LLM 首 token 慢、prompt/记忆太重 | 单模型自决策 + prompt 瘦身 + 分层记忆召回 | ~1.0s |
created→audio | TTS 连接状态串扰、首帧被误丢 | 轮级独立连接 + 首帧/后续帧分层超时 | ~1.29s |
| 稳定性 | 长空闲后无响应、多轮偶发超时 | VAD 长空闲自愈 + 不用短超时读 drain 旧消息 | fail_count = 0 |
架构确定后,真正的工程化工作是把每一层都落地到可运行的代码和可验证的参数。下面按感知层、认知层、表达层、全局层四个层面展开。
第三步:分层工程化落地
3.1 感知层:VAD + ASR
感知层负责回答两个问题:用户有没有在说话?用户说了什么?这一层的优化目标是既听得准,又收得快。
VAD:要逐帧判断,还要会“自愈”
VAD 是整条链路的“守门人”。它的判断质量直接决定:ASR 会不会过早开始、过晚结束、把噪音当语音、把语音尾音切掉。
不这样做会怎样?
早期 Silero VAD 用的是“segment 结束才返回”的模式:等一段语音结束了,才一次性告诉上层“刚才那段是语音”。这种模式有两个隐患:
- 不够实时。VAD 本身无法逐帧输出概率,系统只能等它“宣判”,错失了提前启动后续环节的机会。
- 长空闲后会卡死。VAD 和 ASR 都是有状态的模型,长时间没有语音输入时,内部状态可能进入不可恢复的中间态。用户隔了半分钟再说话,系统没反应,必须重启会话才能恢复。
联犀的终局做法
把 VAD 改成逐帧概率输出:每收到一小段音频(16kHz 下约 512 个样本),就输出一个“这是语音的概率”。然后配合双阈值和滑动窗口平滑:概率高于进入阈值就判为有语音,低于退出阈值就判为静音,中间状态延续上一帧,避免边界抖动。
更重要的是,引入“长空闲自愈”:当连续一段时间(默认 30 秒)没有检测到语音时,主动重置 VAD 和 ASR 状态,让下一轮语音重新建立 ASR 连接。这个逻辑对 mock/energy 等简单 VAD 不生效,避免误伤。
不要踩的坑
- 不要以为“模型有状态就一定可靠”。有状态模型在长序列上容易累积异常,必须有一个兜底机制主动恢复。
- 不要为了灵敏度把阈值调得太低。阈值过低会导致 VAD 触发后无法退出,把环境噪音持续当语音送上去。
- 缓冲窗口不要无限大。窗口越大越平滑,但语音开始和结束的延迟也会增加。
效果
测试环境实测:第一 turn(一轮对话)后等待 35 秒,第二 turn 仍能正常触发识别;长句「天行健,君子以自强不息」以及句中约 300ms 的停顿,都能完整识别。
VAD 选型:为什么选 Silero,还要 fork 它
做 VAD 时通常有三种选择:
- 能量阈值 VAD:根据音量大小判断是否有人说话,实现简单、CPU 占用极低,但对噪音非常敏感,稍微风吹草动就会误判,只能用于开发调试或极端资源受限的设备。
- 云端 VAD:把音频送到云端模型判断,准确率高,但会增加一次网络往返,和“低延迟”目标冲突。
- 本地神经网络 VAD:Silero VAD 是典型代表,基于轻量神经网络,本地运行,准确率和抗噪能力都较好。
联犀最终选了 Silero VAD 本地运行。但直接用的 Go 绑定只支持“segment 结束才返回”,无法满足流式概率需求。于是我们 fork 了 Silero 的 Go 封装,自己实现了逐帧 Predict 接口,才有了前面的逐帧概率 + 长空闲自愈能力。
一句话总结:选型不是越简单越好,也不是越复杂越好,而是要在“本地实时”和“模型准确”之间取平衡。Silero 正好是那个甜点,只是需要把接口改成真正流式。
ASR:提前开工,不要现用现连
ASR 本身的识别速度通常只有几百毫秒,真正的隐性延迟往往在建连和等待上。
不这样做会怎样?
如果 ASR 的 WebSocket 连接等到首帧音频来了才建立,建连期间的音频帧因为没有缓冲会被直接丢弃。用户可能刚开口的前几个字就被漏掉,或者识别结果从中间开始。
联犀的终局做法
把 ASR 启动从“lazy”改成“eager”:会话初始化时就启动 ASR,音频先进入一个缓冲池(200 帧,约 4 秒),建连完成后无缝衔接。同时通过连接预热机制复用热连接,首轮之后不再重复走 TCP + TLS + WebSocket 握手。
豆包 ASR 的 end_window_size(服务端判断语音结束的尾音窗口)也从 600ms 收敛到 200ms,缩短服务端判断“用户说完”的时间。这个参数不能一味调小,太低会误切自然停顿的从句,200ms 是在实测中取的平衡点。
不要踩的坑
- 缓冲不是越大越好。4 秒缓冲能覆盖大多数建连场景,再大就会牺牲实时性。
- 不要只测冷启动。多轮对话的热连接复用收益,在真实设备场景下比 mock 环境大得多。
- 收口参数要端到端调。
end_window_size单独看准确率可能没问题,但放到整条链路上可能会让用户觉得“AI 不等我说完就抢答”。
效果
ASR 建连从“首帧等待”变成后台预热,首句识别的丢帧问题基本消除。
3.2 认知层:LLM + 模型选型
认知层负责“听懂并决定怎么回复”。stt→created 这一段曾经是最大瓶颈,但真正的大头不全是模型推理,而是同步记忆检索和 prompt 过长。
LLM:单模型自决策,不要双模型快排
不这样做会怎样?
一个常见的想法是:用双模型设计——一个“无工具快排模型”先判断要不要调工具,一个“带工具正式模型”做真正推理。这样看起来有“快路径”,但实际会增加一次模型调用和路由切换开销。而且不同模型的函数调用格式不一致,标准 ReAct 循环在流式输出下很难处理工具调用标记。
联犀的终局做法
改为基于 ToolCallingChatModel(具备函数调用能力的大模型运行时) 的单模型自决策运行时:所有请求都带 tools schema,由 LLM 自己决定调不调工具。这样消除了“先判断走哪条路”这层决策成本,也避免了双模型切换时的额外一次 LLM 调用。
Prompt 层面也做了大量瘦身:
- 技能知识注入长度上限从 15000 降到 6000,skill 注入量减少 60%;
- 硬编码知识库指令改为从 KB 元数据动态生成,无 KB 时完全不出现;
- 记忆归档规则文本从约 400 字符压缩到 80 字符;
- 增加 60 字长度约束,减少 LLM 生成时间和 TTS 数据量。
记忆检索从 embedding 向量召回改为全文索引文本检索(Jaccard + trust_score),同步注入长期记忆这个曾经的 3 秒瓶颈被降到毫秒级。
不要踩的坑
- prompt 越长,首 token 越慢。System prompt 里的每一行文字,都会让 LLM 在输出第一个 token 前多算一遍 attention。
- 不要为了“看起来聪明”每轮都召回全部记忆。记忆系统要分层:会话历史 → memory profile → 长期记忆,只有必要时才走最重的召回链。
- 不要默认开启模型的思考模式。DeepSeek 的 TTFT 看起来很快,但默认 thinking 模式会先输出一段内部思考,用户感知到的延迟反而更长。
效果
stt→created 从 4 秒级降到 1 秒级;某个 clone(智能体副本)的 prompt 从 5551 字符压缩后,首 token 延迟从 10 秒以上降到 3 秒以内。
模型选型:参数大不一定快
同一条语音链路上,不同模型的延迟表现并不完全符合直觉:
| 模型 | end→audio | 备注 |
|---|---|---|
doubao-seed-2-0-lite-260215 | 1.167s | 生产默认 |
doubao-1.5-pro-32k-250115 | 1.215s | |
doubao-seed-2-0-code-preview-260215 | 1.364s | |
doubao-seed-2-0-mini-260215 | 1.593s | |
doubao-seed-2-0-pro-260215 | 1.669s | |
deepseek-v4-flash | 未优于 doubao-lite | TTFT 仅约 200ms,但默认 thinking 模式会输出隐藏思考过程,显著拖累端到端;关闭 thinking 后,工具调用格式与流式行为仍与豆包存在差异 |
Mini 和 Pro 反而更慢,DeepSeek 首 token 虽然快但端到端并不占优。这说明在语音延迟这条链上,模型参数规模或首 token 速度都不是唯一决定因素,工具调用格式处理效率、流式行为、隐藏推理成本都会影响最终体感。
DeepSeek 的思考模式陷阱
DeepSeek 的 TTFT 实测约 200ms,看起来比豆包更快。但它默认会开启 thinking/reasoning 模式——即使是一个简单的“音量多少”,模型也会先输出一段内部思考,再给出回答。这段思考用户不可见,但会显著增加实际响应时间和 token 消耗。
联犀通过模型厂商提供的关闭思考模式参数(如 reasoning_effort 或 extra_fields)来关闭 thinking。即使关闭,由于工具调用格式和流式行为与豆包存在差异,DeepSeek 的端到端延迟也没有比豆包 lite 更优。最终生产环境统一由豆包提供服务。
3.3 表达层:TTS + 音频下发
表达层负责把 LLM 生成的文本变成用户能听到的语音。created→audio 这一段的核心问题往往不是合成慢,而是连接状态不正常和首帧分发策略不对。
TTS:连接生命周期要绑定到一次对话
不这样做会怎样?
TTS 也是通过 WebSocket 连接豆包服务端。早期为了省掉每轮对话都建连的开销,采用共享连接复用:同一条 WebSocket 连接上跑多个 session。理论上这是对的——省一次 TLS 握手。但实践中发现两个严重问题:
- session 状态串扰。上一轮的音频帧或事件如果没清干净,会污染下一轮的状态判断。
- 读超时损坏连接。为了清掉旧 session 消息,代码里用了一个给 websocket 连接设置极短读取超时、再尝试读残留消息的“试探读”。但 gorilla/websocket 的文档明确说明:读超时后连接读状态会永久损坏,后续所有读取都会返回
i/o timeout。这就是测试环境偶发“TTS 连接等待 session 启动超时”的根因。
联犀的终局做法
引入 NewSessionProvider(每轮对话创建独立 TTS 连接的机制):每轮对话独占一条 WebSocket 连接,生命周期绑定到 ChatSession(一次对话会话)。这轮对话结束,连接就关闭。下一轮重新建连,彻底避免跨轮状态串扰。
这并不意味着完全放弃复用。全局共享连接池仍然保留,作为可切换基线(通过环境变量控制),方便 A/B 对比和压测。
不要踩的坑
- 不要为了省一次 TLS 握手而牺牲多轮稳定性。平均延迟好看没用,max 延迟和失败率才是语音体感的决定因素。
- 不要用短超时读做“试探性 drain”。websocket 连接读超时后应视为不可用,直接关闭重建。
- 共享连接复用的前提是状态隔离。如果做不到干净隔离,宁愿每轮新建连接。
效果
测试环境 20 轮同 agent 多轮对话压测,fail_count = 0,max 延迟从偶发 3 秒以上收敛到 2.9 秒左右;每轮独立 TTS 连接场景 TOTAL avg 1.86s,TTS avg 1.29s。
音频下发:首帧不要 pacing
TTS 合成出音频帧后,服务端并不是立刻一次性把所有帧推给设备,而是按一定节奏发送(pacing),避免设备端网络拥塞。但这个 pacing 策略不能一视同仁。
不这样做会怎样?
如果首帧音频也要按正常节奏 pacing,用户听到的第一个字会多等几十到几百毫秒。体感上就是“AI 顿了一下才开始说话”。
联犀的终局做法
音频发送逻辑 sendAudioPaced 对前 5 帧音频直通发送,不做 pacing;从第 6 帧开始再按节奏发送。这样既保证首句快速出现,又避免后续音频洪峰冲垮设备端网络。
效果
设备端首帧到达时间明显缩短,对话感更自然。
3.4 全局层:超时与生命周期兜底
前三层是“怎么让各环节更快”,全局层是“怎么让系统在异常情况下不崩、不慢、不丢帧”。
超时策略:首帧等得起,后续帧等不起
TTS 流式合成时,音频帧是一帧一帧推给设备的。这里有一个很容易被忽略的超时设计。
不这样做会怎样?
如果所有帧都用统一的 3 秒超时,当 LLM 首 token 较慢(比如工具调用需要 3 秒以上),TTS 首帧还没生成就被判定为超时丢弃,系统会误以为 TTS 已经完成或失败,表现为“TTS 耗时为 0”的假象。
联犀的终局做法
把首帧等待和后续帧间等待分开:
- 首帧等待 5 秒:给 LLM 首 token 和 TTS 启动留足时间。
- 收到首帧后恢复 3 秒帧间超时:一旦进入正常播放节奏,就不能容忍长时间卡顿,快速失败或重试。
不要踩的坑
- 不要用统一的超时覆盖首帧和后续帧。两者的风险完全不同:首帧慢可能是正常推理,后续帧慢更可能是连接卡死。
- 超时不是越短越好。过短会误杀慢但正常的回复,导致反复重试反而更慢。
效果
消除了 LLM/工具调用慢首 token 场景下“TTS=0”的假象,统计口径更准确,偶发失败减少。
第四步:建立可复现的验证口径
优化落地后,必须用统一口径反复验证,否则数字不可信。联犀的测试基于 devicesim 语音模拟器的端到端延迟压测口径,测试对象是同一个 agent(配置好的一套 AI 技能/人格)连续 20 轮语音对话,测量指标为 AudioStop→RespAudioStart(用户说完到听见 AI 第一句语音)。
优化演进
| 阶段 | end→audio | 说明 |
|---|---|---|
| 优化前 | 5s+ | 同步记忆检索、每轮重建连接、完整句子后才送 TTS |
| 上一版优化后 | ~800ms | 短查询、单轮、无复杂工具调用 |
| 最新 20 轮同 agent | 1.86s | 混合场景(闲聊 + 设备查询/控制),更真实的连续对话 |
最新 1.86s 看起来比上一版的 800ms“大”,不是因为退化,而是测试口径从“单轮短查询”变成了“连续 20 轮混合对话”。真实用户不会只说一句话,多轮对话中的状态保持、连接生命周期、工具调用开销都会体现进来。
20 轮同 agent 分段统计
| 指标 | 平均值 | 说明 |
|---|---|---|
end→stt | ~568ms | ASR 识别完成 |
stt→created | ~1.0s | LLM 首 token 到达 |
created→audio | ~1.29s | TTS 首帧音频 |
end→audio | 1.86s | 端到端 |
| 失败数 | 0 | 20 轮全部成功 |
不同场景对比
| 场景 | 20 轮平均 end→audio | 说明 |
|---|---|---|
| 无工具调用闲聊 | 2.54s | 食物偏好、回忆等纯对话 |
| 混合工具调用 | 3.87s | 设备查询/控制 + 闲聊 |
| 每轮独立 TTS 连接 | 1.86s | 短查询/偏好表达为主 |
这组数据说明:工具链本身有成本。当 LLM 需要调用设备属性查询或控制时,首 token 和整体生成时间都会变长。这也是为什么架构上要尽量让简单问题走轻链路,复杂问题才进入重工具链。
第五步:在效果与体验之间做工程权衡
延迟不是越低越好,纯粹的跑分没有意义。优化必须在维护用户体验和回复效果的前提下进行。
比如 VAD 的等待窗口:调得太短,AI 会在用户喘口气、停顿思考时抢答,对话变得很“急”;调得太长,用户说完后要多等几百毫秒。联犀把 end_window_size 从 600ms 收敛到 200ms,不是因为它能让数字更好看,而是因为实测发现 200ms 在“自然停顿”和“快速收口”之间最平衡。
再比如 LLM 的 prompt 和记忆:每塞一段长期记忆、每多一条技能知识,prompt 都会变长,首 token 时间就会增加。如果为了压延迟而把记忆全砍掉,AI 会立刻变“健忘”,用户体验反而更差。所以联犀采用分层记忆召回——会话内历史轻量保留,长期记忆按需召回,而不是每轮全量注入。
还有模型选型:lite 模型确实比 pro 快,但面对复杂推理或多工具组合时,lite 可能答不准,反而需要用户反复追问,整体对话时间更长。所以简单查询走 lite,复杂任务才走 pro,这才是“延迟优化”的正确打开方式。
一句话:跑分是手段,体验是目的。延迟、准确率、自然度、失败率必须一起看,不能为了压一个数字而牺牲其他维度。
效果总结
联犀把 AI 语音交互设备的端到端延迟,从最初的 5 秒以上,降到了同 agent 连续 20 轮对话平均 1.86 秒;在简单短查询场景下,实际最优 case 已经能做到约 1.6 秒,20 轮压测失败数为 0。
这个效果的实现路径,不是换了一个更快的模型,也不是某个单点优化,而是把整条链路上每个环节的等待、串扰和不确定性都尽量消除掉:
- VAD 能逐帧判断、长空闲后能自愈;
- ASR 提前开工、热连接复用;
- LLM 用单模型自决策、prompt 和记忆召回都瘦身;
- TTS 每轮独立连接、首帧与后续帧分层超时;
- 音频首帧不 pacing、直接推给设备。
写在最后:工程化治理语音延迟的 5 条原则
把上面的分层决策收敛起来,可以得到几条可复用的原则:
第一,拆账。把 end→audio 拆成 end→stt→created→audio,才能知道慢在哪一段。否则团队只能在黑盒里调参。
第二,削峰。平均延迟好看没用,max 延迟和失败率才是决定体感的。TTS 独占连接、首帧超时治理、VAD 长空闲自愈,本质都是在削峰。
第三,稳态。不要让状态在多个 session、多轮对话之间累积。每一轮对话的 TTS 连接、VAD/ASR 状态都要能干净地开始和结束。
第四,架构减法。双模型 ReAct、关键词快排、每轮完整记忆召回,这些看似“优化”的设计往往本身就在制造延迟。架构上做减法,通常比局部加速更有效。
第五,按场景分流。简单问题走轻链路,复杂问题才走重工具链。不是所有请求都值得同等的延迟成本。
语音交互设备的延迟不可能降到零——物理传输、模型推理、语音合成都有各自的硬边界。但当用户对着设备说“音量多少”,能在一两秒内听到“当前音量是55%哦~”的回复时,这种“对话感”就已经建立起来了。
让首句语音在合理时间内稳定出现,让用户感受到“我在和一台反应正常的设备对话”——这就是工程化治理语音延迟的终局目标。
更新日志
2026/6/24 18:37
查看所有更新日志
15f2d-docs(blog): 调整主题为工程化方法论并重构结构于4f7e6-docs(blog): 补充延迟与体验的平衡观点于9911c-docs(blog): 单独强调延迟优化效果总结于81185-docs(blog): 在结尾总结延迟优化成果于ef615-docs(blog): 补充 VAD 选型与 DeepSeek 模型评估于c255d-docs(blog): 降低技术门槛:替换内部描述并解释专业术语于4bfde-docs(blog): 提升语音延迟博客可读性与技术价值于5b9b5-docs(blog): 以终局架构视角重写 AI 语音实时对话延迟优化文章于521a0-docs(blog): 新增 AI 语音实时对话延迟优化技术分享文章于
