如果你准备把 OpenAI Realtime 真上生产,先别被“能跑通 demo”骗了。

真正把系统打爆的,通常不是模型本身,而是 短时鉴权没轮换、打断恢复没状态机、端到端延迟没预算。这三件事不补,语音体验会像在和一台卡顿的对讲机吵架。

先给结论:生产里优先守住这 4 条

  1. 浏览器/移动端优先 WebRTC,服务端桥接才考虑 WebSocket。
  2. 会话 token 必须短 TTL 签发,并支持轮换。
  3. “用户打断 AI”必须有显式状态机,不要靠前端拍脑袋。
  4. 把延迟拆成采集、上行、推理、下行、播放 5 段逐段记账。

什么时候选 WebRTC,什么时候选 WebSocket

推荐判断很简单:

  • 用户设备直连语音/音频:优先 WebRTC
  • 服务端做转写桥接、录音归档、批量调度:可以用 WebSocket
  • 既要低延迟语音,又要服务端审计/路由:前端 WebRTC,服务端只拿事件摘要,不要把整段音频都绕回去

为什么:

  • WebRTC 自带更成熟的实时音视频传输能力
  • NAT/抖动/丢包环境下,WebRTC 的体验通常比你自己拿 WebSocket 硬扛更稳
  • WebSocket 适合事件桥接,不适合把“最后一公里语音体验”全扛在自己肩上

鉴权轮换:别把长期密钥塞进客户端

错误做法:

  • 前端直接持有长期 API Key
  • token 不设 TTL
  • 重连时沿用旧 token,直到 401 才手忙脚乱

正确做法:

  1. 由你的 Go 服务端持有正式凭证。
  2. 服务端给客户端签发短时 session token
  3. token TTL 控制在 1 到 5 分钟
  4. 在过期前 30 秒发起轮换,失败则优雅重连。

Go 端签发短时 token 的服务示意:

type RealtimeSessionRequest struct {
    UserID    string `json:"user_id"`
    DeviceID  string `json:"device_id"`
    Voice     string `json:"voice"`
    ExpiresIn int    `json:"expires_in"`
}

func issueRealtimeSession(w http.ResponseWriter, r *http.Request) {
    req := RealtimeSessionRequest{
        UserID:    mustUserID(r.Context()),
        DeviceID:  r.Header.Get("X-Device-ID"),
        Voice:     "alloy",
        ExpiresIn: 300,
    }

    body, _ := json.Marshal(map[string]any{
        "model":      "gpt-4o-realtime-preview",
        "voice":      req.Voice,
        "expires_in": req.ExpiresIn,
    })

    upstreamReq, _ := http.NewRequest("POST", "https://api.openai.com/v1/realtime/sessions", bytes.NewReader(body))
    upstreamReq.Header.Set("Authorization", "Bearer "+os.Getenv("OPENAI_API_KEY"))
    upstreamReq.Header.Set("Content-Type", "application/json")

    resp, err := http.DefaultClient.Do(upstreamReq)
    if err != nil {
        http.Error(w, "issue session failed", http.StatusBadGateway)
        return
    }
    defer resp.Body.Close()
    io.Copy(w, resp.Body)
}

轮换策略:

func shouldRotate(expireAt time.Time, now time.Time) bool {
    return expireAt.Sub(now) <= 30*time.Second
}

打断恢复:不要只停播放器,要停“回复事务”

Realtime 语音最常见的假稳定,是前端点了“停止播放”,但后端/模型侧仍在继续生成。

结果就是:

  • 用户说第二句话时,上一个 response 还没真正结束
  • 服务端 transcript 拼接错位
  • UI 以为恢复了,模型以为你还在听上一段

最稳的办法是显式状态机:

IDLE -> LISTENING -> THINKING -> SPEAKING
SPEAKING --interrupt--> CANCELLING -> LISTENING
THINKING --timeout/error--> RECOVERING -> LISTENING

服务端至少保存:

  • session_id
  • conversation_id
  • response_id
  • last_user_audio_seq
  • last_ack_event_id

收到用户打断时,做 3 件事:

  1. 停止本地播放
  2. 向上游发送 cancel / truncate 类事件
  3. 清理当前 response_id,把状态切回 LISTENING

示意代码:

type SessionState struct {
    SessionID      string
    ConversationID string
    ResponseID     string
    Phase          string
}

