青雲的博客
拆开 Codex 第二部:一次 Turn 怎样进入模型 第 10 章

Prompt 怎样变成一份 Responses 请求

沿固定的 rust-v0.144.6 源码,把 normalized Prompt 拆成 ResponsesApiRequest,再追到 HTTP SSE、Responses Lite 与 WebSocket 的真实 wire shape。

源码版本
rust-v0.144.6
验证日期
Commit
5d1fbf26c43abc65a203928b2e31561cb039e06d

第 9 章结束时,只有一份可以交给 build_prompt 的直接产物:history 被投影成 normalized Vec<ResponseItem>,也就是 Prompt.input 的候选值。base instructions、tools、parallel-call capability 和 output schema 分别由第 8 章的 instruction/context 原料、当前 TurnContext 与 tool planning 提供;它们会在本章的 build_prompt 中和 input 汇合。很多阅读者会在这里停下来,把这组跨 owner 的原料叫作“prompt”,然后直接想象成一段要 POST 的 JSON。

这一步想象得太快了。Codex 的 Prompt 还没有决定顶层 instructions 是否存在、工具放在 tools 还是 input、WebSocket 是否能省掉已经发送过的输入,也没有决定哪些字段要被 serde 省略。真正做这些决定的是 client 层的 request builder。本文只追踪这条边界,不提前进入工具执行和下一轮采样。

一条对象到 wire 的路径

flowchart TB
  accTitle: Prompt 到 Responses wire 的边界
  accDescr: 第九章交来的 normalized input 与其他 owner 的 request controls 先汇成 Prompt,再经 request builder 变成 ResponsesApiRequest,并选择普通 HTTP Server-Sent Events(SSE)或 WebSocket;Responses Lite 在同一 Agent loop 中主要改变输入前缀与字段布局
  PROMPT[Prompt: normalized input + request controls] --> BUILD[build_responses_request]
  BUILD --> REQUEST[ResponsesApiRequest logical shape]
  BUILD --> LITE_INPUT[Responses Lite input prefix]
  LITE_INPUT --> REQUEST
  REQUEST --> HTTP[HTTP POST /responses]
  REQUEST --> WS[WebSocket response.create]
  HTTP --> STREAM[ResponseStream / ResponseEvent]
  WS --> STREAM

图里的 REQUEST 是一个逻辑请求对象,不等于最终字节;这张图表达数据形状,不是函数调用时序。实际运行时,client session 先选择 HTTP 或 WebSocket,各分支再调用同一个 request builder 得到逻辑 shape。HTTP endpoint 编码 JSON、补 headers、发出 /responses POST,并以 Server-Sent Events(SSE)接收流式响应;WebSocket endpoint 则发出 response.create frame。WebSocket 还要决定发送完整 input 还是增量 input。两条路最后都回到 core 的 ResponseStream,所以 transport 不是第三层 Agent loop。

Prompt 不是字符串

Prompt 的字段先把责任分开:input 是第 9 章交来的 normalized Vec<ResponseItem>tools 是当前 ToolRouter 对模型可见的规格副本;parallel_tool_calls 是由模型能力写入 Prompt 的布尔值,request builder 再做 Lite gate;base_instructions 是独立的 BaseInstructionsoutput_schemaoutput_schema_strict 是本轮最终输出约束。后四类字段不是第 9 章的交付物,而是在 build_prompt 中由 TurnContext、router 和上游 context assembly 汇合。

build_prompt 不发请求,它只把 input、model-visible tools、base instructions 和 output schema 放进 Prompt。具体实现是:把输入接进来,调用 router.model_visible_specs() 得到规格副本,读取 turn_context.model_info.supports_parallel_tool_calls,复制 base instructions,再复制最终输出 schema。它也不执行工具。工具是否能连上 MCP、是否需要动态发现,发生在 built_tools 及其更早的 runtime 准备阶段;本章只接受已经进入 Prompt.tools 的结果。

