做 VoiceAgent 的朋友应该都有过这种经历:ASR、LLM、TTS 单测都通了,放浏览器里一走 WebRTC 通道立刻冒出一堆问题,要么没声音,要么延迟忽高忽低,要么对讲中突然断流。我在这个方向折腾了小半年,最大的体会是:WebRTC 跟前端平时写的 HTTP 请求完全不是一个思考维度,它是一套从采集、编码、传输到播放的完整实时链路,任何一个环节掉链子,用户感知的都是"这机器人反应好慢"或者"声音好卡"。这一章就把 VoiceAgent 中 WebRTC 的基本流程完整拆开,从信令协商到媒体传输,从浏览器端到服务端再到 ASR/TTS 引擎,给你一条可以直接照着落地的路径。

这一章适合正在做或准备做语音助手、AI 客服、智能外呼这类项目的同学,尤其是后端工程师想搞懂浏览器端音频怎么接入服务端,或者前端工程师想理解媒体服务器的处理逻辑,都应该能从中找到自己需要的部分。我尽量用实际项目里跑通过的配置和踩过的坑来讲,少说虚的。

1. VoiceAgent 为什么绕不开 WebRTC:从音频通道选型说起

1.1 先统一 VoiceAgent 的架构认知

VoiceAgent 本质上是把"听、想、说"三个能力串起来:用户说话的声音进来,经过 ASR 转成文字,LLM 根据对话上下文生成回复文本,TTS 再把文本转成语音播出去。这个链条里最容易被低估的其实是第一公里和最后一公里——音频的实时传输通道。很多人一开始想的是"反正就是传音频嘛,我用 WebSocket 推 PCM 不就行了",但实际跑起来就会发现事情没那么简单。

我当初搭建第一个原型时就是用的 WebSocket 推原始 PCM 数据。单机测试、局域网内、网络状况良好的前提下确实能跑通,一旦放到公网环境,丢包、抖动、乱序这些问题全部暴露出来。PCM 裸流没有任何抗丢包能力,也没有自适应码率机制,网络一抖,声音就撕裂,而且整个链路的延迟波动非常大。这还只是传输层的麻烦,更麻烦的是你还要自己实现回声消除、噪声抑制、自动增益这些音频处理模块——这些在 WebRTC 里是开箱即用的。

1.2 WebRTC 解决的核心痛点

WebRTC 在 VoiceAgent 场景下解决的核心问题可以归纳为三件事。

第一是低延迟实时传输。WebRTC 默认走 UDP 承载 RTP 媒体流,配合 SRTP 加密,相比 TCP 的拥塞控制和重传机制,在实时场景下延迟表现稳定得多。语音对话的交互时延目标通常在 300ms 以内,这个指标在公网环境下靠裸 WebSocket 推 PCM 很难稳定达成,但 WebRTC 的资源预留、抖动缓冲和拥塞控制机制可以做到。

第二是抗网络劣化。WebRTC 内置了 NACK 重传、前向纠错(FEC)、码率自适应等机制。我在公网实测中,5% 丢包率下 Opus 编码的语音仍然可以保持可懂度,虽然会有轻微音质损失,但不会出现断断续续完全听不清的情况。这对 VoiceAgent 的商业场景非常重要——用户不会因为网络波动就完全无法使用。

第三是音频处理能力。浏览器端的 getUserMedia 配合 WebRTC 的 AudioProcessing 模块,可以提供回声消除(AEC)、噪声抑制(NS)、自动增益(AGC)和静音检测。这个能力直接决定了语音识别率。我做过对比实验:相同 ASR 引擎,开启 AEC 和 NS 后识别准确率能提升 10 到 15 个百分点,特别是在办公室这种有键盘声、空调声的环境里。

1.3 VoiceAgent 对双工通信的额外要求

VoiceAgent 还有一个普通音视频通话不太强调的需求——全双工打断能力。用户说话的过程中,Agent 可能在播放 TTS 回复,用户随时可能插话打断。这个交互模式要求音频通道必须双向实时开放,而且要有机制让播放中的 TTS 能够快速停止,同时继续采集用户的新语音。

WebRTC 的双工通道天然支持这种模式。浏览器同时有音频采集和音频播放两个方向,应用层只需要在检测到用户开始说话时,停止播放 TTS 音频、把新的识别结果送进对话引擎即可。相比之下,如果用"录音—上传—合成—下载播放"这种半双工方案,打断交互根本无从谈起,每一步都有几百毫秒的固定时延,累积下来体感极差。这也是为什么目前主流 VoiceAgent 框架都选择 WebRTC 作为音频传输方案的根本原因。