func interrupt(state *SessionState, send func(any) error) error {
    if state.ResponseID == "" {
        state.Phase = "LISTENING"
        return nil
    }

    evt := map[string]any{
        "type":        "response.cancel",
        "response_id": state.ResponseID,
    }
    if err := send(evt); err != nil {
        state.Phase = "RECOVERING"
        return err
    }

    state.ResponseID = ""
    state.Phase = "LISTENING"
    return nil
}

端到端延迟预算:别只盯模型耗时

保守目标下,我更建议你先把总延迟压到 800ms - 1500ms 首包可感知响应,而不是追求实验室里 300ms 的神话数字。

先按 5 段拆账:

  1. Capture:本地采集/VAD 切片
  2. Uplink:客户端到上游传输
  3. Inference:模型推理与事件生成
  4. Downlink:下行事件返回
  5. Playback:播放器缓冲与播报

可接受的保守预算:

  • Capture:80ms - 150ms
  • Uplink:80ms - 150ms
  • Inference:250ms - 700ms
  • Downlink:50ms - 120ms
  • Playback:80ms - 250ms

Go 里最起码把这些指标打出来:

var (
    captureMs   = prometheus.NewHistogram(...)
    uplinkMs    = prometheus.NewHistogram(...)
    inferMs     = prometheus.NewHistogram(...)
    downlinkMs  = prometheus.NewHistogram(...)
    playbackMs  = prometheus.NewHistogram(...)
    e2eMs       = prometheus.NewHistogram(...)
)

上报链路建议:

start := time.Now()
// capture done
captureMs.Observe(float64(time.Since(start).Milliseconds()))

uplinkStart := time.Now()
// send audio chunk
uplinkMs.Observe(float64(time.Since(uplinkStart).Milliseconds()))

inferStart := time.Now()
// first model event arrived
inferMs.Observe(float64(time.Since(inferStart).Milliseconds()))

WebRTC 连接恢复:把“重连”分成两类

别把所有断开都当成一回事。至少分两类:

1) 传输层闪断

表现:

  • ICE 失败
  • 网络切换(Wi‑Fi → 4G)
  • 短暂丢包导致媒体中断

动作:

  • 优先重建 PeerConnection
  • 尝试复用会话上下文
  • 重新协商前先检查 token 是否快过期

2) 会话层失效

表现:

  • token 过期
  • 上游返回 401/403
  • response/event 序列错乱

动作:

  • 重新签发 session token
  • 新建 session
  • 只恢复最近一段必要上下文,不要把整段历史音频全倒回去

恢复伪代码:

func recoverSession(reason string) RecoveryPlan {
    switch reason {
    case "ice_failed", "network_switch":
        return RecoveryPlan{RebuildPeer: true, ReissueToken: false, ReplaySummary: true}
    case "token_expired", "auth_failed":
        return RecoveryPlan{RebuildPeer: true, ReissueToken: true, ReplaySummary: true}
    default:
        return RecoveryPlan{RebuildPeer: true, ReissueToken: true, ReplaySummary: false}
    }
}

一套够用的生产防线

上线前至少补这 6 个闸门:

  1. 短时 token + 轮换
  2. 显式中断状态机
  3. 首包延迟和整轮延迟指标
  4. 401/ICE 失败分类告警
  5. 客户端网络切换后的自动恢复测试
  6. 服务端摘要回填,而不是无限会话累积

推荐告警:

  • realtime_first_audio_p95 > 1500ms
  • session_rotate_fail_total > 0
  • interrupt_recovery_fail_rate > 5%
  • ice_restart_total 15 分钟内明显飙升

排障顺序:别一上来怪模型

出问题时,按这个顺序查:

  1. 鉴权:是不是客户端拿了过期 token?
  2. 连接:是不是 ICE / NAT / 网络切换导致媒体断流?
  3. 状态机:打断后有没有残留 response_id
  4. 预算:慢的是采集、传输、推理还是播放?
  5. 回放策略:恢复时是不是把太多旧上下文又塞回去了?

最小可行方案(MVP)

如果你现在只想先把系统稳住,而不是做一台炫技机器,建议先这样:

  • 前端语音链路统一走 WebRTC
  • Go 服务端只负责签发短时 token 和记录事件摘要
  • token TTL 先设 300 秒,提前 30 秒轮换
  • 用户打断时强制 response.cancel
  • 先把首包延迟压到 1.5s 内,再谈更花哨的优化

总结

OpenAI Realtime 真正难的,不是“把语音连上”,而是在不稳定网络、可打断交互和短时凭证约束下,还能维持稳定体验

生产里最值钱的顺序是:先守鉴权,再补中断状态机,最后做延迟预算。这三刀下去,系统才像产品,不像 demo。