这也解释了一个看似奇怪的默认值:Prompt::default() 直接使用 BaseInstructions::default(),其中默认文本由协议模型加载。client 只有在判断 prompt.base_instructions.text.is_empty() 时,才会在 Lite 输入里省掉那条 developer message。Lite 只改变 base instructions 的承载位置,不能据此反推上游没有生成它。

进入本章前已经具备的原料可以写成五个接口。其中只有 Prompt.input 由第 9 章直接交来;其余字段由第 8 章的 instruction/context assembly、当前 TurnContext 或 tool planning 提供,最后在 build_prompt 里汇合:

build_prompt 的输入来源第 10 章的接收字段这里会发生什么
第 9 章 normalized Vec<ResponseItem>Prompt.inputclone、Lite 图片 detail 处理、必要时清理非 OpenAI 的内部 metadata
instruction assembly / base bundlePrompt.base_instructions普通 Responses 变成顶层 instructions,Lite 变成 developer input item
当前 ToolRouterPrompt.tools序列化成 Vec<Value>,普通 Responses 放顶层,Lite 放 AdditionalTools
TurnContext.model_infoPrompt.parallel_tool_calls普通 request 复制 Prompt capability;Responses Lite 由 use_responses_lite gate 强制为 false
TurnContext.final_output_json_schemaPrompt.output_schema包装成 text.format 的 JSON schema;不进入工具定义

这个表里没有 ContextManagerWorldStateSnapshot 或 durable transcript。它们在更早的组装阶段影响 input,但不会作为独立字段穿过 client。读到 request builder 时,应该只追踪表中已经存在的字段。

build_responses_request:真正的 policy gate

build_responses_request 是本章的主角。它先调用 get_formatted_input_for_request 取得 input 的 clone。Responses Lite 会在这份 clone 上清掉 message、function output 和 custom output 里的 InputImage.detail;原始 Prompt.input 不被改写。非 OpenAI provider 还会清理内部 chat-message metadata passthrough。两个动作都发生在 wire preparation,不是 history normalization,也不是 durable state 修改。

随后 client 把 Prompt.tools 交给 create_tools_json_for_responses_api。这个函数对每个 ToolSpecserde_json::to_value,返回 Vec<Value>;因此这一段有一个真实的错误边界:工具规格序列化失败会沿 ? 从 request builder 返回,而不是等到模型回复后才暴露。

普通 Responses 的映射最直接:prompt.base_instructions.text 进入顶层 instructions,工具数组进入顶层 toolstool_choice 固定为 autostream 固定为 trueparallel_tool_calls 取 Prompt 的能力值。

其余字段仍有自己的 gate。reasoning 和 verbosity 先按模型能力决定,service tier 按模型目录过滤,prompt cache key 默认使用 thread id,client metadata 来自 Responses metadata。store 是必填布尔字段:普通 provider 为 false,Azure Responses endpoint 才设为 true;它还会参与后面的 item-id 保留判断。

最后要区分“值为空”和“字段省略”。instructions 为空时由 API 类型的 serde 规则省略;普通分支的 tools 始终是数组,哪怕内容为空。textstream_optionsservice_tier 等可选字段按 Option 省略。reasoninginclude 没有 skip_serializing_if:模型不支持 reasoning 时,wire 里仍会看到 reasoning: nullinclude: []

这里有个容易被忽略的顺序:Prompt 是内部类型,ResponsesApiRequest 才是 API wire contract。build_responses_request 返回的是后者,HTTP 和 WebSocket 都从这个逻辑对象开始。client session 负责选择 transport;HTTP endpoint 发 POST 并接收 SSE,WebSocket endpoint 发 response.create frame。

Provider 和 model capability 还会再筛一次

