实时语音的难点并不是“把文字一段段打印出来”,而是让采集、传输、断句、生成、播放和会话历史在同一时间轴上保持一致。用户在模型说到一半时插话,耳朵听到的位置、音频缓冲区的位置与服务端保存的回答位置可能完全不同;只停止扬声器,历史里仍可能留着用户从未听过的内容。

本文面向已经做过流式接口、准备实现浏览器或服务端语音应用的开发者。你需要了解事件驱动程序与基本音频缓冲。目标不是给出一个神奇的“低延迟参数”,而是建立能测量、能打断、能恢复、也能明确降级的实时状态机。

视觉资产记录:封面(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 根据话语内容判断是否说完。前者的 thresholdprefix_padding_mssilence_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