最近在一个项目里设计实时语音对话能力时,我们遇到了一个看似简单、实际很容易说不清楚的问题:
使用 ASR、Agent 和 TTS 级联出来的语音对话,到底算不算双工?它和模型原生的语音双工又有什么区别?
如果只看产品体验,用户可以在 Agent 说话时继续开口,系统能立刻停止播放、取消当前生成并开始听新的问题,这当然已经具备双工对话最重要的特征。
但如果只看模型链路,声音先被识别成文字,文字交给 Agent 推理,回答再经过 TTS 变成声音,它又明显不是端到端的 Audio-to-Audio。
这两个判断并不矛盾。更准确的结论是:
这套设计采用的是级联式应用层全双工语音架构,而不是原生端到端语音双工模型。
“全双工”和“原生双工”描述的不是同一个层面。前者主要回答输入与输出能不能同时发生、用户能不能自然打断;后者回答模型是否直接理解和生成音频。
先把“双工”拆成四个层次
很多语音产品说自己支持双工,但“双工”至少可能指四件不同的事。
| 层次 | 关注的问题 | 当前项目的实现 |
|---|---|---|
| 传输层 | 上行与下行数据能否同时传输 | 浏览器与后端使用全双工 WebSocket |
| 流式处理层 | ASR、Agent、TTS 能否边输入边输出 | ASR 流式转写,Agent 流式生成,TTS 流式返回 PCM |
| 交互层 | Agent 播放时用户能否继续说话和打断 | 持续录音 + Local VAD + Barge-in |
| 模型层 | 模型是否直接消费和生成音频 | 否,仍然是 ASR → Agent → TTS 级联 |
因此,判断一个语音系统是不是双工,不能只看它是否使用名为 bidirection 的接口,也不能只看它是不是 Audio-to-Audio 模型。
一个系统完全可能在传输层和交互层实现真正的全双工,同时在模型层保持级联。这正是这个项目当前选择的路线。
整体架构:三条实时链路由 VoiceSession 编排
这套语音链路由浏览器、FastAPI 语音编排层和语音服务三部分组成。
麦克风 PCM 上行和 TTS PCM 下行可以并发;VoiceSession 负责状态、轮次、取消和 ASR / TTS 生命周期。
浏览器不是一个被动播放器
浏览器同时承担录音、预处理、本地语音检测和音频播放:
- Web Audio 获取麦克风输入,并将设备原生采样率重采样为 PCM16 mono 16kHz;
- 音频以 20ms 小帧持续发送,后端聚合成 200ms 数据块交给 ASR;
- 下行 PCM16 24kHz 使用播放时间游标连续排程,减少片段之间的空隙和爆音;
- 即使 Agent 正在播放回复,麦克风仍然保持采集;
- Local VAD 检测到用户重新开口后,立即触发打断。
浏览器与 FastAPI 之间只需要一条 WebSocket。二进制帧承载上下行 PCM,JSON 承载 voice.start、状态变化、转写事件、Agent 文本增量和 playback.clear 等控制消息。
这条连接在传输意义上是真正的全双工:麦克风音频上行时,下行语音与控制消息仍然可以同时到达。
VoiceSession 是语音系统真正的控制中心
语音体验并不是把 ASR、LLM 和 TTS 三个 API 串起来就结束了。真正决定对话是否自然的,是中间的 VoiceSession。
它需要同时维护:
idle、listening、thinking、speaking等会话状态;- 当前轮次和 Turn Lock,避免两个回答交叉执行;
- ASR 流、Agent task、TTS pump 和 TTS Session 的生命周期;
- VAD 事件、partial transcript 与 idle commit;
- 当前播放是否需要清空,以及哪些异步任务必须被取消;
- 文字聊天和语音聊天共用的
session_id、消息时间线和用量记录。
换句话说,VoiceSession 不是一个简单的 WebSocket Handler,而是一套面向实时语音的任务编排器。
一次语音轮次是怎样流动的
一次完整的交互大致经过九步:
- 浏览器发送
voice.start,绑定当前 Agent 和会话。 - 麦克风持续产生 PCM16 16kHz、20ms 音频帧。
- VoiceSession 将音频聚合为 200ms 数据块,发送给流式 ASR。
- ASR 持续返回 partial 和 final transcript,页面同步显示转写过程。
- final transcript 被提交给 Agent Harness。
- LangGraph Agent 流式返回
text_delta,页面先显示文字。 - TTS pump 从文本流中切出完整句,清理 Markdown 后送入 TTS queue。
- TTS 流式返回 PCM16 24kHz 音频,浏览器边收边播。
- 如果用户在思考或播放阶段重新开口,Local VAD 发出
Speech.START,触发全链路打断。
这里有一个对延迟很重要的设计:Agent 与 TTS 不是前后串行等待,而是异步流水线。
Agent 还在生成后面的内容时,前面已经完成的句子就可以进入 TTS。这样不必等整个答案生成完才开始说话,又能在句子边界上做 Markdown 清理和更稳定的语音合成。
它不是极限低延迟方案,但在可控性、语义完整度和首音速度之间取得了更现实的平衡。
真正困难的不是“能听能说”,而是自然打断
很多语音 Demo 看起来已经能对话,但用户一旦在播放过程中插话,系统就会暴露问题:旧音频还在播放、上一轮 Agent 继续调用工具、新问题与旧答案混在一起,甚至 TTS 的残留片段会在下一轮突然出现。
所以,Barge-in 不能只做成前端的“停止播放”。一次可靠的打断至少要同时完成:
Speech.START
→ 停止并清空所有已排程的浏览器 AudioBufferSourceNode
→ 重置未来播放时间游标
→ 发送 playback.clear / interrupt
→ 取消当前 LLM task
→ 取消 TTS pump task
→ 关闭并废弃当前 TTS Session
→ 回到 listening
被取消的 TTS WebSocket 不应该继续复用。重新建立会话虽然增加一点连接成本,却能避免上一轮的 SessionCanceled、残留音频或协议状态污染下一轮。
ASR 也需要轮次边界。当前语句提交以后重建上游识别流,可以避免前一轮累积的转写上下文影响新的问题。
另外,播放期间持续录音会带来扬声器回灌。浏览器侧的回声消除、降噪和自动增益是基础,但 VAD 阈值、设备差异和外放音量仍然会直接影响误打断率。双工体验最终不是一个布尔值,而是一组需要持续评测的工程指标。
它与原生 Audio-to-Audio 双工有什么区别
原生双工语音模型通常直接接收连续音频并直接生成音频。声音不需要先完整压缩成文字语义,模型可以同时利用词义、停顿、语速、重音、情绪和其他声学信息。

