文章类型技术长文 所属专栏Agent 网络通信 预计阅读45 分钟 文档状态已发布
返回

Codex 网络层深度解析——WebSocket、状态续接与 HTTPS 降级

从 Session、Turn、Provider 与 Transport 分层出发,源码级拆解 Codex 的 Responses API 请求、WebSocket 预热复用、状态续接、断线重试与 HTTPS 降级。

开始阅读全文8995 字 · 45 分钟 查看系列目录Agent 网络通信
关键词 AgentCodexWebSocketResponses API网络通信
栏目 AgentNetworking;专栏 Agent 网络通信;标签 Agent、Codex、WebSocket、Responses API、网络通信

版本说明:本文依据 2026 年 8 月 5 日读取的 OpenAI 官方 Codex 文档,以及 openai/codex 官方仓库 main 分支源码撰写。Codex 的网络实现仍在快速演进,文中的默认超时、重试次数和内部 Header 属于版本化实现细节,发布或复现时应再次核对源码。

本文范围:讨论本地 Codex 客户端——CLI、桌面端或 IDE 扩展中的模型通信路径。Codex Cloud 的任务调度、云端容器生命周期和浏览器回传不在本文范围内。

Codex 的网络层很容易被误解成“把一次提示词 POST 到 Responses API,然后读取流式结果”。这个描述只适用于最简单的一轮问答,却无法解释真实编码任务中的几个核心现象:

  • 为什么一次用户任务会连续发起多次模型请求;
  • 为什么同一条 WebSocket 可以承载多次 response.create
  • 为什么连接能够复用,而 x-codex-turn-state 又绝不能跨 Turn 复用;
  • 为什么有时只发送新增的 Tool Result,有时必须重新发送完整上下文;
  • 为什么 WebSocket 中断后先重连,重试耗尽后却切换到 HTTPS;
  • 为什么一旦发生传输降级,后续 Turn 也会继续使用 HTTPS。

这些行为并不是彼此独立的优化。它们共享同一套状态模型:Session 级客户端保存长期稳定的 Provider、认证和降级状态;Turn 级客户端保存当前任务轮次的连接、Sticky Routing Token、上一请求与上一响应;工具执行结果再作为新的 Input Item 推动下一次采样。

OpenAI 在介绍 Responses API WebSocket 模式时指出,复杂 Agent 任务会产生大量“模型决策—本地工具执行—结果回填”的往返请求,持久连接和增量请求的目的,就是降低这些高频往返的固定开销。1

版本演进说明:OpenAI 在 2026 年 1 月公开 Codex Agent Loop 时,Codex 仍以 HTTPS 请求与 SSE 响应为主,并刻意不使用 previous_response_id,以保持请求无状态并兼容 ZDR。2026 年 4 月公开的 WebSocket 方案随后改用持久连接、连接内存缓存和增量 response.create。本文描述的是后者以及当前官方仓库中的实现,不应把两篇官方文章理解为同一时间点的架构。21


1. Codex 网络模块划分#

Codex 网络模块划分

先从源码中的对象生命周期入手。Codex 没有把所有网络状态塞进一个全局 HTTP Client,而是明确拆成 Session、Turn、Provider/Transport 和 Tool Runtime 四个层次。

1.1 Session 级 ModelClient#

源码把 ModelClient 定义为 Session 生命周期对象。它保存的是在多个 Turn 之间应保持稳定的内容:

  • 当前线程标识;
  • 选中的模型 Provider;
  • ChatGPT、API Key、AWS 等认证来源;
  • Session 来源,例如 CLI、VS Code、子 Agent;
  • HTTP Client Factory,以及由它解析出的代理、TLS 和自定义 CA 策略;
  • 是否启用请求压缩、计时指标和 attestation;
  • disable_websockets:本 Session 是否已经永久降级到 HTTP;
  • cached_websocket_session:可被后续 Turn 取走的 WebSocket 传输缓存。

其中最值得注意的是 disable_websockets。它不是单次请求的临时变量,而是放在由 Arc 共享的 ModelClientState 中。一个 Turn 如果确认当前环境中的 WebSocket 不可靠并激活 HTTPS 降级,后续 Turn 会看到同一个禁用状态,不再重复尝试同一条失败路径。3

这体现了一个重要判断:某次网络抖动属于 Request;某种传输在当前网络环境中不可用,则属于 Session。

1.2 Turn 级 ModelClientSession#

每次用户任务轮次会新建一个 ModelClientSession。它负责在同一 Turn 内发起一次或多次 Responses 请求,并保存当前轮次需要的连续性状态:

pub struct ModelClientSession {
client: ModelClient,
websocket_session: WebsocketSession,
turn_state: Arc<OnceLock<String>>,
}

WebsocketSession 进一步保存:

  • 当前 ResponsesWebsocketConnection
  • 上一次完整 ResponsesApiRequest
  • 上一次响应结束后得到的 response_id 和服务端新增 Items;
  • 上一次响应是否来自未写入 rollout trace 的预热;
  • 当前连接是否被复用。

这里有一个容易混淆的细节:ModelClientSession 是 Turn 级对象,但其持有的物理 WebSocket 连接在对象析构时会归还给 Session 级 ModelClient 缓存,下一 Turn 可以再次取出。与此同时,turn_state 每次 new_session() 都重新创建,不会随连接一起跨 Turn 传播。

