雨天小六

读懂 Codex(五):回答是怎样流出来的——模型连接与事件

· 更新于 2026-07-31 · 专栏:读懂 Codex

#Codex#Agent Runtime#Responses API#WebSocket#软件架构

上一章结束时,Codex 已经得到一份结构化 Prompt。其中有基础指令、规范化历史、本次可用的工具,以及输出约束。接下来的问题不是简单地“调用一次模型然后取回字符串”。

一次采样期间,界面可能先显示几段文字,随后出现一项工具调用;网络可能在中途断开,重连后请求又被发送一次;同一 Turn 还可能执行工具并继续采样。要让这些行为可恢复、可取消且不污染历史,Runtime 至少要分清三个边界:

  • 哪些内容只是尚未完成的显示增量;
  • 哪个事件交付了一项可记录的完整模型输出;
  • 什么信号才表示整次响应已经成功结束。

Codex 的模型调用层就在这些边界之间工作。它把内部 Prompt 映射成 Responses 请求,通过 SSE 或 WebSocket 接收线协议事件,再把两种传输统一成 Runtime 可消费的 ResponseEvent

一次采样返回的不是一个字符串

先明确“采样”在本章中的范围。一次采样从 Runtime 向模型发送一个 Prompt 开始,以收到该响应的 response.completed 结束。模型可以在这段时间内返回多个有类型的输出项,例如:

Response
├── Reasoning
├── Message(role=assistant, ...)
└── FunctionCall(call_id="call_17", name="exec_command", ...)

这些输出项统称为 ResponseItem。一项 Assistant Message 内部可以继续产生多个文字增量,一项工具调用也可以逐段产生参数。因而下面三个概念不能混用:

概念表示什么能否直接写入 History
Delta某个输出项尚未完成的一小段内容
ResponseItem已完成的一项消息、Reasoning 或工具调用
Response Completed整次模型响应已结束,并附带响应 ID、Token 用量等信息它提交响应边界,不代替具体 Item

如果模型输出工具调用,一次采样会先产生完整的 Tool Call Item,再以 Completed 结束。Runtime 随后执行工具,并把结果加入 History,开始同一 Turn 中的下一次采样。工具如何执行留到下一章;本章先把模型输出可靠地送到这条边界。

Prompt 在客户端层被编译成 Responses 请求

内部 Prompt 仍然与具体网络协议无关。ModelClient 收到它后才构造 ResponsesApiRequest

内部数据Responses 请求字段
当前模型model
Base Instructionsinstructions
规范化 Historyinput
ToolSpec 列表tools
并行工具能力parallel_tool_calls
推理强度与摘要配置reasoning
输出 Schema 与回答详细度text
Session 级缓存身份prompt_cache_key

请求还会设置 tool_choice: "auto"stream: true。这一步保留了上一章强调的类型边界:History 仍以 ResponseItem[] 发送,工具仍以结构化定义发送,JSON Schema 进入 text.format,而不是先拼成一篇巨大的文字提示词。

Responses Lite 使用另一种编码方式。它把工具定义包装成 Developer 角色的 AdditionalTools 输入项,把 Base Instructions 包装成 Developer Message,并清空顶层 instructionstools;同时关闭并行工具调用。也就是说,Lite 是请求能力适配,不只是给同一份 JSON 换一个地址。

内容标准 ResponsesResponses Lite
基础指令顶层 instructionsInput 前缀中的 Developer Message
工具定义顶层 toolsInput 前缀中的 AdditionalTools
并行工具调用按模型和 Prompt 决定关闭
Reasoning Context使用服务默认行为显式覆盖全部 Turn

这层适配让上游 Runtime 继续面对同一个 Prompt,协议差异被限制在模型客户端内部。

两层客户端对应两种生命周期

连接复用最容易引入跨 Turn 状态污染。Codex 没有把所有状态都塞进一个长期客户端,而是分成 ModelClientModelClientSession

对象生命周期主要状态
ModelClient整个 Codex SessionProvider、认证、Thread ID、Prompt Cache Key、传输回退状态、可复用 WebSocket 状态
ModelClientSession一个 Turn本 Turn 的流式请求、增量请求基线引用、x-codex-turn-state
WebsocketSession可在 Turn 之间转移物理连接、上次完整请求、上次完成响应及连接复用信息

这里的关键字段是 x-codex-turn-state。服务端可以在 Turn 开始时返回这枚粘性路由令牌,客户端在同一 Turn 后续的重试、增量追加或继续采样中原样带回。它不能进入下一个 Turn。

