《SpringAI 智能面试平台+RAG 知识库》

大家好,我是小 G。

很多开发者第一次做 AI 语音应用时,脑子里通常是这条链路:用户说话,转成文字,丢给大模型,再把回答播出来。

听起来就是三段调用:ASR -> LLM -> TTS。

推到生产环境,问题马上来了:用户还没说完,系统已经误判结束;用户想打断,AI 还在自顾自朗读;会议室里有空调声和键盘声,ASR 开始胡乱转写;网络稍微抖一下,下行音频就卡成一段一段;文本回答看起来没问题,语音交互却像慢半拍的电话客服。

文本 Agent 接上麦克风和扬声器,只能得到一个能说话的 Demo;生产系统还要处理实时音频、语音模型、对话状态和端云协同。

下文先说明 ASR、TTS 和 VAD 各自负责什么,再结合 interview-guide 项目讨论音频采集、流式传输、播放队列和状态机。最后再看云端 API、本地模型与端云混合方案各自适合什么场景。

术语说明

术语说明

为避免阅读时产生困惑,本文涉及的核心术语做如下说明:

端侧 = 客户端(浏览器/App),指用户设备上的前端代码

Barge-in = 打断/插话打断,即用户在大模型响应过程中主动中断 AI 说话

增量结果 = 流式输出 = partial results,指 ASR 实时返回的识别中间结果

级联方案 = ASR + LLM + TTS 分阶段串联的架构

原生 Realtime API = 实时多模态语音接口,常见形态是音频进、音频出,也可以同时输出文本事件和工具调用事件

AI 语音系统到底解决了什么问题?

AI 语音系统到底解决了什么问题?

先说清楚我们到底在解决什么问题。

语音 Agent 更接近实时协作系统:用户说话时,系统要同步完成理解、生成和播放。和文字对话相比,语音多了几个维度:

实时性:用户说话的时候,系统就得开始工作,不能等用户说完再反应。

多模态信息:语气、停顿、情绪,这些在文字里都丢了。

打断能力:人说话可以互相插嘴,机器也得支持。

端到端延迟:文字聊天慢 1 秒用户还能忍,语音慢 1 秒就感觉对方“没反应”。

市面上常见的语音交互有两类:

命令式语音助手:常见于智能家居和车载控制。用户说“打开空调”,系统把语音映射到预定义意图和设备指令。Siri、小爱同学等产品也在接入开放问答能力,不能简单归为固定菜单。

大模型语音 Agent:能理解开放问题、调用工具、持续多轮对话。你问“帮我看看上周那个接口超时是怎么回事”,它需要理解意图、检索上下文、生成回答、还要用语音和你来回确认。

这两类产品的工程重心差别很大。下文讨论大模型语音 Agent 的工程化落地。

语音识别(ASR)是怎么把声音变成文字的?

语音识别(ASR)是怎么把声音变成文字的?

ASR(Automatic Speech Recognition)看起来就是“音频进、文字出”,但背后至少包含三个判断:

这段音频说的是什么字。

这些字怎么切分成词和句子。

标点、数字、英文、技术名词怎么规范化。

比如用户说“帮我查一下 Java 21 的虚拟线程”,ASR 要同时识别中文、英文、数字和技术词。如果识别成“加瓦二十一的虚拟线程”,后面的 LLM 再强也得先猜半天。

ASR 的三条技术路线

ASR 的三条技术路线