因此,Codex 同时实现了两件看似矛盾、其实层次不同的事:

  1. 传输连接可以跨对象复用,避免重复握手;
  2. Turn 语义状态严格隔离,避免把上一轮 Sticky Routing Token 带到下一轮。

1.3 Provider、认证与 Transport#

Provider 层负责回答四个问题:

  1. 请求发往哪个 Base URL;
  2. 使用什么认证材料;
  3. 后端使用哪种 Wire API;
  4. 后端是否支持 WebSocket、独立搜索等能力。

当前源码只保留 responses 这一种 Wire API。旧的 chat 配置会直接报错,引导用户迁移到 Responses API。Provider 配置还可以声明:

  • base_url
  • API Key 所在环境变量;
  • 固定 Header 和从环境变量读取的 Header;
  • 命令生成的 Bearer Token;
  • AWS Profile 与 Region;
  • Request Retry、Stream Retry、Stream Idle Timeout、WebSocket Connect Timeout;
  • supports_websockets

Transport 则是具体的承载方式:

  • Responses over WebSocket;
  • Responses over HTTPS 流;
  • Bedrock 的 Responses 兼容实现;
  • Ollama、LM Studio 等本地 Responses 兼容端点。

Provider 能力决定是否有资格使用 WebSocket,Session 降级状态决定当前是否还允许使用 WebSocket,两者共同构成:

websocket_enabled
= provider.supports_websockets
AND NOT session.disable_websockets

1.4 Tool Executor#

Tool Executor 与 Turn 推进

Tool Executor 不属于模型 Transport,却决定了网络层为什么必须支持高频续接。

模型可能返回一个完整 Function Call。Codex 的 ToolCallRuntime 会把调用交给 Tool Router,再根据工具是否支持并行执行来获取不同的锁:

  • 支持并行的工具获取读锁,可与其他并行工具同时执行;
  • 不支持并行的工具获取写锁,独占执行区间;
  • 用户取消时,Cancellation Token 会终止工具任务或等待运行时完成清理;
  • 普通工具错误会转换为 FunctionCallOutput,而不是让整个 Turn 崩溃;
  • 工具输出作为新的 ResponseInputItem 写回上下文,触发下一次模型请求。4

因此,Codex 一次 Turn 的真实循环是:

WebSocket 的价值不是“让一条回答显示得更流畅”,而是让这条循环中的多次请求共享连接,并尽可能只发送新增状态。


2. Provider 与请求路由#

Codex 的上层 Agent Loop 不直接判断“这是 ChatGPT 还是 API Key”。它先通过 Provider 与 Auth Manager 解析出当前请求所需的 ApiProviderAuthProvider,然后让 HTTP 与 WebSocket 路径共享这份解析结果。

2.1 ChatGPT 登录路径#

Codex 的产品界面允许用户直接“Continue with ChatGPT”。在源码中,只要认证模式属于 ChatGPT、ChatGPT Auth Tokens、Headers、Agent Identity 或 Personal Access Token 一类,OpenAI Provider 的默认 Base URL 就会指向:

https://chatgpt.com/backend-api/codex

ChatGPT 路径的特点是:

  • 认证由登录流程和 Auth Manager 管理;
  • Token 可以自动刷新;
  • 401 并不立即等价于永久失败,客户端会进入一次认证恢复流程;
  • Session、Thread、Installation、Originator 等 Codex 产品元数据会随请求发送;
  • 其配额和产品使用策略与直接 API Key 路径不同。

OpenAI 的 Codex 入门页面同时提供“Continue with ChatGPT”和“Enter API key”两种入口。5

2.2 OpenAI API Key 路径#

当使用 OpenAI API Key 时,默认 Base URL 是:

https://api.openai.com/v1

这一路径使用标准 OpenAI API 认证,并可附带 OpenAI-OrganizationOpenAI-Project 等环境 Header。它更适合:

  • 按 API 用量计费;
  • 由企业统一分发 Project Key;
  • 需要明确组织、项目和 API 配额边界的环境;
  • 自动化或无浏览器登录的运行方式。

ChatGPT 登录与 API Key 最终都被解析成统一的 SharedAuthProvider,因此后面的 WebSocket 建连、HTTPS 请求和遥测代码不需要复制两套实现。

2.3 自定义兼容 Provider#

用户可以在 ~/.codex/config.toml 中增加自定义 model_providers。一个典型配置可能包含:

model_provider = "corp-gateway"
[model_providers.corp-gateway]
name = "Corporate Responses Gateway"
base_url = "https://llm.example.com/v1"
env_key = "CORP_LLM_TOKEN"
wire_api = "responses"
request_max_retries = 4
stream_max_retries = 5
stream_idle_timeout_ms = 300000
supports_websockets = true
[model_providers.corp-gateway.http_headers]
X-Client = "codex"

需要注意四点:

  1. 兼容 Responses JSON 并不自动等于兼容 WebSocket。 Provider 必须显式声明 supports_websockets = true,服务端也必须实现 Responses WebSocket 语义。
  2. 自定义 Provider 可以使用环境变量 Key、固定 Bearer Token、命令生成的 Token 或无认证模式,但这些认证配置彼此有冲突检查。
  3. WebSocket Upgrade 同样要穿过企业代理、TLS 和自定义 CA,因此“普通 HTTPS 可用”不必然代表 WebSocket 可用。
  4. Provider、认证和遥测路由属于机器本地配置,不能被项目仓库中的 .codex/config.toml 任意覆盖,避免恶意仓库重定向模型流量。6