左侧级联架构保留 ASR、Agent、TTS 以及转写、工具和事件检查点;右侧原生架构用统一实时模型直接连接输入与输出音频。两者都能实现双工交互,但优化目标不同。
级联式全双工与原生端到端双工的主要区别可以概括为:
| 维度 | 级联式应用层全双工 | 原生 Audio-to-Audio 双工 |
|---|---|---|
| 核心链路 | Audio → ASR → Text Agent → TTS → Audio | Audio → Realtime Model → Audio |
| 中间语义 | 有明确 transcript 和文本回答 | 语义与音频状态更多保留在模型内部 |
| 延迟来源 | ASR endpoint、Agent 首 token、分句、TTS 首包 | 实时模型直接处理音频,理论链路更短 |
| 语气理解 | 主要依赖 ASR 可保留的信息 | 可以直接利用停顿、语气和情绪 |
| 语音表现 | 由 TTS 音色和文本控制 | 更容易生成连续、自然的韵律响应 |
| 工具与工作流 | 可直接复用成熟 Text Agent、LangGraph、工具和 HITL | 需要模型或外层 Runtime 提供可靠工具编排 |
| 可观测性 | 每一层都有文本、事件和明确状态 | 内部决策更端到端,调试与归因更困难 |
| 审计与存档 | transcript 天然适合搜索、审计和持久化 | 往往需要额外转写或结构化记录 |
| 供应商替换 | ASR、模型、TTS 可以分别替换 | 能力更集中于实时模型供应商 |
| 故障定位 | 可以判断问题出在识别、推理还是合成 | 体验统一,但错误边界更模糊 |
原生双工的优势,是更短的感知—响应路径和更完整的声学上下文。它更接近人与人交谈:用户没有说完时,模型已经开始理解意图;语气变化、犹豫和情绪也可以成为输入;输出声音不必等到完整文本句子确定后才合成。
级联架构的优势,则是可控、可替换、可观测,而且能够复用已经成熟的 Agent 工程体系。对于需要工具调用、权限控制、Human-in-the-loop、会话审计和结构化产物的企业 Agent,这些能力往往比少几百毫秒更重要。
所以“原生”不天然等于“更适合所有 Agent”。它是另一组产品取舍。
WebSocket 全双工,也不等于 WebRTC
另一个容易混在一起的概念,是实时语音传输。
当前架构使用 WebSocket 传输原始 PCM。它实现简单,JSON 控制事件和二进制音频可以复用一条连接,也很适合普通 Web 应用与后端服务编排。
但它不是完整的实时媒体栈。WebRTC 还包含 RTP、DTLS、抖动缓冲、网络拥塞控制、带宽自适应、Opus 编码和更成熟的回声处理能力。
因此:
- 在网络稳定的浏览器语音助手场景里,WebSocket + PCM 足够直接;
- 在移动网络、跨地域、长时间通话和强弱网环境里,WebRTC 的媒体能力更有优势;
- 是否使用 WebRTC,与模型是不是 Audio-to-Audio 是两个独立选择。
一个系统可以使用 WebRTC 传输音频,但后端仍然是 ASR → Agent → TTS;也可以通过 WebSocket 连接原生实时语音模型。不要把媒体协议和模型架构混为一谈。
为什么当前阶段选择级联式全双工
在这个项目里,语音不是一个独立的聊天产品,而是现有 Agent 工作能力的另一个入口。
用户通过文字或语音发起请求,背后使用的是同一个 Agent、同一个 session_id、同一套 LangGraph Harness、工具系统、会话历史和权限边界。这意味着语音模式不能为了追求“像真人”而丢掉 Agent 已有的工作能力。
级联架构让这件事变得自然:
- ASR 把语音转换成 Agent 已经理解的输入形式;
- Agent 仍然可以调用工具、进入工作流、触发中断和人工确认;
- 文本增量同时用于 UI、持久化和 TTS;
- TTS 只负责表达,不接管任务逻辑;
- 每个阶段都能被取消、重试、监控和替换。
从工程角度看,这不是退而求其次,而是先守住 Agent 的可靠性,再通过应用层编排获得接近自然对话的实时体验。
后续应该怎么继续演进
这套架构还可以沿几个方向继续优化。
第一是从完整句 TTS 逐步走向更细粒度的稳定语义片段。目标不是盲目逐字符发送,而是在不破坏语义和 Markdown 清理的前提下降低首音延迟。
第二是建立真正的语音 Eval,而不是只测接口是否连通。至少应该持续记录:
- 首个 partial transcript 延迟;
- 用户停顿到 final transcript 的 endpoint latency;
- Agent 首 token 与 TTS 首音延迟;
- 打断信号到音频完全停止的延迟;
- 误打断率与漏打断率;
- 播放队列 underrun、爆音和片段间空隙;
- ASR 错误对工具调用与任务结果的影响。
第三是根据场景引入 WebRTC、语义 VAD 和更完善的回声处理,让弱网和外放环境更加稳定。
第四是保留混合路线。闲聊、陪伴和强情绪表达可以交给原生 Audio-to-Audio;工具密集、需要审计和结构化交付的工作任务仍走级联 Agent。两条链路共享会话与权限,而不是强迫所有语音需求使用同一种模型。
双工是一种系统能力,不是一个模型标签
语音 Agent 是否自然,不由某一个 ASR、TTS 或实时模型单独决定。
它取决于音频是否持续采集、上下行是否真正并发、状态是否清晰、任务能否及时取消、播放能否瞬间清空、回声是否被抑制、轮次是否会互相污染,以及 Agent 的工具和上下文能否完整保留。
当前设计不是原生端到端语音模型,但它已经在传输层、处理层和交互层实现了真实的双工能力。
更重要的是,它没有为了语音重新发明一套 Agent,而是让语音进入现有的 Agent Harness:
声音是新的交互通道,Agent 仍然是工作的执行主体,VoiceSession 负责把实时对话变成一条可控、可打断、可观测的执行链路。
这可能比简单地接入一个“原生双工模型”,更接近企业语音 Agent 真正需要解决的问题。