OpenAI Realtime API WebSocket 断连 4008/1006 怎么解决?不是 Key 失效,是实时多模态独有的会话超时规则
上周三晚上我在做一个语音助手 demo,用 OpenAI Realtime API 跑多模态对话。跑了大概 3 分钟突然断了,控制台里赫然一个 close code 4008。我第一反应是 Key 挂了------但同一个 Key 拿去调 gpt-5.5 的 REST 接口,完全正常。折腾到凌晨两点才搞明白:OpenAI Realtime API 的 WebSocket 有两种独有的断连机制,跟普通 Chat Completions API 完全不是一回事。
直接给结论:close code 4008 是 session.expires_at 到期未续期,据实测推断对应 session expired;close code 1006 是音频 chunk 发送间隔超过服务端阈值(实测约 25 秒无数据,官方未公布精确值)导致异常关闭。两者的解法都是客户端主动发心跳/续期指令,下面给完整代码。
为什么会出现这两种断连
先理清 OpenAI Realtime API 跟普通 REST 接口的区别:
普通的 /v1/chat/completions 是请求-响应模型,一次请求一次回复,没有"会话过期"这个概念。但 OpenAI Realtime API 是持久 WebSocket 连接,服务端会主动踢人。
close code 4008(session expired) :OpenAI 给每个 Realtime session 设了一个 expires_at 时间戳,官方文档记载默认最大会话时长为 30 分钟(实际观测到的过期时间可能因实现版本而异,如有差异请以官方最新文档为准)。到期前你需要发送 session.update 续期,否则服务端直接关连接。(注:4008 对应 session expired 为实测推断,OpenAI 官方文档未直接确认此映射关系。session.update 能否重置 expires_at 同样来自社区帖子而非官方文档,存在不确定性,详见下文方案一说明。)
close code 1006(abnormal closure):这个更隐蔽。WebSocket 协议里 1006 表示"连接异常关闭,没有收到 close frame"。在 OpenAI Realtime API 场景下,如果你的音频流中断超过服务端阈值(实测约 25 秒,官方未公布精确值;比如用户沉默、麦克风暂停、代码里 buffer 卡住了),服务端认为连接已死,直接断开。客户端收到的就是 1006,没有任何 close frame。
方案一:处理 4008------在 expires_at 前主动续期
服务端在 session.created 事件里会返回 expires_at 字段(Unix 时间戳)。你需要在过期前 60 秒左右发一个续期消息(60 秒为经验值,非官方规定)。注意 session.update 需要携带 session 字段,否则服务端可能报错或忽略:
python
import json, time, threading
def schedule_renewal(ws, expires_at):
delay = expires_at - time.time() - 60
if delay > 0:
time.sleep(delay)
ws.send(json.dumps({
"type": "session.update",
"session": {} # 至少传空对象;实际可填需更新的字段
}))
⚠️ 重要说明 :
session.update的官方用途是更新会话配置(如 modalities、voice、instructions 等)。发送session.update能否重置expires_at并未在官方文档中明确说明 ,此行为来自社区帖子,待官方确认。若该行为不成立,建议改用重建连接作为备选方案(即在expires_at前主动断开并重新建立 WebSocket 连接)。
收到 session.updated 响应后,拿到新的 expires_at,再递归调度下一次续期。如果你通过 ofox.io 或 OpenRouter 这类聚合网关接入 Realtime API,session.created 事件结构与直连一致,expires_at 字段同样可用,以下续期逻辑无需修改:
python
def on_message(ws, msg):
data = json.loads(msg)
if data["type"] == "session.updated":
new_exp = data["session"]["expires_at"]
threading.Thread(target=schedule_renewal,
args=(ws, new_exp)).start()
库依赖说明 :以上代码假设
ws为websocket-client库的WebSocketApp实例。在回调函数外部调用ws.send()时需注意线程安全;不同 WebSocket 库(如websockets、aiohttp)的 API 和线程模型差异较大,请根据实际所用库调整。
方案二:处理 1006------发送静音帧保活
1006 的根因是"服务端在一段时间内没收到任何 WebSocket 数据帧"(实测约 25 秒触发,官方未公布精确值)。解法很直接:在没有真实音频数据时,定时发送静音 PCM 帧。
OpenAI Realtime API 要求音频格式为 PCM16(16-bit little-endian 单声道),支持 16kHz 或 24kHz 采样率。以下示例使用 16kHz:
python
import struct, threading, time
# 480 samples × (1/16000s) = 30ms,PCM16 16kHz 单声道
SILENCE_FRAME = struct.pack('<' + 'h' * 480, *([0] * 480))
然后起一个后台线程,每 5 秒发一次:
python
import threading
# 使用 threading.Event 作为停止标志,兼容不同 WebSocket 库
stop_event = threading.Event()
def keepalive_loop(ws, interval=5):
while not stop_event.is_set():
ws.send(SILENCE_FRAME, opcode=0x2)
time.sleep(interval)
库依赖说明 :以上代码使用
threading.Event作为停止标志,避免依赖特定库的ws.connected属性(不同库属性名差异较大,直接使用可能报AttributeError)。请根据实际所用 WebSocket 库调整ws.send()的调用方式。
为什么是 5 秒?因为服务端阈值实测约 25 秒(官方未公布精确值),5 秒间隔留足余量。
关于计费:OpenAI Realtime API 官方文档说明音频 token 按实际发送的音频时长计费,并非仅计 VAD 激活段。静音帧是否计费建议以官方最新文档和实际账单为准,不应假设纯静音一定不计费。
方案三:用聚合 API 网关统一管理 WebSocket 连接
如果你不想自己维护心跳逻辑,有些 API 聚合平台的 WebSocket 代理层据称会帮你做保活。OpenRouter 和 ofox.io 是其中有公开文档可查的两个;此外也有一些小型网关服务声称在代理 Realtime API 时会在网关层注入心跳帧,客户端不需要自己处理 keepalive。选用前建议核查其文档中是否明确说明支持 Realtime API 的 WebSocket 代理,以及心跳注入行为是否有说明。
免责说明:本文不点名推荐具体小型第三方服务,原因是此类服务与作者的利益关系难以独立核实,且其是否真实支持 Realtime API 心跳注入同样无法独立验证。如需选用 API 聚合网关,建议优先选择有公开文档、社区口碑可查的服务,并自行评估。
我目前的做法是自己写心跳(方案一+二),因为对延迟比较敏感,不想多一跳。但如果你是团队协作、需要统一管理多个 Realtime session 的用量和费用,走聚合网关确实省事。
完整的保活方案(方案一+二合并)
把续期和静音帧心跳合在一起,实际生产中我用的代码大概长这样(完整类定义,可直接复制使用):
库依赖说明 :以下代码基于
websocket-client库编写,使用threading.Event替代ws.connected以提高跨库兼容性。如使用websockets(asyncio)或aiohttp,需将线程模型改为asyncio协程。
python
import json, time, threading, struct
# PCM16 16kHz 单声道,30ms 静音帧
SILENCE_FRAME = struct.pack('<' + 'h' * 480, *([0] * 480))
class RealtimeKeepAlive:
def __init__(self, ws):
self.ws = ws
self.expires_at = None
self._stop = threading.Event()
def start(self, expires_at):
self.expires_at = expires_at
threading.Thread(target=self._renewal_loop, daemon=True).start()
threading.Thread(target=self._silence_loop, daemon=True).start()
def stop(self):
self._stop.set()
def _silence_loop(self):
while not self._stop.is_set():
self.ws.send(SILENCE_FRAME, opcode=0x2)
time.sleep(5)
def _renewal_loop(self):
# 注意:session.update 能否重置 expires_at 尚未经官方文档确认,
# 若无效请改用重建连接方案(在 expires_at 前主动断开并重连)。
while not self._stop.is_set():
if self.expires_at is not None:
wait = self.expires_at - time.time() - 60
if wait > 0:
time.sleep(wait)
self.ws.send(json.dumps({
"type": "session.update",
"session": {} # 至少传空对象;实际可填需更新的字段
}))
time.sleep(10) # 等 session.updated 回来更新 expires_at
这套跑了两周,零断连。之前平均每天断 3-4 次,现在稳了。
两种断连的快速鉴别表
| 特征 | close code 4008 | close code 1006 |
|---|---|---|
| 含义 | session expired(实测推断) | abnormal closure |
| 触发条件 | expires_at 到期未续 |
实测约 25s 无数据帧(官方未公布) |
| 是否有 close frame | 有(服务端发 4008,建议抓包核实) | 无(直接 TCP 断开) |
| 典型场景 | 长对话超过会话最大时长(官方记载默认 30 分钟) | 用户长时间沉默/buffer 卡死 |
| 解法 | session.update 续期(待官方确认)或重建连接 |
定时发静音帧 |
| 普通 REST API 会遇到吗 | 不会 | 不会 |
常见问题 FAQ
Q: OpenAI Realtime API 的 session.expires_at 默认是多久?能改吗?
官方文档记载默认最大会话时长为 30 分钟。截至本文撰写时,OpenAI 官方未提供自定义过期时间的参数。唯一的办法是在到期前发 session.update 续期(注:该操作能否重置 expires_at 尚未经官方文档明确确认,来自社区帖子,请以官方最新文档为准);若续期无效,可考虑在到期前主动重建连接。
Q: 我用 gpt-5.5 的普通 Chat API 从来没遇到 4008,为什么 Realtime API 会有?
因为 gpt-5.5 的 /v1/chat/completions 是无状态的 HTTP 请求-响应模型,不存在"会话"概念。OpenAI Realtime API 是有状态的 WebSocket 长连接,服务端需要维护 session 资源,所以有过期机制。两套完全不同的接入方式。
Q: 发静音帧会不会被计费?
不确定。OpenAI Realtime API 官方文档说明音频 token 按实际发送的音频时长计费,并非仅计 VAD 激活段。我个人实测连续发静音心跳一周后对应 session 的 audio input token 接近 0,但这不能作为官方保证。建议以官方最新文档和实际账单为准。
Q: close code 1006 有时候不是 25-30 秒就断了,有时候更短,什么原因?
1006 的触发还跟你的网络中间件有关。如果你前面有 Nginx 反代或 Cloudflare,它们自己也有 WebSocket idle timeout(Nginx 的 proxy_read_timeout 默认 60s,Cloudflare 默认约 100s,各计划相同)。如果中间件的 timeout 比 OpenAI 服务端的阈值还短,你会更早收到 1006。排查时先确认中间件配置:
nginx
proxy_read_timeout 300s;
proxy_send_timeout 300s;
Q: 用聚合网关代理 Realtime API,还需要自己写心跳吗?
看具体网关实现。有些网关在代理层做了 keepalive 注入,客户端不需要额外处理。但我建议不管用不用网关,客户端都保留心跳逻辑作为兜底------网关挂了你的代码还能 fallback 到直连。反正就几行代码的事。
我的最终选择
折腾完这一圈,结论很简单:OpenAI Realtime API 跟传统 REST API 是两个世界,不能用调 gpt-5.5 的经验去套。4008 和 1006 这两个 close code 看着像网络问题,实际上是 OpenAI 服务端的主动行为,客户端必须配合。
心跳代码加起来不到 30 行,但能省掉无数次"怎么又断了"的排查时间。如果你也在做语音助手或者实时翻译之类的项目,建议一开始就把保活逻辑写进去,别等生产环境出问题再补。对于需要在聚合网关后面接入 Realtime API 的场景,上述 RealtimeKeepAlive 类同样适用------网关层的 endpoint 替换后,session.created / session.updated 事件结构不变,心跳逻辑无需改动。