2.4 Bedrock 和本地模型 Provider#

Codex 官方源码内置四类主要 Provider ID:

Provider默认地址/寻址认证WebSocket
OpenAIChatGPT Backend 或 api.openai.com/v1ChatGPT / API Key支持
Amazon Bedrock按 AWS Region 解析 Mantle/Responses 端点Bedrock API Key 或 AWS SDK 凭证链当前源码不支持
Ollamahttp://localhost:11434/v1默认无不支持
LM Studiohttp://localhost:1234/v1默认无不支持

Amazon Bedrock 路径仍然使用 Responses 语义,但模型请求由 Bedrock 处理,OpenAI 托管 API 不在请求链路中。OpenAI 官方文档明确说明,Codex Runtime 仍在本地 CLI、桌面端或 IDE 中运行,AWS 负责身份、Region、配额、治理和推理请求。78

源码还明确禁止 AWS Provider 与 supports_websockets 同时启用,因为当前尚未为 WebSocket Upgrade 实现 AWS SigV4 签名。因此,本篇讨论的 WebSocket 预热、Sticky Routing 和 WS→HTTPS 降级主要适用于支持 Responses WebSocket 的 OpenAI 或兼容 Provider;Bedrock、Ollama 和 LM Studio 默认直接进入 HTTP Responses 路径。9


3. Responses API 请求结构#

Responses API 请求结构

Codex 在 HTTP 和 WebSocket 两条路径上共享同一个 ResponsesApiRequest。WebSocket 不是另一套业务协议,它是在相同字段基础上增加 type: response.createprevious_response_idgenerate10

3.1 Instructions#

instructions 是模型的基础行为约束,来自 Codex 当前模型与任务环境的 Base Instructions。它定义模型在编码任务中的角色、工具使用方式、输出约束等。

在普通 Responses 模式中,它位于请求顶层:

{
"model": "...",
"instructions": "...",
"input": [...]
}

responses_lite 变体中,源码可能把工具定义和基础指令改写成 Developer Role 的 Input Items,并把顶层 instructions 置空。这是同一逻辑信息的不同封装,不改变本文讨论的连接与状态模型。

3.2 Input Items#

input 是一组结构化 ResponseItem,而不是单纯的字符串消息。它可以包含:

  • 用户消息;
  • 助手消息;
  • 模型产生的 Function Call;
  • 客户端执行后的 Function Call Output;
  • Reasoning 或其他服务端返回 Item;
  • 当前 Turn 注入的环境、技能、工具和上下文记录。

这使 Codex 可以把“工具调用—工具结果”作为可验证的结构化事件回填,而不是拼成一段自然语言。

在增量 WebSocket 请求中,input 可能不是完整历史,而只是基于 previous_response_id 的新增 Items。例如上一轮模型请求执行测试后,下一次请求只需要补充:

{
"type": "function_call_output",
"call_id": "call_test_1",
"output": "3 tests passed"
}

3.3 Tools#

工具定义会被序列化成 Responses API 可识别的 Tool JSON。每个工具通常包含名称、说明和参数 Schema。Codex 会将当前 Step 真正可用的工具暴露给模型,而不是假设每一轮都有完全相同的工具集合。

工具集合是否变化非常重要,因为增量请求要求非 Input 字段与上一次请求保持一致。若本轮因为权限、MCP 或模式切换导致工具列表变化,Codex 会放弃 previous_response_id + delta,改发完整请求。

3.4 Parallel Tool Calls#

请求中的 parallel_tool_calls 来自当前 Prompt 能力与模型模式。它表示模型是否可以在同一次采样中提出多个并行工具调用。

但这不意味着所有工具都真的会并行执行。最终执行仍由 ToolCallRuntime 与 Tool Router 判定:

  • 工具声明支持并行:进入共享读锁;
  • 工具不支持并行:获取独占写锁;
  • 同一 Turn 的文件修改、Shell 和其他有副作用工具因此可以被串行化。

换言之,parallel_tool_calls 是模型输出许可,Tool Runtime 才是客户端执行许可。

3.5 Reasoning 配置#

Codex 的 reasoning 对象可包含:

  • effort:推理强度;
  • summary:是否以及如何返回推理摘要;
  • context:推理上下文范围。

某些模型还支持顺序交付 Reasoning Summary、输出 verbosity 和严格 JSON Schema。源码在构建请求时会根据模型能力过滤不支持的配置,避免把客户端设置机械地发送给所有 Provider。

值得注意的是,Reasoning 配置也参与增量请求兼容性比较。若用户在同一状态链中更改推理强度或摘要策略,客户端不能再假设新请求只是上一请求的简单追加。

3.6 Client Metadata#

Codex 使用 client_metadata 携带产品侧和可观测性上下文。其典型字段包括:

  • installation_id
  • session_id
  • thread_id
  • turn_id
  • window_id
  • request_kind:Turn、Prewarm、Compaction、Memory;
  • Parent Thread / Parent Turn / Subagent 信息;
  • Sandbox 和 Workspace 快照;
  • Turn 开始时间;
  • W3C traceparent / tracestate
  • WebSocket 请求开始时间;
  • x-codex-turn-state 的兼容投影。

源码把完整 Turn Metadata 视为一个规范化快照,再按需要投影为 Client Metadata 或兼容 Header,而不是让多份元数据各自成为事实来源。11

