【拯救 5.6 Sol(1.5)】codex首包卡死问题调查和解法

本文探讨的问题:

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
请求头 + 请求体(整段上下文,可达 ~90K token)

rect rgb(90, 30, 30)

Note over C,O: ④ 等待响应头 —— 首包卡死在这一步

Note right of O: OpenAI 已受理请求,但后端
迟迟不开始产出任何字节
健康:数百 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
Upgrade: websocket
(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(1.5)】codex首包卡死问题调查和解法

【拯救 5.6 Sol(2)】让 Codex 又快又准的原生模型工具