模型不是一个字符串:Provider、目录与 ModelInfo 已经拆过 provider 与模型目录怎样形成有效能力;这里不重复选择过程,只看它们怎样裁剪 request。当前版本的 WireApi 只有 Responses。provider 配置写成已移除的 wire_api = "chat" 会在反序列化时收到带迁移提示的错误,其他未知值也会被拒绝。也就是说,所谓“OpenAI-compatible provider”在这里不是任意 Chat Completions 端点;它至少要兑现 Responses wire contract。

WebSocket 又是 Responses 之上的独立 capability。supports_websockets 为 false 时,client 直接使用 HTTP;AWS SigV4 provider 目前还禁止同时声明 WebSocket 支持,因为 upgrade request 尚未接入对应签名。这个校验发生在 provider 配置层,早于本章后面的 WS fallback。

model capability 也会让字段静默缺席。模型不支持 verbosity 时,配置值只会触发 warning,text.verbosity 不进入请求;service tier 只有非 default 且存在于模型目录里时才保留;Lite 即使继承到 supports_parallel_tool_calls=true,request builder 仍把 parallel_tool_calls 设成 false。排查“配置写了但 body 没有”时,先看 capability gate,不要立刻归因于 serde。

为什么 Lite 没有顶层 instructionstools

Responses Lite 不是另一套 Agent loop,也不是把普通请求删掉几个字段。client 仍然从同一个 ResponsesApiRequest 逻辑结构出发,只在构造字段时走另一条分支:先建立一个 AdditionalTools input item,把工具规格放进去;如果 base instruction 文本非空,再追加一个 developer role 的 message;然后把这些前缀插到 input 开头,并返回空的 instructionsNone 的顶层 tools

因此 Lite 请求的第一项总是 additional_tools(即使工具数组为空),第二项可能是 developer message。第二项不是必然存在,判断条件就是 base text 是否为空。parallel_tool_calls 也被显式关掉,即使 model info 声明支持并行调用;这是 Lite wire contract 的限制,不是本轮工具执行逻辑改变。

Lite 还会在请求副本上去掉图片 detail,并通过 header/metadata 标记 Lite 模式。某些 hosted tools 在 Lite 的 spec plan 中会被省略,因为 Lite 接受的是 client-executed schema;这件事只影响模型可见工具集合,不能推出“Lite 不支持工具”。独立的 web search、image generation 或客户端工具仍可能保留,具体取决于上游 tool plan。

工具 schema 和最终输出 schema 不是一回事

工具规格的 JSON 由 ToolSpec 的 enum variant 决定:function、namespace、tool search、web search 或 custom。create_tools_json_for_responses_api 只负责把已经选好的规格序列化;它不把本轮最终输出 schema 塞进每个工具。

ResponsesApiTool 里确实有一个叫 output_schema 的字段,但它标了 #[serde(skip)]。在普通 Responses tool JSON 路径里,这个字段不会随工具定义发给模型;Code Mode 的 nested schema 另有自己的合同。真正面向整轮模型输出的 schema 来自 Prompt.output_schema,在 create_text_param_for_request 里被包装成 text.format:类型是 json_schema,名称固定为 codex_output_schema,同时携带 strict 和 schema value。本章没有证明 provider 接受或拒绝任意 schema,不能把“成功构造 TextControls”写成“schema 已经通过服务端验证”。

HTTP:一份请求,一条 SSE 流

HTTP 路径从 stream_responses_api 开始。每次 sampling attempt 都重新解析当前 auth/provider setup,构造 Responses transport 和 options,再调用同一个 build_responses_request。options 携带 session/thread/source、兼容性 header、turn state、Lite 标记和压缩设置。request body 在交给 endpoint 前还可能执行 item-id preparation:当 item IDs 没启用且不是需要存储的 Azure 请求时,输入 item 的 id 会在 wire copy 上被清掉。这不是第 9 章的 history projection。

