上一章结束时,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 Instructions | instructions |
| 规范化 History | input |
| 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,并清空顶层 instructions 和 tools;同时关闭并行工具调用。也就是说,Lite 是请求能力适配,不只是给同一份 JSON 换一个地址。
| 内容 | 标准 Responses | Responses Lite |
|---|---|---|
| 基础指令 | 顶层 instructions | Input 前缀中的 Developer Message |
| 工具定义 | 顶层 tools | Input 前缀中的 AdditionalTools |
| 并行工具调用 | 按模型和 Prompt 决定 | 关闭 |
| Reasoning Context | 使用服务默认行为 | 显式覆盖全部 Turn |
这层适配让上游 Runtime 继续面对同一个 Prompt,协议差异被限制在模型客户端内部。
两层客户端对应两种生命周期
连接复用最容易引入跨 Turn 状态污染。Codex 没有把所有状态都塞进一个长期客户端,而是分成 ModelClient 和 ModelClientSession。
| 对象 | 生命周期 | 主要状态 |
|---|---|---|
ModelClient | 整个 Codex Session | Provider、认证、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。
这条统一事件线有两个直接收益。第一,Runtime 不需要为 SSE 和 WebSocket 各写一套消息、Reasoning 和工具调用处理器。第二,传输可以在失败后切换,而上游仍消费同一种事件类型。
五类事件构成一次响应的状态机
与内容主链最相关的事件可以按生命周期排列:
Created:服务端已创建响应;OutputItemAdded:一个输出项开始,Runtime 建立当前活动项;OutputTextDelta、Reasoning Delta、Tool Input Delta:内容逐段到达;OutputItemDone(ResponseItem):该输出项已完整;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 不会只凭“这是同一连接”就使用增量。它先检查:
- 模型、Instructions、Tools、Tool Choice、Reasoning、Service Tier、Prompt Cache Key、输出控制等非 Input 属性是否一致;
- 当前 Input 的前缀是否严格等于“上次请求 Input + 服务端上次返回的完成项”;
- 上次响应是否存在有效 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 会:
- 在整个 Codex Session 范围禁用 WebSocket;
- 清空当前 WebSocket 连接和增量基线;
- 重置重试计数;
- 通过 HTTP SSE 重放请求。
HTTP 426 Upgrade Required 是更直接的信号,不需要先耗尽普通流重试预算。回退一旦生效,后续 Turn 也继续走 HTTP,避免每轮都重复尝试已知不兼容的 WebSocket。
可以用接近 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 怎样找到执行器,命令如何经过权限、审批和沙箱,工具结果又怎样回到下一次模型采样。
本章细节导航
- 5.1—5.6:模型/Provider 能力、客户端生命周期、请求身份与序列化
- 5.7—5.12:SSE、WebSocket、预热、增量请求、Previous Response 与回退
- 5.13—5.19:响应状态机、文字/Reasoning/工具增量与 History 提交
- 5.20—5.26:安全缓冲、限流、错误恢复、输出 Schema、取消与 Realtime
评论
登录后即可评论