这些字段通常不影响模型语义内容,却对以下能力至关重要:

  • Sticky Routing;
  • 端到端 Trace;
  • 区分预热与正式推理;
  • 统计连接是否复用;
  • 调试某个 Session、Thread 和 Turn 的异常;
  • 区分主 Agent 与子 Agent 请求。

4. WebSocket 建立和预热#

WebSocket 预热与正式请求复用

Codex 当前同时存在两个容易混淆的动作:

  • preconnect_websocket():只建立连接,不发送 Prompt;
  • prewarm_websocket():建立连接后发送 response.create,但设置 generate=false,等待一个完整响应结束。

本文框架中的“预热”指第二种。

4.1 WebSocket Upgrade#

WebSocket 客户端先根据 Provider 的 Responses URL 构造 WebSocket URL,再通过共享 HttpClientFactory 建立连接。这个工厂保证 HTTP 与 WebSocket 观察到同一套:

  • 代理策略;
  • DNS 与连接配置;
  • TLS;
  • 自定义 CA;
  • 企业网络路由。

握手仍然从 HTTP 开始。成功时服务端返回 101 Switching Protocols,连接升级为 WebSocket。Codex 还提供一个 handshake probe:它使用与真实连接相同的 URL、Header、认证、TLS 与 CA,仅验证 Upgrade 是否成功,并短暂等待服务端是否立即返回 Close Frame,用来区分“握手成功但立刻被策略拒绝”的情况。12

源码为 WebSocket 启用了 permessage-deflate,以压缩重复的 JSON 事件与请求载荷。

4.2 认证 Header#

WebSocket 握手 Header 来自多个层次的合并:

  1. Provider 固定 Header;
  2. 当前请求附加 Header;
  3. 默认客户端 Header;
  4. Auth Provider 添加的认证 Header;
  5. Session / Thread / Originator 信息;
  6. Codex 兼容性 Header;
  7. 可选 Attestation;
  8. WebSocket Beta Header。

当前源码会为 Responses WebSocket v2 加入:

OpenAI-Beta: responses_websockets=2026-02-06

ChatGPT 登录与 API Key 使用不同的认证材料,但在这里都被统一成 AuthProvider.add_auth_headers(),因此连接逻辑无需感知具体登录模式。

4.3 Connect Timeout#

Provider 配置单独暴露 websocket_connect_timeout_ms。当前源码默认值为 15 秒。连接函数被包裹在 tokio::time::timeout 中,超过时转换为 Transport Timeout。

这个超时与 Stream Idle Timeout 解决不同问题:

超时阶段典型原因
Connect TimeoutDNS、TCP、TLS、Upgrade代理不支持 Upgrade、证书问题、网络不可达
Stream Idle Timeout已连接并发送请求后服务端挂起、半开连接、事件长期不再到达

4.4 response.create#

WebSocket 上的业务请求仍然是 Responses API 结构,只是在最外层加入事件类型:

{
"type": "response.create",
"model": "...",
"instructions": "...",
"input": [...],
"tools": [...],
"parallel_tool_calls": true,
"reasoning": {...},
"client_metadata": {...}
}

如果可以增量续接,还会加入:

{
"previous_response_id": "resp_...",
"input": [/* 仅新增 items */]
}

同一 WebSocket 在任意时刻只处理一个 Response Stream。源码在整个响应生命周期持有连接 Mutex,防止两个请求同时在同一 Socket 上交错消费事件。

4.5 generate=false 预热#

prewarm_websocket() 调用与正式推理相同的 WebSocket 请求路径,但设置:

{
"type": "response.create",
"generate": false
}

它会等待直到收到 ResponseEvent::Completed,才认为预热完成。源码明确把这次请求定义为连接准备,而不是模型推理:

  • 不计入正常 inference trace;
  • 不按正式推理记录 Session Telemetry;
  • 但会占用本 Turn 的第一次 WebSocket 尝试预算;
  • 失败后仍进入正常 Stream Retry / Fallback 逻辑。

与“只握手”的 preconnect 相比,generate=false 预热更进一步:它不仅证明 WebSocket 能连上,还验证 Responses 协议链路能够完成一次完整的 response.create → response.completed

4.6 预热连接如何被正式请求复用#

预热结束后,WebsocketSession 中会保留:

  • 已建立的连接;
  • 预热时的完整请求;
  • 预热响应的 response_id
  • 服务端新增的 Output Items;
  • “该 Response ID 来自未写入推理 Trace 的预热”这一标记。

正式首轮请求到来时,Codex 会判断它是否与预热请求具有相同的非 Input 属性,并且 Input 是否只是严格扩展。如果成立,正式请求可以:

  1. 复用同一条 WebSocket;
  2. 使用预热得到的 previous_response_id
  3. 只发送新增 Input,甚至发送空 Delta;
  4. 在 Rollout Trace 中仍记录逻辑上的完整请求,避免传输优化破坏可复现性。

预热真正节省的不是模型计算本身,而是首轮正式请求中的:

  • TCP/TLS/Upgrade 开销;
  • 服务端连接初始化;
  • 重复请求体传输;
  • 某些固定路由与协议准备延迟。

5. Turn 内连接和状态复用#

连接复用、Sticky Routing 和增量请求经常被混为一种状态。实际上,它们分别解决三个问题:

状态解决的问题作用域
WebSocket Connection避免重复建连传输层,可缓存
x-codex-turn-state保持同一 Turn 的服务端路由连续性严格 Turn 级
previous_response_id引用服务端上一响应,只发送增量 Input请求链级