2. 一次完整对话的链路拆解:从按下按钮到听到回话

2.1 整个生命周期里到底发生了什么

我先用一条时间线把一次完整语音交互跑一遍,后面各节再分别深入。假设用户打开了一个语音助手页面,点击"开始对话"按钮:

  1. 浏览器调用 getUserMedia 采集麦克风音频。
  2. 前端创建 RTCPeerConnection,把本地音频轨道通过 addTrack 加入连接。
  3. 前端通过 WebSocket 连接信令服务器,发送携带 SDP offer 的消息。
  4. 服务端收到 offer 后创建对应的 RTCPeerConnection,设置远端描述,生成 answer 返回给前端。
  5. 双方通过信令通道交换 ICE 候选,完成 NAT 穿透,建立 UDP 媒体通道。
  6. 浏览器音频以 Opus 编码通过 RTP 包持续发送到服务端。
  7. 服务端从 PeerConnection 收到音频帧,解码成 PCM 数据,切分成 20ms 或 40ms 的块,通过 WebSocket 或 gRPC 流式推送给 ASR 引擎。
  8. ASR 识别出的文本增量进入对话系统,触发 LLM 生成回复。
  9. LLM 生成的完整回复交给 TTS 引擎合成语音,得到 PCM 音频。
  10. 服务端把 TTS 的 PCM 编码成 Opus,通过同一个 PeerConnection 的音频发送通道传给浏览器。
  11. 浏览器播放 TTS 音频,同时保持麦克风采集,随时准备接收用户的新一轮输入。
  12. 用户中途插话,前端检测到语音活动,服务端立即停止 TTS 播放,新一轮识别开始。

这条链路看起来步骤多,但每一步的延迟都是可控的。我在优化后的生产环境中,从用户说完最后一个字到开始听到 Agent 回复,端到端延迟能做到 800ms 到 1.2s 之间,其中 WebRTC 传输和编解码本身只占不到 100ms,大头在 ASR 尾点判断、LLM 首 token 时间和 TTS 首包时间上。

2.2 角色划分:谁负责什么

把这条链路里的参与者拆开看,包括四个角色。

浏览器端是媒体生产者和消费者。它负责采集麦克风音频、编码发送、接收并播放远端音频。同时它还承担一部分信号处理,比如回声消除、降噪、自动增益,这些都在 getUserMedia 之后、编码之前完成。

信令服务器负责连接建立。它只做两件事:转发 SDP offer/answer 和 ICE 候选。连接建立之后,信令服务器就不参与媒体数据转发了,真正的音频数据走的是浏览器和服务端媒体节点之间的 UDP 通道。

媒体服务器是核心中转和处理节点。它接收浏览器的音频流,解码成 PCM 供 ASR 消费;它接收 TTS 合成的 PCM,编码成 Opus 发送给浏览器。在一些架构里,媒体服务器还承担 VAD 检测、音量检测、断句判断等逻辑。实现方式可以是专门的 SFU(比如 mediasoup、Janus),也可以直接集成 WebRTC 库到业务服务里(Go 用 Pion,Python 用 aiortc,Node.js 用 werift 或 node-webrtc)。

对话引擎是决策中枢。它把 ASR 的文字输入拼进上下文,通过 LLM 生成回复,再把回复文本交给 TTS。这一层不关心音频格式,只关心文本流,但它的延迟直接决定了用户体感,所以工程师通常会为它设计流式输出协议,尽量让首 token 提前。

2.3 为什么信令和媒体要分离

很多第一次接触 WebRTC 的人会疑惑:既然浏览器能直接发音频数据,为什么还要一个信令服务器中转 SDP 和 ICE?直接让前端把音频发到后端不就行了?

关键在于 WebRTC 的建立过程需要"协商"。浏览器不知道服务端的 IP 地址和端口,也不知道用什么编码格式、什么安全参数来加密媒体流。SDP 本质上是双方交换"我支持什么、我期望什么"的协作文件。而 ICE 要解决的是 NAT 穿透问题,判断浏览器和服务端之间有没有可用的直连路径,如果没有,还需要借助 TURN 服务器中转。

这个协商过程本身跟媒体传输无关,所以设计上把它独立成信令通道。实际项目中,信令通常走 WebSocket,协议格式用 JSON 即可。媒体数据走后建立的 UDP 通道,这样媒体传输不被 HTTP 的队头阻塞影响,延迟才能得到保障。

3. 信令协商落地:SDP、ICE 与连接建立的工程细节