类型代表方案优势短板适合场景云端 APIOpenAI Audio Transcriptions(gpt-4o-transcribe、gpt-4o-mini-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专有名词和口音适配更好数据准备和训练成本高高频垂直场景、强业务词表

类型代表方案优势短板适合场景

类型

代表方案

优势

短板

适合场景

云端 APIOpenAI Audio Transcriptions(gpt-4o-transcribe、gpt-4o-mini-transcribe、whisper-1、gpt-4o-transcribe-diarize)、Azure Speech、Google Speech、Deepgram、阿里云 ASR接入快,语言覆盖广,运维成本低成本、网络延迟、数据合规受限客服、会议转写、轻量语音助手

云端 API

OpenAI Audio Transcriptions(gpt-4o-transcribe、gpt-4o-mini-transcribe、whisper-1、gpt-4o-transcribe-diarize)、Azure Speech、Google Speech、Deepgram、阿里云 ASR

gpt-4o-transcribe
gpt-4o-mini-transcribe
whisper-1
gpt-4o-transcribe-diarize

接入快,语言覆盖广,运维成本低

成本、网络延迟、数据合规受限

客服、会议转写、轻量语音助手

开源通用模型Whisper、faster-whisper、Whisper.cpp、FunASR可本地部署,可控性强,支持私有化;faster-whisper 可接入 Silero VAD 过滤实时性要自己做工程优化;Whisper turbo 未针对翻译训练,翻译效果差私有化转写、离线字幕、企业内网

开源通用模型

Whisper、faster-whisper、Whisper.cpp、FunASR

可本地部署,可控性强,支持私有化;faster-whisper 可接入 Silero VAD 过滤

实时性要自己做工程优化;Whisper turbo 未针对翻译训练,翻译效果差

私有化转写、离线字幕、企业内网

领域定制模型金融、医疗、车载专用 ASR专有名词和口音适配更好数据准备和训练成本高高频垂直场景、强业务词表

领域定制模型

金融、医疗、车载专用 ASR

专有名词和口音适配更好

数据准备和训练成本高

高频垂直场景、强业务词表

补充说明:

OpenAI 的 gpt-4o-transcribe-diarize 支持说话人标签,适合会议转写等多人场景。它目前只用于 /v1/audio/transcriptions,不支持 Realtime API;当音频超过 30 秒时,需要配置 chunking_strategy;它也不支持 prompt、logprobs、timestamp_granularities[]。如果不需要说话人标签,优先看 gpt-4o-transcribe、gpt-4o-mini-transcribe 或 whisper-1。

gpt-4o-transcribe-diarize
/v1/audio/transcriptions
chunking_strategy
prompt
logprobs
timestamp_granularities[]
gpt-4o-transcribe
gpt-4o-mini-transcribe
whisper-1

Whisper turbo(large-v3-turbo)是 large-v3 的推理优化版,速度快但未针对翻译任务训练,执行 --task translate 时会输出原始语言而非英语,需要翻译时请用 medium 或 large。

--task translate

实时转写要和录音文件转写分开看。OpenAI 当前文档把低延迟实时转写放在 Realtime transcription 里,模型是 gpt-realtime-whisper;文件上传、说话人分离这类任务走 Audio Transcriptions。

gpt-realtime-whisper

选型建议:如果你的核心需求是“实时对话”,不要只看离线 WER(Word Error Rate,词错误率)。你更应该关注:

首段延迟:用户说完到看到第一个字的时间

增量结果稳定性:能不能实时看到识别进度

端点检测准确率:能不能准确判断用户说完了

噪声环境表现:远场、多人说话时准不准

热词能力:能不能识别你的业务专属词汇

流式 ASR 和非流式 ASR 的区别

流式 ASR 和非流式 ASR 的区别

对首字延迟和自然打断要求较高的实时对话,通常会使用流式 ASR;轮次明确、语音较短的场景也可以采用非流式识别。二者的主要差别是:

非流式 ASR:等用户说完一段话,再整段识别。延迟 = 说话时长 + 识别时间。

流式 ASR:边说边识别,用户话音刚落就能拿到结果。延迟 ≈ 端点检测时间 + 实时识别时间。

interview-guide 项目用的是阿里云 DashScope 的 qwen3-asr-flash-realtime。这类接入方式通过 WebSocket 持续追加音频,服务端 VAD 负责判断何时提交一轮识别:

// QwenAsrService.java
OmniRealtimeConfig config = OmniRealtimeConfig.builder()
    .modalities(Collections.singletonList(OmniRealtimeModality.TEXT))
    .enableTurnDetection(true)  // 开启服务端 VAD
    .turnDetectionType("server_vad")
    .turnDetectionSilenceDurationMs(400)  // 400 ms 静音判定用户说完
    .transcriptionConfig(transcriptionParam)
    .build();
// QwenAsrService.java
OmniRealtimeConfig config = OmniRealtimeConfig.builder()
    .modalities(Collections.singletonList(OmniRealtimeModality.TEXT))
    .enableTurnDetection(true)  // 开启服务端 VAD
    .turnDetectionType("server_vad")
    .turnDetectionSilenceDurationMs(400)  // 400 ms 静音判定用户说完
    .transcriptionConfig(transcriptionParam)
    .build();

服务端 VAD 的好处是不用客户端自己实现完整的语音活动检测逻辑;代价也直接写在参数里:turnDetectionSilenceDurationMs(400) 表示静音持续 400 ms 后才认为一句话结束。DashScope 文档给出的取值范围是 200-6000 ms,值越低响应越快,也越容易把自然停顿切断;值越高,断句更保守,延迟也会增加。端侧和服务端 VAD 可以组合使用,但并非固定架构:需要低延迟打断时,可让端侧先报告 speech_start,服务端结合 ASR 和 VAD 事件确认轮次结束;客户端较轻或网络环境稳定时,也可以只使用服务端端点检测。

turnDetectionSilenceDurationMs(400)
speech_start

语音合成(TTS)是怎么把文字变成声音的?

语音合成(TTS)是怎么把文字变成声音的?

TTS(Text To Speech)负责把模型回复合成音频。它看起来是输出层,但其实很影响用户对整个 Agent 的感知。

同一句“我帮你查一下”,不同 TTS 的差异可能体现在:

首包音频要等多久

音色是否自然,长句是否喘得像真人

数字、代码、英文缩写是否读得准确

是否支持情绪、语速、停顿、音高控制

TTS 的技术演进

TTS 的技术演进

传统 TTS 分好几步走:

文本规范化 -> 文本分析 -> 声学模型 -> 声码器 -> 波形输出
文本规范化 -> 文本分析 -> 声学模型 -> 声码器 -> 波形输出

神经 TTS、神经音频编解码器和生成式语音模型正在减少传统流水线中的人工模块。VALL-E、Fish Speech、CosyVoice 的建模方法并不相同,音质、延迟、流式能力和部署成本也不能只按“端到端”一项比较。对实时语音 Agent 来说,单句音质之外,还要看首包延迟、能否流式播放以及中断后的状态处理。

如果你必须等整段文字生成完才能合成,用户体感会非常慢。如果能按短句甚至 token 流式合成,首包体验会好很多。

实时 TTS 的两条路线

实时 TTS 的两条路线

类型代表方案特点云端实时 TTSOpenAI Speech、阿里云 qwen3-tts-flash-realtime / Qwen-TTS-Realtime、Azure TTS、ElevenLabs流式输出,支持实时合成本地 TTSpiper1-gpl(GPL-3.0,原 Piper 已归档)、Fish Speech(Fish Audio Research License)、CosyVoice可控性强,适合离线场景;商用前需逐项核对代码、模型权重和声音资源的许可证

类型代表方案特点

类型

代表方案

特点

云端实时 TTSOpenAI Speech、阿里云 qwen3-tts-flash-realtime / Qwen-TTS-Realtime、Azure TTS、ElevenLabs流式输出,支持实时合成

云端实时 TTS

OpenAI Speech、阿里云 qwen3-tts-flash-realtime / Qwen-TTS-Realtime、Azure TTS、ElevenLabs

流式输出,支持实时合成

本地 TTSpiper1-gpl(GPL-3.0,原 Piper 已归档)、Fish Speech(Fish Audio Research License)、CosyVoice可控性强,适合离线场景;商用前需逐项核对代码、模型权重和声音资源的许可证

本地 TTS

piper1-gpl(GPL-3.0,原 Piper 已归档)、Fish Speech(Fish Audio Research License)、CosyVoice

可控性强,适合离线场景;商用前需逐项核对代码、模型权重和声音资源的许可证

interview-guide 用的也是阿里云实时 TTS,通过 WebSocket 合成音频。DashScope 当前 Java SDK 示例里推荐的模型名是 qwen3-tts-flash-realtime,项目里的封装类仍然叫 QwenTtsRealtime:

qwen3-tts-flash-realtime
QwenTtsRealtime
// 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();
// 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();

这段代码采用 commit 模式,客户端追加文本后主动调用 commit() 触发合成。DashScope 文档里还提供 server_commit 模式,由服务端判断提交时机,延迟和句子完整性之间的取舍会不一样。

commit
commit()
server_commit

VAD 如何控制对话轮次?

VAD 如何控制对话轮次?

VAD(Voice Activity Detection,语音活动检测)这个组件经常被忽略,但它对体验影响极大。

VAD 不识别说话内容,通常输出某一小段音频包含语音的概率或语音开始、结束事件。应用据此判断:

用户开始说话了吗?

用户说完了吗?

当前语音活动是否足以触发一次打断或提交。

普通 VAD 不能单独判断声音来自用户、旁人、音乐还是扬声器回放。系统播放的回声需要 AEC 或播放参考信号处理;多人场景还可能需要说话人分离、声纹或面向目标说话人的 VAD。真实用户的说话方式也会增加端点检测难度:

句中会停顿:“这个问题……我想问一下……”

会有短反馈:“嗯”“对”“不是”

会边想边说,音量忽大忽小

旁边可能有人说话,扬声器里也可能正在播放 AI 的声音

端侧 VAD 还是服务端 VAD?

类型代表方案优势短板端侧 VADWebRTC VAD、Silero VAD、@ricky0123/vad-web响应快,不消耗服务端资源需要在客户端部署模型,阈值和噪声场景要自己调服务端 VADDashScope ASR 的 server_vad、OpenAI Realtime turn detection、部分云端 ASR 内置端点检测客户端逻辑简单,和识别服务集成更紧增加服务端负载,有网络延迟,断句策略受供应商接口约束

类型代表方案优势短板

类型

代表方案

优势

短板

端侧 VADWebRTC VAD、Silero VAD、@ricky0123/vad-web响应快,不消耗服务端资源需要在客户端部署模型,阈值和噪声场景要自己调

端侧 VAD

WebRTC VAD、Silero VAD、@ricky0123/vad-web

响应快,不消耗服务端资源

需要在客户端部署模型,阈值和噪声场景要自己调

服务端 VADDashScope ASR 的 server_vad、OpenAI Realtime turn detection、部分云端 ASR 内置端点检测客户端逻辑简单,和识别服务集成更紧增加服务端负载,有网络延迟,断句策略受供应商接口约束

服务端 VAD

DashScope ASR 的 server_vad、OpenAI Realtime turn detection、部分云端 ASR 内置端点检测

客户端逻辑简单,和识别服务集成更紧

增加服务端负载,有网络延迟,断句策略受供应商接口约束

⚠️ VAD 不能只看离线准确率:短语音(<1 秒,比如“嗯”“对”“不是”)、低音量插话、远场人声、扬声器回声,都会让 VAD 的线上表现和实验集差很多。faster-whisper 的 README 也把 Silero VAD 默认策略描述为偏保守:默认只移除超过 2 秒的静音。语音 Agent 里如果把 VAD 当成唯一的打断判据,很容易漏掉短反馈。

⚠️ VAD 不能只看离线准确率:短语音(<1 秒,比如“嗯”“对”“不是”)、低音量插话、远场人声、扬声器回声,都会让 VAD 的线上表现和实验集差很多。faster-whisper 的 README 也把 Silero VAD 默认策略描述为偏保守:默认只移除超过 2 秒的静音。语音 Agent 里如果把 VAD 当成唯一的打断判据,很容易漏掉短反馈。

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?.(); // 用户说完
  },
});
// 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-500 ms 当作通用阈值。