ResponsesClient::stream_request 先把 ResponsesApiRequest 编码成 JSON,再补 x-client-request-id、session/thread 与 subagent headers,最后 POST responses。endpoint 把 Accept 设成 text/event-stream,将响应交给 SSE mapper,返回 API 层的 ResponseStream。core 再把它映射成自己的 ResponseStream,供 sampling 层消费。

WebSocket:不是把 HTTP body 原样搬过去

WebSocket 也从 build_responses_request 得到同一份逻辑 request,但随后转换成 ResponseCreateWsRequest。API 类型会补上 previous_response_idgenerate 两个 WS 专用字段,并把请求包在 ResponsesWsRequest::ResponseCreate 中,序列化标签是 response.create

第一次请求,或者上一次响应不能作为可靠基线时,发送完整 input。连接复用后,client 会先检查参与复用判断的非 input request properties 是否一致;stream_optionsclient_metadata 明确不参与这项比较。然后它把“上一次 request input + 上一次 server output”与新 request 的前缀比较。当前 prepare_websocket_request 调用传入 allow_empty_delta=true,所以只要前缀匹配、上一次响应有非空 id,剩余 delta 可以为空,也可以被压缩成 previous_response_id 加 input delta。非前缀、instructions/tools/reasoning 等参与复用判断的字段变化,都会回退到完整 response.create

这条规则直接保证 wire correctness:服务端只有在拥有同一个 response baseline 时,才知道 delta 应该接在哪。previous_response_id 缺失时看起来仍像一个合法 WS frame,但语义已经不再是增量续写。因此 WebSocket 复用有明确前提,不能视为无条件的增量协议。

ModelClientSession::stream 先判断当前是否启用 WebSocket;未启用就直接进入 HTTP。WebSocket 握手若返回 426 Upgrade Requiredstream_responses_websocket 会交回 FallbackToHttpstream 在同一次调用里接住它并转入 HTTP。这里没有新 turn,也没有重建 Prompt。到此只保留 transport 的选择与交接;重试预算、fallback 的作用域和跨 turn 行为统一留给第 13 章。

这次 fallback 也不只是临时换一条调用路径。force_http_fallback 会把 session-scoped disable_websockets 置为 true,并把缓存的 WebSocket session 清空;同一份 client session 后续再进入 stream 时会继续选择 HTTP。这个 sticky 状态属于当前 client session,不是全局 provider 配置。它怎样被触发、重试预算怎样消耗,仍由第 13 章展开。

WebSocket 的增量测试把这条边界钉得很实:同一测试序列明确断言第一帧是无增量基线的 response.create(input 长度为 1),第二帧带 previous_response_id=resp-1,input 只剩新消息;另一个 v2 测试还检查握手必须带 beta header。测试证明的是 request shape 和 handshake,不证明工具循环已经完成。

一个请求对象,两个传输入口

HTTP 和 WebSocket 的差别集中在“如何发”和“如何压缩 input”,而不是“模型这轮要做什么”。两条路径都会把 provider 返回的事件映射到 ResponseEvent,再交给 core 的 ResponseStreamResponseStream 只是一个可轮询的事件流,Drop 时还会通知 mapper 停止消费;它不承诺 turn 已完成,更不直接拥有工具调用。

这也是本章的停止线。这里的 handoff 指已确定的 request shape 和 transport 状态,不暗示同一个 Rust request 对象跨 HTTP/WS 分支或 fallback 生命周期被持有。下一章接手的是:

  1. 一份已经确定的 ResponsesApiRequest shape,或它在 WS 上的 response.create 形状;
  2. 已选好的 transport 参数、headers,或显式的 FallbackToHttp 交接;
  3. 一个等待 ResponseEventResponseStream

下一章会解释这条流如何被两层循环消费。ResponseEvent::Completed 只是 provider 的响应边界;它不自动等于 turn completion,工具调用、pending input 和 stop hook 仍可能让外层循环继续。这个区别如果现在提前抹平,后面会把网络层的结束错写成任务层的结束。