3.1 信令服务器的职责与实现选择

信令服务器在 VoiceAgent 架构里看起来很简单,但别小瞧它。它的职责包括:接收浏览器连接、生成房间/会话标识、转发 SDP 消息、转发 ICE 候选、处理断连清理。如果做多实例部署,还需要处理会话路由和消息持久化。

我建议直接用 WebSocket 加 JSON 协议,消息结构保持简单。实际项目中我的信令消息格式大概是这样:

{
  "type": "offer",
  "sdp": "v=0\r\no=- 4611731479060408045 2 IN IP4 127.0.0.1\r\n...",
  "sessionId": "agent-session-001"
}

应答消息:

{
  "type": "answer",
  "sdp": "v=0\r\no=- ...",
  "sessionId": "agent-session-001"
}

ICE 候选的消息再单独一种类型:

{
  "type": "ice-candidate",
  "candidate": "candidate:842163049 1 udp 1677729535 ...",
  "sessionId": "agent-session-001"
}

信令服务器本身的实现用 Node.js 的 ws 库、Go 的 gorilla/websocket、或者 Python 的 websockets 都可以,代码量不大。生产环境唯一要注意的是心跳检测和会话超时回收,防止浏览器异常关闭后服务端 PeerConnection 泄漏。

3.2 Offer/Answer 协商:谁主动、谁被动

在前面描述的链路中,浏览器是 offerer,服务端是 answerer。这个方向是 VoiceAgent 场景下的常规做法,原因是浏览器端作为发起方,在用户点击"开始对话"时才创建连接,逻辑上比较自然。服务端作为 answerer 相对被动,收到 offer 后创建自己的 PeerConnection 并配置处理逻辑。

有一类常见问题是 SDP 里的编码参数不匹配。浏览器支持的音视频编码和参数集合是确定的,但不同浏览器、不同版本会有差异。比如 Chrome 对 Opus 的支持参数是 maxplaybackrate=48000,而 Safari 在某些版本里会协商成 48000 但实际编码质量不同。如果服务端强制使用某个固定参数,可能导致协商后浏览器端编码器行为不符合预期。

稳妥的做法是服务端不要过度修改 SDP,只做最小必要的调整。比如我在 Pion 的场景里会先设置接收媒体参数,再让 negotiation 过程自动完成编码协商,不要手写死 Opus 参数。如果你需要强制使用 stereo 或者某个特定的码率,可以在 SDP 里显式声明,但要同时兼容纯 mono 的降级路径。

另外提醒一个细节:SDP 协商完成后,不要把 offer/answer 的 session 数据乱存乱改。我见过有同事把 SDP 里的 a=sendrecv 改成 a=recvonly,意图是让浏览器只发不收,结果 WebRTC 内部状态机出现不一致,音频流拉起失败。正确的单向传输方式是用 RTCRtpSender 的 replaceTrack 或者直接设置 track 的 enabled 属性,而不是动 SDP。

3.3 ICE 候选与 NAT 穿透的实战处理

ICE 是 WebRTC 最容易被忽略但坑最多的地方。简单说,ICE 的作用是让两个本来可能在不同 NAT 后面的设备找到一条可用的通信路径。浏览器收集本机 IP、反射地址(通过 STUN 服务器获取公网映射)和 relay 地址(通过 TURN 服务器获取转发地址),把候选发给服务端,服务端也做同样的事,然后双方尝试连通性测试。

VoiceAgent 场景下最常见的部署方式是:浏览器在用户的公网环境,服务端在云机房。这种情况下,两者之间通常不需要 TURN,因为云机房有公网 IP,浏览器通过 STUN 发现自己的公网映射地址后,服务端可以直接连通。但我强烈建议在正式环境配一套 TURN 服务兜底,因为有些企业网络、校园网、运营商 NAT 环境下,直连路径可能被防火墙阻断,没有 TURN 就只能干瞪眼。

ICE 建立失败的两个典型症状:一是在 createAnswer 之后连接状态一直停留在 new 或者 checking,二是有时候能连通、有时候连不通。排查方式是在两端都开启 ICE 日志,检查是否收集到了 srflx 或 relay 类型的候选。如果只有 host 类型候选,说明 STUN 配置没生效,或者浏览器处于无网络状态。

Pion 服务端开启 ICE 日志的方式是在代码里加:

m := webrtc.SettingEngine{}
m.SetLoggerFactory(webrtc.NewLoggerFactory(func() webrtc.LoggerFactory {
    return &webrtc.DefaultLoggerFactory{Logger: nil}
}))