因此,每个 Turn 都创建新的 ModelClientSession 和空的 turn_state。Turn 结束时,仍然健康的 WebSocket 连接等传输状态可以放回 ModelClient 缓存,供下一 Turn 取用。复用的是连接,重置的是 Turn 语义状态。

这种设计比“每次请求都重新连接”复杂,但它明确回答了状态所有权问题:

  • HTTP 回退是否继续生效,由 Session 级 ModelClient 决定;
  • 当前 Turn 应送回哪枚路由令牌,由 Turn 级 ModelClientSession 决定;
  • 连接是否仍可复用,由 WebsocketSession 决定。

SSE 与 WebSocket 在事件层汇合

当前 Provider 支持 Responses WebSocket,并且本 Session 没有进入 HTTP 回退状态时,ModelClientSession::stream 优先选择 WebSocket;否则通过 HTTP POST /responses 建立 SSE 流。

两条路径在线路上不同:

  • SSE 由一次 HTTP 请求承载,服务端持续返回 text/event-stream
  • WebSocket 先建立双向连接,再发送 response.create,后续响应以文本帧返回。

但它们不会把两套协议一路泄漏到 run_turn。SSE Data 和 WebSocket Text Frame 都先反序列化为 ResponsesStreamEvent,再经过同一个事件映射函数,变成统一的 ResponseEvent

Codex 将 Prompt 映射成 Responses 请求,经 WebSocket 或 HTTP SSE 接收事件,再统一为 ResponseEvent 并分别驱动界面、历史和采样完成边界
图 5-1:传输差异在 codex-api 层收敛。Delta 进入低延迟展示,OutputItemDone 交付可持久化的完整项目,Completed 提交整次采样。移动端可横向滑动,点击可查看 SVG 原图。

这条统一事件线有两个直接收益。第一,Runtime 不需要为 SSE 和 WebSocket 各写一套消息、Reasoning 和工具调用处理器。第二,传输可以在失败后切换,而上游仍消费同一种事件类型。

五类事件构成一次响应的状态机

与内容主链最相关的事件可以按生命周期排列:

  1. Created:服务端已创建响应;
  2. OutputItemAdded:一个输出项开始,Runtime 建立当前活动项;
  3. OutputTextDelta、Reasoning Delta、Tool Input Delta:内容逐段到达;
  4. OutputItemDone(ResponseItem):该输出项已完整;
  5. Completed:整个响应完成。

run_turn 在收到 OutputItemAdded 后设置 active_item。此后的文字 Delta 必须属于这个活动项;如果没有活动项却收到文字增量,实现会把它视为协议或状态错误。界面此时可以收到 Item Started 和内容增量,所以用户无需等待整条回答生成完毕。

OutputItemDone 到达后,Runtime 清理该项的流式解析状态,并处理服务端给出的完整 ResponseItem。Assistant Message 会成为完成的 Turn Item;Tool Call 会先作为完整调用记录下来,再进入工具处理路径。

Completed 则处理响应级工作:发送 Raw Response Completed 事件、记录 Token Usage、刷新限流信息,并检查 end_turn。服务端若明确给出 end_turn: false,Runtime 会要求继续采样。

除了这条内容主线,ResponseEvent 还承载 Rate Limit、服务端实际模型、Models ETag、安全缓冲和账户验证等元数据。它们不会变成对话消息,而是更新 Session 状态或转成界面事件。

Delta 负责即时反馈,Done Item 负责事实

流式系统很容易犯的错误,是把每个文字 Delta 直接追加到持久化 History。网络若在一半断开,History 就会留下一个模型从未完成的句子;重试请求又可能在这个半句之后继续,模型视图与服务端响应边界从此错位。

Codex 把两种职责分开:

  • Delta 只驱动界面展示和流内解析;
  • OutputItemDone 提供完整、带类型的 ResponseItem
  • 完整 Item 通过 record_conversation_items 进入 History 和 Rollout;
  • Completed 再确认整次响应正常结束。

这不是说 Item Done 之前的内容没有价值。它们降低了首字延迟,还能让工具参数差异消费者提前观察参数变化。只是这些内容仍是暂态,不应冒充已经提交的对话事实。

当前源码也没有一个统一、官方命名为 ResponseAccumulator 的核心对象。一次响应的暂态分散在 active_item、Assistant Message 流式解析器、Plan Mode 状态和工具参数差异消费者中,完整项则由 OutputItemDone 交付。后面实现 Mini Codex 时可以主动把这些职责收进一个较小的 ResponseAccumulator,但那是教学实现的取舍,不是官方类型的翻译。

预热减少首个请求的准备时间

WebSocket 可以按需连接,但首次 Turn 若把认证、握手和请求准备全部串在用户提交之后,首个事件会更晚出现。Codex 因此提供启动预热。