在固定版本上复现 wire shape

以下命令都在第二部导读创建并校准过的 disposable archive 中执行,源码版本是 rust-v0.144.6,commit 是 5d1fbf26c43abc65a203928b2e31561cb039e06d。本机 Tokio worker 的默认栈不足以稳定跑这些 integration tests,所以命令显式把 justrust_min_stack 设为 16777216。它解决的是测试线程栈容量,不改变请求断言。

这四组测试虽然只连接本机 mock 或 loopback server,test body 仍会先调用 skip_if_no_network!。只看 Cargo 最后的 ok 不够:一旦 CODEX_SANDBOX_NETWORK_DISABLED 存在,macro 会打印跳过原因并提前返回,test harness 仍可能把它记成通过。所以下面的每条命令都先要求该标记不存在;检查失败时整条命令不执行,也不使用 env -u 擦掉运行环境留下的事实。实际校准还要确认输出里没有 Skipping test because it cannot execute when network is disabled in a Codex sandbox.

普通 Responses:先看 headers,再看 body

: "${ARCHIVE_CODEX_RS:?先执行第二部导读的 archive 准备脚本}"
cd "$ARCHIVE_CODEX_RS"
test -z "${CODEX_SANDBOX_NETWORK_DISABLED+x}" &&
  just --set rust_min_stack 16777216 test --locked -p codex-core --test all chatgpt_auth_sends_correct_request

固定版本上的结果:

PASS codex-core::all suite::client::chatgpt_auth_sends_correct_request
Summary: 1 test run, 1 passed

这个测试没有试图断言所有 request 字段。它用 mock server 验证 /api/codex/responses、authorization、account、session/thread headers、stream=true 和 reasoning encrypted content include。它证明的是 ChatGPT auth 这条 HTTP 入口的关键合同,不是一个“任意 provider 都一样”的完整 body 快照。

Lite:同一条 loop 的另一种输入布局

: "${ARCHIVE_CODEX_RS:?先执行第二部导读的 archive 准备脚本}"
cd "$ARCHIVE_CODEX_RS"
test -z "${CODEX_SANDBOX_NETWORK_DISABLED+x}" &&
  just --set rust_min_stack 16777216 test --locked -p codex-core --test all responses_lite_uses_input_items_for_instructions_and_tools

结果是 Summary: 1 test run, 1 passed。断言只覆盖 body 的形状:顶层 instructionstools 不存在,input[0] 是 developer-role additional_toolsinput[1] 是 base instructions 非空时才出现的 developer message。这个实验把上面的 field mapping 变成可复现的 wire 证据,不覆盖 Lite 的回答质量。

WebSocket:先完整,再增量

: "${ARCHIVE_CODEX_RS:?先执行第二部导读的 archive 准备脚本}"
cd "$ARCHIVE_CODEX_RS"
test -z "${CODEX_SANDBOX_NETWORK_DISABLED+x}" &&
  just --set rust_min_stack 16777216 test --locked -p codex-core --test all responses_websocket_v2_requests_use_v2_when_provider_supports_websockets

测试结果同样是 Summary: 1 test run, 1 passed。它启动一个 mock WebSocket server,发送两次 prompt,检查第二个 frame 的 previous_response_id 和尾部 input,并检查 v2 handshake header。要验证非前缀或非 input 字段变化会回退完整请求,可以再运行同目录下的 responses_websocket_uses_incremental_create_on_prefixresponses_websocket_creates_on_non_prefixresponses_websocket_creates_when_non_input_request_fields_change;这些测试锁的是 prepare_websocket_request 的边界,不是协议宣传语。

最终输出 schema:放在 text.format

如果本轮设置了 final output schema,Prompt.output_schema 会走 create_text_param_for_request。可以直接运行固定版本的 JSON-result 集成测试:

: "${ARCHIVE_CODEX_RS:?先执行第二部导读的 archive 准备脚本}"
cd "$ARCHIVE_CODEX_RS"
test -z "${CODEX_SANDBOX_NETWORK_DISABLED+x}" &&
  just --set rust_min_stack 16777216 test --locked -p codex-core --test all codex_returns_json_result_for_gpt5

这个过滤串会命中 gpt5 与 gpt5-codex 两个测试,nextest 摘要应显示 Summary: 2 tests run: 2 passed(固定版本的该 suite 在 Windows 上由 cfg 排除)。mock request matcher 会检查:

text.format.type = json_schema
text.format.name = codex_output_schema
text.format.strict = true
text.format.schema = <the supplied schema>

这个实验只证明 strict=true 的普通 JSON-result case。在 build_prompt 里,普通来源把 output_schema_strict 设为 true,Guardian reviewer source 则设为 false;request builder 只把这个值透传到 text.format.strict。因此不能把一次 true case 写成全局常量。

这条 schema 和工具的 parameters 是两条字段链。前者约束整轮最终文本,后者描述某个 tool call 的参数;工具内部的 ResponsesApiTool.output_schema 还会被 serde(skip) 排除。Code Mode 里更复杂的 nested schema 处理放到 Code Mode 的工具合同,本章只固定 request builder 的边界。

第二部导读已经在 disposable archive 中完成 workspace lockfile 校准,因此这里可以统一使用 just test --locked。固定 source checkout 从未被 Cargo 写入;archive 内的 lockfile 变化只是准备阶段的机械校准,不是 request 行为证据。若准备脚本失败,先解决离线依赖或版本身份问题,不要把命令改回固定 checkout。

失败边界:哪些事实已经证明,哪些还没

观察到的现象源码能证明的结论不能顺手推出的结论
Lite body 没有顶层 tools工具规格被放进 AdditionalTools input itemLite 没有工具或使用了另一套 loop
WS 第二帧只有尾部 inputbaseline 前缀匹配且 previous response id 非空,使用了增量请求不能据此断言所有 WS 请求都走 delta,或 delta 必须非空
ResponseStream 收到 completedprovider stream 到达 terminal eventturn 已完成、没有工具 follow-up
text.format 带 JSON schematurn-level output schema 被编码到 requestschema 已被 provider 接受或工具 schema 也相同

如果要继续改造,最小的安全切口是 request builder 的局部变体:例如新增一个 provider capability,明确它只改变 ResponsesApiRequest 的可选字段,再为 ordinary、Lite、WS full、WS incremental 各加一条 wire contract。不要在这里直接修改 ContextManagerRegularTask,那会跨过第 9 章和第 11 章的 ownership 边界。

交给下一章

交付对象第 10 章已经封口第 11 章是否继续
request shapeordinary 与 Lite 的字段映射、序列化和 provider/model gate否,不重讲 request shape
transportHTTP 与 WebSocket 的选择、full/delta 局部分支否;完整恢复矩阵统一交给第 13 章
ResponseStreamHTTP / WebSocket client call 返回的统一 stream/event 边界是,下一章唯一继续消费的是 transport 返回的 ResponseStream

第 11 章 Codex 为什么有两层循环 只从这条 ResponseStream 开始,回答 ResponseEvent 到底由哪一层消费。第 12 章 一条 Responses 流怎样变成下一步行动 再把事件映射到 durable item、tool future 和 follow-up 条件。

本章依赖的输入来自 History 为什么不能直接拿去做 Prompt:只有把 normalized projection 和 live history 分开,才不会把 request body 当成全部会话状态。认证失败与 transport retry 的完整恢复矩阵留给 错误、重试与恢复;会话快照和 durable transcript 的边界留给 会话状态的三层边界

到这里,Prompt 才真正变成“可以发出去的一份请求”。但它还不是一次 turn 的结论。网络只交付事件,下一章才处理这些事件怎样改变任务状态。