Node.js 端可以在创建 RTCPeerConnection 时传入 iceCandidatePoolSize,同时监听 connectionStateChange 日志。实际调式中我看到最多的问题是 ICE 超时设置太短,默认情况下 ICE 过程需要几秒,如果服务端业务逻辑里有 5 秒超时限制,就可能出现在慢网络环境下协商总是失败的情况。建议把 ICE 相关的超时放宽到 10 秒以上。

4. 音频媒体流的走向:从麦克风到 ASR,再到 TTS 回来

4.1 浏览器端采集参数怎么配才合理

采集是整个链路的第一环,参数配置直接决定了后续语音识别的质量。我的建议是:

const stream = await navigator.mediaDevices.getUserMedia({
  audio: {
    channelCount: 1,
    echoCancellation: true,
    noiseSuppression: true,
    autoGainControl: true,
    sampleRate: 48000
  }
});

有几个点要说明。channelCount 必须设成 1,单声道。ASR 引擎大多数只接收单声道 PCM,双声道会导致数据量翻倍且没有收益,还会增加编码开销。echoCancellation 必须开启,否则播放 TTS 时麦克风会采集到扬声器的回声,ASR 会把 TTS 内容也识别进去,造成对话混乱。noiseSuppression 和 autoGainControl 建议同时开启,特别是嘈杂环境的使用场景。

sampleRate 这里设 48000 是因为 WebRTC 内部 Opus 编码器支持的最高采样率就是 48kHz。浏览器采集到的音频会先进入 WebRTC 的音频处理模块,然后以 Opus 编码发送。服务端解码出来的是 Opus 帧对应采样率的 PCM,需要转成 ASR 引擎要求的采样率。目前主流 ASR 引擎大多接受 16kHz 或者 8kHz 的单声道 PCM,所以在服务端要做一次重采样。

我踩过的坑是:有些浏览器在部分 Linux 系统上不支持 48000 采样率的采集,会静默降级到 44100 或 16000。处理方式是调用 getSettings() 检查实际参数,或者在采集后做一次 AudioContext 的 resample 兜底。

4.2 媒体服务器选型与实现

服务端接收 WebRTC 媒体流有两条路线。一条是用现成的 SFU 媒体服务器,比如 mediasoup、Janus、LiveKit。另一条是把 WebRTC 库嵌入到自己的业务进程中,比如 Go 用 Pion,Python 用 aiortc,Node.js 用 werift。

两条路线的选择逻辑很清晰。如果你只需要一个纯音频通道,不需要视频混流、不需要房间管理,我建议直接用 Pion 或者 aiortc 嵌入到业务服务里。少一层独立服务的部署和运维成本,音频流的处理逻辑也直接写在业务代码里,调试直观。如果你可能后续扩展视频、多方通话、录制、转推等功能,那就上现成 SFU,功能更全,但学习曲线也更陡。

用 aiortc 做一个接收音频的服务端核心代码框架大概是这样的:

import asyncio
from aiortc import RTCPeerConnection, RTCSessionDescription, MediaStreamTrack

class AudioReceiver(MediaStreamTrack):
    kind = "audio"

    async def recv(self):
        frame = await self.track.recv()
        # frame 是一个 AudioFrame,包含 PCM 数据
        pcm_data = frame.to_ndarray()
        # 这里把 pcm_data 推给 ASR 引擎
        return frame

async def handle_offer(sdp):
    pc = RTCPeerConnection()
    
    @pc.on("track")
    async def on_track(track):
        receiver = AudioReceiver()
        receiver.track = track
        pc.add_track(receiver)

    await pc.setRemoteDescription(RTCSessionDescription(sdp=sdp, type="offer"))
    answer = await pc.createAnswer()
    await pc.setLocalDescription(answer)
    return pc.localDescription.sdp

Pion 的 Go 版本写法稍有不同,核心也是创建 PeerConnection、设置 OnTrack 回调、处理 negotiated media。无论哪个库,服务端的基本职责就两个:接收音频、解码成 PCM 交给下游;接收 TTS 的 PCM、编码成 Opus 发送给浏览器。

这里必须强调:WebRTC 服务端不是收到原始 PCM,而是收到经过 Opus 编码压缩后的 RTP 包。RTCPeerConnection 的 OnTrack 回调拿到的是解码后的裸音频帧。Pion 里这个帧的格式是 16-bit little-endian PCM,aiortc 是 AudioFrame 对象。如果你在服务端直接拿 RTP 包里的数据拿去喂 ASR,那就是拿压缩后的 Opus 比特流给 ASR,必然失败。