5.1 WebSocket Connection Cache#

ModelClientSession 会优先使用已有连接:

  • 若连接存在且内部 Stream 未关闭,标记为 reused;
  • 若连接不存在或已关闭,清除上次请求与响应状态,重新建立连接;
  • Turn 级对象销毁时,把 WebsocketSession 归还到 Session 级缓存;
  • 下一 Turn 可以取出物理连接,但会获得新的 turn_state

连接缓存的价值在于减少握手;它本身并不授权跨 Turn 复用任何语义 Token。

5.2 x-codex-turn-state#

服务端可以通过两种方式返回 Turn State:

  • WebSocket Upgrade Response Header;
  • 后续 Responses Stream Event。

客户端把第一次得到的值写入 OnceLock<String>,之后在当前 Turn 的:

  • 重试;
  • 增量追加;
  • 工具结果回填;
  • 续写请求;

中反复发送同一个值。HTTP 路径把它放在 x-codex-turn-state Header 中;WebSocket 路径也会把它加入 Client Metadata 兼容字段。

5.3 Sticky Routing#

从客户端视角看,x-codex-turn-state 是一个不透明 Token。Codex 不解析其内部内容,只遵守“收到后原样回放”的协议。

它的作用可以理解为:让同一 Turn 的后续请求尽量保持服务端侧的路由与状态连续性。注意,这并不意味着服务器保存了完整聊天历史;真正的 Input 与 previous_response_id 仍有各自职责。

Sticky Routing 的工程意义在于:工具循环会在几秒内连续发出多次请求,如果每次都被当成完全无关的新请求,服务端可能失去本轮专用的路由上下文、缓存或调度状态。

5.4 为什么 Turn State 不能跨 Turn 使用#

源码注释直接把它定义为 Client/Server Contract:

  • Turn 开始时接收;
  • 当前 Turn 的所有后续请求保持不变;
  • 不得发送到另一个 Turn。

如果跨 Turn 使用,可能产生三类问题:

  1. 路由污染:新用户任务被误认为上一 Turn 的延续;
  2. 状态错配:新 Turn 的 Model、Reasoning 或安全上下文与旧 Token 指向的服务端状态不一致;
  3. 调试失真:Session ID 相同,但 Turn ID 与 Sticky Token 的归属冲突。

所以 new_session() 每次都创建新的 OnceLock,即使物理 WebSocket 来自 Session Cache,也不会继承旧 Turn State。

5.5 previous_response_id#

previous_response_id 是 Responses API 层的引用。它告诉服务端:当前请求建立在某个已完成 Response 之上。

OpenAI 当前公开文档进一步限定了这一状态的边界:活动 WebSocket 只在连接内存中保留最近一个 previous-response state;该机制与 store=false 和 ZDR 兼容。若 ID 不在连接缓存中,store=true 时服务端可能从持久状态恢复,store=false/ZDR 下则返回 previous_response_not_found。单条 WebSocket 当前最长持续 60 分钟,到期后必须重连。13

Codex 从 response.completed 中获取 response_id,同时记录服务端在该响应中新增的 Output Items。下一次请求构造时,客户端会把以下三部分视为上一状态基线:

上一完整请求的 input
+ 上一响应新增的 output items
+ 当前新增 input items

若当前请求的前缀完全等于“上一请求 + 上一响应”,剩余部分就是可以单独发送的 Delta。

5.6 增量请求和完整请求的切换条件#

增量请求与完整请求的切换条件

Codex 不会因为“有 previous response id”就盲目使用增量请求。源码首先比较所有会影响模型语义的非 Input 属性:

  • model;
  • instructions;
  • tools;
  • tool choice;
  • parallel tool calls;
  • reasoning;
  • store;
  • stream;
  • include;
  • service tier;
  • prompt cache key;
  • text controls。

然后再确认:

  1. 当前 Input 长度至少覆盖上一请求 Input 与服务端新增 Items;
  2. 该前缀逐项相等;
  3. 上一响应具有非空 response_id
  4. 新增 Items 可以被安全切分。

client_metadata 不参与语义匹配;stream_options 也被有意排除,因为它控制当前响应如何交付,而不是 previous_response_id 所引用的上下文内容。

任一条件不满足,Codex 就发送完整请求。这个机制的本质是:

增量是有证明条件的优化,完整请求是语义正确性的保底路径。


6. WebSocket Event Pump#

WebSocket Event Pump

Codex 的 WebSocket 不是在业务代码中直接 send() / recv()。底层 WsStream 创建了一个专门 Pump Task,把 Socket I/O 与上层响应处理解耦。

6.1 请求发送 Channel#

上层发送请求时,不直接持有 Socket Writer,而是把 WsCommand::Send 投递到容量为 32 的 MPSC Channel,并通过 One-shot Channel 等待发送结果。

有界 Channel 的意义是:如果上层异常地产生过多发送命令,它会形成背压,而不是无限积压写请求。

6.2 事件接收 Channel#

Pump Task 使用 tokio::select! 同时监听:

  • 上层发送命令;
  • Socket 下行消息。

收到 Text、Binary、Close 或 Frame 后,它会转发给内部接收 Channel;上层的 run_websocket_response_stream() 再把 Text JSON 解析成 Responses 事件,并放进容量为 1600 的 Response Event Channel。

