实时语音的难点并不是“把文字一段段打印出来”,而是让采集、传输、断句、生成、播放和会话历史在同一时间轴上保持一致。用户在模型说到一半时插话,耳朵听到的位置、音频缓冲区的位置与服务端保存的回答位置可能完全不同;只停止扬声器,历史里仍可能留着用户从未听过的内容。
本文面向已经做过流式接口、准备实现浏览器或服务端语音应用的开发者。你需要了解事件驱动程序与基本音频缓冲。目标不是给出一个神奇的“低延迟参数”,而是建立能测量、能打断、能恢复、也能明确降级的实时状态机。
视觉资产记录:封面(1600 × 900)与文中闭环图(1400 × 800)均为 WEB/SUN 于 2026-07-16 创作的程序化 SVG;来源/许可为本项目原创自有资产,未使用第三方图片。
图 1(1400 × 800):延迟要按边界测量;打断要停止播放、取消生成并把未听到的部分从历史截去。原创程序化 SVG,WEB/SUN,2026-07-16。
先选会话,不要先选动画
“实时”至少包含三种产品:文字增量流、语音识别的实时转写、模型直接收发音频的语音会话。文字流关心首个可见 token;转写关心话语片段和最终稿;语音会话还要处理扬声器播放、回声、插话和工具调用。把三者共用一个 isStreaming 布尔值,会让取消和恢复必然混乱。
OpenAI 的 Realtime 总览按采集位置建议连接方式:浏览器和移动端直接采集/播放通常使用 WebRTC;已有电话、媒体服务器或 worker 音频管线时使用 WebSocket。浏览器不应持有长期 API 密钥;GA 指南要求由受信任后端通过 POST /v1/realtime/client_secrets 创建临时凭据,再建立 WebRTC 会话。
传输选择也决定谁拥有“已经播放”的事实。WebRTC 和 SIP 的输出缓冲由服务端管理;WebSocket 的播放队列在应用端,因此应用必须自己记录每段音频真正播放到的毫秒游标。这个游标不是 UI 进度条,而是之后截断会话历史的依据。
把延迟写成五段预算
端到端延迟至少拆成:麦克风采集与编码、上行网络、断句、模型首段音频、下行解码与播放。每段记录单调时钟时间戳,并分别观察中位数与尾部,不要只报一个平均值。本文没有执行线上音频调用,因此不提供虚构毫秒数;阈值必须在目标设备、网络和语言上复现。
浏览器、服务端与供应商时钟不能直接相减。客户端记录采集、入队和播放,服务端记录收到事件、创建回答与工具耗时,再用同一个 trace ID 关联;只比较同一时钟域内的间隔。测试还要固定设备、耳机、网络整形、音频长度和是否预热,分别报告冷连接与已建立会话,避免把握手改善误写成模型变快。
断句通常是最容易误调的一段。VAD 指南区分两种机制:server_vad 依据音量和静音时长切分,semantic_vad 根据话语内容判断是否说完。前者的 threshold、prefix_padding_ms、silence_duration_ms 可直接调节;后者可用 eagerness 改变等待倾向。短静音能更快回答,也更容易截断思考停顿;语义判断可能更自然,却仍需在口音、噪声和长句上评测。
产品应保留手动模式:按住说话、点击发送或关闭自动 create_response。在车内、会议室、助听设备或浏览器回声消除失败时,明确的手势比继续猜测更可靠。切换模式必须可见,不能让用户以为系统正在听而实际缓冲未提交。
| 边界 | 应记录的事实 | 常见误判 |
|---|---|---|
| 采集 | 首帧、丢帧、采样配置 | 把权限弹窗时间算成模型延迟 |
| 断句 | speech started/stopped、提交原因 | 只调静音阈值,不测噪声与口音 |
| 生成 | response ID、首段音频、完成状态 | 把转写 delta 当模型实际理解 |
| 播放 | 入队、开始、实际播放游标 | 以“已下载”冒充“已听到” |
| 打断 | 新语音起点、停止、取消、截断 | 只静音播放器,不修正历史 |
打断是一笔跨端事务
Interruption and truncation说明:启用 VAD 后,检测到用户开口会取消进行中的回答。WebRTC 与 SIP 由服务端自动截掉尚未播放的音频;WebSocket 因为客户端管理播放,客户端收到 input_audio_buffer.speech_started 后要立即停止输出,记录上一条回答实际播放到哪里,再发送 conversation.item.truncate。
一个可审查的 WebSocket 控制器可以只允许以下转换。代码是对官方事件语义的本地状态建模示例,已映射到 examples/ai-web/src/ai-voice.ts,通过 TypeScript 类型检查与 Vitest。本文未发起付费 API 调用,也没有真实音频端到端延迟证据,所以这些更广的运行时结论仍是 source-reviewed。
type VoiceState =
| { tag: 'listening' }
| { tag: 'speaking'; itemId: string; playedMs: number }
| { tag: 'interrupting'; itemId: string; playedMs: number };
function onSpeechStarted(state: VoiceState) {
if (state.tag !== 'speaking') return { state, events: [] };
const next: VoiceState = {
tag: 'interrupting',
itemId: state.itemId,
playedMs: state.playedMs,
};
return {
state: next,
events: [
{ type: 'local.playback.stop' },
{
type: 'conversation.item.truncate',
item_id: state.itemId,
content_index: 0,
audio_end_ms: state.playedMs,
},
],
};
}
生成取消与历史截断不是同一个动作:取消阻止更多输出,截断让上下文只保留用户听到的部分。官方文档还指出,音频和 transcript 无法精确对齐;截断会去掉未播放部分的 transcript,却不会提供一份精确切到字词的截断稿。因此 UI 不应拿实时转写当法律记录或逐字字幕真值。
每个动作都要幂等。重复 speech_started 不应重复清空无关回答;过期 response 的音频 delta 必须按 ID 丢弃;新的回答只有在上一笔截断已确认或明确超时降级后才进入播放。网络断开时先停止本地输出,标记当前 turn 为 uncertain,重新连接后不要把旧音频悄悄续播。
工具调用不能阻塞耳朵
语音回答可能在中间提出工具调用。此时界面应进入可听懂的 pending 状态,例如“我正在查询”,但不能让模型先口头宣称支付、删除或预约成功。工具参数仍要经过 Schema、权限、幂等与审批;结果返回后再生成确认语句。用户插话取消时,区分“取消播报”和“取消外部动作”:已经提交的外部事务不能靠 response.cancel 回滚。
高风险工具最好采用两阶段交互:模型先复述规范化参数,用户明确确认后应用才执行。语音识别容易混淆姓名、号码与金额,关键实体同时在屏幕显示。听不清、参数不全或权限不足时,降级到文字确认,而不是用更自信的音色掩盖不确定性。
可观测性与失败演练
日志关联 session、turn、response、item 与本地 playback segment,但不默认保存原始音频。记录传输类型、VAD 模式、断句原因、各边界时间、取消原因、截断游标、丢弃的迟到事件和重连结果。OpenAI Realtime 的转写是异步辅助信息,监控面板要把 audio response 与 transcription 分开,不能以“转写看起来正确”推断模型一定听对。
上线前至少演练:用户在第一音节插话、连续两次插话、噪声误触发、长停顿、耳机切换、浏览器切到后台、下行音频迟到、工具执行中断网、WebSocket 重连、VAD 关闭的手动提交。每个案例断言三件事:不再播放旧声音、历史没有用户未听内容、外部动作状态没有被语音取消语义伪造。
当 WebRTC 不可用、麦克风权限拒绝或延迟持续超标时,降级到文字流;当自动断句不可靠时,降级到按键说话;当音频输出失败时,保留可读 transcript。降级仍使用同一 turn ID 与工具政策,避免“备用模式”绕开权限和审计。
结论
自然的实时语音来自明确所有权:传输层知道连接,VAD 决定候选话轮,模型产生增量,播放器维护真正听到的游标,应用把取消、截断、工具和恢复编排成状态机。延迟按阶段测量,打断按事务处理,失败时降低交互能力而不是降低安全边界。
如果系统能回答“用户实际听到了哪一毫秒、哪次打断取消了哪个 response、上下文为何只保留到这里”,它才拥有可靠的实时会话,而不只是会发声的流式 Demo。
Sources
- Realtime and audio — OpenAI,访问于 2026-07-16。
- Realtime conversations — Interruption and truncation — OpenAI,访问于 2026-07-16。
- Voice activity detection — OpenAI,访问于 2026-07-16。
- Connect with WebRTC — OpenAI,访问于 2026-07-16。