4.3 Opus 编码与 RTP 传输参数

Opus 是 VoiceAgent 场景下事实上的标准音频编码。它支持从 6 kbps 到 510 kbps 的码率范围,采样率支持 8k、12k、16k、24k、48kHz,帧长可以设 2.5ms 到 60ms。WebRTC 默认协商的 Opus 配置通常是 48kHz 采样率、20ms 帧长、自适应码率,码率范围在 32 到 128 kbps 之间波动。

对 VoiceAgent 来说,我建议主动把码率控制在 32 到 64 kbps。语音对话不需要高音质音乐级别的码率,32kbps 已经能保证 ASR 的可懂度,64kbps 更加保险,带宽占用也很小。关键是避免 WebRTC 的带宽估计机制因网络波动把码率降得过低,导致音频质量劣化到 ASR 识别率下降。我在 Pion 里会设置 MediaEngine 的 Opus 参数:

mediaEngine := webrtc.MediaEngine{}
opusCodec := webrtc.RTPCodecCapability{
    MimeType:  webrtc.MimeTypeOpus,
    ClockRate: 48000,
    Channels:  2,
}

注意这里 Channels 设成 2 是因为 Opus 在 WebRTC 里的默认协商是立体声,但实际音频内容往往是单声道。浏览器端用单声道采集后,Opus 编码器仍然可以输出立体声格式,服务端解码时需要做一次声道折叠,转成单声道 PCM 再喂给 ASR。这个转换遗漏是我见过的一个低级但高频的错误。

RTP 层面还有一个概念要懂:SSRC 和 PT(payload type)。WebRTC 协商时会为每个媒体流分配一个 SSRC 标识和 payload type。服务端接多路会话时,必须用 SSRC 区分不同的音频来源。如果多路客户端共用一个 PeerConnection 导致 SSRC 冲突,会出现音频串流。建议一个会话对应一个 PeerConnection,不要复用。

5. VoiceAgent 引擎对接:流式识别、打断与 TTS 注入的配合

5.1 流式 ASR 接入:切片、推流与尾点判断

ASR 接入是整个 VoiceAgent 里最影响体验的环节。关键点在于流式识别,不是等用户说完再送一整段音频,而是边收边识别。服务端从 WebRTC 解码出 PCM 后,通常按 20ms 或 40ms 的块切分,持续推给 ASR 引擎,ASR 引擎返回增量识别结果。

我用的方案是在服务端维护一个环形缓存,积累到 100ms 或者 200ms 的音频就合成一个大块发给 ASR。这样做的好处是减少网络请求次数,同时 ASR 内部的 VAD 模块有足够上下文判断语音边界。延迟上,100ms 的缓冲在可接受范围内,换来的是 ASR 服务端压力降低十倍。

ASR 的协议选择上,如果使用云厂商的流式接口,一般走 WebSocket 或 gRPC 双向流。比较常见的做法是:

  • 建立 WebSocket/ gRPC 流
  • 持续发送音频二进制帧
  • 服务端定期返回中间识别结果(partial result)
  • 当 VAD 判断用户说完一句话时,ASR 返回 final result
  • 对话引擎拿 final result 触发 LLM

尾点判断(endpointing)是最难调的部分。太早,用户还在犹豫就断句了,识别结果残缺;太晚,用户说完要等很久才出结果,体感延迟高。不同 ASR 引擎的 VAD 参数可以调,比如静音超时时间。我通常在 500ms 到 800ms 之间调,用户语速快设置短一些,语速慢的用户设置长一些。有些引擎支持自动根据音量大小调整尾点阈值,我在生产环境开了这个能力。

5.2 打断(Barge-in)机制:双向通道的杀手级应用

打断是 VoiceAgent 与传统的 IVR 语音导航最本质的区别。传统 IVR 是"请按1,请按2"这种单向的,用户说话期间 Agent 不会发声。而 VoiceAgent 中 Agent 在播放 TTS 时,用户随时可能插话,系统必须能感知到用户在说话,并立即停止 TTS 播放,转去听用户的新指令。

打断机制的实现涉及三个层面。第一层是音频采集端,麦克风始终在采集,WebRTC 的全双工能力保证了这一路不会因为播放 TTS 而被关闭。第二层是 VAD 检测,在 TTS 播放期间,服务端持续检测来自浏览器的音频能量。如果发现能量超过阈值且持续一段时间(比如 150ms),就判定用户开始说话。第三层是业务逻辑联动:检测到打断后,停止向浏览器发送 TTS 音频,同时把当前正在播放的 TTS 状态置为 interrupted,并将新一轮 ASR 结果接入对话上下文。