onSpeechEnd

我的建议是:VAD 不要只当开关用,它应该输出一组对话控制信号。比如:

speech_start:用户开始说话

speech_start

speech_end:检测到语音活动结束(可携带置信度)

speech_end

maybe_barge_in:可能是用户在打断

maybe_barge_in

non_speech:当前分片未检测到语音活动

non_speech

一次完整的语音对话是怎么跑起来的?

一次完整的语音对话是怎么跑起来的?

先把链路放在一起看,后面的延迟、打断和端云协同才好理解。

一次语音 Agent 对话大概经过这些步骤:

音频采集:麦克风采集原始音频

前处理:AEC 消回声、NS 降噪、AGC 增益

VAD 检测:判断用户是否在说话,是否说完

音频上传:把处理后的音频发到服务端

ASR 转写:把音频转成文字(流式输出增量结果)

上下文组装:拼接系统指令、历史对话、工具定义

LLM 推理:理解意图、生成回复、必要时调用工具

TTS 合成:把回复文字转成音频(流式输出音频块)

音频下行:客户端边收边播

状态回写:记录本次对话,为下一轮准备上下文

实时语音链路中,一部分工作可以在用户结束说话前完成。

优秀的系统会尽量把可以提前做的事提前做:

用户刚开始说话时,先加载会话状态和工具定义

ASR 出现稳定前缀后,提前做意图预判

LLM 输出第一个短句时,TTS 立刻开始合成

工具调用较慢时,先播一句自然的过渡语

做法很直接:把能并行的环节提前启动,用流式输出把等待拆散。

实时语音为什么比文字对话难这么多?

实时语音为什么比文字对话难这么多?

语音对话的难点不在某一个模型,而在整条链路都被实时性约束住了。

难点一:延迟预算非常紧

难点一:延迟预算非常紧

文本聊天慢 1 秒,用户通常还能忍。语音对话慢 1 秒,用户会明显感觉对方“没反应”。

一轮语音交互的延迟来自这些环节:

环节常见耗时优化方向采集与编码音频帧大小、浏览器缓冲小帧采集,减少无意义缓冲VAD 端点检测等待静音确认用户说完动态静音阈值,短句快速提交ASR音频上传、解码、增量转写稳定流式 ASR,热词,端侧预处理LLM首 token 延迟、工具调用、上下文过长Prompt 缓存,短回复,异步工具TTS首包合成、长句切分、声码器推理句子级流式合成,预热音色播放网络抖动、解码、播放器缓冲边收边播,控制播放队列和缓冲区

环节常见耗时优化方向

环节

常见耗时

优化方向

采集与编码音频帧大小、浏览器缓冲小帧采集,减少无意义缓冲

采集与编码

音频帧大小、浏览器缓冲

小帧采集,减少无意义缓冲

VAD 端点检测等待静音确认用户说完动态静音阈值,短句快速提交

VAD 端点检测

等待静音确认用户说完

动态静音阈值,短句快速提交

ASR音频上传、解码、增量转写稳定流式 ASR,热词,端侧预处理

ASR

音频上传、解码、增量转写稳定

流式 ASR,热词,端侧预处理

LLM首 token 延迟、工具调用、上下文过长Prompt 缓存,短回复,异步工具

LLM

首 token 延迟、工具调用、上下文过长

Prompt 缓存,短回复,异步工具

TTS首包合成、长句切分、声码器推理句子级流式合成,预热音色

TTS

首包合成、长句切分、声码器推理

句子级流式合成,预热音色

播放网络抖动、解码、播放器缓冲边收边播,控制播放队列和缓冲区

播放

网络抖动、解码、播放器缓冲

边收边播,控制播放队列和缓冲区

如果每段都多 200 ms,整轮对话马上就变成“慢半拍”。

所以实时语音优化要盯端到端 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);
  }
};
// VoiceInterviewPage.tsx
const handleAudioData = (audioData: string) => {
  // AI 播放时停发音频,避免自己的声音被识别
  if (isAiSpeakingRef.current) {
    return;
  }
  if (wsRef.current && wsRef.current.isConnected()) {
    wsRef.current.sendAudio(audioData);
  }
};

前端通过 isAiSpeakingRef 标记 AI 是否在说话,说话时停发音频。后端收到 control 消息取消生成。

isAiSpeakingRef
control

难点三:噪声环境比测试环境复杂太多

难点三:噪声环境比测试环境复杂太多

语音 Demo 往往在安静办公室里跑,生产环境可能是:

车内、工厂、商场、地铁站

远场麦克风,用户离设备两三米

多人同时说话

用户开着外放,AI 的声音又被麦克风收回去

这会影响整条链路:

VAD 把噪声当成人声,导致误触发

ASR 把背景人声转成文本,污染用户意图

TTS 播放被麦克风采集,造成自我打断

interview-guide 前端通过 getUserMedia 开了 3 个常见音频前处理选项:

getUserMedia
const stream = await navigator.mediaDevices.getUserMedia({
  audio: {
    echoCancellation: true, // AEC:消除扬声器回声
    noiseSuppression: true, // NS:压低背景噪声
    autoGainControl: true, // AGC:自动增益,让音量更稳定
    sampleRate: 16000,
  },
});
const stream = await navigator.mediaDevices.getUserMedia({
  audio: {
    echoCancellation: true, // AEC:消除扬声器回声
    noiseSuppression: true, // NS:压低背景噪声
    autoGainControl: true, // AGC:自动增益,让音量更稳定
    sampleRate: 16000,
  },
});

这三个参数能解决一部分问题,但不能指望它们覆盖所有场景。浏览器只是接收约束并尽力匹配,具体效果受浏览器、设备、麦克风和播放环境影响;AEC 在强回声场景下效果有限,NS 也可能把用户声音削掉一截。如果你要做硬件或 App 方案,端侧音频前处理会变成非常现实的工程投入。

难点四:上下文不只是文字历史

难点四:上下文不只是文字历史

文本 Agent 的上下文主要是消息历史。语音 Agent 的上下文更多:

当前用户是否正在说话

上一段回答播放到了哪里

用户是正常提问,还是正在打断

ASR 的增量文本是否稳定

用户语气是疑问、否定、犹豫,还是不耐烦

当前是否有工具调用正在执行

如果只把最终 ASR 文本喂给 LLM,很多信息会丢掉。

比如用户说“不是……我是说上个月那笔订单”,文本里能看到纠正,但看不到他是在打断 AI;系统如果不知道上一段回答播到哪里,就很难知道用户在否定哪一句。

interview-guide 用 WebSocket 消息类型区分了不同状态:

// voiceInterview.ts
export interface WebSocketSubtitleMessage {
  type: "subtitle";
  text: string;
  isFinal: boolean; // true 只表示这一段 ASR 转写已经结束或稳定
}

export interface WebSocketAudioResponseMessage {
  type: "audio";
  data: string; // Base64 音频
  text: string; // 对应的文字
}

export interface WebSocketControlMessage {
  type: "control";
  action: string; // 'submit' | 'cancel' | 'pause'
  data?: Record<string, unknown>;
}
// voiceInterview.ts
export interface WebSocketSubtitleMessage {
  type: "subtitle";
  text: string;
  isFinal: boolean; // true 只表示这一段 ASR 转写已经结束或稳定
}

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 是 ASR 事件状态,不等于用户已经点击或说出“提交”。如果产品采用手动提交,应由独立的 control: submit 事件确认;自动轮次模式则由端点检测、ASR 最终事件和业务状态共同决定是否提交,不能复用一个布尔值表达两层含义。