这种分层使网络读取、协议解析和 Agent Loop 消费彼此解耦。值得注意的是,Socket 到内部消息的第一段 Channel 是无界的,因此它更倾向于优先读走网络数据,真正面向业务消费者的背压主要发生在后面的有界 Response Event Channel。

6.3 Ping/Pong#

Pump 收到 Ping 后立即在同一 Socket 上发送 Pong,收到 Pong 则忽略。这样可以:

  • 满足标准 WebSocket 保活协议;
  • 避免某些代理、NAT 或服务端因长时间无控制帧而回收连接;
  • 把保活逻辑与 Responses 业务事件隔离。

Ping/Pong 成功只能说明连接仍可双向传输控制帧,不代表模型请求一定会完成。因此 Stream Idle Timeout 仍然需要观察业务事件是否持续到达。

6.4 Close Frame#

Close Frame 是明确的协议终止信号。Codex 会把它转交上层,然后结束 Pump。

在 Responses 处理层,只有先收到 response.completed 才算正常完成;如果服务端在此之前发出 Close,客户端返回:

websocket closed by server before response.completed

如果底层流直接结束而没有 Close,也会被归类为:

stream closed before response.completed

6.5 response.completed#

response.completed 是一次 Responses 请求的事务边界。收到它后,Codex 会:

  • 提取 response_id
  • 记录 Token Usage 与 end_turn
  • 汇总本响应所有 OutputItemDone
  • LastResponse 通过 One-shot Channel 交给 WebsocketSession
  • 为下一次增量请求保存基线;
  • 结束当前 WebSocket Response Stream。

在此事件之前出现的文本看起来再完整,也不能被当作请求已经成功提交。

6.6 Tool Call 事件#

Responses 事件层暴露:

  • OutputItemAdded
  • ToolCallInputDelta
  • OutputItemDone
  • OutputTextDelta
  • Reasoning Summary / Content Delta。

ToolCallInputDelta 允许 UI 或调试层观察参数逐步生成,但真正进入 Tool Router 的应是完成的 Tool Call Item。完成后,ToolCallRuntime 执行工具并把结果转换成下一次请求的 FunctionCallOutput

因此要区分:

  • 参数 Delta:网络流中的未完成数据;
  • 完成 Tool Call:可调度的执行单元;
  • FunctionCallOutput:工具执行后回填模型的结构化 Input。

6.7 Rate Limit 事件#

Codex 对 codex.rate_limits 做专门处理:解析成 RateLimitSnapshot 并通过 ResponseEvent::RateLimits 发送给上层,而不是把它混进普通文本事件。

WebSocket 中还可能包裹 HTTP 风格错误,例如:

{
"type": "error",
"status": 429,
"error": {
"type": "usage_limit_reached"
},
"headers": {
"x-codex-primary-used-percent": "100.0"
}
}

这类事件会被转换成 Transport HTTP Error,让统一错误映射逻辑继续处理。也就是说,使用 WebSocket 不会让 HTTP 状态语义消失,只是它们可能以内嵌错误事件的形式出现。


7. Codex 的断线恢复状态机#

Codex 断线恢复状态机

Codex 把“初始请求失败”和“流已开始后断开”分开预算,并在 Stream Retry 耗尽后尝试切换传输。14

7.1 Request Retry 与 Stream Retry#

当前 Provider 配置分别暴露:

  • request_max_retries:默认 4;
  • stream_max_retries:默认 5。

源码中的通用 HTTP Retry Policy:

  • 重试 Transport Error;
  • 重试 5xx;
  • 不在这一层自动重试 429;
  • 基础延迟 200ms。

Stream Retry 则用于已经进入流式处理后的中断,包括 WebSocket、HTTP SSE 采样流和远程压缩流。它的恢复循环位于 Turn 层,因此可以复用同一个 ModelClientSession、Turn State 和工具上下文。

这两个预算不能合并:

  • Request Retry 关注“请求有没有成功建立”;
  • Stream Retry 关注“已建立的响应有没有完整到达终止事件”。

7.2 Stream Idle Timeout#

当前源码默认 stream_idle_timeout_ms = 300000,即 300 秒。Provider 可以覆盖,硬编码默认值也可能随版本变化。

WebSocket 路径在两个位置使用这一超时:

  • 发送 response.create 时等待 Socket Send 完成;
  • 循环等待下一条 WebSocket Message。

只要连续 300 秒没有下一条消息,就返回:

idle timeout waiting for websocket

配置文档中这一字段有时仍以 SSE Idle Timeout 描述,但当前源码把同一个 Provider stream_idle_timeout 传给 WebSocket Connection。这是理解“配置名与实际作用范围”时需要注意的版本细节。

7.3 退避等待#

处理可重试流错误时,Codex优先使用错误对象携带的 Retry Delay;如果服务端没有提供,则使用本地 Backoff 函数。

正确流程是:

错误分类
→ 计算 delay
→ 记录 retry_count / max_retries / turn_id
→ 必要时通知 UI
→ sleep(delay)
→ 使用同一 Turn ClientSession 再次请求

退避发生在 Turn 层而不是 Socket Pump 内。Pump 只负责报告“这条连接已失败”,恢复策略由更高层决定。

7.4 首次重连静默处理#

在 Release 构建中,如果当前使用 WebSocket,Codex默认隐藏第一次重连通知。理由很现实:移动网络抖动、代理瞬时切换或服务端短暂停顿可能在第一次快速重试中恢复,立刻向用户展示红色错误只会制造噪声。