有个细节:浏览器端播放 TTS 时,用户说话的声音会被 WebRTC 的 AEC 处理掉吗?不会。AEC 消除的是浏览器扬声器播放的声音,而不是用户自己的声音。AEC 模块会把 TTS 的参考信号和麦克风采集信号做比对,过滤掉回声,保留人声。所以服务端收到的音频里,用户的声音是完好的,可以正常触发打断检测。

打断检测放在哪一端也有讲究。放在浏览器端可以立刻拿到音频能量信息,通过信令通知服务端,延迟低但实现麻烦。放在服务端更统一,所有客户端逻辑一致,但会引入几十毫秒的网络传输延迟。我在生产环境中用的是服务端方案,因为前端做 VAD 容易受不同浏览器音频处理差异影响,统一到服务端更好维护。

5.3 TTS 音频回传:从 PCM 到 WebRTC 发送的完整路径

TTS 合成的结果通常是 16kHz 或 24kHz 的单声道 PCM。要让它通过 WebRTC 发给浏览器,需要经过编码和打包。过程是:把 PCM 数据按 20ms 帧切分,用 Opus 编码器编码,打包成 RTP 包,通过 PeerConnection 的发送通道发出去。

在 Pion 里,可以通过 TrackLocalStaticSample 或者 TrackLocalStaticRTP 来推送音频数据。我用的模式是创建一个音频轨道的 TrackLocalStaticSample,FIFO 式地往里面写 PCM 数据即可。但这里有一个重要事项:必须先处理 TTS 引擎输出采样率和 WebRTC 内部采样率不一致的问题。如果你的 PeerConnection 协商的是 48000Hz,而 TTS 输出是 16000Hz,你需要先重采样到 48000Hz,否则播放速度会不对,声音会变尖变快。

播放时的节奏控制也很关键。WebRTC 有抖动缓冲机制,但服务端发送的速度必须和音频播放的节奏匹配。Pion 的 TrackLocalStaticSample 在写入时如果不控制速率,可能会造成浏览器端音频堆积或者空洞。正确做法是按实时速率写入——每 20ms 的音频帧,实际等待 20ms 再写下一个。如果要实现更精确的 pacing,可以用 Ticker 或者定时器控制写入节奏,实测中大概会有 10 到 30ms 的累积偏差,需要通过时间戳计算修正。

还有一个优化点:TTS 首包优化。LLM 生成回复文本后,如果等整段文本都生成完再送 TTS,延迟会叠加。更优的做法是让 LLM 流式输出,每当生成一句完整的话就送给 TTS,TTS 合成完这一句立刻通过 WebRTC 发送。用户听到的是 Agent 边说边想的效果,体感响应时间大幅缩短。这也要求 TTS 引擎支持流式输入,不是所有引擎都支持,选型时要注意。

6. 断线重连、质量监控与参数调优的实测经验

6.1 网络抖动对对话体验的影响与对策

公网环境下网络抖动是常态,尤其在 WiFi 覆盖差、移动网络信号不稳定的场景。WebRTC 本身有抖动缓冲和自适应码率机制,但 VoiceAgent 场景有一个特殊问题:ASR 对音频连续性非常敏感。如果网络抖动导致几十毫秒的音频丢失,人耳可能感知不到,但 ASR 识别出的文字可能就缺字了。

缓解方案有两层。第一层是 WebRTC 内建策略,开启 NACK 重传。Pion 里可以通过 SettingEngine 配置,aiortc 默认开启。NACK 的好处是丢失的 RTP 包会被及时重传,对语音质量影响小,坏处是引入额外延迟。第二层是业务策略,在服务端设置一个小型 jitter buffer,将收到的音频帧缓冲 50 到 100ms 再送入 ASR,让抖动不至于直接导致 ASR 断流。这个缓冲和 WebRTC 自带的 jitter buffer 叠加会增加延迟,所以要根据实际网络状况折中。我的经验是,在抖动小于 50ms 的网络环境下,不额外加缓冲;网络质量差的场景,加 80ms 缓冲换识别稳定性。

6.2 ICE 重连与会话恢复策略

WebRTC 连接在移动网络切换、WiFi 断流后可能会断。WebRTC 的 ICE restart 机制可以在不重建 PeerConnection 的情况下恢复连接。但浏览器端触发 ICE restart 需要应用层配合,不是自动的。