isFinal
control: submit

难点五:回声导致的误打断

难点五:回声导致的误打断

AI 播放的声音被麦克风采集后,VAD 或 ASR 可能误判为用户说话,导致 AI 自我打断。

interview-guide 的当前做法是:

if (isAiSpeakingRef.current) {
  return; // AI 说话时停发音频
}
if (isAiSpeakingRef.current) {
  return; // AI 说话时停发音频
}

这种“静默丢弃”的方案能避免自我打断,但也会屏蔽用户在 AI 说话期间的插话。

更精细的方案一般会这样做:

AI 说话时继续接收音频,但不发到 ASR

在 AEC 处理后的音频上运行端侧 VAD,而非原始麦克风音频

结合回声参考、连续帧能量、VAD 置信度和播放队列状态判断是不是用户真的在插话

难点六:端侧能力决定体验下限

难点六:端侧能力决定体验下限

很多团队把所有能力都放云端,结果在弱网环境下体验崩得很快。

端侧至少应该承担这些职责:

麦克风采集和音频前处理

VAD 或轻量打断检测

播放缓冲和取消播放

网络断开时的提示和重连

云端模型决定上限,端侧工程决定下限。这句话在语音系统里很实在。

从 interview-guide 看基础版语音 Agent 是怎么实现的?

从 interview-guide 看基础版语音 Agent 是怎么实现的?

下面以 interview-guide 项目为例,看一个基础版语音面试 Agent 是怎么跑起来的。

整体架构

整体架构

┌─────────────────────────────────────────────────────────────┐
│                        前端 (React)                          │
├─────────────────────────────────────────────────────────────┤
│  AudioRecorder        WebSocket         VoiceInterviewPage   │
│  - getUserMedia       - sendAudio       - 状态管理          │
│  - AudioWorklet       - sendControl     - 手动提交          │
│  - VAD 检测           - 控制消息         - 分块播放          │
└─────────────────────────────────────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────────┐
│                     后端 (Spring Boot)                       │
├─────────────────────────────────────────────────────────────┤
│  VoiceInterviewWebSocketHandler                             │
│  - 会话管理(创建、暂停、恢复、结束)                         │
│  - ASR ready / reconnect 状态同步                            │
│  - 音频路由到 ASR,手动 submit 后触发 LLM                     │
│  - LLM 句子流输出,TTS 边合成边推送                           │
├─────────────────────────────────────────────────────────────┤
│  QwenAsrService          DashscopeLlmService      QwenTtsService │
│  - qwen3-asr-flash-      - qwen-max / qwen-plus   - qwen-tts-    │
│    realtime              - 工具调用支持           realtime       │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│                        前端 (React)                          │
├─────────────────────────────────────────────────────────────┤
│  AudioRecorder        WebSocket         VoiceInterviewPage   │
│  - getUserMedia       - sendAudio       - 状态管理          │
│  - AudioWorklet       - sendControl     - 手动提交          │
│  - VAD 检测           - 控制消息         - 分块播放          │
└─────────────────────────────────────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────────┐
│                     后端 (Spring Boot)                       │
├─────────────────────────────────────────────────────────────┤
│  VoiceInterviewWebSocketHandler                             │
│  - 会话管理(创建、暂停、恢复、结束)                         │
│  - ASR ready / reconnect 状态同步                            │
│  - 音频路由到 ASR,手动 submit 后触发 LLM                     │
│  - LLM 句子流输出,TTS 边合成边推送                           │
├─────────────────────────────────────────────────────────────┤
│  QwenAsrService          DashscopeLlmService      QwenTtsService │
│  - qwen3-asr-flash-      - qwen-max / qwen-plus   - qwen-tts-    │
│    realtime              - 工具调用支持           realtime       │
└─────────────────────────────────────────────────────────────┘

前端:音频采集与 VAD

前端:音频采集与 VAD

前端的核心是 AudioRecorder 组件。它做了这么几件事:

AudioRecorder

第一步,获取麦克风权限并配置音频参数:

const stream = await navigator.mediaDevices.getUserMedia({
  audio: {
    echoCancellation: true,
    noiseSuppression: true,
    autoGainControl: true,
    sampleRate: 16000, // ASR 需要 16 kHz
  },
});
const stream = await navigator.mediaDevices.getUserMedia({
  audio: {
    echoCancellation: true,
    noiseSuppression: true,
    autoGainControl: true,
    sampleRate: 16000, // ASR 需要 16 kHz
  },
});

第二步,初始化端侧 VAD:

const vadInstance = await window.vad.MicVAD.new({
  getStream: async () => stream,
  onSpeechStart: () => {
    onSpeechStart?.(); // 触发回调
  },
  onSpeechEnd: () => {
    onSpeechEnd?.();
  },
});
await vadInstance.start();
const vadInstance = await window.vad.MicVAD.new({
  getStream: async () => stream,
  onSpeechStart: () => {
    onSpeechStart?.(); // 触发回调
  },
  onSpeechEnd: () => {
    onSpeechEnd?.();
  },
});
await vadInstance.start();

第三步,使用 AudioWorklet 做音频分块采集:

VAD 的 onSpeechEnd 只表示语音活动可能结束,音频仍然要分块发送给服务端。interview-guide 的实现是:

onSpeechEnd
await audioContext.audioWorklet.addModule("/audio-worklet/pcm-processor.js");

const workletNode = new AudioWorkletNode(audioContext, "pcm-processor");
workletNode.port.onmessage = (event) => {
  if (!recordingActiveRef.current) {
    return;
  }
  const base64 = arrayBufferToBase64(event.data as ArrayBuffer);
  onAudioData(base64); // 200 ms Int16 PCM,发送给后端 ASR
};

source.connect(workletNode);
workletNode.connect(gainNode);
gainNode.connect(audioContext.destination);
await audioContext.audioWorklet.addModule("/audio-worklet/pcm-processor.js");

const workletNode = new AudioWorkletNode(audioContext, "pcm-processor");
workletNode.port.onmessage = (event) => {
  if (!recordingActiveRef.current) {
    return;
  }
  const base64 = arrayBufferToBase64(event.data as ArrayBuffer);
  onAudioData(base64); // 200 ms Int16 PCM,发送给后端 ASR
};

source.connect(workletNode);
workletNode.connect(gainNode);
gainNode.connect(audioContext.destination);

pcm-processor.js 运行在音频渲染线程中,负责把浏览器输入的 Float32 音频重采样成 16 kHz、Int16 PCM,并按 200 ms 一块通过 postMessage 交回主线程。相比已经废弃的 ScriptProcessorNode,AudioWorkletNode 不会把音频处理压在 UI 主线程上,延迟和卡顿风险更低。

pcm-processor.js
postMessage
ScriptProcessorNode
AudioWorkletNode

这里有个设计选择:为什么不等 VAD 触发 onSpeechEnd 再发音频?

onSpeechEnd

因为 VAD 检测有延迟,等它确认用户说完了再开始发音频,会多等一段静音确认时间。更合理的做法是持续分块发送,VAD 触发 onSpeechEnd 只是告诉后端“这一段可能结束了,可以准备提交给 LLM”。

onSpeechEnd

不过,interview-guide 的语音面试没有采用“检测到静音就自动提交”。它的做法是ASR 持续转写、用户手动点击提交。这样可以避免候选人中途停顿时被系统抢答,也能解决“后面的话覆盖前面的回答”的体验问题:前端只把 ASR 结果作为回答草稿,进入下一轮面试由 submit 控制消息决定。

submit

前端:音频播放