Session 启动后,Runtime 会尽力构造一个启动用 TurnContext 和 StepContext,建立当前工具 Router,并用 Base Instructions、工具规格和空 Input 生成一份 Prompt。预热请求使用:

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

generate: false 表示建立连接和服务端响应链,而不是执行一次应计入正常 Rollout 的模型推理。客户端会等待该预热响应的 Completed,然后把得到的连接、完整请求基线和 Response ID 留给首个正常 Turn。

预热是尽力而为的优化。它有超时和取消边界;失败后不会阻止用户任务,而是让正常请求重新建立连接。这样可以把性能优化与正确性解耦:预热成功时少等一段,失败时协议语义不变。

Previous Response 只在严格匹配时使用

持久 WebSocket 解决了重复握手,但还没有解决重复发送长 Prompt 的问题。同一 Turn 的后续采样通常是在此前 History 末尾增加一个工具结果。如果每次都重传完整 Input,随着会话增长,线上请求会越来越大。

WebSocket 请求可以携带 previous_response_id,并只发送新增 Input。不过 Codex 不会只凭“这是同一连接”就使用增量。它先检查:

  1. 模型、Instructions、Tools、Tool Choice、Reasoning、Service Tier、Prompt Cache Key、输出控制等非 Input 属性是否一致;
  2. 当前 Input 的前缀是否严格等于“上次请求 Input + 服务端上次返回的完成项”;
  3. 上次响应是否存在有效 Response ID。

条件全部成立时,客户端发送:

{
  "type": "response.create",
  "previous_response_id": "resp_123",
  "input": [
    {"type": "function_call_output", "call_id": "call_17", "output": "..."}
  ]
}

若基础指令、工具表、模型、输出 Schema 或历史前缀发生变化,客户端退回完整请求。这样做牺牲了一些潜在复用,换来一个明确不变量:增量请求与完整逻辑请求必须让模型看到同一份内容。

预热后的首个真实请求还有一个特例。若真实请求与预热基线完全匹配,可以引用预热 Response ID 并发送空 Input 增量。传输层虽然省略了重复内容,Rollout Trace 仍记录完整逻辑请求,保证以后调试或回放时看到的是模型实际上下文,而不是线路压缩后的残片。

连接、Previous Response 与 Prompt Cache 是三种复用

这三种机制经常被统称为“缓存”,但它们优化的对象不同:

机制省掉什么失效条件
WebSocket 连接复用再次握手和建立传输连接关闭、超时或被重置
previous_response_id重发已有 Input 与已知返回项请求属性变化或 Input 前缀不匹配
prompt_cache_key服务端对稳定 Prompt 前缀的重复计算由服务端缓存策略、Key 和前缀变化决定

prompt_cache_key 默认使用 Session ID,也可以被显式覆盖。它是请求中的缓存身份,不是 Response ID。第四章介绍的稳定 History 前缀有助于 Prompt Cache;本章的增量请求减少线上载荷;持久连接减少传输准备。三者可以同时生效,但不能互相替代。

x-codex-turn-state 又是第四种不同的状态:它服务于同一 Turn 的粘性路由,不是缓存键,也不能跨 Turn 延续。

只有 Completed 才能证明流成功

TCP 连接正常关闭,不等于模型响应成功。服务端可能在只返回一半 Item 后断开,代理可能因空闲超时切断 SSE,WebSocket 也可能在完成事件前收到 Close Frame。

因此,SSE 和 WebSocket 都执行相同的终止检查:

  • 收到 response.completed:正常结束;
  • 收到 response.failed:按错误码映射为具体错误;
  • 收到 response.incomplete:产生流错误;
  • 连接在 Completed 前关闭:产生流错误;
  • 超过空闲等待时间:产生流错误;
  • 用户取消:终止当前 Turn,不作为网络重试。

response.failed 也不是一个笼统的“请求失败”。解析层会区分上下文窗口超限、配额不足、用量限制、无效请求、策略拒绝、服务过载和普通可重试错误。上下文窗口超限需要压缩或调整输入,配额问题需要用户或账户状态变化;盲目重发同一请求不会解决它们。

服务端实际使用的模型若因后端路由与请求不同,会通过 ServerModel 元数据事件报告。这与 WebSocket 回退 HTTP 完全不同:前者描述服务端模型选择,后者描述客户端传输选择。

重试会重建 Prompt,回退会改变 Session 状态

可重试错误进入采样外层循环。第一次尝试使用已经准备好的 Input;后续重试会从最新 History 再生成一次规范化 Prompt,而不是永久保存并机械重放旧请求。

