从WebRTC到OpenAI Realtime:Voice Agent实时音频架构深度拆解
说实话,我刚开始做 Voice Agent 的时候,第一反应是:这玩意儿不就是把录音丢给 Whisper,再把结果丢给 GPT,最后用 TTS 播出来吗?后来真正把延迟压到可用的范围,才发现完全不是那么回事。最近在看 Justin Uberti 的分享时,我脑子里那根一直没串起来的线突然通了——他当年做 WebRTC 时踩过的每一个坑,几乎都能在 OpenAI Realtime AI 的音视频架构里找到影子。这也是我写这篇 Voice Agent 学习笔记的起点。
这篇文章不打算把 Realtime API 的文档抄一遍,而是想从“架构重构”的角度,拆一下人机实时语音到底难在哪、WebRTC 留下的工程遗产为什么在 AI 时代反而更值钱、以及我们自己动手搭 Voice Agent 时,哪些环节最容易被轻视。适合正在做语音助手、客服机器人、AI 陪练或智能硬件对话交互的开发者,也适合那些已经在用 Realtime API,但一直被“杂音、延迟、打断不灵”折磨的人。
1. 先搞清楚:为什么一个做 WebRTC 的人,会去做 OpenAI Realtime AI
1.1 WebRTC 解决的核心问题,从来不是“让两个浏览器连起来”
很多人一提 WebRTC,第一反应是视频会议、直播连麦、屏幕共享。但 Justin Uberti 当年参与定义 WebRTC 时,真正想解决的是实时通信里最底层的那堆脏活:两个终端怎么互相发现、怎么穿越 NAT、怎么在丢包的网络里把声音和画面尽量平滑地送到对面、怎么加密又不太影响延迟。
把这些拆开就是一套完整的基础设施:信令协商、ICE 候选、STUN/TURN 转发、DTLS/SRTP 加密、RTP/Opus 音频编码、NACK 重传、FEC 前向纠错、Jitter Buffer 抖动缓冲。这套东西的价值不在于某个具体协议,而在于它把“实时通信”从厂商私有协议里解放了出来,变成了浏览器原生支持的能力。
所以 Justin 不是一般的协议工程师。他是那种面对“现实网络非常糟糕,但用户希望像面对面说话一样自然”这种矛盾,做了十年工程取舍的人。
1.2 人机语音对话,本质上还是同一个实时通信问题
过去 WebRTC 的场景是两个人在对话,现在 Voice Agent 的场景是一个人跟一个 AI 模型在对话。从网络角度看,对面那个端点从“另一台手机”变成了“模型推理服务”,但整条链路的约束几乎一模一样:
- 麦克风采集的声音要尽量低延迟地送到远端;
- 远端返回的声音要尽量平滑地在本地播放;
- 网络抖动、丢包、带宽变化都会直接毁掉体验;
- 双方都可能随时开口,系统必须有处理打断的能力;
- 回声消除、降噪、自动增益这些东西不能省。
Justin 在分享里反复强调一个观点:不要把人机实时语音当成一个 AI 功能,而要当成一个实时通信系统来设计。这句话我特别认同。很多团队做 Voice Agent 时,把目光全放在模型选型和 prompt 上,结果音频链路稍微一抖,整个产品就崩了。
1.3 OpenAI Realtime AI 到底重构了什么
如果说传统 Voice Agent 是“ASR + LLM + TTS”三个独立模块串起来,那 OpenAI Realtime AI 最大的重构,就是把这些步骤压进了同一个流式模型里。音频不再需要先转成文字,再等文字完整返回,再交给语音合成;而是模型直接吃音频、输出音频,同时还能在需要的时候输出文本和工具调用。
这个重构的价值,不只是少了几百毫秒。更重要的是,它改变了错误传播方式。老的管线里,ASR 识别错一个词,后面 LLM 和 TTS 全都会跟着错;而现在模型直接面对原始音频,可以通过语气、停顿、重音这些语音模态信息来理解意图。再加上流式输出,模型不需要等整段话生成完再开口,而是边生成边推给播放端。
这也是 Justin 这类做实时通信的人能发挥价值的地方:模型再强,也需要一个低延迟、抗抖动、支持双向打断的音频通道把它送到用户耳朵里。
2. 把 Voice Agent 的实时音频链路拆开看
2.1 音频采集与前处理:第一道关口最容易翻车
Voice Agent 听起来高大上,但入口其实就是一个麦克风。麦克风采集到的原始音频,在真实环境里会有环境噪声、回声、音量忽大忽小、人声和音乐混杂等各种问题。如果不做前处理,后面模型再强也没用。
WebRTC 在浏览器里帮我们封装了很多能力,比如
getUserMedia
里的
echoCancellation
、
noiseSuppression
、
autoGainControl
三个约束。很多前端同学可能一直没注意过,但这就是第一道关。
- 回声消除(AEC):AI 在播放声音时,这个声音又通过麦克风传回去,不消除就是“AI 说话时听见自己说话”;
- 降噪(NS):去掉空调声、键盘声、马路噪声;
- 自动增益(AGC):人离麦克风远了也能保持音量稳定。
如果是写原生 iOS/Android 应用,或者走自定义音频管线,这些模块就得自己接或找专门的音频引擎。这是 Voice Agent 最容易被低估的工作量。我见过不少项目,模型调用都通了,最后因为会议室里回声太严重,语音助手根本没法用。
2.2 全双工与打断:决定“像人”还是“像对讲机”
老式语音交互是“半双工”的,用户按住说话,AI 说完,用户再说,像对讲机。Voice Agent 要自然,就必须是全双工的:用户可以随时插话,AI 要能停下来,然后再基于被打断后的上下文继续。
这个能力背后有两个关键模块:
- VAD(语音活动检测):判断用户什么时候开始说话、什么时候说完。可以放在客户端,也可以放在服务端;
- Kevin 打断控制:模型生成音频时,一旦检测到用户插话,必须立刻停止当前输出,同时保留双方已经说过的内容。
OpenAI Realtime API 的
turn_detection
参数就是做这件事的,里面有几个值值得细调:
-
threshold:VAD 触发阈值,默认 0.5,太高容易漏说话,太低容易被噪声触发; -
prefix_padding_ms:在检测到人声之前保留多少音频缓冲,默认 300,太小会丢掉开头几个字; -
silence_duration_ms:用户停顿多久判定为说完,默认 500,越大越不容易抢话,但响应会更慢。
这些参数没有一个是可以“抄作业”的,必须根据业务场景反复试。客服场景里用户说话往往比较正式,停顿短;儿童陪练场景里小朋友可能边说边想,停顿长,如果还用默认 500ms 就会频繁抢话。
2.3 流式管线:从“串行三段”到“并行单流”
传统 Voice Agent 的链路是:
- 采集音频,等到用户说完一段;
- 把整段音频送 ASR,得到文本;
- 把文本送 LLM,等完整回复;
- 把完整文本送 TTS,等整段音频合成;
- 播放音频。
每步都是“等”,总的延迟会叠加。而且为了不丢内容,通常要等用户停顿或者按下按键才真正开始,体验很差。
Realtime 思路是流式的:音频持续向上送,模型一边听一边推理,一旦判断用户说完了,立刻开始生成回复,并且可以边生成边播放。更关键的是,模型不一定要等整个句子识别完,它可以结合语义和语音信息提前做反应。这个“边听边想”的能力,是传统串行管线做不到的。
我做学习笔记时列过一个对比:
| 维度 | 传统 ASR+LLM+TTS | Realtime AI |
|---|---|---|
| 延迟叠加 | 每一步都要等完整结果 | 流式边收边出 |
| 错误传播 | ASR 错词会影响后续 | 直接处理音频,保留更多模态信息 |
| 打断处理 | 需要自己拼状态 | 平台原生支持 turn detection |
| 工具调用 | 需要额外路由 | 模型可以边对话边请求函数 |
| 实现复杂度 | 较低,但难优化 | 较高,但性能上限高 |
当然,传统方案也不是没有价值。如果预算有限、不需要极低延迟,用现成 ASR + LLM + TTS 也能做出不错的语音助手。但如果目标是“像真人一样流畅”的 Voice Agent,那 Realtime 这种架构重构基本是必选项。
2.4 会话状态与记忆:不要把状态堆在代码里
实时语音的另一个坑是会话状态。用户上一句说了“帮我查一下上海天气”,下一句很可能就是“那北京呢?”如果系统把每次请求都当成独立的一次 API 调用,那恢复上下文的成本全落在开发者头上。
Realtime API 的做法是把会话状态放在服务端,开发者通过
conversation.item.create
和
response.create
这些事件来管理对话轮次。模型自己维护一个上下文窗口,开发者只需要在必要时插入系统指令、工具结果,或者做截断。
实际项目里需要注意 context 长度管理。语音对话一旦持续很久,token 会越攒越多,既增加费用又拖慢响应。我的经验是:在轮次之间做摘要压缩,只保留最近 N 轮完整内容,最早的部分用一段摘要代替。这个操作最好在 Voice Agent 的编排层做,不要指望模型自己无限记住所有内容。
2.5 推流与拉流:WebRTC 的老本行在 Voice Agent 里更值钱
“WebRTC 推流和拉流”这个词,放在 Voice Agent 里就是一条 PeerConnection 上的两条音轨:客户端把麦克风音频推到服务端,服务端把模型生成的音频拉回客户端播放。
很多第一次接触的人会问:为什么不用 WebSocket 传音频?WebSocket 当然能传,但音频数据在 WebSocket 里通常需要自己分包、排序、处理抖动和丢包。这些正是 WebRTC 已经解决好的问题。尤其是从浏览器端接入时,WebRTC 可以直接复用浏览器内部的音视频引擎,连采集、编码、播放都省了不少胶水代码。
如果做的是多方场景,比如 AI 虚拟教室、AI 面试官同时面对多个考生,那就需要 SFU 或者 MCU 来做音视频分发。这时候 WebRTC 的架构复杂度会上去,但好处是:你只需要一个 SFU 集群,就能同时服务很多人,音频共享和录制也都好做。
3. 实操:一套可以直接参考的 Voice Agent 最小架构
3.1 先选路径:三种落地方式怎么选
现在想搭一个 Voice Agent,市面上大概有三种路径:
| 路径 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| Realtime API + WebSocket | 产品原型、工具型助手 | 接入快,官方维护 | 延迟受网络影响较大 |
| Realtime API + WebRTC | 浏览器端产品、对延迟敏感 | 原生音视频引擎,弱网能力更强 | 需要理解 SDP/ICE |
| 自建 ASR+LLM+TTS 管线 | 定制化强、成本敏感 | 每家模型可换 | 链路复杂,优化工作量大 |
我的建议是:如果产品核心是“对话质量”,先从 Realtime API + WebSocket 打通业务逻辑;如果核心是“实时体验”,比如语音助手要从浏览器直接收音,那就直接上 WebRTC。别一开始就自建管线,除非你有专门的音视频团队。
3.2 用 OpenAI Realtime API 建立会话
这里给一份简化到能跑的思路。第一步是创建 Realtime session,并配置模型、声音、指令和 VAD 参数。下面用 Python 示例,只是为了展示事件结构,实际项目里请把 API Key 放在环境变量或密钥管理服务里。
import asyncio
import json
import os
import websockets
async def main():
url = "wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview"
headers = {
"Authorization": f"Bearer {os.environ['OPENAI_API_KEY']}",
"OpenAI-Beta": "realtime=v1",
}
async with websockets.connect(url, additional_headers=headers) as ws:
await ws.send(json.dumps({
"type": "session.update",
"session": {
"modalities": ["audio", "text"],
"instructions": "你是一个耐心的客服助手,可以用简短自然的语音回答用户。",
"voice": "alloy",
"input_audio_transcription": {
"enabled": True
},
"turn_detection": {
"type": "server_vad",
"threshold": 0.5,
"prefix_padding_ms": 300,
"silence_duration_ms": 500,
"create_response": True
}
}
}))
async for raw in ws:
event = json.loads(raw)
# 在这里处理会话创建、响应生成、打断等事件
# 实际项目里要继续处理 input_audio_buffer
print(event.get("type"))
asyncio.run(main())
这段代码里最值得关注的是
turn_detection
。它让平台帮我们判断用户何时说完,而不是自己在业务代码里维护一个“录音开关”。
3.3 浏览器端推流和拉流:直接用 WebRTC 接 Realtime
如果产品跑在浏览器里,我强烈建议用 WebRTC 而不是 WebSocket。核心原因是浏览器音频采集后的原始数据,如果走 WebSocket,通常要自己处理编码格式,而 WebRTC 能把麦克风音轨直接推到远端。
最小流程是这样的:
-
浏览器调用
getUserMedia拿到麦克风音轨; -
创建
RTCPeerConnection,把音轨通过addTrack加进去; - 创建 offer,把 SDP 发给 OpenAI Realtime API 换取 answer;
-
设置远端 answer 后,服务端返回的音频音轨会自动触发
ontrack事件; -
把返回的
MediaStream接到<audio>元素上播放。
const pc = new RTCPeerConnection({
iceServers: [{ urls: "stun:stun.cloudflare.com:3478" }],
});
pc.ontrack = (event) => {
audioEl.srcObject = event.streams[0];
};
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
});
stream.getTracks().forEach((track) => pc.addTrack(track, stream));
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// 把 offer.sdp 发送给 Realtime API 的 WebRTC 协商接口,拿到 answer 后
// await pc.setRemoteDescription({ type: "answer", sdp: answer });
这里有一个很容易踩的坑:
getUserMedia
的三项音频约束尽量保持开启。很多开发者在调试时为了“少一层处理”把它们关了,结果回声和噪声问题全部暴露,反而更难定位。
3.4 延迟预算分配:每个环节能分到多少毫秒
做 Voice Agent 不能只盯着模型推理时间,要把整个路径的延迟预算拆开。一套可接受的实时语音对话,通常希望“用户说完到 AI 开始回答”的间隔在 800ms 以内,优秀体验要压到 500ms 以内。
简单分一下:
| 环节 | 参考预算 |
|---|---|
| 客户端采集与前处理 | 20-50ms |
| 上行编码与传输 | 50-150ms |
| 服务端 VAD 与排队 | 50-150ms |
| 模型首 token 推理 | 150-400ms |
| 下行传输与播放缓冲 | 50-150ms |
这个预算很紧张。如果你发现总延迟超过 1 秒,先用日志把每一段的耗时打出来,看看瓶颈在模型、网络还是播放缓冲。很多人说“AI 反应慢”,最后定位到是播放端为了防卡顿把缓冲区调得太大,白白加了 300ms。
4. 老问题新场景:弱网卡顿与打断优化
4.1 WebRTC 弱网卡顿到底是怎么产生的
“WebRTC 弱网卡顿怎么优化”是搜索量很高的一个问题,说明大家都被这玩意折磨过。卡顿的直接原因无非三种:
- 网络抖动:数据包到达时间不均匀,播放端缓冲不够就卡;
- 丢包:数据包丢失后,需要重传或用冗余数据恢复;
- 带宽不足:码率太高,网络扛不住,就会导致队列拥塞。
WebRTC 处理这些问题的思路很成熟:接收端会维护一个 Jitter Buffer,把音频包先攒一小段再平滑播放;发送端根据反馈动态调整码率;丢包时用 NACK 请求重传,或者用 FEC 增加冗余数据。
4.2 在 Voice Agent 里能做哪些针对性优化
人机语音和视频会议有个很大的不同:AI 可以主动控制节奏。比如网络差的时候,模型可以主动降低回复的复杂度和语速,但这需要构建层拿到网络状态并反馈给模型。这块目前还比较少见,但我觉得是未来 Voice Agent 架构的竞争点。
在纯技术层面,我实测下来有效的优化有这么几个:
- 优先选择离用户近的服务端接入点,降低 RTT。RTT 太高时 NACK 重传基本失效,因为等重传包到达不如直接丢帧;
- 音频编码不要一味追求高码率。Voice Agent 场景一般 24-32kbps 的 Opus 已经足够清晰,码率越低抗弱网能力越强;
- 打开 Opus 的 in-band FEC,可以在丢包时用小延迟换连续性;
- 播放端不要等整段音频到达再播,而是收到一个 chunk 就解码一个 chunk,同时用 Jitter Buffer 控制平滑度;
- 监控 ICE candidate pair 类型。如果一直走 TURN relay,延迟和抖动通常比直连差,需要看是否为 NAT 打洞失败导致。
4.3 打断延迟优化:比弱网卡顿更需要精细化调参
打断是 Voice Agent 体验的另一个生死线。用户刚插一句话,AI 还在继续说,就会觉得“这 AI 是不是傻”。
Realtime API 的服务端 VAD 可以帮助检测用户开始说话,但响应速度受
prefix_padding_ms
影响。这个参数太大会导致“AI 已经听到了用户插话,却还要先缓冲一段再处理”,太小又可能把正常停顿误判成插话。
我目前的习惯是:
-
对普通话清晰度高的用户,
threshold可以设到 0.6 左右; - 对儿童、老人、或口音较重的用户,降到 0.4,提高召回率;
-
prefix_padding_ms保持 300-400ms; -
silence_duration_ms设 500-600ms,既不给用户太大压迫感,又不至于太啰嗦。
另外,如果检测到用户打断,客户端最好立刻把当前播放的音频切到静音或低音量,同时发送
response.cancel
事件。不要等服务端告诉你可以停,那太慢了。客户端先行,服务端再同步清理生成状态,这是 Voice Agent 里最关键的双侧配合。
5. 常见问题与排查技巧实录
下面这些是我自己在接入过程中踩过或者看别人踩过的坑,整理成了一份速查表。
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| 连接返回 401/403 | API Key 无效或没有 Realtime 模型权限 | 检查 API Key、账户权限和模型 access |
| 能连上但麦克风没声音 | 浏览器没有授权麦克风,或权限被拒绝 |
检查
getUserMedia
返回值和控制台日志
|
| AI 说话时带回声 | AEC 没开,或者扬声器音量太大串音 |
打开
echoCancellation
,调整播放音量
|
| VAD 经常误触发 | 环境噪声太大,或阈值太低 |
开启降噪,把
threshold
调高
|
| 用户说完后迟迟不响应 |
silence_duration_ms
太大,或网络 RTT 高
| 调小静音判定,检查服务端日志耗时 |
| 打断后 AI 还继续说话 |
没发
response.cancel
,或客户端没停播放
| 客户端先静音,再取消服务端响应 |
| 弱网下声音断断续续 | 抖动缓冲太小,或丢包率太高 | 调大 Jitter Buffer,打开 FEC,降低码率 |
5.1 API Key 与账号问题
第一次接入时,最常卡住的地方并不是代码,而是账号和密钥。OpenAI 的 API Key 要在平台后台创建,而且不同模型可能有单独的白名单或访问权限。我的建议很简单:先确认你的账号能看到并调用
gpt-4o-realtime-preview
或你准备用的实时语音模型,再开始写代码。
调试阶段不要把 Key 写死在代码里,更不要提交到 Git。用一个环境变量文件,然后加进
.gitignore
。万一 Key 泄露了,立刻去后台吊销重建,不要抱侥幸心理。
5.2 音频格式不匹配
走 WebSocket 自采集音频时,最容易遇到采样率不匹配。Realtime 接口对音频输入的采样率有要求,一般是 24kHz 或 16kHz。如果你从浏览器拿到的音频是 48kHz,就需要在前端或服务端做重采样。这个步骤如果做错,常见表现是“声音变快或者变慢”,或者“AI 完全听不懂”。
用 WebRTC 通道的一个优势就在这里:浏览器和 Realtime 服务端会协商好音频格式,省掉自己处理编码和采样率转换的麻烦。
5.3 VAD 参数不是抄来的
VAD 参数没有标准答案。同一个参数,会议室和咖啡馆的表现完全不一样。所以一定要把 VAD 参数做成可配置项,最好能通过后端动态下发,这样产品上线后还能远程调。
5.4 成本控制
Realtime 模型按音频时长计费,而且价格不低。如果做长对话,要让 VAD 更“聪明”,尽量别把静音也传给模型。另外,可以设置最大响应时长和最大音频缓冲,避免模型在无人说话时也连续生成无意义音频。
6. 我的学习体会和下一步实验方向
把 Justin Uberti 这条线从 WebRTC 走到 OpenAI Realtime AI 串起来之后,我最深的体会是:实时语音不是“AI 换了一个输入输出方式”,而是一个彻头彻尾的通信系统问题。模型负责聪明,但把聪明传递到用户耳朵里的,还是那套音频采集、编码、传输、播放、重传、打交道的底子。
我在实际项目里最大的教训是:不要等音频链路完美了再开始做业务逻辑。先用最简单的方式把端到端打通,哪怕延迟高一点,先把对话流程、工具调用、上下文管理跑通,然后再回头优化网络和音频参数。否则很容易陷在各种低层细节里,产品迟迟推不出来。
接下来的实验方向,我准备把 OpenAI Realtime API 接到传统呼叫中心场景里,用 SIP 网关把电话线变成 WebRTC 音轨,再从 WebRTC 推到模型。这一步如果跑通,就意味着同一个 Voice Agent 架构可以同时覆盖 App、网页和普通电话,价值会大很多。到时候我还会把延迟数据和调参经验整理出来继续分享。
更多推荐
所有评论(0)