我的策略是在服务端监听 PeerConnection 的 connectionState 变化:如果状态变成 disconnected,启动一个 3 秒的计时器,期间内如果能恢复(状态回到 connected),则继续;如果超时仍未恢复,触发 ICE restart。ICE restart 的做法是浏览器端重新调用 createOffer,带上 ICE 属性里新的 ufrag/pwd,然后重新走一次信令协商。

这里要特别注意:VoiceAgent 对话是有状态的。如果连接断开超过 10 秒,用户可能已经说完了好几句话,ASR 的音频流中间缺了一段,重连后如果直接继续识别,结果大概率是错的。我的处理方式是:断线超过 5 秒则主动终止当前会话的对话状态,要求前端提示用户重新说一遍。如果你做的是外呼场景,还涉及呼叫保持、静音等更复杂的逻辑,需要提前设计好状态机。

6.3 用 getStats 采集质量指标的实测方法

WebRTC 自带 getStats 接口,能拿到非常详细的媒体统计信息,包括 RTT、丢包率、抖动、码率、音频帧数等。这些数据对 VoiceAgent 的调优和排障非常重要。我在项目中会在浏览器端每 5 秒采集一次 stats,打点到监控系统,服务端同样从 PeerConnection 取 stats 汇总。

浏览器端关键指标:

  • RTT(往返时延):正常公网环境下应小于 200ms,如果长时间大于 500ms,对话延迟会非常明显。
  • packetsLost / packetsReceived 的比值,即丢包率:应小于 2%,超过 5% 就需要考虑码率自动降级或者网络切换。
  • jitter:应小于 30ms,过大说明网络稳定性差。
  • audioLevel(音频能量):用于确认麦克风确实在采集声音,排查无声问题时很管用。

服务端关键指标:

  • 每小时断线次数、ICE restart 次数
  • ASR 收到的有效语音秒数 / WebRTC 收到的总音频秒数,这个比值可以反映音频质量是否达标
  • TTS 发送到播放的端到端延迟

我在监控面板上把这些指标做了实时告警,特别是丢包率超过 5% 和 RTT 超过 500ms 的情况。有一次线上用户反馈"机器人听不懂我说什么",面板显示那段时间丢包率平均 8%,查下来是用户所在区域的运营商网络问题,不是代码 bug。没有这些指标,这种问题只能靠猜。

7. 高频故障排查:我在实际项目中踩过的几个坑

7.1 建立连接后一直收不到音频

这是我最常被问到的问题。现象是浏览器端 console 没有报错,PeerConnection 状态已经变成 connected,但服务端 OnTrack 一直没有触发,或者触发了但是音量为零。

排查链路如下。先确认浏览器端确实在采集:用 navigator.mediaDevices.getUserMedia 获得 stream 后,把 stream 赋值给一个 audio 元素播放,看有没有声音。我之前遇到过一个场景,浏览器默认选择了蓝牙耳机作为输入设备,但蓝牙耳机没电了,getUserMedia 还欺负返回的静音流,这种问题靠 WebRTC 层面完全无解,必须引导用户检查系统音频输入设备。

确认采集正常后,检查 addTrack 是否真的加入了 PeerConnection。一个隐蔽的坑是:在调用 setLocalDescription 之后才 addTrack,导致 SDP 里没有包含媒体描述,协商出来的连接只有数据通道没有音轨。正确顺序是在调用 addTrack 之后再 createOffer。

最后查服务端是否设置了正确的 MediaEngine 编解码器。Pion 里如果没注册 Opus codec,收到的 RTP 包会被直接丢弃,OnTrack 不触发或者抛错。我有一次就是复制代码时漏掉了 Opus 注册,排查了整整一个下午。

7.2 音量正常但 ASR 识别率奇低

这种情况的典型表现是:录音回放能听到清晰的人声,但 ASR 识别结果乱码或者频繁提示未识别。问题往往不在 WebRTC 传输,而在 PCM 数据格式。ASR 引擎通常要求 16-bit little-endian PCM 单声道。WebRTC 服务端解码出来的 PCM 一般就是 16-bit little-endian,但如果音频帧是双声道的,要先把双声道折叠成单声道。我之前就有一次忘了做声道转换,把立体声的左右声道数据直接交错排列喂给 ASR,识别率直接掉到 30% 以下,后来检查 PCM 数据才发现每个 sample 的左右声道是交替的,必须做平均或取单边。

采样率不匹配也是一个常见原因。如果 ASR 配置的是 16kHz,而你送的是 48kHz 的 PCM,识别率也会显著下降。建议在服务端统一做一次重采样,保证喂给 ASR 的数据格式与引擎配置严格一致。重采样用 soxr 或者 kissfft 都行,Pion 生态里也有现成的重采样模块。

