AI 语音技术详解:从 ASR、TTS 到实时语音 Agent 的工程化落地
大家好,我是 Guide。
很多开发者第一次做 AI 语音应用时,都会有一个很朴素的想法:用户说话,转成文字,丢给大模型,再把回答播出来。
听起来就是三段调用:ASR -> LLM -> TTS。
真推到生产环境,问题马上来了:用户还没说完,系统已经误判结束;用户想打断,AI 还在自顾自朗读;会议室里有空调声和键盘声,ASR 开始胡乱转写;网络稍微抖一下,下行音频就卡成一段一段;看起来模型很聪明,真正说话时却像慢半拍的电话客服。
AI 语音系统最折磨人的地方就在这里:它不是把文本 Agent 接上麦克风和扬声器这么简单,而是一套实时音频工程、语音模型、对话状态和端云协同共同组成的系统。
本文接近 2w 字,建议收藏,通过本文你将搞懂:
- ASR、TTS、VAD 的核心原理,以及云端 API 和本地模型该怎么选。
- 实时语音交互的核心难点:延迟、打断、噪声、上下文和端侧能力各自卡在哪里。
- 从 interview-guide 项目看基础版语音 Agent 是怎么一步步实现的。
- WebRTC 在端侧音频处理中的实际作用和配置选择。
- 状态机设计、打断处理、成本控制等生产级落地要点。
- 语音 Agent 的后续演进方向。
AI 语音系统到底解决了什么问题?
在说技术之前,先搞清楚我们到底在解决什么问题。
语音 Agent 的本质目标是让机器能像人一样自然地对话。这听起来简单,但和文字对话相比,语音多了几个维度:
- 实时性:用户说话的时候,系统就得开始工作,不能等用户说完再反应。
- 多模态信息:语气、停顿、情绪,这些在文字里都丢了。
- 打断能力:人说话可以互相插嘴,机器也得支持。
- 端到端延迟:文字聊天慢 1 秒用户还能忍,语音慢 1 秒就感觉对方“没反应”。
市面上常见的语音交互有两类:
- 传统语音助手:Siri、小爱同学、车载语音。你说“打开空调”,它执行固定命令。本质是个语音版的菜单系统。
- 大模型语音 Agent:能理解开放问题、调用工具、持续多轮对话。你问“帮我看看上周那个接口超时是怎么回事”,它需要理解意图、检索上下文、生成回答、还要用语音和你来回确认。
这两者的底层逻辑完全不同。本文主要讨论后者,也就是大模型语音 Agent 的工程化落地。
语音识别(ASR)是怎么把声音变成文字的?
ASR(Automatic Speech Recognition)看起来就是“音频进、文字出”,但背后至少包含三个判断:
- 这段音频说的是什么字。
- 这些字怎么切分成词和句子。
- 标点、数字、英文、技术名词怎么规范化。
比如用户说“帮我查一下 Java 21 的虚拟线程”,ASR 要同时识别中文、英文、数字和技术词。如果识别成“加瓦二十一的虚拟线程”,后面的 LLM 再强也得先猜半天。
ASR 的三条技术路线
| 类型 | 代表方案 | 优势 | 短板 | 适合场景 |
|---|---|---|---|---|
| 云端 API | OpenAI Transcription(gpt-4o-transcribe、whisper-1、gpt-4o-transcribe-diarize)、Azure Speech、Google Speech、Deepgram、阿里云 ASR | 接入快,语言覆盖广,运维成本低 | 成本、网络延迟、数据合规受限 | 客服、会议转写、轻量语音助手 |
| 开源通用模型 | Whisper、faster-whisper、Whisper.cpp、FunASR | 可本地部署,可控性强,支持私有化;faster-whisper 内置 Silero VAD 过滤 | 实时性要自己做工程优化;Whisper turbo 不支持翻译任务 | 私有化转写、离线字幕、企业内网 |
| 领域定制模型 | 金融、医疗、车载专用 ASR | 专有名词和口音适配更好 | 数据准备和训练成本高 | 高频垂直场景、强业务词表 |
补充说明:
- OpenAI 的
gpt-4o-transcribe-diarize支持说话人标签,适合会议转写等多人场景 - Whisper turbo(large-v3-turbo)是 large-v3 的推理优化版,速度快但不支持翻译任务,需要翻译时请用 medium 或 large
选型建议:如果你的核心需求是“实时对话”,不要只看离线 WER(Word Error Rate,词错误率)。你更应该关注:
- 首段延迟:用户说完到看到第一个字的时间
- 增量结果稳定性:能不能实时看到识别进度
- 端点检测准确率:能不能准确判断用户说完了
- 噪声环境表现:远场、多人说话时准不准
- 热词能力:能不能识别你的业务专属词汇
流式 ASR 和非流式 ASR 的区别
做实时对话必须用流式 ASR。区别在于:
- 非流式 ASR:等用户说完一段话,再整段识别。延迟 = 说话时长 + 识别时间。
- 流式 ASR:边说边识别,用户话音刚落就能拿到结果。延迟 ≈ 端点检测时间 + 实时识别时间。
interview-guide 项目用的是阿里云 DashScope 的 qwen3-asr-flash-realtime,这是一个服务端 VAD 驱动的流式 ASR:
// QwenAsrService.java
OmniRealtimeConfig config = OmniRealtimeConfig.builder()
.modalities(Collections.singletonList(OmniRealtimeModality.TEXT))
.enableTurnDetection(true) // 开启服务端 VAD
.turnDetectionType("server_vad")
.turnDetectionSilenceDurationMs(400) // 400ms 静音判定用户说完
.transcriptionConfig(transcriptionParam)
.build();服务端 VAD 的好处是不用客户端做复杂的语音活动检测,但代价是你要等 400ms 静音才判定用户说完。实际体验中这 400ms 挺明显的,所以很多方案会改成客户端 VAD 先触发、前端先提交,等服务端确认。
语音合成(TTS)是怎么把文字变成声音的?
TTS(Text To Speech)负责把模型回复合成音频。它看起来是输出层,但其实很影响用户对整个 Agent 的感知。
同一句“我帮你查一下”,不同 TTS 的差异可能体现在:
- 首包音频要等多久
- 音色是否自然,长句是否喘得像真人
- 数字、代码、英文缩写是否读得准确
- 是否支持情绪、语速、停顿、音高控制
TTS 的技术演进
传统 TTS 分好几步走:
文本规范化 -> 文本分析 -> 声学模型 -> 声码器 -> 波形输出现在主流的端到端模型(比如 VALL-E、Fish Speech、CosyVoice)把这个链路压缩了,效果也更好。但对实时语音 Agent 来说,单句音质不是最关键的,流式可播放性才是。
如果你必须等整段文字生成完才能合成,用户体感会非常慢。如果能按短句甚至 token 流式合成,首包体验会好很多。
实时 TTS 的两条路线
| 类型 | 代表方案 | 特点 |
|---|---|---|
| 云端实时 TTS | OpenAI Speech、阿里云 qwen-tts-realtime、Azure TTS、ElevenLabs | 流式输出,支持实时合成 |
| 本地 TTS | piper1-gpl(注意:原 Piper 已归档)、Fish Speech(本地部署版) | 可控性强,适合离线场景 |
interview-guide 用的也是阿里云的 qwen-tts-realtime,通过 WebSocket 实时合成:
// QwenTtsService.java
QwenTtsRealtimeConfig config = QwenTtsRealtimeConfig.builder()
.voice(voice) // 音色选择
.responseFormat(QwenTtsRealtimeAudioFormat.PCM_24000HZ_MONO_16BIT)
.mode("commit") // 提交模式
.languageType(languageType)
.speechRate(speechRate)
.volume(volume)
.build();
// 发送文本,实时接收音频块
qwenTtsRealtime.appendText(text);
qwenTtsRealtime.commit();每次合成都会建立新的 WebSocket 连接,接收 response.audio.delta 事件,把音频块拼接起来。
VAD 为什么是语音系统的「隐形守门人」?
VAD(Voice Activity Detection,语音活动检测)这个组件经常被忽略,但它对体验影响极大。
VAD 的任务不是识别内容,而是判断:
- 用户开始说话了吗?
- 用户说完了吗?
- 当前声音是人声、背景噪声、音乐,还是系统自己播放的声音?
这件事看似简单,实际非常难。因为真实用户说话不是朗读新闻稿:
- 句中会停顿:“这个问题……我想问一下……”
- 会有短反馈:“嗯”“对”“不是”
- 会边想边说,音量忽大忽小
- 旁边可能有人说话,扬声器里也可能正在播放 AI 的声音
端侧 VAD 还是服务端 VAD?
| 类型 | 代表方案 | 优势 | 短板 |
|---|---|---|---|
| 端侧 VAD | WebRTC VAD、Silero VAD、@ricky0123/vad-web | 响应快,不消耗服务端资源 | 需要在客户端部署模型 |
| 服务端 VAD | DashScope ASR 内置、Whisper ASR 内置 | 不用管客户端 | 增加服务端负载,有网络延迟 |
interview-guide 前端用的是 @ricky0123/vad-web,这是一个基于 ONNX 的端侧 VAD:
// AudioRecorder.tsx
const vadInstance = await window.vad.MicVAD.new({
getStream: async () => stream,
onnxWASMBasePath: "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.22.0/dist/",
baseAssetPath: "https://cdn.jsdelivr.net/npm/@ricky0123/vad-web@0.0.29/dist/",
onSpeechStart: () => {
onSpeechStart?.(); // 用户开始说话
},
onSpeechEnd: () => {
onSpeechEnd?.(); // 用户说完
},
});高频踩坑点:端侧 VAD 触发 onSpeechEnd 后,不要以为用户真的说完了。最好再等 300-500ms 静音确认,避免把用户中途停顿当成结束。
我的建议是:VAD 不要只当开关用,它应该输出一组对话控制信号。比如:
speech_start:用户开始说话speech_end:用户说完了(带置信度)maybe_barge_in:可能是用户在打断noise_only:只有噪声,没人说话
一次完整的语音对话是怎么跑起来的?
先把完整链路拆解清楚,后面讲细节才有上下文。
一次语音 Agent 对话大概经过这些步骤:
1. 音频采集:麦克风采集原始音频
2. 前处理:AEC 消回声、NS 降噪、AGC 增益
3. VAD 检测:判断用户是否在说话,是否说完
4. 音频上传:把处理后的音频发到服务端
5. ASR 转写:把音频转成文字(流式输出增量结果)
6. 上下文组装:拼接系统指令、历史对话、工具定义
7. LLM 推理:理解意图、生成回复、必要时调用工具
8. TTS 合成:把回复文字转成音频(流式输出音频块)
9. 音频下行:客户端边收边播
10. 状态回写:记录本次对话,为下一轮准备上下文高频盲区:实时语音不是等用户说完才开始工作的。
优秀的系统会尽量把可以提前做的事提前做:
- 用户刚开始说话时,先加载会话状态和工具定义
- ASR 出现稳定前缀后,提前做意图预判
- LLM 输出第一个短句时,TTS 立刻开始合成
- 工具调用较慢时,先播一句自然的过渡语
核心做法是用并行和流式把等待时间藏起来。
实时语音为什么比文字对话难这么多?
这是本文的核心问题。让我拆成五个维度来讲。
难点一:延迟预算非常紧
文本聊天慢 1 秒,用户通常还能忍。语音对话慢 1 秒,用户会明显感觉对方“没反应”。
一轮语音交互的延迟来自这些环节:
| 环节 | 常见耗时 | 优化方向 |
|---|---|---|
| 采集与编码 | 音频帧大小、浏览器缓冲 | 小帧采集,减少无意义缓冲 |
| VAD 端点检测 | 等待静音确认用户说完 | 动态静音阈值,短句快速提交 |
| ASR | 音频上传、解码、增量转写稳定 | 流式 ASR,热词,端侧预处理 |
| LLM | 首 token 延迟、工具调用、上下文过长 | Prompt 缓存,短回复,异步工具 |
| TTS | 首包合成、长句切分、声码器推理 | 句子级流式合成,预热音色 |
| 播放 | 网络抖动、解码、播放器缓冲 | WebRTC jitter buffer,边收边播 |
如果每段都多 200ms,整轮对话马上就变成“慢半拍”。
所以实时语音优化的目标不是让某一个组件跑到理论上限,而是端到端 P95/P99 延迟稳定。用户感受到的是整条链路,不是某个模型的 benchmark。
难点二:打断处理不是暂停按钮
语音 Agent 必须支持 Barge-in(插话打断)。
用户说“等一下,不是这个意思”,系统需要同时做几件事:
- 识别出这是用户在说话,而不是背景噪声或扬声器回声
- 立即停止本地播放队列,不能继续把旧回答播完
- 取消服务端仍在生成的 LLM 和 TTS 流
- 把已经播放、未播放、被打断的内容写进对话状态
- 用新的用户音频开启下一轮理解
很多系统打断失败,不是因为 VAD 不准,而是状态机没设计好。比如播放器停了,但服务端 TTS 还在推流;LLM 停了,但历史里已经把未播出的回答记成了“已说过”。
interview-guide 的做法是:
// VoiceInterviewPage.tsx
const handleAudioData = (audioData: string) => {
// AI 播放时停发音频,避免自己的声音被识别
if (isAiSpeakingRef.current) {
return;
}
if (wsRef.current && wsRef.current.isConnected()) {
wsRef.current.sendAudio(audioData);
}
};前端通过 isAiSpeakingRef 标记 AI 是否在说话,说话时停发音频。后端收到 control 消息取消生成。
难点三:噪声环境比测试环境复杂太多
语音 Demo 往往在安静办公室里跑,生产环境可能是:
- 车内、工厂、商场、地铁站
- 远场麦克风,用户离设备两三米
- 多人同时说话
- 用户开着外放,AI 的声音又被麦克风收回去
这会影响整条链路:
- VAD 把噪声当成人声,导致误触发
- ASR 把背景人声转成文本,污染用户意图
- TTS 播放被麦克风采集,造成自我打断
interview-guide 前端通过 getUserMedia 配置了三板斧:
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true, // AEC:消除扬声器回声
noiseSuppression: true, // NS:压低背景噪声
autoGainControl: true, // AGC:自动增益,让音量更稳定
sampleRate: 16000,
},
});这三个参数能解决一部分问题,但不能迷信它们。WebRTC 的 AEC 在强回声场景下效果有限,NS 可能把用户声音也削掉一截。如果你要做硬件或 App 方案,端侧音频前处理会变成非常现实的工程投入。
难点四:上下文不只是文字历史
文本 Agent 的上下文主要是消息历史。语音 Agent 的上下文更多:
- 当前用户是否正在说话
- 上一段回答播放到了哪里
- 用户是正常提问,还是正在打断
- ASR 的增量文本是否稳定
- 用户语气是疑问、否定、犹豫,还是不耐烦
- 当前是否有工具调用正在执行
如果只把最终 ASR 文本喂给 LLM,很多信息会丢掉。
比如用户说“不是……我是说上个月那笔订单”,文本里能看到纠正,但看不到他是在打断 AI;系统如果不知道上一段回答播到哪里,就很难知道用户在否定哪一句。
interview-guide 用 WebSocket 消息类型区分了不同状态:
// voiceInterview.ts
export interface WebSocketSubtitleMessage {
type: "subtitle";
text: string;
isFinal: boolean; // true 表示用户已确认提交
}
export interface WebSocketAudioResponseMessage {
type: "audio";
data: string; // Base64 音频
text: string; // 对应的文字
}
export interface WebSocketControlMessage {
type: "control";
action: string; // 'submit' | 'cancel' | 'pause'
data?: Record<string, unknown>;
}前端根据 isFinal 判断用户是否真的说完了,避免把用户中途停顿当成确认。
难点四(续):回声导致的误打断
还有一个高频踩坑点:AI 播放的声音被麦克风采集后,VAD 或 ASR 会误判为用户说话,导致 AI 自我打断。
interview-guide 的当前做法是:
if (isAiSpeakingRef.current) {
return; // AI 说话时停发音频
}这种“静默丢弃”的方案确实避免了自我打断,但代价是用户在 AI 说话期间的真正打断也被屏蔽了。
更精细的方案:
- AI 说话时继续接收音频,但不发到 ASR
- 在 AEC 处理后的音频上运行端侧 VAD,而非原始麦克风音频
- 用能量阈值区分用户人声(通常 > -20dB)和回声残余
getUserMedia 的 AEC 能力在不同浏览器和设备上差异很大。Chrome 桌面版效果不错,但 Safari 和移动端可能大打折扣。生产环境建议在多种设备上测试 AEC 实际效果。
难点五:端侧能力决定体验下限
很多团队把所有能力都放云端,结果在弱网环境下体验崩得很快。
端侧至少应该承担这些职责:
- 麦克风采集和音频前处理
- VAD 或轻量打断检测
- 播放缓冲和取消播放
- 网络断开时的提示和重连
云端模型决定上限,端侧工程决定下限。这句话在语音系统里尤其明显。
从 interview-guide 看基础版语音 Agent 是怎么实现的?
说了这么多概念,来点实际的。我以 interview-guide 项目为例,讲解一个最基础的语音面试 Agent 是怎么跑起来的。
整体架构
┌─────────────────────────────────────────────────────────────┐
│ 前端 (React) │
├─────────────────────────────────────────────────────────────┤
│ AudioRecorder WebSocket VoiceInterviewPage │
│ - getUserMedia - sendAudio - 状态管理 │
│ - VAD 检测 - sendControl - 音频播放 │
│ - 音频分块 - 重连机制 - UI 更新 │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 后端 (Spring Boot) │
├─────────────────────────────────────────────────────────────┤
│ VoiceInterviewWebSocketHandler │
│ - 会话管理(创建、暂停、恢复、结束) │
│ - 音频路由到 ASR │
│ - 文本路由到 LLM │
│ - TTS 合成结果推送给前端 │
├─────────────────────────────────────────────────────────────┤
│ QwenAsrService DashscopeLlmService QwenTtsService │
│ - qwen3-asr-flash- - qwen-max / qwen-plus - qwen-tts- │
│ realtime - 工具调用支持 realtime │
└─────────────────────────────────────────────────────────────┘前端:音频采集与 VAD
前端的核心是 AudioRecorder 组件。它做了这么几件事:
第一步,获取麦克风权限并配置音频参数:
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
sampleRate: 16000, // ASR 需要 16kHz
},
});第二步,初始化端侧 VAD:
const vadInstance = await window.vad.MicVAD.new({
getStream: async () => stream,
onSpeechStart: () => {
onSpeechStart?.(); // 触发回调
},
onSpeechEnd: () => {
onSpeechEnd?.();
},
});
await vadInstance.start();第三步,音频分块采集:
VAD 的 onSpeechEnd 只是告诉你用户可能说完了,真正的音频还是要分块发送给服务端。interview-guide 的实现是:
const SAMPLES_PER_CHUNK = TARGET_SAMPLE_RATE / 5; // 200ms
let pendingPcm = new Int16Array(0);
scriptProcessor.onaudioprocess = (e) => {
const inputData = e.inputBuffer.getChannelData(0);
const pcmData = float32ToInt16Pcm(inputData);
// 累积音频数据
const combined = new Int16Array(pendingPcm.length + pcmData.length);
combined.set(pendingPcm, 0);
combined.set(pcmData, pendingPcm.length);
// 按 200ms 分块发送
let read = 0;
while (combined.length - read >= SAMPLES_PER_CHUNK) {
const chunk = combined.subarray(read, read + SAMPLES_PER_CHUNK);
read += SAMPLES_PER_CHUNK;
const base64 = int16ToBase64(chunk);
onAudioData(base64); // 发送给后端
}
pendingPcm = combined.subarray(read);
};这里有个设计选择:为什么不等 VAD 触发 onSpeechEnd 再发音频?
因为 VAD 检测有延迟,等它确认用户说完了再开始发音频,会白白多等 400-600ms。更好的做法是持续分块发送,VAD 触发 onSpeechEnd 只是告诉后端“这一段说完了,可以提交给 LLM 了”。
前端:音频播放
interview-guide 用了两种音频播放模式:
模式一:HTMLAudioElement(简单场景):
// VoiceInterviewPage.tsx
const onAudioResponse = (audioData: string, text: string) => {
if (audioData && audioData.length > 0) {
setAiAudio(audioData); // 设置 src,触发自动播放
setAiText(text);
setAiSpeaking(true);
// 设置超时watchdog,防止音频播放异常卡住
const durationMs = estimateWavDurationMs(audioData);
audioPlaybackWatchdogRef.current = setTimeout(
finishAiPlayback,
Math.min(Math.max(durationMs + 1500, 4000), 60_000),
);
}
};模式二:AudioContext 分块播放(更精细控制):
// 分块处理
const handleAudioChunk = (
base64Wav: string,
_index: number,
isLast: boolean,
) => {
// 1. 解码 WAV
const binaryStr = atob(base64Wav);
const bytes = new Uint8Array(binaryStr.length);
const pcmOffset = 44;
const pcmData = new Int16Array(
bytes.buffer,
pcmOffset,
(bytes.length - pcmOffset) / 2,
);
const float32 = new Float32Array(pcmData.length);
// 2. 放入播放队列
chunkQueueRef.current.push(audioBuffer);
if (!isChunkPlayingRef.current) {
playNextChunk();
}
// 3. 最后一包,设置队列空检测
if (isLast) {
drainCheckRef.current = setInterval(() => {
if (chunkQueueRef.current.length === 0 && !isChunkPlayingRef.current) {
finishAiPlayback();
}
}, 100);
}
};
// 播放下一块
const playNextChunk = () => {
if (chunkQueueRef.current.length === 0) {
isChunkPlayingRef.current = false;
return;
}
const buffer = chunkQueueRef.current.shift()!;
const source = ctx.createBufferSource();
source.buffer = buffer;
source.connect(ctx.destination);
source.onended = () => playNextChunk();
source.start(0);
};分块播放的好处是能更快开始播放,不用等完整音频文件加载完。但代价是实现复杂度更高,要自己管理队列和状态。
后端:WebSocket 会话管理
后端通过 VoiceInterviewWebSocketHandler 管理会话生命周期:
// VoiceInterviewWebSocketHandler.java
public class VoiceInterviewWebSocketHandler {
// 会话状态:idle -> listening -> thinking -> speaking -> completed
// 支持:pause(暂停)、resume(恢复)、end(结束)
// 收到客户端音频
public void handleAudioMessage(String sessionId, String audioBase64) {
asrService.sendAudio(sessionId, decodeBase64(audioBase64));
}
// 收到客户端控制消息
public void handleControlMessage(String sessionId, String action, Map data) {
switch (action) {
case "submit" -> llmService.triggerResponse(sessionId, data);
case "cancel" -> cancelCurrentGeneration(sessionId);
case "pause" -> pauseSession(sessionId);
}
}
}interview-guide 的会话状态机:
| 状态 | 含义 | 可转换到 |
|---|---|---|
| IN_PROGRESS | 面试进行中 | PAUSED, COMPLETED |
| PAUSED | 暂停(用户离开页面或主动暂停) | IN_PROGRESS |
| COMPLETED | 面试结束 | - |
暂停/恢复机制很有用。比如用户接电话、切换标签页,可以暂停面试,回来后无缝继续。
后端:ASR 服务
后端的 ASR 服务封装了阿里云 DashScope 的接口:
// QwenAsrService.java
public void startTranscription(String sessionId, Consumer<String> onFinal, Consumer<String> onPartial, Consumer<Throwable> onError) {
// 1. 建立 WebSocket 连接到 DashScope ASR
OmniRealtimeConversation conversation = new OmniRealtimeConversation(param, callback);
// 2. 配置:开启服务端 VAD,400ms 静音判定结束
OmniRealtimeConfig config = OmniRealtimeConfig.builder()
.enableTurnDetection(true)
.turnDetectionSilenceDurationMs(400)
.build();
// 3. 注册回调:识别完成时触发
conversation.updateSession(config);
}
public void sendAudio(String sessionId, byte[] audioData) {
AsrSession session = sessions.get(sessionId);
String audioBase64 = Base64.getEncoder().encodeToString(audioData);
session.getConversation().appendAudio(audioBase64);
}服务端返回识别结果时,Handler 会把增量文字推送给前端:
// WebSocket 推送增量文字
websocket.sendMessage(new WebSocketSubtitleMessage(
"subtitle",
transcript,
isFinal // true 表示这是最终结果
));后端:TTS 服务
// QwenTtsService.java
public byte[] synthesize(String text) {
CountDownLatch latch = new CountDownLatch(1);
ByteArrayContainer audioContainer = new ByteArrayContainer();
QwenTtsRealtime qwenTts = new QwenTtsRealtime(param, callback);
qwenTts.connect();
// 配置音色和参数
QwenTtsRealtimeConfig config = QwenTtsRealtimeConfig.builder()
.voice(voice) // 如 "Cherry"
.responseFormat(QwenTtsRealtimeAudioFormat.PCM_24000HZ_MONO_16BIT)
.speechRate(speechRate)
.build();
qwenTts.updateSession(config);
qwenTts.appendText(text);
qwenTts.commit();
// 等待音频块接收完成
latch.await(30, TimeUnit.SECONDS);
return audioContainer.toByteArray();
}Handler 拿到 PCM 数据后,转成 WAV 推送给前端:
// PCM 转 WAV 并推送
byte[] pcmData = ttsService.synthesize(text);
byte[] wavData = pcmToWav(pcmData);
String base64 = Base64.getEncoder().encodeToString(wavData);
websocket.sendMessage(new WebSocketAudioChunkMessage(
"audio_chunk",
base64,
index,
isLast
));怎么让语音 Agent 支持打断?
打断是语音 Agent 的高频难点。让我专门讲清楚。
打断的三层含义
- 播放层打断:用户说话时,停止当前音频播放
- 生成层打断:取消服务端正在生成的 LLM 和 TTS
- 上下文层打断:正确记录已播放和未播放的内容
interview-guide 的打断逻辑:
// 前端:检测到用户说话时停止播放
const handleAudioData = (audioData: string) => {
// AI 正在说话时,不发音频给后端
if (isAiSpeakingRef.current) {
return; // 静默丢弃,不触发打断逻辑
}
wsRef.current.sendAudio(audioData);
};
// 音频播放完成时
const finishAiPlayback = () => {
aiAudioPendingRef.current = false;
clearAudioPlaybackWatchdog();
setAiSpeaking(false);
setIsSubmitting(false);
// 只有真正播放完的内容才能写入"已说"上下文
commitAiMessage(aiTextRef.current.trim());
};关键设计是:打断不是“暂停”,而是“取消”。已播放的内容记为“已说”,未播放的内容不记。
状态机视角的打断
从状态机角度看,打断是一个几乎可以从任何状态进入的控制事件:
| 当前状态 | 用户打断 | 正确响应 |
|---|---|---|
| listening | 用户插话 | 丢弃当前音频,重新开始识别 |
| thinking | 用户补充 | 取消当前推理,用新输入重新触发 |
| speaking | 用户插话 | 停止播放,清空队列 |
| tool_calling | 用户说“算了” | 取消工具调用,或停止后续播报 |
如果你的系统没有清晰的取消语义,很快就会出现“AI 一边听新问题,一边还在播旧答案”的混乱体验。
浏览器音频捕获与前处理在语音系统中扮演什么角色?
很多文章把 WebRTC 当成“浏览器音视频通话的标准”,讲得很抽象。更准确的说法是:浏览器提供了一套音频捕获和前处理能力,语音 Agent 场景主要用的是 getUserMedia API。
重要区分:
- Media Capture and Streams API(
getUserMedia):负责从麦克风采集音频,可以配置 AEC/NS/AGC 等前处理。这是 interview-guide 实际使用的。 - WebRTC 协议(RTCPeerConnection):负责端到端的实时传输,包含 ICE、DTLS-SRTP、RTP 等协议。如果你用 OpenAI Realtime API(WebRTC 模式)或 Azure Voice Live,才需要这套东西。
interview-guide 的音频通路是:
getUserMedia → ScriptProcessor → Base64 编码 → WebSocket 发送这套通路的传输层是 WebSocket(TCP),不是 WebRTC 的 RTP(UDP)。WebSocket 保证顺序但可能有 TCP 重传延迟;WebRTC 的 UDP 传输更快但丢包不重传。
浏览器音频前处理管线
在语音 Agent 场景下,你主要用到浏览器音频前处理的这些能力:
麦克风输入
│
▼
┌─────────────────────────┐
│ AEC (回声消除) │ 消除扬声器播放的声音
└─────────────────────────┘
│
▼
┌─────────────────────────┐
│ NS (噪声抑制) │ 压低背景噪声
└─────────────────────────┘
│
▼
┌─────────────────────────┐
│ AGC (自动增益控制) │ 让音量更稳定
└─────────────────────────┘
│
▼
┌─────────────────────────┐
│ VAD (语音活动检测) │ 判断是否有人声
└─────────────────────────┘
│
▼
编码输出getUserMedia 的配置选择
interview-guide 用的是最基础的 getUserMedia 配置:
navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
sampleRate: 16000,
},
});但这不是唯一选择,不同场景有不同权衡:
| 参数 | true | false | 建议 |
|---|---|---|---|
| echoCancellation | 消除扬声器回声,但会损失部分音质 | 保留原始音质,但需要自己做 AEC | 开 |
| noiseSuppression | 压低噪声,但可能把用户声音也削掉 | 需要自己做 NS | 环境嘈杂时开,安静时关 |
| autoGainControl | 自动调整音量到合适范围 | 依赖麦克风原始音量 | 开 |
| sampleRate | 越高音质越好,但数据量越大 | 16kHz 对 ASR 够用 | ASR 用 16kHz,TTS 输出可能需要 24kHz |
一个高频踩坑点:WebRTC 的 AEC 能力在不同浏览器、不同设备上差异很大。Chrome 桌面版效果不错,但 Safari 和移动端可能大打折扣。如果你做的是生产级应用,建议在多种设备和浏览器上测试 AEC 效果。
WebRTC 的局限性
WebRTC 适合浏览器场景,但如果你做的是 App 或硬件方案,它就不一定适用了。
移动端 native 开发可以用:
- iOS:AVAudioEngine + 系统内置的音频处理
- Android:AudioRecord + Oboe/AAudio,或者用 Google 的 WebRTC 库
硬件场景(智能音箱、车载)通常需要专门的 DSP 芯片做前端处理,WebRTC 的软件方案满足不了延迟和功耗要求。
级联链路和原生实时模型各有什么优劣?
这是选型时的核心问题。
方案一:级联式 ASR + LLM + TTS
音频 -> VAD -> 流式 ASR -> LLM -> 流式 TTS -> 音频优点:
- ASR 文本可以落库、审计、纠错
- LLM 输入输出都是文本,方便复用现有 Agent 框架
- TTS 可以独立替换音色和供应商
- 每个组件都能单独压测和优化
缺点:
- 每层都有延迟
- ASR 错误会传导到 LLM
- 文本中间层会丢失语气、停顿、情绪
- 打断要跨 ASR、LLM、TTS、播放器统一取消
interview-guide 就是这套方案。它适合的场景:企业知识问答、客服工单、需要合规审计的业务系统。
方案二:原生 Realtime Speech-to-Speech
音频 -> 原生多模态模型 -> 音频代表方案:OpenAI Realtime API、Gemini Live API、阿里通义 Qwen-Omni。
优点:
- 更低的端到端延迟
- 语气、停顿、情绪等副语言信息保留更多
- 可以统一处理音频输入、文本事件、工具调用
缺点:
- 中间过程更黑盒,问题定位更依赖供应商日志
- 文本审计和话术控制需要额外设计
- 成本模型可能按音频 token 或时长计费
- 如果业务强依赖私有化部署,供应商 API 未必满足要求
连接方式选择:
OpenAI Realtime API 支持三种连接方式:
| 连接方式 | 适用场景 |
|---|---|
| WebRTC | 浏览器和移动端应用,有更好的 NAT 穿透和抗抖动能力 |
| WebSocket | 服务端到服务端的中间件场景,低延迟且可控 |
| SIP | VoIP 电话系统集成,适合呼叫中心、电话客服场景 |
我的建议
高频、强实时、强自然感的语音产品,优先评估原生 Realtime API。强合规、强审计、强可控的业务场景,级联链路更稳。
不要第一天就做端云混合。先把一条链路跑通,再逐步替换。
怎么在生产环境中优化语音系统?
讲几个实战抓手。
1. 缩短音频帧和提交粒度
实时音频通常按 10ms、20ms、30ms 分帧。帧太大延迟高,帧太小网络开销大。
interview-guide 的选择是 200ms 分块:
const SAMPLES_PER_CHUNK = TARGET_SAMPLE_RATE / 5; // 16kHz / 5 = 3200 samples = 200ms这意味着用户说完一句话,最快 400-600ms 后服务端才能开始识别。这个延迟能接受,但如果要做得更好,可以:
- 减小分块到 100ms
- 前端先发一小段让 ASR“热启动”
- 用服务端 VAD 的增量结果做流式 LLM 输入
2. 让 LLM 先说短句
语音回复不是写文章。用户不需要一上来听 500 字完整答案。
更好的策略:
- 先输出确认语:“我看一下”
- 工具调用期间播过渡语:“正在查最近一次订单”
- 查到结果后再给结论
- 长解释拆成多句,每句都能独立合成
3. TTS 按语义边界切分
TTS 切分太碎听起来断断续续;切分太长首包延迟高。
建议按优先级切:
- 句号、问号、感叹号
- 分号、冒号
- 较长逗号短语
- 超长句强制切分
同时要避免把数字、英文缩写、代码名切坏。比如"GPT-4o-mini-tts"不能被随便拆成几段读。
4. 控制上下文长度
语音 Agent 很容易把所有转写、工具结果、播放状态都塞进上下文。短期看没事,长会话里会导致延迟和成本一起上涨。
建议把上下文分成三层:
- 短期原文:最近几轮完整转写和回答
- 会话摘要:用户目标、已确认事实、未完成事项
- 事件状态:当前播放进度、是否被打断、工具调用结果
LLM 不需要知道每个音频帧发生了什么,它需要知道和当前决策相关的高信噪比状态。
5. 全链路可观测
interview-guide 用 Redis 做会话状态缓存:
// VoiceInterviewService.java
private static final String SESSION_CACHE_KEY_PREFIX = "voice:interview:session:";
private void cacheSession(VoiceInterviewSessionEntity session) {
String cacheKey = getSessionCacheKey(session.getId());
RBucket<VoiceInterviewSessionEntity> bucket = redissonClient.getBucket(cacheKey);
bucket.set(session, Duration.ofHours(CACHE_TTL_HOURS));
}生产环境还要记录:
- 上行音频时长
- 有效人声时长
- ASR token 或分钟数
- LLM 输入输出 token
- TTS 字符数、音频秒数、被打断秒数
- 每轮端到端延迟和取消次数
没有这些指标,语音 Agent 的成本会很难收敛。
语音 Agent 还能怎么演进?
interview-guide 是最基础版本,还有很多可以优化的地方。
端云混合
目前 interview-guide 基本是“云端为主”的设计。进阶方向是把更多能力下沉到端侧:
| 环节 | 当前 | 演进方向 |
|---|---|---|
| VAD | 端侧 VAD + 服务端 VAD | 纯端侧 VAD,减少服务端压力 |
| ASR | 纯云端 | 简单命令放端侧,复杂识别放云端 |
| LLM | 纯云端 | 小模型端侧兜底,断网可用 |
| TTS | 纯云端 | 固定提示音放端侧,自然对话放云端 |
端云混合的核心是把实时性强、隐私敏感、断网要兜底的能力尽量放端侧。
本地模型部署
如果你对数据合规有要求,可以考虑本地部署 ASR 和 TTS:
- ASR:faster-whisper、FunASR、SenseVoice
- TTS:piper1-gpl(原 Piper 已归档)、Fish Speech、CosyVoice
注意:原 Piper 仓库(rhasspy/piper)已于 2025 年 10 月归档,开发已迁移到 OHF-Voice/piper1-gpl。如果要用 Piper,请使用新仓库。
本地部署的优势是可控、可离线。劣势是工程成本高:GPU/内存/并发容量要自己压测,流式推理、模型热加载、显存回收都要自己做。
原生 Realtime API
如果你觉得级联链路的延迟和体验不够好,可以评估原生 Realtime API:
- OpenAI Realtime API
- Gemini Live API
- 阿里通义 Qwen-Omni
这些 API 把 ASR、LLM、TTS 融合成一个统一的多模态模型,理论上延迟更低、体验更自然。但代价是更黑盒、更贵、更难调试。
打断体验优化
目前 interview-guide 的打断是“静默丢弃”:AI 说话时用户的声音直接不发。这种方式简单,但体验不够自然。
更好的做法:
- AI 说话时继续接收音频,但不发到 ASR
- 检测到用户声音后,先降低 AI 播放音量(渐变而不是突然停止)
- 打断后保留已播放内容的上下文
多模态扩展
interview-guide 目前只有语音。可以扩展成:
- 语音 + 屏幕共享:面试官可以看到候选人的 IDE
- 语音 + 摄像头:看候选人的表情和肢体语言
- 语音 + 白板:一起画架构图
这些多模态能力需要更复杂的流管理和状态同步。
面试里怎么回答 AI 语音系统问题?
如果面试官问:“你怎么设计一个实时语音 Agent?”
可以按这个思路回答:
- 先拆链路:客户端采集音频,VAD 判断说话边界,ASR 流式转写,LLM 做意图理解和工具调用,TTS 流式合成,客户端边收边播。
- 再讲难点:实时语音核心难点是端到端延迟、用户打断、噪声环境、上下文状态和端云协同。
- 再讲状态机:需要管理 listening、thinking、speaking、interrupted 等状态,打断时要取消播放、取消生成,并处理已播放和未播放上下文。
- 最后讲选型:云端 API 上线快,本地模型可控但工程成本高,端云混合适合生产,实时体验强的场景可以评估 Speech-to-Speech API。
一句话总结:
AI 语音 Agent 的核心不是“语音识别 + 大模型 + 语音合成”,而是围绕实时音频流构建一套可取消、可观测、可降级的对话系统。
关键事实参考
本文写作时(2026-05-05)参考并交叉验证了以下资料,后续模型和 API 能力变化较快,生产选型前建议再次确认官方文档:
- OpenAI Audio and speech 文档:OpenAI 区分 Realtime API、Transcription API、Speech API,并列出当前语音转写和 TTS 模型。
- OpenAI Realtime API 文档:Realtime API 支持低延迟多模态交互,连接方式包括 WebRTC、WebSocket、SIP。
- Gemini Live API 文档:Live API 面向低延迟实时语音和视觉交互,处理连续音频、图像、文本流。
- Azure Voice Live WebRTC 文档:Azure Voice Live 的 WebRTC 会话通过 WebSocket 控制通道交换 SDP。
- OpenAI Whisper 仓库:Whisper 是通用语音识别模型,支持多语言识别、翻译和语言识别,代码和权重采用 MIT License。
- faster-whisper 仓库:基于 CTranslate2 的 Whisper 实现,强调更快推理和更低内存使用,并集成 Silero VAD 过滤能力。
- Silero VAD 仓库:开源语音活动检测模型,支持 PyTorch 和 ONNX 运行方式。
- piper1-gpl 仓库:本地神经 TTS 系统,原 Piper 已归档,开发迁移至此。
- FunASR 仓库:开源语音识别工具包,覆盖 ASR、VAD、标点恢复、说话人相关能力。
- 阿里云百炼 qwen-tts-realtime 文档:qwen-tts-realtime 的 API 文档和 Python SDK 示例。
总结
AI 语音技术看起来是 ASR、TTS、VAD 几个模块的拼接,真正落地时考验的是系统工程能力。
核心要点回顾:
- 底层链路:实时语音 Agent 至少包含采集、前处理、VAD、ASR、LLM、工具调用、TTS、流式播放和状态回写。
- 实时难点:延迟、打断、噪声、上下文和端侧能力是最容易把 Demo 打回原形的五个因素。
- 架构选择:级联式 ASR + LLM + TTS 可控、易审计;原生 Speech-to-Speech 延迟低、体验自然;端云混合是生产里常见折中。
- 工程重点:一定要设计状态机、取消语义、播放确认、全链路 trace 和成本指标。
- 选型原则:先用云端能力跑通闭环,再基于成本、合规、延迟和私有化需求逐步替换本地模型或端侧能力。
总结一下:语音 Agent 的用户体验不是模型一个人决定的,而是整条实时链路共同决定的。模型负责聪明,工程负责不掉链子。两者缺一不可。