前端:音频播放

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),
    );
  }
};
// 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);
  for (let i = 0; i < binaryStr.length; i++) {
    bytes[i] = binaryStr.charCodeAt(i);
  }

  // 下面按项目约定处理 44 字节 WAV 头和 24 kHz、单声道、16-bit PCM。
  // 如果服务端可能返回其他 WAV 格式,应先解析 WAV 头,不能固定写死。
  const pcmOffset = 44;
  const pcmData = new Int16Array(
    bytes.buffer,
    pcmOffset,
    (bytes.length - pcmOffset) / 2,
  );
  const float32 = new Float32Array(pcmData.length);
  for (let i = 0; i < pcmData.length; i++) {
    float32[i] = pcmData[i] / 32768;
  }

  const audioBuffer = ctx.createBuffer(1, float32.length, 24_000);
  audioBuffer.copyToChannel(float32, 0);

  // 2. 放入播放队列
  chunkQueueRef.current.push(audioBuffer);
  if (!isChunkPlayingRef.current) {
    playNextChunk();
  }

  // 3. 最后一包或服务端 audio_complete 后,等待队列播完
  if (isLast) {
    scheduleChunkDrainCompletion();
  }
};

// 播放下一块
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);
};
// 分块处理
const handleAudioChunk = (
  base64Wav: string,
  _index: number,
  isLast: boolean,
) => {
  // 1. 解码 WAV
  const binaryStr = atob(base64Wav);
  const bytes = new Uint8Array(binaryStr.length);
  for (let i = 0; i < binaryStr.length; i++) {
    bytes[i] = binaryStr.charCodeAt(i);
  }

  // 下面按项目约定处理 44 字节 WAV 头和 24 kHz、单声道、16-bit PCM。
  // 如果服务端可能返回其他 WAV 格式,应先解析 WAV 头,不能固定写死。
  const pcmOffset = 44;
  const pcmData = new Int16Array(
    bytes.buffer,
    pcmOffset,
    (bytes.length - pcmOffset) / 2,
  );
  const float32 = new Float32Array(pcmData.length);
  for (let i = 0; i < pcmData.length; i++) {
    float32[i] = pcmData[i] / 32768;
  }

  const audioBuffer = ctx.createBuffer(1, float32.length, 24_000);
  audioBuffer.copyToChannel(float32, 0);

  // 2. 放入播放队列
  chunkQueueRef.current.push(audioBuffer);
  if (!isChunkPlayingRef.current) {
    playNextChunk();
  }

  // 3. 最后一包或服务端 audio_complete 后,等待队列播完
  if (isLast) {
    scheduleChunkDrainCompletion();
  }
};

// 播放下一块
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);
};

分块播放的好处是能更快开始播放,不用等完整音频文件加载完。代价也很明确:要自己管理队列、顺序、取消和“最后一包”语义。

新版实现里,服务端还会在所有 TTS 分片发送完成后额外推一个 audio_complete 控制消息。这样前端不再依赖某个音频分片必须带 isLast=true,即使某一句 TTS 合成失败,也能在已成功分片播放完后正确结束“面试官正在说话”的状态。

audio_complete
isLast=true

⚠️ 注意:浏览器要求 AudioContext 必须在用户交互后创建或恢复(autoplay policy)。如果在页面加载时创建 AudioContext,大多数浏览器会将其置于 suspended 状态。建议在用户点击“开始面试”按钮时调用 audioContext.resume() 确保播放正常。

⚠️ 注意:浏览器要求 AudioContext 必须在用户交互后创建或恢复(autoplay policy)。如果在页面加载时创建 AudioContext,大多数浏览器会将其置于 suspended 状态。建议在用户点击“开始面试”按钮时调用 audioContext.resume() 确保播放正常。

suspended
audioContext.resume()

后端:WebSocket 会话管理

后端:WebSocket 会话管理

后端通过 VoiceInterviewWebSocketHandler 管理会话生命周期:

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);
        }
    }
}
// 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, COMPLETEDPAUSED暂停(用户离开页面或主动暂停)IN_PROGRESSCOMPLETED面试结束-

状态含义可转换到

状态

含义

可转换到

IN_PROGRESS面试进行中PAUSED, COMPLETED

IN_PROGRESS

面试进行中

PAUSED, COMPLETED

PAUSED暂停(用户离开页面或主动暂停)IN_PROGRESS

PAUSED

暂停(用户离开页面或主动暂停)

IN_PROGRESS

COMPLETED面试结束-

COMPLETED

面试结束

-

暂停/恢复机制很有用。比如用户接电话、切换标签页,可以暂停面试,回来后无缝继续。

后端:ASR 服务

后端:ASR 服务

后端的 ASR 服务封装了阿里云 DashScope 的接口:

// QwenAsrService.java
public void startTranscription(
    String sessionId,
    Consumer<String> onFinal,
    Consumer<String> onPartial,
    Runnable onReady,
    Consumer<Throwable> onError
) {
    // 1. 创建会话并建立 WebSocket 连接
    OmniRealtimeConversation conversation = new OmniRealtimeConversation(param, callback);
    conversation.connect();

    // 2. 配置:开启服务端 VAD,400 ms 静音判定结束
    OmniRealtimeConfig config = OmniRealtimeConfig.builder()
        .enableTurnDetection(true)
        .turnDetectionSilenceDurationMs(400)
        .build();

    // 3. 发出会话配置。这里不能立刻把本地状态改成 ready
    conversation.updateSession(config);
}

// 4. callback 收到服务端 session.updated 事件后再开放音频上行
public void onSessionUpdated(String sessionId) {
    AsrSession asrSession = sessions.get(sessionId);
    asrSession.markReady();
    asrSession.getOnReady().run(); // 通知前端 asr_ready
}

public void sendAudio(String sessionId, byte[] audioData) {
    AsrSession session = sessions.get(sessionId);
    if (!session.awaitReady(1200)) {
        throw new IllegalStateException("ASR session not ready");
    }
    String audioBase64 = Base64.getEncoder().encodeToString(audioData);
    session.getConversation().appendAudio(audioBase64);
}
// QwenAsrService.java
public void startTranscription(
    String sessionId,
    Consumer<String> onFinal,
    Consumer<String> onPartial,
    Runnable onReady,
    Consumer<Throwable> onError
) {
    // 1. 创建会话并建立 WebSocket 连接
    OmniRealtimeConversation conversation = new OmniRealtimeConversation(param, callback);
    conversation.connect();

    // 2. 配置:开启服务端 VAD,400 ms 静音判定结束
    OmniRealtimeConfig config = OmniRealtimeConfig.builder()
        .enableTurnDetection(true)
        .turnDetectionSilenceDurationMs(400)
        .build();

    // 3. 发出会话配置。这里不能立刻把本地状态改成 ready
    conversation.updateSession(config);
}

// 4. callback 收到服务端 session.updated 事件后再开放音频上行
public void onSessionUpdated(String sessionId) {
    AsrSession asrSession = sessions.get(sessionId);
    asrSession.markReady();
    asrSession.getOnReady().run(); // 通知前端 asr_ready
}

public void sendAudio(String sessionId, byte[] audioData) {
    AsrSession session = sessions.get(sessionId);
    if (!session.awaitReady(1200)) {
        throw new IllegalStateException("ASR session not ready");
    }
    String audioBase64 = Base64.getEncoder().encodeToString(audioData);
    session.getConversation().appendAudio(audioBase64);
}

这一步很关键。new OmniRealtimeConversation(...) 只创建对象,connect() 才建立连接;updateSession(config) 发出配置后,还要等待服务端 session.updated 事件。前端在收到后端 asr_ready 之前应禁用麦克风;如果就绪超时或收到 error,后端关闭旧连接、重新建立会话,并把重连状态推给前端。上面的回调名称是说明性写法,实际事件类型和方法名要按项目使用的 DashScope SDK 版本实现。