7.3 回声和啸叫问题

回声问题在 VoiceAgent 里比较隐蔽。表现是:用户说话时听到自己的回声,或者 Agent 播放 TTS 时 TTS 的声音又被识别进 ASR,造成对话死循环。

排查思路是分层。先确认浏览器端 echoCancellation 是否开启。有些浏览器在特定设备上,getUserMedia 的 echoCancellation 约束被忽略,需要检查 getSettings() 的实际值。再检查播放链路:如果是通过 HTMLAudioElement 播放接收到的音频流,确保 audio 元素的 volume 不是异常高,同时关注是否经过系统默认的音频处理链。

服务端造成回声的另一个场景是:你把 TTS 音频又通过 WebRTC 的接收方向传了一遍,导致浏览器播放时又采集到 TTS 的声音。这种一般发生在代码逻辑复用不当的情况下,TTS 的 PCM 数据被错误地推入了 ASR 消费队列。排查方法是在服务端对每一帧音频打上方向标记,确保 TTS 数据只走发送方向,ASR 只消费接收方向。

7.4 高延迟问题:瓶颈到底在哪

用户反馈"机器人反应慢"时,第一步不是怀疑 WebRTC,而是先分段时间。我把端到端延迟拆成四段:浏览器音频采集和编码、网络传输到服务端、ASR 识别到 LLM 生成、TTS 合成和回传。每段埋点计时,用日志或者 trace 把每段耗时打印出来,再对比看哪一段超预期。

实测中我遇到的延迟大头有几种。一是 LLM 推理时间,特别是用了比较大参数的模型且没有做流式输出时,首 token 时间可能长达 1 到 2 秒。二是 TTS 引擎的合成时间,如果等整段文本都合成完再播,延迟会显著放大。三是 ASR 的尾点判断时间设置太长,用户说完后还要等 1 秒静默才触发识别。这些问题的根源都不在 WebRTC,但用户感知是"整体延迟高",容易误导排查方向。正确的做法是建立从用户音频输入到 Agent 应答输出的全链路 trace,才能准确定位每一个延迟贡献点。

我在项目里用的是 OpenTelemetry 做链路追踪,每段打一个 span,之后在 Jaeger 里查看火焰图。对比几轮之后,哪段异常一目了然。如果你项目里还没有可观测性基建,建议至少先埋好时间戳点,用最简单的日志差值也能定位问题。

7.5 几个容易被忽略的工程配置

最后分享几个我在实际运行中发现的最容易被忽略的配置项。

一是浏览器端的 RTCIceTransportPolicy。如果设成 relay,浏览器会强制走 TURN 中继,即使有直连路径也不用,延迟会增加几十毫秒甚至更多。默认的 all 策略是正确选择。

二是服务端的 NAT 穿透配置。如果服务端在 NAT 后面,必须正确配置 STUN 服务器并允许服务端收集 srflx 候选。云服务器一般都绑定公网 EIP,这个问题不大,但如果是内网部署就要特别注意。

三是音频输出设备。浏览器播放 WebRTC 音频时,如果用户系统默认输出设备是蓝牙耳机且断连,浏览器可能自动切到扬声器,但音量默认不是最大,用户听起来感觉"声音变小了"。这个可以在前端用 setSinkId 管理音频输出设备,并监听 devicechange 事件。

四是 WebRTC 的带宽估计。如果服务端没有正确配置带宽上限,WebRTC 会根据实际网络状况自行决定码率。在 VoiceAgent 这种纯音频场景,我通常会限制最大上行和下行码率为 128kbps,避免过度占用带宽,同时在弱网下给 Opus 编码器留出降级空间。

这套链路我目前维护了两套线上 VoiceAgent 服务,一套是浏览器端 Web 对话,一套是智能外呼系统,都是基于 WebRTC 作为音频传输底座。如果让我再搭一次,我仍然会选 WebRTC,但会更早地把监控埋点和全链路 trace 建起来——很多问题在开发环境根本不会出现,只有真实网络环境和真实用户量才会暴露,没有观测手段的调试,基本就是靠运气。

最后再分享一个小技巧:调试 WebRTC 时,浏览器 chrome://webrtc-internals 这个页面是神器。它能看到所有 PeerConnection 的 SDP、ICE 状态、候选对、RTT 曲线、丢包统计和音频能量变化。用户报障时,让他打开这个页面导出日志,你的排查效率能提升好几倍。配合服务端打点,绝大多数问题都可以在一轮之内定位。

Logo

邀请您加入社区

更多推荐