Skip to content
AI Voice Agent 架构:语音输入、模型推理与实时响应
总体架构:从麦克风到扬声器的数据流
语音 Agent 的输入和输出都是音频。相比文本对话,语音交互增加了一条连续音频流的处理链路:音频没有天然的分隔符,识别结果随说话过程不断修正,合成语音需要等待足够文本才能确定韵律,而且用户随时可能打断正在播放的回复。这些约束决定了语音 Agent 的架构需要围绕“流”来设计。
一个完整的语音 Agent 可以拆成四条链路:
- 语音输入链路:音频采集、VAD、唤醒词、流式 ASR;
- 模型推理链路:LLM 基于文本流进行增量推理,输出 token 流;
- 语音输出链路:文本切分、SSML、流式 TTS、播放控制;
- 交互控制链路:打断、超时、会话状态、turn-taking。
可以用以下管道描述核心数据流:
麦克风
→ 音频分帧(10~30ms/帧)
→ VAD(检测说话开始/结束)
→ 流式 ASR(partial result / final result)
→ LLM 流式推理(token 流)
→ 文本切分与 SSML
→ 流式 TTS(增量合成)
→ 播放队列
→ 扬声器控制流与数据流垂直交叉:VAD 事件会触发 ASR 开始/停止,ASR final 会触发 LLM 开始生成,LLM token 会触发 TTS 合成,播放状态又会反馈给交互控制模块,用于实现打断和超时。
语音输入链路:VAD、唤醒词与流式 ASR
VAD:把连续音频切成轮次
VAD(Voice Activity Detection,语音活动检测)负责判断一段音频里是否有人声。它的输出不是文本,而是状态:静音、说话开始、说话中、说话结束。
VAD 状态切换构成一次“语音轮次”:
silence --语音能量超过阈值--> speech_start
speech_start --持续发声--> speech_in_progress
speech_in_progress --静音持续 N 毫秒--> speech_endspeech_end 之后,这一段音频就可以作为一个完整轮次交给 ASR 识别。VAD 不仅用于切分,也用于省电和节省算力:VAD 未触发时,ASR 不需要处理音频。
一些流式 API 会把 VAD 事件直接通过消息发给客户端,例如 Sarvam 流式 STT 会在 WebSocket 上发送 START_SPEECH 和 END_SPEECH 事件[6]。
唤醒词:在有/无唤醒词的设备上区别对待
在智能音箱等设备上,VAD 通常不会持续工作,而是先由低功耗唤醒词引擎监听特定词。唤醒词触发后,才启动 VAD 和 ASR。借助大语言模型的语音 Agent 可以省去唤醒词,把“唤醒”理解为一次普通的用户输入;但在需要低功耗待机的场景下,唤醒词仍是音频链路的前置环节。
在一个纯软件 Agent 中,唤醒词并不是必需组件。VAD 检测到人声后直接进入 ASR 是更常见的做法。唤醒词可以看作 VAD 之前的一道“语义门”。
流式 ASR:partial result 与 final result
与离线 ASR 不同,流式 ASR 不需要等待用户说完才返回结果。识别器每收到一段音频,就输出一个当前最优的临时文本,称为 partial result(中间结果)。当一句话识别完成时,输出稳定的 final result(最终结果)。
以 Knovvu 实时转录协议为例,WebSocket 消息包含三种类型:partial-result、milestone-result 和 final[7]。partial-result 可能随音频输入不断修正:
用户说:"今天天气怎么样"
第 1 次 partial: "今天天"
第 2 次 partial: "今天天气"
第 3 次 partial: "今天天气怎么"
final: "今天天气怎么样"这种“边听边出文本”的能力是延迟优化的关键。Agent 编排层可以在 VAD 检测到说话结束后立即把当前 partial 发送给 LLM,而不必等待 final 到达。代价是 LLM 可能基于不完整的 ASR 结果开始推理,所以编排层需要决定:是等待 final 以保证正确性,还是使用 partial 以降低延迟。
下面是一个用 Node.js 描述的语音输入管道的抽象模型。真实 SDK 的接口各不相同,但事件序列通常一致:
js
import { EventEmitter } from 'node:events';
class VoiceInput extends EventEmitter {
#vad;
#asr;
constructor(vad, asr) {
super();
this.#vad = vad;
this.#asr = asr;
this.#vad.on('speechStart', () => this.emit('speechStart'));
this.#vad.on('speechEnd', () => this.emit('speechEnd'));
this.#asr.on('partial', (text) => this.emit('partial', text));
this.#asr.on('final', (text) => this.emit('final', text));
}
feed(audioChunk) {
if (this.#vad.isSpeech(audioChunk)) {
this.#asr.send(audioChunk);
}
}
}这个类把 VAD 和 ASR 封装成一个输入源。外部代码只关心四个事件:speechStart、speechEnd、partial、final。
音频格式与采样率
流式 ASR 通常接收 PCM 音频,常见采样率为 16kHz、16bit、单声道[6][7]。如果音频来自浏览器采集,可能是 48kHz,需要在前端或网关处重采样。采样率不一致是语音 Agent 搭建初期容易遇到的问题。
模型推理:LLM 流式输出与首 token 延迟
LLM 在语音 Agent 中承担对话生成任务。它接收 ASR 输出文本,生成回复文本。与文本聊天相比,语音场景对首 token 延迟(TTFT,Time To First Token)更敏感:用户已经通过语音表达了完整意图,等待时间过长会明显破坏对话感。
LLM 流式推理的输出是 token 序列。客户端通过 SSE、WebSocket 或 gRPC 流接收增量结果。SSE 是最常见的文本流协议之一,下面是一个请求 LLM 流式补全的 Node.js 示例:
js
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'your-model',
messages: [{ role: 'user', content: text }],
stream: true,
}),
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const events = buffer.split('\n\n');
buffer = events.pop();
for (const event of events) {
if (!event.startsWith('data:')) continue;
const data = event.slice(5).trim();
if (data === '[DONE]') {
return;
}
const parsed = JSON.parse(data);
const delta = parsed.choices[0]?.delta?.content;
if (delta) {
handleToken(delta);
}
}
}该示例有两点值得注意。
第一,SSE 消息以空行分隔,网络分片不一定正好落在消息边界上,所以需要 buffer 拼接后按 \n\n 切分,最后一段留在 buffer 中继续等待后续数据。
第二,服务端发送完所有数据后会发送一个 [DONE] 标记。收到该标记后应立即结束读取,而不是继续等待,否则连接会一直挂起。
流式接口的另一个选择是 WebSocket。WebSocket 更适合双向流:客户端可以在接收 LLM token 的同时发送新的用户语音事件,这正是打断功能需要的通道。
LLM 是否成为延迟瓶颈,主要取决于三个因素:
- 模型规模与推理引擎:大模型 TTFT 更高;
- 输入长度:ASR 文本、历史消息、系统提示词越长,prefill 时间越长;
- 是否流式返回:非流式接口必须等完整输出,流式接口可以在生成第一个 token 后立即开始后续环节。
在模块化架构中,LLM 通常以独立服务方式部署,通过消息队列或 WebSocket 与编排层连接。多个 LLM 实例可以水平扩展,会话状态由编排层或会话存储维护。
语音输出链路:文本切分、SSML、流式 TTS 与播放控制
LLM 输出是一串文本 token,TTS 需要这些 token 汇集成足够完整的句子才能合成自然语音。如果每个 token 都立刻送给 TTS,合成结果会像朗读单词列表;如果等 LLM 输出完整段落再合成,延迟又会增加。因此,文本切分是语音输出链路的核心环节。
按句子边界切分
流式文本切分的基本策略是:收集 token,遇到句子结束符(中文的句号、感叹号、问号和英文的 . ! ?)时,把已积累的文本作为一个合成单元发送给 TTS。
js
let pending = '';
for await (const token of llmTokenStream) {
pending += token;
const match = pending.match(/^.*?[。!?.!?]/);
if (match) {
const sentence = match[0];
tts.synthesize(sentence);
pending = pending.slice(sentence.length);
}
}
if (pending.trim()) {
tts.synthesize(pending);
}这个示例会把“你好,今天天气不错。”切分成“你好,”和“今天天气不错。”两段。第一段通常会被 TTS 快速合成并开始播放,用户不必等整句生成完毕。
切分粒度不是越细越好。"你好," 这部分能作为独立合成单元,是因为它语义完整;但 "今天天气" 不适合提前合成,因为后续的“不错”会改变整句语调。因此切分应选择句子边界或意群边界,而不是固定 token 数。
SSML:控制停顿、语速与发音
SSML(Speech Synthesis Markup Language)是一种给 TTS 提供控制指令的 XML 标记。常见用途是在句子之间插入停顿,避免 TTS 把两个句子粘连:
xml
<speak>
第一句话。
<break time="500ms"/>
第二句话。
</speak>也可以指定数字、日期、缩略语的读法。流式 TTS 通常支持在文本流中嵌入 SSML 事件,例如在文本流中遇到数字时,先转换为适合朗读的文本再送入 TTS。
使用 SSML 时需要注意,不是所有 TTS 引擎都支持全部标签,且 SSML 要求 XML 格式合法。向文本中插入 SSML 时,如果原始文本包含 <、>、& 等字符,必须先转义。
流式 TTS 与播放控制
流式 TTS 接口通常返回音频块序列,而不是一次性返回完整音频文件。客户端收到音频块后放入播放队列。这个队列同时承担抖动缓冲(jitter buffer)的角色,需要处理两个问题:
- 缓冲不足:网络或合成速度波动导致播放中断;
- 缓冲过多:延迟增加,打断后需要丢弃大量已合成的音频。
常见做法是让播放队列维持一个较小的抖动缓冲,比如 200~500ms,既保证播放连续,又不至于在打断时清空太多数据。
js
class PlaybackQueue {
#buffer = [];
#playing = false;
#abort = null;
push(audioChunk) {
this.#buffer.push(audioChunk);
if (!this.#playing) {
this.#playNext();
}
}
clear() {
this.#buffer = [];
if (this.#abort) {
this.#abort.abort();
this.#abort = null;
}
this.#playing = false;
}
async #playNext() {
this.#playing = true;
while (this.#buffer.length > 0) {
const chunk = this.#buffer.shift();
this.#abort = new AbortController();
await playAudio(chunk, { signal: this.#abort.signal });
this.#abort = null;
}
this.#playing = false;
}
}打断发生时,调用 queue.clear() 会清空等待播放的音频块,并通过 AbortController 中止正在播放的音频块,使声道立即空闲。playAudio 函数需要支持 signal 选项,才能在 abort() 被调用时立刻停止当前音频块的播放。
交互控制:半双工、全双工、打断与超时
半双工与全双工
半双工交互严格遵循“用户说 -> 系统处理 -> 系统播放 -> 用户再说”的顺序。用户在系统播放期间不能输入,或者输入被忽略。实现简单,但对话体验不自然。
全双工交互允许用户在系统播放期间随时说话。系统需要持续监听输入音频,并决定何时打断当前回复。全双工并不要求音频物理上双向同时传输,而是指应用层可以随时接收新输入并做出反应。
VAD 中断与语义中断
打断检测分为两层:
- 音频层:VAD 在系统播放期间检测到用户语音,触发 barge-in;
- 语义层:确定用户的新输入是否构成一个完整的意图,从而决定是否真正终止系统当前回复。
如果只有音频层,系统会在用户发出“嗯”、“啊”等语气词时立刻中断回复,造成误打断。Azure Voice Live API 将“可靠的中断检测”和“高级轮次结束检测”作为独立能力提供,目的就是区分“用户真的想插话”和“用户在思考”[4]。
在模块化架构中,编排层可以这样设计打断流程:
- 系统播放 TTS 音频时,VAD 仍然处理麦克风输入;
- VAD 检测到 speechStart,播放器降低音量或暂停播放;
- ASR 开始流式识别用户新输入;
- 若 ASR 在短时间内产生稳定文本,则判定为真实打断,取消当前 LLM 生成和 TTS 播放;
- 若 VAD 检测到的是短暂噪声或语气词,则恢复播放。
其中第 4 步还可以进一步结合语义:如果 ASR 结果已经构成一个完整请求,则立即打断;如果只是“等一下”“不对”等简短反馈,也需要打断,但下一轮回复应当以“修正”为目标。这属于 LLM 的对话策略,不在音频链路内解决。
turn-taking 状态机
turn-taking 的逻辑可以用状态机描述。以下是一个简化版本:
speech_started asr_final
IDLE ────────────────> LISTENING ───────────────> THINKING
|
| tts_started
v
IDLE <────────────────── SPEAKING <───────────────────┘
tts_done / interrupt对应 Node.js 实现:
js
class TurnManager {
#state = 'idle';
get state() {
return this.#state;
}
onSpeechStart() {
if (this.#state === 'speaking' || this.#state === 'thinking') {
this.interrupt();
}
this.#state = 'listening';
}
onAsrFinal(text) {
this.#state = 'thinking';
llm.generate(text).then((stream) => {
let pending = '';
stream.on('token', (token) => {
pending += token;
const sentence = pending.match(/^.*?[。!?.!?]/)?.[0];
if (sentence) {
tts.synthesize(sentence);
pending = pending.slice(sentence.length);
}
});
stream.on('end', () => {
if (pending.trim()) {
tts.synthesize(pending.trim());
}
});
});
}
onTtsStart() {
this.#state = 'speaking';
}
interrupt() {
this.#state = 'listening';
llm.abortCurrentGeneration();
tts.clearQueue();
}
}onSpeechStart 在 thinking 或 speaking 状态下触发 interrupt(),把状态重置为 listening。如果 VAD 检测到的只是一次噪声,ASR 很快返回空结果,系统不会进入下一轮生成。onAsrFinal 中的 token 回调先积累文本,遇到句子结束符才将完整句子送入 TTS,避免逐词合成;stream.on('end') 负责在生成结束时把剩余文本送入 TTS。
超时处理
超时包括两类:
- 用户开始说话后长时间没有结束(vad 超时);
- 用户没有说话,等待输入超时(inactivity 超时)。
VAD 参数中的 silence_duration_ms 决定“说多久算结束”[1]。这个值不宜过短,否则用户正常停顿会被误判为句子结束;也不宜过长,否则每轮对话延迟都会增加。Azure OpenAI Realtime API 的可配置参数包括 threshold、prefix_padding_ms 和 silence_duration_ms[1],说明这些阈值需要针对具体场景调节。
模块间通信:事件模型、协议选择与背压
事件模型
语音 Agent 内部模块通过事件传递状态变化。不同模块的事件汇聚到编排层,编排层根据当前状态决定下一步动作。常见事件包括:
| 事件 | 触发方 | 含义 |
|---|---|---|
audio_chunk | 音频前端 | 一段 PCM 音频 |
speech_started | VAD | 检测到说话开始 |
speech_ended | VAD | 检测到说话结束 |
asr_partial | ASR | 中间识别结果 |
asr_final | ASR | 最终识别结果 |
llm_started | LLM | 开始生成 |
llm_delta | LLM | 增量 token |
llm_done | LLM | 生成结束 |
tts_started | TTS | 开始合成 |
tts_audio | TTS | 一段合成音频 |
tts_done | TTS | 合成结束 |
interrupt | 编排层 | 检测到打断 |
所有事件都应携带 sessionId 和 turnId,使链路追踪成为可能。
协议选择
客户端与服务端之间的语音流,常见选择是 WebSocket 和 WebRTC。Azure OpenAI Realtime API 的延迟参考给出了量级:WebRTC 约 100ms,WebSocket 约 200ms,SIP 用于电话集成[2]。WebRTC 适用于浏览器和移动应用,因为它内置音频采集、回声消除和网络自适应;WebSocket 适用于服务端到服务端或受控客户端,因为它实现简单,可以直接传输二进制音频和 JSON 事件。
服务端内部模块之间可以使用 gRPC 流。gRPC 支持双向流,且有完善的背压和取消机制,适合 ASR、LLM、TTS 这类长时间运行的流式调用。
背压
流式管线中,上游生产速度可能超过下游消费速度。例如 LLM 生成 token 的速度超过 TTS 合成速度,TTS 合成速度超过播放速度。
背压处理方式有三种:
- 丢弃:丢弃不重要的中间数据,例如旧的 partial result;
- 缓冲:在播放队列中缓存音频,受 jitter buffer 上限约束;
- 限速:暂停拉取上游数据,直到下游完成当前任务。
事件队列应设置长度上限。队列满时,编排层优先丢弃 asr_partial 或 llm_delta,而不是丢弃 asr_final 或 tts_audio。因为最终结果可以由后续状态补齐,而音频一旦丢失会造成播放故障。
会话状态与上下文管理
语音 Agent 需要维护会话状态,才能处理多轮对话和打断。一个最小会话状态如下:
js
{
sessionId: 'session_01J...',
state: 'speaking',
turn: {
id: 'turn_42',
asrText: '',
llmText: '',
status: 'generating',
},
history: [
{ role: 'user', text: '今天天气怎么样' },
{ role: 'assistant', text: '今天晴,气温 20 度。' },
],
}会话状态由编排层维护。状态存储可以是内存、Redis 或数据库。水平扩展时,需要把同一会话固定到同一实例,或将会话状态放入外部存储,否则后续请求可能落到不同实例,导致上下文丢失。
上下文管理还需要考虑 LLM 的上下文窗口限制。语音对话的文本历史通常较短,但 ASR 文本可能包含重复的 partial 修正,直接全部塞入上下文会浪费 token。常见做法是只把 final result 写入上下文,或者在 partial result 被 final 覆盖后,从上下文中删除之前的 partial 文本。
Azure OpenAI Realtime API 提供三种会话类型:voice-agent(默认语音对话)、translation(连续翻译)和 transcription(实时转写)[1]。会话类型决定模型如何处理输入流。自建流水线时,编排层需要自己实现类似的会话分类。
延迟预算与端到端性能优化
端到端延迟(E2E latency)从用户说出一个完整句子开始,到系统播放出对应的语音回复为止。可以拆成以下环节:
| 环节 | 延迟来源 | 典型量级 |
|---|---|---|
| 音频采集 | 麦克风、音频帧缓冲 | 10~40ms |
| 音频传输 | 客户端到服务端 RTT | 50~200ms |
| VAD 端点检测 | 等待静音确认 | 200~600ms |
| ASR 识别 | 从说话结束到输出 final | 100~500ms |
| LLM prefill | 处理输入文本、生成首个 token | 数百 ms 到数秒 |
| TTS 首帧 | 从文本到第一段音频 | 100~500ms |
| 播放缓冲 | 防抖动缓冲 | 100~300ms |
交互设计领域经常引用三个参考数字:300ms 以内用户感知为“即时”,500ms 以内可以接受,超过 2s 用户会明显感觉对话不连贯。这些是经验值,不是协议标准,但可以作为延迟预算的起点。不同产品形态的预算差异很大,语音助手类交互可以参考上述数值,实时字幕、同声传译等场景对延迟的敏感度又有所不同,需要按具体产品设定目标。
根据预算倒推,VAD 的静音判定时间、ASR 的尾部等待时间、LLM 的 TTFT、TTS 的首帧时间是四个最值得优化的点。
- 缩短 VAD 静音时间:把
silence_duration_ms从 800ms 降到 400ms,每轮对话可省 400ms; - 提前启动 LLM:在 VAD 检测到 speechEnd 时,立即用 ASR partial 启动 LLM,节省等待 final 的时间;
- 流式 TTS:LLM 每生成一个句子就立刻合成播放,不必等完整回复;
- 降低 prefill 延迟:压缩系统提示词和历史消息,减少输入 token 数量。
需要注意,这些优化相互影响。提前启动 LLM 可能生成基于错误 ASR 文本的回复,需要在正确性与延迟之间做权衡。
示例:最小语音 Agent 的事件序列
下面用一个具体事件序列展示一个最小语音 Agent 如何工作。该序列假设使用的是 WebSocket 双向流式接口。
客户端 -> 服务端: 建立 WebSocket 连接
客户端 -> 服务端: { type: 'audio_chunk', data: <PCM> }
服务端 -> 客户端: { type: 'speech_started' }
客户端 -> 服务端: { type: 'audio_chunk', data: <PCM> }
服务端 -> 客户端: { type: 'asr_partial', text: '你好' }
客户端 -> 服务端: { type: 'audio_chunk', data: <PCM> }
服务端 -> 客户端: { type: 'asr_partial', text: '你好,请介绍一下' }
服务端 -> 客户端: { type: 'speech_ended' }
服务端 -> 客户端: { type: 'asr_final', text: '你好,请介绍一下' }
服务端 -> 客户端: { type: 'llm_delta', text: '好的' }
服务端 -> 客户端: { type: 'llm_delta', text: ',' }
服务端 -> 客户端: { type: 'tts_started' }
服务端 -> 客户端: { type: 'tts_audio', data: <PCM> }
服务端 -> 客户端: { type: 'llm_delta', text: '我' }
...
服务端 -> 客户端: { type: 'tts_audio', data: <PCM> }
服务端 -> 客户端: { type: 'llm_done' }
服务端 -> 客户端: { type: 'tts_done' }在支持打断的场景中,tts_audio 播放期间如果客户端收到新音频,会再次出现 speech_started,此时服务端应暂停当前 tts_audio 流,并开始新一轮 ASR 识别。
这个事件序列可以直接作为接口契约的原型。服务端实现时,可以按事件类型编写处理器:
js
ws.on('message', (raw) => {
const msg = JSON.parse(raw);
switch (msg.type) {
case 'audio_chunk':
vad.feed(msg.data);
break;
case 'speech_started':
currentTurn = createTurn();
break;
case 'asr_final':
currentTurn.asrText = msg.text;
llm.startGeneration(currentTurn);
break;
default:
break;
}
});speech_started 会创建新的轮次对象;asr_final 到达后把识别文本写入当前轮次并启动 LLM 生成;audio_chunk 始终送入 VAD,由 VAD 决定是否发送给 ASR。
部署模式与水平扩展
语音 Agent 的部署模式可以按音频流终止位置划分。
在云端集中部署模式中,客户端只负责采集音频,所有语音处理都在服务端完成。优点是模型升级方便,缺点是音频需要经过网络传输,延时受 RTT 影响。Azure Realtime API 的延迟参考正对应这种模式:WebRTC 约 100ms,WebSocket 约 200ms[2]。
在混合部署模式中,VAD、唤醒词、ASR 的一部分可以放在端侧,LLM 和 TTS 放在云端。端侧 VAD 可以大幅减少无效音频的上行传输,但仍需要保持一条长连接来接收 TTS 音频流。
在模块化架构中,ASR、LLM、TTS 是三个独立的可水平扩展服务:
- ASR 服务无状态,可以按并发会话数扩展;
- LLM 服务有状态(KV cache),需要通过会话亲和性将同一会话路由到同一实例,或使用推理引擎的上下文缓存;
- TTS 服务无状态,但合成结果需要按序送回播放队列。
会话状态建议与计算节点分离。编排层把会话状态写入 Redis 或内存网格,即使某个 ASR 或 LLM 实例重启,会话仍然可以继续。
测试、可观测性与运行监控
语音 Agent 的测试与普通 Web 服务不同,输入是音频流,输出也是音频流,中间缺少文本响应这样易于断言的接口。
自动化测试可以使用回声测试:预录一段用户语音,通过测试脚本送入系统,断言最终输出的音频文本是否正确。流式行为需要单独验证,例如:ASR 是否按预期输出 partial 和 final;LLM 是否在收到 partial 后过早生成;TTS 是否在句子边界才切分。
可观测性方面,以下指标是基础项:
- 端到端延迟:从 VAD speechStart 到 tts_audio 首帧播放;
- ASR final 延迟:从 speech_ended 到 asr_final;
- LLM TTFT:从请求发送到第一个 token;
- TTS 首帧延迟:从文本送入到第一段音频返回;
- 打断检测延迟:从用户插话到系统停止播放;
- 播放欠载率:播放队列为空导致播放中断的次数。
所有事件都应携带 sessionId 和 turnId。日志与链路追踪系统把这些 ID 作为关联键,可以还原一轮对话的完整时间线。
架构选型与未来演进
语音 Agent 有三种实现路线。
第一种是自建模块化流水线:分别选择或训练 ASR、LLM、TTS 模块,自己实现编排层。这种路线灵活,可以替换任意模块,但需要处理音频流、事件协议、断句、打断、状态同步等问题。
第二种是使用实时语音 API:例如 OpenAI Realtime API、Azure Voice Live API。这些 API 把 ASR、LLM、TTS 封装成一个统一的语音进、语音出接口,由服务端管理 VAD、断句和事件流[5]。Azure Voice Live API 明确将识别、生成式 AI 和 TTS 集成到单一接口,无需手动编排[3]。
第三种是未来的统一语音模型:直接以音频输入、音频输出的端到端模型。这种路线可以避免 ASR 错误的传播,也能更自然地处理语气词、语速、情感等副语言信息。但目前这类模型的延迟和部署成本仍然较高。
选择哪种路线,取决于延迟预算、可控性要求和团队对语音链路的熟悉程度。模块化流水线在模型选择上最自由,但工程复杂度最高;实时语音 API 交付最快,但受限于特定平台。
边界与注意点
ASR 输出文本与普通文本不同。流式 ASR 的 partial result 可能包含重复或错误字符,直接作为 LLM 输入需要处理好“最终结果覆盖中间结果”的一致性。
LLM 输出文本在被 TTS 合成前,通常需要逆文本规范化。数字、单位、日期、缩写要转换为适合朗读的形式。年份与普通数值的读法不同,年份通常逐位读出,普通数值按位权读出。TTS 引擎需要根据上下文判断数字的读法。
流式 TTS 对文本切分敏感。把不完整的从句送入 TTS,合成语音可能出现异常停顿。应在句子边界或意群边界切分,而不是按固定 token 数切分。
打断实现中,VAD 与语义判断需要配合。只依赖 VAD 会造成误打断,只依赖语义判断会反应过慢。编排层应把 VAD 事件作为“候选打断”,等待 ASR 结果确认。
最后,音频格式的一致性需要从输入到输出全程保证。采集端、ASR、TTS、播放端的采样率和声道数不一致,会引入大量微妙的故障。建议在系统最前端统一转换为 16kHz 单声道 PCM,并在 TTS 输出端转换为播放设备所需的格式。
参考链接
- [1] https://learn.microsoft.com/en-us/azure/foundry/openai/how-to/realtime-audio
- [3] https://learn.microsoft.com/en-us/azure/ai-services/speech-service/voice-live
- [4] https://learn.microsoft.com/zh-cn/azure/ai-services/speech-service/voice-live
- [5] https://developers.openai.com/api/docs/guides/realtime
- [6] https://docs.sarvam.ai/api/api-guides-tutorials/speech-to-text/streaming-api
- [7] https://docs.knovvu.com/docs/sr-transcribe-in-real-time