new OmniRealtimeConversation(...)
connect()
updateSession(config)
session.updated
asr_ready
error

服务端返回识别结果时,Handler 会把增量文字推送给前端:

// WebSocket 推送增量文字
websocket.sendMessage(new WebSocketSubtitleMessage(
    "subtitle",
    transcript,
    isFinal  // true 表示这是最终结果
));
// WebSocket 推送增量文字
websocket.sendMessage(new WebSocketSubtitleMessage(
    "subtitle",
    transcript,
    isFinal  // true 表示这是最终结果
));

后端:TTS 服务

后端:TTS 服务

// QwenTtsService.java
public byte[] synthesize(String text) throws Exception {
    CountDownLatch latch = new CountDownLatch(1);
    ByteArrayContainer audioContainer = new ByteArrayContainer();

    QwenTtsRealtime qwenTts = new QwenTtsRealtime(param, callback);
    try {
        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();

        if (!latch.await(30, TimeUnit.SECONDS)) {
            throw new TimeoutException("TTS synthesis timed out");
        }
        return audioContainer.toByteArray();
    } finally {
        qwenTts.close();
    }
}
// QwenTtsService.java
public byte[] synthesize(String text) throws Exception {
    CountDownLatch latch = new CountDownLatch(1);
    ByteArrayContainer audioContainer = new ByteArrayContainer();

    QwenTtsRealtime qwenTts = new QwenTtsRealtime(param, callback);
    try {
        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();

        if (!latch.await(30, TimeUnit.SECONDS)) {
            throw new TimeoutException("TTS synthesis timed out");
        }
        return audioContainer.toByteArray();
    } finally {
        qwenTts.close();
    }
}

示例省略了音频回调和业务异常映射,但没有把超时当成成功。实际实现还要在取消时关闭连接,并在捕获 InterruptedException 后恢复线程中断标记。

InterruptedException

Handler 拿到 PCM 数据后,转成 WAV 推送给前端:

// LLM 每输出一个完整句子,就提交给并发 TTS 队列
OrderedTtsChunkEmitter chunkEmitter = new OrderedTtsChunkEmitter(session, semaphore);
llmService.chatStreamSentences(userText, sentence -> {
    chunkEmitter.submit(sentence);
});

// TTS 分片按句子顺序推送,最后发送 audio_complete 控制消息
chunkEmitter.finish();
chunkEmitter.awaitCompletion();
// LLM 每输出一个完整句子,就提交给并发 TTS 队列
OrderedTtsChunkEmitter chunkEmitter = new OrderedTtsChunkEmitter(session, semaphore);
llmService.chatStreamSentences(userText, sentence -> {
    chunkEmitter.submit(sentence);
});

// TTS 分片按句子顺序推送,最后发送 audio_complete 控制消息
chunkEmitter.finish();
chunkEmitter.awaitCompletion();

这里要压的是整段等待时间:LLM 边生成句子,TTS 边合成,前端边播放。后端用 max-concurrent-tts-per-session 控制单会话并发 TTS 数量,用 tts-timeout-seconds 避免某一句卡住整轮播放;如果所有句子级 TTS 都失败,再退回整段文本合成兜底。

max-concurrent-tts-per-session
tts-timeout-seconds

怎么让语音 Agent 支持打断?

怎么让语音 Agent 支持打断?

打断是语音 Agent 的高频难点,单靠一个暂停按钮解决不了。

打断的三层含义

打断的三层含义

播放层打断:用户说话时,停止当前音频播放

生成层打断:取消服务端正在生成的 LLM 和 TTS

上下文层打断:正确记录已播放和未播放的内容

interview-guide 当前版本还没有实现真正的 barge-in。下面的代码只是 AI 播放期间停止向后端发送麦克风音频,用来规避回声触发 ASR;代价是用户插话也会被一起丢弃:

// 当前实现:AI 播放期间丢弃麦克风音频
const handleAudioData = (audioData: string) => {
  if (isAiSpeakingRef.current) {
    return;
  }
  wsRef.current.sendAudio(audioData);
};

// 音频播放完成时
const finishAiPlayback = () => {
  aiAudioPendingRef.current = false;
  clearAudioPlaybackWatchdog();
  setAiSpeaking(false);
  setIsSubmitting(false);

  // 正常播放完成后提交整段文本
  commitAiMessage(aiTextRef.current.trim());
};
// 当前实现:AI 播放期间丢弃麦克风音频
const handleAudioData = (audioData: string) => {
  if (isAiSpeakingRef.current) {
    return;
  }
  wsRef.current.sendAudio(audioData);
};

// 音频播放完成时
const finishAiPlayback = () => {
  aiAudioPendingRef.current = false;
  clearAudioPlaybackWatchdog();
  setAiSpeaking(false);
  setIsSubmitting(false);

  // 正常播放完成后提交整段文本
  commitAiMessage(aiTextRef.current.trim());
};

要支持真正的打断,端侧 VAD 必须在播放期间继续检测用户语音,触发后停止当前播放器、清空未播队列,并把 cancel 传到后端正在运行的 LLM/TTS。上下文还要根据播放进度记录已经播出的文本;当前的 finishAiPlayback() 只在完整播放结束时提交整段内容,不具备这项能力。

cancel
finishAiPlayback()

状态机视角的打断

状态机视角的打断

从状态机角度看,打断是一个几乎可以从任何状态进入的控制事件:

当前状态用户打断正确响应listening用户继续说话继续收音和转写;这不属于打断thinking用户补充取消当前推理,用新输入重新触发speaking用户插话停止播放,清空队列tool_calling用户说“算了”可取消的查询立即取消;已有副作用的操作进入幂等、补偿或人工确认流程

当前状态用户打断正确响应

当前状态

用户打断

正确响应

listening用户继续说话继续收音和转写;这不属于打断

listening

用户继续说话

继续收音和转写;这不属于打断

thinking用户补充取消当前推理,用新输入重新触发

thinking

用户补充

取消当前推理,用新输入重新触发

speaking用户插话停止播放,清空队列

speaking

用户插话

停止播放,清空队列

tool_calling用户说“算了”可取消的查询立即取消;已有副作用的操作进入幂等、补偿或人工确认流程

tool_calling

用户说“算了”

可取消的查询立即取消;已有副作用的操作进入幂等、补偿或人工确认流程

如果你的系统没有清晰的取消语义,很快就会出现“AI 一边听新问题,一边还在播旧答案”的混乱体验。

浏览器音频捕获与前处理在语音系统中扮演什么角色?

浏览器音频捕获与前处理在语音系统中扮演什么角色?

WebRTC 经常被笼统地用于指代浏览器音视频能力。语音 Agent 需要区分浏览器的音频捕获/前处理 API 和 WebRTC 实时传输协议。

重要区分:

Media Capture and Streams API(getUserMedia):负责从麦克风采集音频,可以传入 AEC/NS/AGC、采样率等约束。这是 interview-guide 实际使用的。

getUserMedia

WebRTC 协议(RTCPeerConnection):负责端到端的实时传输,包含 ICE、DTLS-SRTP、RTP 等协议。如果你接 OpenAI Realtime API 的 WebRTC 模式、Azure Voice Live 或自建实时音视频链路,才会用到这套传输层。

interview-guide 的音频通路是:

getUserMedia → AudioWorklet → Base64 编码 → WebSocket 发送
getUserMedia → AudioWorklet → Base64 编码 → WebSocket 发送

这套通路的传输层是 WebSocket(TCP),不是 WebRTC 的 RTP/SRTP。WebSocket 保证顺序,但弱网下会受到 TCP 重传影响;WebRTC 通常优先走 UDP,配合抖动缓冲、丢包隐藏等机制降低实时音频卡顿,网络受限时也可能 fallback 到 TCP/TURN。