下列情况会立即显示:

  • 已是第二次或更多次重试;
  • Debug 构建;
  • 当前并未启用 WebSocket。

这是“隐藏瞬时错误”和“避免用户误以为卡死”之间的折中。

7.5 “Reconnecting”状态上报#

当需要向上层报告时,Session 会收到类似:

Reconnecting... 2/5

的状态事件,同时保留原始错误信息。UI 因此可以:

  • 显示当前重试次数;
  • 区分正在恢复与已经失败;
  • 避免长时间无反馈;
  • 在最终降级时显示传输切换 Warning。

7.6 失败连接销毁#

WebSocket Response Stream 遇到终止错误时,不会等待可能永远无法完成的优雅 Close Handshake。源码会:

  1. 从连接对象中 take() 出失败的 WsStream
  2. 释放 Mutex;
  3. 直接 Drop 失败流,终止 Pump Task;
  4. 立即把错误发送给调用方。

这是一个重要的“先销毁污染连接,再做恢复”原则。如果保留半开连接,下一次重试可能继续复用一个已经失去协议同步的 Socket。

7.7 新 WebSocket 建立#

下一次重试进入 websocket_connection() 时,会检测:

  • Connection 不存在;或
  • 外层对象还在,但内部 Stream 已被清空。

若需要新连接,客户端先清除:

  • last_request
  • last_response_rx
  • warmup 标记;
  • connection reused 标记。

然后重新执行与初始连接相同的 Provider、认证、Header、TLS 和 Telemetry 逻辑。

清除 Last Request/Response 非常关键。它确保一次连接级故障不会让新 Socket 继续引用旧连接上的不可靠 previous_response_id 链。


8. WebSocket 向 HTTPS 降级#

HTTPS 降级不是简单地把 ws:// 改成 https:// 再发同一份帧。它会改变 Session 的传输决策,并清空所有 WebSocket 专属续接状态。

8.1 何时触发降级#

源码中有两类明确入口。

入口一:服务端返回 HTTP 426 Upgrade Required

如果 WebSocket 建连返回 426,客户端直接得到 FallbackToHttp,不再把它当成普通网络抖动反复重连。

入口二:Stream Retry 达到上限

retries >= max_retries 时,恢复器调用:

client_session.try_switch_fallback_transport(...)

若当前仍可从 WebSocket 切换到 HTTP,就向 UI 发送:

Falling back from WebSockets to HTTPS transport.

8.2 重试计数如何重置#

降级激活后,恢复函数把当前 Stream Retry 计数重置为 0,再返回请求循环。

这是必要的,因为:

  • 前面的失败预算属于 WebSocket;
  • HTTPS 是新的 Transport;
  • 若沿用已耗尽的计数,HTTPS 会在第一次错误时立即终止,等于没有真正给降级路径恢复机会。

8.3 降级为什么保持在 Session 级#

force_http_fallback() 会原子地把 Session 级 disable_websockets 设为 true,并清空 Session 中缓存的 WebSocket 状态。

这反映了源码对故障范围的判断:

当 WebSocket 已经连续失败到需要切换 Transport 时,问题更可能来自当前企业代理、TLS Inspection、网络策略或 Provider 能力,而不是某一个 Response 的偶发错误。

如果只在当前请求临时降级,下一 Tool Call 或下一 Turn 又会重新尝试 WebSocket,形成:

WS 重试 → HTTP 成功 → 再次 WS 重试 → HTTP 成功 → ...

这样的抖动。Session 级熔断避免了重复付出握手和失败延迟。

8.4 后续 Turn 如何选择 Transport#

每次 ModelClientSession.stream() 都先检查:

provider.supports_websockets
AND NOT session.disable_websockets

如果 Session 已降级,后续 Turn 会直接调用 HTTPS Responses Path,而不是重新探测 WebSocket。

只有创建新的 Codex Session,或明确重置相应客户端状态,才会重新获得 WebSocket 尝试机会。

这是一种轻量的 Session 级 Circuit Breaker:它不永久修改用户配置,只在当前会话生命周期内记住失败结论。

8.5 previous_response_not_found 后的完整请求回退#

WebSocket 服务端可能返回错误码:

previous_response_not_found

Codex 将其映射成 Retryable Error,并给出“Retrying the full request”的语义。完整回退路径由两部分共同完成:

  1. 终止流错误会使当前内部 WebSocket Stream 被取出并销毁;
  2. 下一次请求发现连接已关闭,重建连接前清空 Last Request 与 Last Response。

重新建连后,prepare_websocket_request() 无法取得可用 Last Response,因此不会附带 previous_response_id,而是发送完整 request.input

即使没有发生连接重建,只要以下任一条件失效,增量构造器也会自然退回完整请求:

  • Request 属性不一致;
  • Input 前缀不匹配;
  • Response ID 为空;
  • Last Response 不可用。

因此,previous_response_not_found 的恢复不是“伪造另一个 ID”,而是放弃服务端引用优化,用客户端持有的完整上下文重建语义状态。


9. 完整案例时序#

Codex 完整案例时序

下面用一个真实编码任务串起全部机制:

用户要求 Codex 读取 GitHub Issue,定位代码问题,修改文件,执行测试,创建 Pull Request,并返回结果。执行期间 WebSocket 流中断,多次重连失败,最终切换到 HTTPS 后继续完成任务。

9.1 阶段一:预热不是“空连一下”#