这一点对流式响应尤其重要。某个完整 OutputItemDone 可能已经写入 History,随后连接才断开。重试从最新 History 构造请求,至少不会假装这些已经完成的项目不存在。尚未 Done 的界面 Delta 仍是暂态,不会进入新 Prompt。

等待时间优先采用错误中的 Retry-After;没有明确延迟时使用带抖动的退避。WebSocket 的普通重试预算耗尽后,Runtime 会:

  1. 在整个 Codex Session 范围禁用 WebSocket;
  2. 清空当前 WebSocket 连接和增量基线;
  3. 重置重试计数;
  4. 通过 HTTP SSE 重放请求。

HTTP 426 Upgrade Required 是更直接的信号,不需要先耗尽普通流重试预算。回退一旦生效,后续 Turn 也继续走 HTTP,避免每轮都重复尝试已知不兼容的 WebSocket。

Codex Responses WebSocket 从预热、完整或增量请求到重试和 Session 级 HTTP SSE 回退的流程
图 5-2:预热和 Previous Response 是可失效的优化;Completed 是成功边界。可重试错误先按预算重试,WebSocket 不再可靠时切换为 Session 级 HTTP 回退。

可以用接近 Python 的伪代码概括这层控制:

async def sample(prompt: Prompt, turn: TurnContext) -> SamplingResult:
    retries = 0

    while True:
        try:
            stream = await model_session.stream(prompt)

            async for event in stream:
                match event:
                    case OutputItemAdded(item):
                        stream_state.start(item)
                    case TextDelta(text):
                        await ui.publish_delta(text)
                    case OutputItemDone(item):
                        await history.record(item)
                    case Completed(response_id, usage, end_turn):
                        await usage_store.record(usage)
                        return SamplingResult(response_id, end_turn)

            raise IncompleteStream("missing response.completed")

        except Cancelled:
            raise TurnAborted
        except NonRetryableError:
            raise
        except RetryableError as error:
            if retries >= turn.max_stream_retries:
                if model_session.enable_http_fallback():
                    retries = 0
                    prompt = await rebuild_prompt_from_history()
                    continue
                raise

            retries += 1
            await asyncio.sleep(error.retry_after or backoff(retries))
            prompt = await rebuild_prompt_from_history()

伪代码省略了认证刷新、Reasoning 解析、工具参数增量和多项并发处理,但保留了四个关键不变量:取消不重试,Delta 不落历史,Completed 才成功,重试使用最新 History。

调试模型流要同时看内容和传输

当回答流异常时,只检查最终聊天消息往往不够。可以按现象定位:

现象优先检查
首个字符很慢启动预热是否完成、WebSocket 是否复用、认证是否阻塞
文本显示了一半后重新出现是否在 Completed 前断流,界面显示的是否只是暂态 Delta
History 中缺少模型调用是否收到 OutputItemDone,而不只是收到参数 Delta
每次都发送完整长请求请求非 Input 属性是否变化,History 前缀是否严格匹配
WebSocket 失败后每轮都再次尝试Session 级 disable_websockets 是否正确保持
日志显示模型变化区分 Server Model 事件与 HTTP 传输回退
用量没有更新是否真正收到带 Usage 的 Completed

调试记录也应同时保留两个视角:

  • 逻辑请求:模型完整看到的 Instructions、Input、Tools 与输出约束;
  • 线路请求:这次 WebSocket 是否只发送 Previous Response 和 Input Delta。

如果只记录线路请求,预热后的空增量看起来像“模型没有收到 Prompt”;如果只记录逻辑请求,又无法解释为什么网络载荷很小。Codex 的 Inference Trace 会在传输复用预热响应时补记完整逻辑请求,就是为了不混淆这两个视角。

模型调用层是一道协议防火墙

现在可以把本章的职责压缩成三层边界:

  • Prompt → ResponsesApiRequest:隔离 Runtime 领域模型与 Provider 请求格式;
  • ResponsesStreamEvent → ResponseEvent:隔离 SSE、WebSocket 与 Runtime 事件处理;
  • Delta → Done Item → Completed:隔离即时展示、可持久化事实与响应提交。

连接预热、Previous Response 和 Prompt Cache 都只优化这条主链,不能改变它的正确性条件。任何优化失效,都应退回完整请求或 HTTP 传输;任何流没有 Completed,都不能伪装成一次成功响应。

到这里,模型已经可以可靠地返回一项完整 Tool Call,但它仍然只是结构化意图。下一章将从 OutputItemDone(FunctionCall) 继续向下,检查 ToolRouter 怎样找到执行器,命令如何经过权限、审批和沙箱,工具结果又怎样回到下一次模型采样。

本章细节导航

延伸阅读

评论


← 返回文章列表