浏览器音频前处理管线

浏览器音频前处理管线

在语音 Agent 场景下,你主要用到浏览器音频前处理的这些能力:

麦克风输入
    │
    ▼
┌─────────────────────────┐
│  AEC (回声消除)          │  消除扬声器播放的声音
└─────────────────────────┘
    │
    ▼
┌─────────────────────────┐
│  NS (噪声抑制)            │  压低背景噪声
└─────────────────────────┘
    │
    ▼
┌─────────────────────────┐
│  AGC (自动增益控制)       │  让音量更稳定
└─────────────────────────┘
    │
    ▼
┌─────────────────────────┐
│  VAD (语音活动检测)       │  判断是否有人声
└─────────────────────────┘
    │
    ▼
编码输出
麦克风输入
    │
    ▼
┌─────────────────────────┐
│  AEC (回声消除)          │  消除扬声器播放的声音
└─────────────────────────┘
    │
    ▼
┌─────────────────────────┐
│  NS (噪声抑制)            │  压低背景噪声
└─────────────────────────┘
    │
    ▼
┌─────────────────────────┐
│  AGC (自动增益控制)       │  让音量更稳定
└─────────────────────────┘
    │
    ▼
┌─────────────────────────┐
│  VAD (语音活动检测)       │  判断是否有人声
└─────────────────────────┘
    │
    ▼
编码输出

getUserMedia 的配置选择

getUserMedia 的配置选择

interview-guide 用的是最基础的 getUserMedia 配置:

getUserMedia
navigator.mediaDevices.getUserMedia({
  audio: {
    echoCancellation: true,
    noiseSuppression: true,
    autoGainControl: true,
    sampleRate: 16000,
  },
});
navigator.mediaDevices.getUserMedia({
  audio: {
    echoCancellation: true,
    noiseSuppression: true,
    autoGainControl: true,
    sampleRate: 16000,
  },
});

但这不是唯一选择,不同场景有不同权衡:

参数truefalse建议echoCancellation消除扬声器回声,但会损失部分音质保留原始音质,但需要自己做 AEC开noiseSuppression压低噪声,但可能把用户声音也削掉需要自己做 NS环境嘈杂时开,安静时关autoGainControl自动调整音量到合适范围依赖麦克风原始音量开sampleRate越高音质越好,但数据量越大16 kHz 对多数 ASR 已够用按模型要求配置;浏览器不一定严格按约束输出

参数truefalse建议

参数

true

false

建议

echoCancellation消除扬声器回声,但会损失部分音质保留原始音质,但需要自己做 AEC开

echoCancellation

消除扬声器回声,但会损失部分音质

保留原始音质,但需要自己做 AEC

noiseSuppression压低噪声,但可能把用户声音也削掉需要自己做 NS环境嘈杂时开,安静时关

noiseSuppression

压低噪声,但可能把用户声音也削掉

需要自己做 NS

环境嘈杂时开,安静时关

autoGainControl自动调整音量到合适范围依赖麦克风原始音量开

autoGainControl

自动调整音量到合适范围

依赖麦克风原始音量

sampleRate越高音质越好,但数据量越大16 kHz 对多数 ASR 已够用按模型要求配置;浏览器不一定严格按约束输出

sampleRate

越高音质越好,但数据量越大

16 kHz 对多数 ASR 已够用

按模型要求配置;浏览器不一定严格按约束输出

AEC/NS/AGC 在不同浏览器和设备上的表现差异较大。Chrome 桌面版、Safari 和移动端都要单独测试,测试场景至少覆盖外放、耳机、会议室和移动网络。

WebRTC 的边界

WebRTC 的边界

WebRTC 很适合浏览器实时音频,但如果你做的是 App 或硬件方案,就要看平台能力和功耗约束。

移动端 native 开发可以用:

iOS:AVAudioEngine + 系统内置的音频处理

Android:AudioRecord + Oboe/AAudio,或者用 Google 的 WebRTC 库

硬件场景(智能音箱、车载)通常需要专门的 DSP 或音频前端算法处理回声、阵列波束和远场拾音,单靠浏览器式软件前处理不够。

级联链路和原生实时模型各有什么优劣?

级联链路和原生实时模型各有什么优劣?

这是选型时的核心问题。

方案一:级联式 ASR + LLM + TTS

方案一:级联式 ASR + LLM + TTS

音频 -> VAD -> 流式 ASR -> LLM -> 流式 TTS -> 音频
音频 -> VAD -> 流式 ASR -> LLM -> 流式 TTS -> 音频

优点:

ASR 文本可以落库、审计、纠错

LLM 输入输出都是文本,方便复用现有 Agent 框架

TTS 可以独立替换音色和供应商

每个组件都能单独压测和优化

缺点:

每层都有延迟

ASR 错误会传导到 LLM

文本中间层会丢失语气、停顿、情绪

打断要跨 ASR、LLM、TTS、播放器统一取消

interview-guide 就是这套方案。它适合的场景:企业知识问答、客服工单、需要合规审计的业务系统。

方案二:原生 Realtime Speech-to-Speech

方案二:原生 Realtime Speech-to-Speech

音频 -> 原生多模态模型 -> 音频
音频 -> 原生多模态模型 -> 音频

代表方案:OpenAI Realtime API、Gemini Live API、阿里通义 Qwen-Omni。

优点:

更低的端到端延迟

语气、停顿、情绪等副语言信息保留更多

可以统一处理音频输入、文本事件、工具调用

缺点:

中间过程更黑盒,问题定位更依赖供应商日志

文本审计和话术控制需要额外设计

成本模型可能按音频 token 或时长计费

如果业务强依赖私有化部署,供应商 API 未必满足要求

连接方式选择:

OpenAI Realtime API 当前文档提供三类连接方式:

连接方式适用场景WebRTC浏览器和移动端应用,适合直接采集麦克风并播放模型音频WebSocket服务端到服务端的中间件场景,低延迟且可控SIPVoIP 电话系统集成,适合呼叫中心、电话客服场景

连接方式适用场景

连接方式

适用场景

WebRTC浏览器和移动端应用,适合直接采集麦克风并播放模型音频

WebRTC

浏览器和移动端应用,适合直接采集麦克风并播放模型音频

WebSocket服务端到服务端的中间件场景,低延迟且可控

WebSocket

服务端到服务端的中间件场景,低延迟且可控

SIPVoIP 电话系统集成,适合呼叫中心、电话客服场景

SIP

VoIP 电话系统集成,适合呼叫中心、电话客服场景

我的建议

我的建议

高频、强实时、强调自然交互的语音产品,可以优先评估原生 Realtime API。需要逐步审计 ASR 文本、控制回复话术或私有化部署时,级联链路通常更容易观测和替换组件,但组件增多也会引入更多超时与故障点。

不要第一天就做端云混合。先把一条链路跑通,再逐步替换。

怎么在生产环境中优化语音系统?

怎么在生产环境中优化语音系统?

1. 调整音频帧和上行分块

1. 调整音频帧和上行分块

编解码和语音处理通常使用 10 ms、20 ms、30 ms 等帧长,应用层可以把多个帧合并成一次网络分块。帧长和上行分块是两个不同概念:处理帧太大会增加算法延迟,网络分块太小则会增加消息和编码开销。

interview-guide 的选择是 200 ms 分块:

// pcm-processor.js
this.targetSampleRate = 16000;
this.samplesPerChunk = 3200; // 200 ms at 16 kHz
// pcm-processor.js
this.targetSampleRate = 16000;
this.samplesPerChunk = 3200; // 200 ms at 16 kHz