Codex 发送 generate=false,等待完整 response.completed,得到可被首轮正式请求引用的 Response ID。预热失败会占用本 Turn 的 Stream Retry 预算,不会被当作毫无成本的旁路操作。

9.2 阶段二:每次 Tool Call 都产生新的模型往返#

读 Issue、搜索代码、编辑文件、运行测试、创建 PR 并不是一条连续模型输出,而是多次:

Responses → Tool Call → 本地执行 → FunctionCallOutput → Responses

WebSocket 复用和 previous_response_id 正是为了减少这些往返中的重复连接与重复 Input。

9.3 阶段三:流中断后不能假设上一响应完成#

即使模型已经输出了一部分修复建议,只要没有 response.completed,Codex 就不能把该请求写成稳定 Last Response,也不能安全地基于其 Response ID 做下一次增量追加。

失败连接被销毁,新连接清除上一请求链,必要时用完整上下文重建。

9.4 阶段四:HTTPS 降级不丢失 Agent 状态#

WebSocket 状态被清空,不等于整个 Turn 被重启。以下状态仍在客户端:

  • Conversation History;
  • 已执行的工具结果;
  • 本地文件修改;
  • Turn Context;
  • Tool Call Runtime 状态;
  • 当前目标与测试输出。

所以 HTTPS 可以用完整 Input 继续任务,而不是从“修复 Issue”第一步重新开始。

9.5 阶段五:传输成功不等于工具副作用可随意重放#

如果断线发生在“GitHub 已创建 PR,但 PR 结果尚未回填”这一窗口,仅靠模型传输重试仍可能重复创建 PR。这个问题属于下一篇要讨论的工具幂等与副作用恢复,而不是 WebSocket 本身能解决的问题。

Codex 的网络层保证模型请求尽量连续;外部工具是否可以安全重放,仍需要 Tool Call ID、业务唯一键和执行日志共同约束。


结论:Codex 的网络层是一台分层状态机#

Codex 网络层最值得借鉴的不是“使用了 WebSocket”,而是它把不同寿命的状态放在了不同对象里:

  1. Session 级 ModelClient 保存 Provider、认证、代理策略和传输熔断结论;
  2. Turn 级 ModelClientSession 保存 Sticky Routing Token、连接使用状态和请求续接基线;
  3. Response 级状态response.completedresponse_id 与 Output Items 为提交边界;
  4. Tool Call 级状态 由 Tool Runtime 执行,再转换成下一次 Responses Input;
  5. 物理连接复用逻辑 Turn 隔离 被明确分开;
  6. 增量请求 只是通过严格条件证明后启用的优化,完整请求始终是正确性回退;
  7. WebSocket 重试 解决瞬时故障,Session 级 HTTPS 降级 解决当前环境中的持续传输不兼容。

这也是为什么连接复用和故障恢复必须放在同一篇理解:恢复逻辑并不是附加在 WebSocket 外面的几个 retry,而是在决定哪些状态可以保留、哪些状态必须丢弃、下一次请求还能不能安全引用上一响应。


参考资料#

Footnotes#

  1. OpenAI Engineering,Speeding up agentic workflows with WebSockets in the Responses API:https://openai.com/index/speeding-up-agentic-workflows-with-websockets/ 2

  2. OpenAI Engineering,Unrolling the Codex agent loop:https://openai.com/index/unrolling-the-codex-agent-loop/

  3. OpenAI Codex 官方源码,codex-rs/core/src/client.rshttps://github.com/openai/codex/blob/main/codex-rs/core/src/client.rs

  4. OpenAI Codex 官方源码,Tool Call 并发、取消与结果回填:https://github.com/openai/codex/blob/main/codex-rs/core/src/tools/parallel.rs

  5. OpenAI,Get started with Codex:https://openai.com/codex/get-started/

  6. OpenAI Codex 官方配置参考:https://developers.openai.com/codex/config-reference

  7. OpenAI Help Center,Use Codex with Amazon Bedrock:https://help.openai.com/en/articles/20001252-use-codex-with-amazon-bedrock

  8. OpenAI Help Center,Configure Codex with Amazon Bedrock:https://help.openai.com/en/articles/20001253-configure-codex-with-amazon-bedrock

  9. OpenAI Codex 官方源码,codex-rs/model-provider-info/src/lib.rshttps://github.com/openai/codex/blob/main/codex-rs/model-provider-info/src/lib.rs

  10. OpenAI Codex 官方源码,Responses 请求与事件类型:https://github.com/openai/codex/blob/main/codex-rs/codex-api/src/common.rs

  11. OpenAI Codex 官方源码,Responses Client Metadata:https://github.com/openai/codex/blob/main/codex-rs/core/src/responses_metadata.rs

  12. OpenAI Codex 官方源码,Responses WebSocket Endpoint:https://github.com/openai/codex/blob/main/codex-rs/codex-api/src/endpoint/responses_websocket.rs

  13. OpenAI API Docs,WebSocket Mode:https://developers.openai.com/api/docs/guides/websocket-mode

  14. OpenAI Codex 官方源码,Stream Retry 与 Transport Fallback:https://github.com/openai/codex/blob/main/codex-rs/core/src/responses_retry.rs

Codex 网络层深度解析——WebSocket、状态续接与 HTTPS 降级
https://jupiter-ws.cn/posts/agent-networking/03-codex-network-layer/
作者
Jupiter
发布于
2026-08-05
许可协议
CC BY-NC-SA 4.0