如果你准备把 OpenAI Realtime 真上生产,先别被“能跑通 demo”骗了。
真正把系统打爆的,通常不是模型本身,而是 短时鉴权没轮换、打断恢复没状态机、端到端延迟没预算。这三件事不补,语音体验会像在和一台卡顿的对讲机吵架。
先给结论:生产里优先守住这 4 条
- 浏览器/移动端优先 WebRTC,服务端桥接才考虑 WebSocket。
- 会话 token 必须短 TTL 签发,并支持轮换。
- “用户打断 AI”必须有显式状态机,不要靠前端拍脑袋。
- 把延迟拆成采集、上行、推理、下行、播放 5 段逐段记账。
什么时候选 WebRTC,什么时候选 WebSocket
推荐判断很简单:
- 用户设备直连语音/音频:优先
WebRTC - 服务端做转写桥接、录音归档、批量调度:可以用
WebSocket - 既要低延迟语音,又要服务端审计/路由:前端
WebRTC,服务端只拿事件摘要,不要把整段音频都绕回去
为什么:
- WebRTC 自带更成熟的实时音视频传输能力
- NAT/抖动/丢包环境下,WebRTC 的体验通常比你自己拿 WebSocket 硬扛更稳
- WebSocket 适合事件桥接,不适合把“最后一公里语音体验”全扛在自己肩上
鉴权轮换:别把长期密钥塞进客户端
错误做法:
- 前端直接持有长期 API Key
- token 不设 TTL
- 重连时沿用旧 token,直到 401 才手忙脚乱
正确做法:
- 由你的 Go 服务端持有正式凭证。
- 服务端给客户端签发短时 session token。
- token TTL 控制在 1 到 5 分钟。
- 在过期前 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_idconversation_idresponse_idlast_user_audio_seqlast_ack_event_id
收到用户打断时,做 3 件事:
- 停止本地播放
- 向上游发送 cancel / truncate 类事件
- 清理当前
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 段拆账:
- Capture:本地采集/VAD 切片
- Uplink:客户端到上游传输
- Inference:模型推理与事件生成
- Downlink:下行事件返回
- 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 个闸门:
- 短时 token + 轮换
- 显式中断状态机
- 首包延迟和整轮延迟指标
- 401/ICE 失败分类告警
- 客户端网络切换后的自动恢复测试
- 服务端摘要回填,而不是无限会话累积
推荐告警:
realtime_first_audio_p95 > 1500mssession_rotate_fail_total > 0interrupt_recovery_fail_rate > 5%ice_restart_total15 分钟内明显飙升
排障顺序:别一上来怪模型
出问题时,按这个顺序查:
- 鉴权:是不是客户端拿了过期 token?
- 连接:是不是 ICE / NAT / 网络切换导致媒体断流?
- 状态机:打断后有没有残留
response_id? - 预算:慢的是采集、传输、推理还是播放?
- 回放策略:恢复时是不是把太多旧上下文又塞回去了?
最小可行方案(MVP)
如果你现在只想先把系统稳住,而不是做一台炫技机器,建议先这样:
- 前端语音链路统一走 WebRTC
- Go 服务端只负责签发短时 token 和记录事件摘要
- token TTL 先设 300 秒,提前 30 秒轮换
- 用户打断时强制
response.cancel - 先把首包延迟压到 1.5s 内,再谈更花哨的优化
总结
OpenAI Realtime 真正难的,不是“把语音连上”,而是在不稳定网络、可打断交互和短时凭证约束下,还能维持稳定体验。
生产里最值钱的顺序是:先守鉴权,再补中断状态机,最后做延迟预算。这三刀下去,系统才像产品,不像 demo。