这不会让 ASR 等到整句话结束才开始工作,但会给上行音频引入最多一个分块周期;再叠加服务端 VAD 的静音断句时间,用户会感到“话音落下后还要等一下”。如果要做得更好,可以:

减小分块到 100 ms

前端先发一小段让 ASR“热启动”

使用 ASR 的稳定增量转写提前做意图判断;VAD 事件只负责提供语音活动和轮次边界

2. 让 LLM 先说短句

2. 让 LLM 先说短句

语音回复不是写文章。用户不需要一上来听 500 字完整答案。

更好的策略:

先输出确认语:“我看一下”

工具调用期间播过渡语:“正在查最近一次订单”

查到结果后再给结论

长解释拆成多句,每句都能独立合成

3. TTS 按语义边界切分

3. TTS 按语义边界切分

TTS 切分太碎听起来断断续续;切分太长首包延迟高。

建议按优先级切:

句号、问号、感叹号

分号、冒号

较长逗号短语

超长句强制切分

同时要避免把数字、英文缩写、代码名切坏。比如"GPT-4o-mini-tts"不能被随便拆成几段读。

interview-guide 当前采用的就是这个思路:LLM 流式输出过程中,只要检测到一个完整句子,就立刻提交给 OrderedTtsChunkEmitter 做句子级 TTS。前端收到 audio_chunk 后立即入队播放,收到 audio_complete 后再等待播放队列自然清空。这样首段语音不需要等整段回答生成和合成结束。

OrderedTtsChunkEmitter
audio_chunk
audio_complete

4. 控制上下文长度

4. 控制上下文长度

语音 Agent 很容易把所有转写、工具结果、播放状态都塞进上下文。短期看没事,长会话里会让延迟和成本一起上涨。

建议把上下文分成三层:

短期原文:最近几轮完整转写和回答

会话摘要:用户目标、已确认事实、未完成事项

事件状态:当前播放进度、是否被打断、工具调用结果

LLM 不需要知道每个音频帧发生了什么,它需要知道和当前决策相关的高信噪比状态。

5. 全链路可观测

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));
}
// 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 还能怎么演进?

语音 Agent 还能怎么演进?

interview-guide 是最基础版本,还有很多可以优化的地方。

端云混合

端云混合

目前 interview-guide 基本是“云端为主”的设计。进阶方向是把更多能力下沉到端侧:

环节当前演进方向VAD端侧 VAD + 服务端 VAD纯端侧 VAD,减少服务端压力ASR纯云端简单命令放端侧,复杂识别放云端LLM纯云端小模型端侧兜底,断网可用TTS纯云端固定提示音放端侧,自然对话放云端

环节当前演进方向

环节

当前

演进方向

VAD端侧 VAD + 服务端 VAD纯端侧 VAD,减少服务端压力

VAD

端侧 VAD + 服务端 VAD

纯端侧 VAD,减少服务端压力

ASR纯云端简单命令放端侧,复杂识别放云端

ASR

纯云端

简单命令放端侧,复杂识别放云端

LLM纯云端小模型端侧兜底,断网可用

LLM

纯云端

小模型端侧兜底,断网可用

TTS纯云端固定提示音放端侧,自然对话放云端

TTS

纯云端

固定提示音放端侧,自然对话放云端

端云混合不是把所有模型都塞到客户端。更稳的做法是:实时性强、隐私敏感、断网要兜底的能力优先下沉;需要大模型理解、复杂推理、统一审计的能力留在云端。

本地模型部署

本地模型部署

如果你对数据合规有要求,可以考虑本地部署 ASR 和 TTS:

ASR:faster-whisper、FunASR、SenseVoice

TTS:piper1-gpl(原 Piper 已归档)、Fish Speech、CosyVoice

注意:原 Piper 仓库(rhasspy/piper)已于 2025 年 10 月归档,开发已迁移到 OHF-Voice/piper1-gpl。piper1-gpl 采用 GPL-3.0,商业项目需要评估开源合规要求;项目目前也在招募新的维护者,长期支持存在不确定性。Fish Speech 不是 Apache 2.0:其当前模型、代码和文档受 Fish Audio Research License 约束,研究和非商业用途可免费使用,商业用途需要另行取得 Fish Audio 的书面许可。CosyVoice 也应分别核对代码、模型权重和声音资源的许可证。

OHF-Voice/piper1-gpl

Fish Audio Research License

本地部署的优势是可控、可离线。劣势是工程成本高:GPU/内存/并发容量要自己压测,流式推理、模型热加载、显存回收都要自己做。

原生 Realtime API

原生 Realtime API

如果级联链路的延迟和自然度已经压不下去,可以评估原生 Realtime API:

OpenAI Realtime API(支持 WebRTC、WebSocket 和 SIP,具体模型名从配置或模型网关读取)

Gemini Live API

阿里通义 Qwen-Omni

这些 API 把 ASR、LLM、TTS 融合到统一的多模态链路里,延迟和自然度通常更有优势。代价也很现实:中间过程更黑盒,成本模型变化快,调试和审计都要额外设计。

OpenAI 在 2025 年 8 月把 Realtime API 推到 GA,并发布专用语音模型 gpt-realtime。Realtime 模型更新较快,生产代码不应把模型名散落在业务逻辑里,应该由配置中心或模型网关统一管理,并在上线前核对当前模型目录。

gpt-realtime

GA 发布时,Realtime API 同时提供或补充了几类能力:

远程 MCP 服务器支持,可像级联方案一样调用外部工具;

图像输入支持,模型可结合用户看到的屏幕内容进行对话;

SIP 电话集成,支持与传统电话网络连接。

价格也不要写死。Realtime 模型通常会区分文本、音频、缓存输入和输出等计费口径,实际接入前一定要以官方 pricing 页为准。

打断体验优化

打断体验优化

目前 interview-guide 的打断是“静默丢弃”:AI 说话时用户的声音直接不发。这种方式简单,但体验不够自然。

更好的做法:

AI 说话时继续接收音频,但不发到 ASR

检测到用户声音后,先降低 AI 播放音量(渐变而不是突然停止)

打断后保留已播放内容的上下文

多模态扩展

多模态扩展

interview-guide 目前只有语音。可以扩展成:

语音 + 屏幕共享:面试官可以看到候选人的 IDE

语音 + 摄像头:看候选人的表情和肢体语言

语音 + 白板:一起画架构图

这些多模态能力需要更复杂的流管理和状态同步。

面试里怎么回答 AI 语音系统问题?

面试里怎么回答 AI 语音系统问题?

如果面试官问:“你怎么设计一个实时语音 Agent?”

可以按这个思路回答:

先拆链路:客户端采集音频,VAD 判断说话边界,ASR 流式转写,LLM 做意图理解和工具调用,TTS 流式合成,客户端边收边播。

再讲难点:实时语音核心难点是端到端延迟、用户打断、噪声环境、上下文状态和端云协同。

再讲状态机:需要管理 listening、thinking、speaking、interrupted 等状态,打断时要取消播放、取消生成,并处理已播放和未播放上下文。

最后讲选型:云端 API 上线快,本地模型可控但工程成本高;端云混合和 Speech-to-Speech API 是否合适,要根据延迟、合规、成本和可观测需求评估。

总结

总结

AI 语音 Agent 是一条实时音频流链路:VAD 决定对话轮次,ASR、LLM 和 TTS 需要流式衔接,播放、打断、取消和会话状态必须协同处理。级联方案的中间过程更容易控制和审计,原生实时模型在延迟与自然度上可能更有优势;端云如何划分,则要结合实时性、隐私、离线兜底、成本和运维能力判断。无论采用哪种方案,都应把它设计成可取消、可观测、可降级的系统,而不是三段 API 调用的简单串联。