本文探讨的问题:
- codex卡死在thinking,没有报错,就是看上去完全禁止了
- 反代使用5.6会卡住,停止再继续就没问题了
- codex模型执行完工具就卡在那里
…
太长不读:如果出现上述问题,按照下列优先级:
1.能ouath登录就ouath登录(几乎一定能解决)
2.实在不能ouath登录,cpa或者sub2api配置允许websockets。并在config.toml中[model_providers.名字]下配置supports_websockets = true(几乎一定能解决)。
3.new-api + cpa:new-api 至今不支持 WebSocket。需要自行合并 PR #5062 。然后开启cpa配置允许websockets。并在config.toml配置上述字段(几乎一定能解决)
4.无论如何也不想打开ws:反代客户端需要存在「首包超时→同号重连」的逻辑(详见下文),如果没有,需要二开cpa或sub2api(不得已为之)
下面是原理和一些简单发现。大晚上写东西可能有疏漏,太困了。但大差不差了
请求OpenAI的两种模式
HTTP/SSE 模式(逐请求)
每一次模型调用都是一次全新的独立请求:重新建连、把整段上下文发上去,等 OpenAI 应答,应答以 SSE(流式)一个事件一个事件地吐回来。下一次模型调用话,从头来一遍。
WebSocket 模式(一段会话复用一条连接)
会话开始时只握手一次,把连接升级成一条长期存活的双向管道;此后每一轮对话都只是往这条已经打开的管道里发几个帧,应答也是帧返回。
HTTP/SSE 模式
一次 /v1/responses 请求"在 CPA↔OpenAI 这一跳的生命周期如下:
sequenceDiagram
participant C as CPA(发起方,uTLS 客户端)
participant O as OpenAI(上游)
Note over C,O: ① TCP 三次握手(建立可靠字节通道)
C->>O: SYN
O->>C: SYN-ACK
C->>O: ACK
Note over C,O: ② TLS 握手(uTLS 伪装 Chrome 指纹,建立加密通道)
C->>O: ClientHello
O->>C: ServerHello + 证书
C->>O: 密钥交换 + Finished
Note over C,O: ③ 发送 HTTP 请求(把整段对话上下文发上去)
C->>O: POST /v1/responses<br/>请求头 + 请求体(整段上下文,可达 ~90K token)
rect rgb(90, 30, 30)
Note over C,O: ④ 等待响应头 —— 首包卡死在这一步
Note right of O: OpenAI 已受理请求,但后端<br/>迟迟不开始产出任何字节<br/>健康:数百 ms;卡死:可能几十分钟或永久不返回
O-->>C: (长时间一个字节都不回)
end
Note over C,O: ⑤ 响应头终于到达,客户端的 http.Do() 才返回
O->>C: 200 OK + SSE 响应头
Note over C,O: ⑥ SSE 流式返回事件(首包一到,后续正常)
O->>C: event: response.created
O->>C: event: reasoning / output delta …
O->>C: event: [DONE]
请求流程:一条连接,从 TCP 握手(①)→ TLS 握手(②)→ 发出 HTTP 请求(③)→ 阻塞等待响应头(④) → 收到响应头(⑤)→ SSE 逐事件流式返回(⑥)。整个过程里,唯一会长时间卡死的是第 ④ 步。
- 需要注意的是:本机网络问题,无法访问openai服务器会在①②③环节中断,体现到codex中就是 reconnect。
为什么会卡住?
以CPA为例:在 CPA 源码里,第 ③④⑤ 步对应一次调用 httpClient.Do(httpReq)(internal/runtime/executor/codex_executor.go:1121):这个调用发出请求、并阻塞到响应头到达才返回。
关键在于这个 HTTP 客户端在"等响应头"这一步没有任何超时:
位置 |
事实 |
后果 |
|---|
helps.NewUtlsHTTPClient(ctx, cfg, auth, 0) |
构造时 timeout=0 |
不设 client.Timeout |
utls_client.go(transport) |
未设 ResponseHeaderTimeout |
传输层不给"等响应头"设上限 |
结果 |
Do() 在第 ④ 步可无限期阻塞 |
上游卡多久就等多久 |
所以"首包卡死"就是第 ④ 步的无限阻塞。
那么到这里锅就可以确定了,openai收到请求不干事,死活不发响应头。这就是造成一系列问题的根本原因
我们在测试中进行了日志跟踪,"看起来是卡死"其实有两种可能:
形态 |
描述 |
卡在哪 |
证据 |
|---|
header-stall(首包/响应头卡死) |
连 200 响应头都迟迟不发 |
第 ④ 步(等响应头) |
就是它——逐帧日志显示选定上游后有 8.5 分钟空白才进入读流阶段,说明卡在读流之前,即 http.Do 里 |
body-stall(响应体卡死) |
200 头秒到,SSE 流开了却卡在首个内容 |
第 ⑥ 步之内 |
零证据——健康请求 response.created 亚秒即到 |
所以这里引入了一个兜底性的解法:
- 给第 ④ 步套一个预算(
first-event-timeout-seconds,现设 6 秒):把 ExecuteStream(含 http.Do)丢到 goroutine 上,一个预算同时覆盖住"等响应头 + 等首字节"。健康请求首字节 1~3 秒到,6 秒有充足余量;卡死则零字节。
- 超时→同号重连:一旦超时,丢弃这条卡住的请求、用同一个账号重新发起(
first-event-retries,现设 4)。因为卡死是单个请求实例摇到的坏运气(可能是openai服务端排队?不确定),重发=重新摇一次骰子,实测多数一两次就中。
感兴趣的佬可自行二开更改cpa或sub2api,或提交pr,不确定上游是否会接受。前文说的很清楚了,最大的锅在openai
WebSocket 模式:为何直接绕过首包卡死
一次 WebSocket 会话的生命周期
sequenceDiagram
participant K as Codex
participant U as OpenAI(经 nginx / new-api / CPA 透传)
Note over K,U: 会话开始:一次性握手(TCP + TLS + HTTP Upgrade)
K->>U: GET /v1/responses<br/>Upgrade: websocket<br/>(key 藏在 Sec-WebSocket-Protocol 里)
U->>K: 101 Switching Protocols
Note over K,U: 此后整段会话复用这一条连接,双向收发"帧",不再有新的 HTTP 请求
K->>U: 帧:response.create(第 1 轮)
U->>K: 帧:response.created / delta … / done
K->>U: 帧:response.create(第 2 轮)
U->>K: 帧:… 响应事件 …
K->>U: 帧:response.create(第 N 轮)
U->>K: 帧:… 响应事件 …
Note over K,U: 每一轮都直接发帧,没有"新建 HTTP 请求 → 等响应头"这一步
WebSocket 的本质:握手一次,把 HTTP 连接"升级"成一条长期存活的双向管道;之后所有来回都是轻量的帧,在这条已建好的管道里流动。
纠正一个可能的误解:HTTP 并不是"每次请求都新建一条连接"——HTTP/1.1 keep-alive、HTTP/2 都能在同一条连接上跑多个请求。
维度 |
HTTP/SSE |
WebSocket |
|---|
一段会话里 |
每一轮对话 = 一次独立的请求-响应周期 |
整段会话 = 一条持久连接 |
"等响应头"这一步(第④步) |
每一轮都要经历一次 |
只在会话开始的握手时有一次(返回 101 很快),之后每轮不再有 |
承载单位 |
每轮一个完整 HTTP 请求(含头、体、SSE 响应) |
每轮只是若干"帧" |
生产观测 |
每轮一条 gin 请求日志 |
一条连接只在开/关时记一次;多轮请求复用同一个 request_id |
- 由此可见为什么WebSocket能解决问题:让 HTTP 概率卡死的那一步(第 ④ 步"等响应头")在 HTTP 里是"每轮一次",在 WebSocket 里被压缩成"整段会话一次"。
系列文章:
【拯救 5.6 Sol(1)】开箱即用、快速高效、减少上下文腐烂的Codex子代理实践
【拯救 5.6 Sol(2)】让 Codex 又快又准的原生模型阅读工具
编写中和开发中;类似claude code的read、grep、glob,但更加强大:允许直接原生读取pdf、word、PPT等多类型文件,自动处理转换多种编码,原生读取二进制视图…
【拯救 5.6 Sol(3)】Codex 子代理机制完全解读
编写中;对配置字段逐行解读