Prompt 怎样变成一份 Responses 请求
沿固定的 rust-v0.144.6 源码,把 normalized Prompt 拆成 ResponsesApiRequest,再追到 HTTP SSE、Responses Lite 与 WebSocket 的真实 wire shape。
第 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 是独立的 BaseInstructions;output_schema 和 output_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.input | clone、Lite 图片 detail 处理、必要时清理非 OpenAI 的内部 metadata |
| instruction assembly / base bundle | Prompt.base_instructions | 普通 Responses 变成顶层 instructions,Lite 变成 developer input item |
当前 ToolRouter | Prompt.tools | 序列化成 Vec<Value>,普通 Responses 放顶层,Lite 放 AdditionalTools |
TurnContext.model_info | Prompt.parallel_tool_calls | 普通 request 复制 Prompt capability;Responses Lite 由 use_responses_lite gate 强制为 false |
TurnContext.final_output_json_schema | Prompt.output_schema | 包装成 text.format 的 JSON schema;不进入工具定义 |
这个表里没有 ContextManager、WorldStateSnapshot 或 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。这个函数对每个 ToolSpec 做 serde_json::to_value,返回 Vec<Value>;因此这一段有一个真实的错误边界:工具规格序列化失败会沿 ? 从 request builder 返回,而不是等到模型回复后才暴露。
普通 Responses 的映射最直接:prompt.base_instructions.text 进入顶层 instructions,工具数组进入顶层 tools。tool_choice 固定为 auto,stream 固定为 true,parallel_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 始终是数组,哪怕内容为空。text、stream_options、service_tier 等可选字段按 Option 省略。reasoning 和 include 没有 skip_serializing_if:模型不支持 reasoning 时,wire 里仍会看到 reasoning: null 与 include: []。
这里有个容易被忽略的顺序: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 没有顶层 instructions 和 tools
Responses Lite 不是另一套 Agent loop,也不是把普通请求删掉几个字段。client 仍然从同一个 ResponsesApiRequest 逻辑结构出发,只在构造字段时走另一条分支:先建立一个 AdditionalTools input item,把工具规格放进去;如果 base instruction 文本非空,再追加一个 developer role 的 message;然后把这些前缀插到 input 开头,并返回空的 instructions 与 None 的顶层 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_id 和 generate 两个 WS 专用字段,并把请求包在 ResponsesWsRequest::ResponseCreate 中,序列化标签是 response.create。
第一次请求,或者上一次响应不能作为可靠基线时,发送完整 input。连接复用后,client 会先检查参与复用判断的非 input request properties 是否一致;stream_options 与 client_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 Required,stream_responses_websocket 会交回 FallbackToHttp,stream 在同一次调用里接住它并转入 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 的 ResponseStream。ResponseStream 只是一个可轮询的事件流,Drop 时还会通知 mapper 停止消费;它不承诺 turn 已完成,更不直接拥有工具调用。
这也是本章的停止线。这里的 handoff 指已确定的 request shape 和 transport 状态,不暗示同一个 Rust request 对象跨 HTTP/WS 分支或 fallback 生命周期被持有。下一章接手的是:
- 一份已经确定的
ResponsesApiRequestshape,或它在 WS 上的response.create形状; - 已选好的 transport 参数、headers,或显式的
FallbackToHttp交接; - 一个等待
ResponseEvent的ResponseStream。
下一章会解释这条流如何被两层循环消费。ResponseEvent::Completed 只是 provider 的响应边界;它不自动等于 turn completion,工具调用、pending input 和 stop hook 仍可能让外层循环继续。这个区别如果现在提前抹平,后面会把网络层的结束错写成任务层的结束。
在固定版本上复现 wire shape
以下命令都在第二部导读创建并校准过的 disposable archive 中执行,源码版本是 rust-v0.144.6,commit 是 5d1fbf26c43abc65a203928b2e31561cb039e06d。本机 Tokio worker 的默认栈不足以稳定跑这些 integration tests,所以命令显式把 just 的 rust_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 的形状:顶层 instructions 与 tools 不存在,input[0] 是 developer-role additional_tools,input[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_prefix、responses_websocket_creates_on_non_prefix 和 responses_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 item | Lite 没有工具或使用了另一套 loop |
| WS 第二帧只有尾部 input | baseline 前缀匹配且 previous response id 非空,使用了增量请求 | 不能据此断言所有 WS 请求都走 delta,或 delta 必须非空 |
ResponseStream 收到 completed | provider stream 到达 terminal event | turn 已完成、没有工具 follow-up |
text.format 带 JSON schema | turn-level output schema 被编码到 request | schema 已被 provider 接受或工具 schema 也相同 |
如果要继续改造,最小的安全切口是 request builder 的局部变体:例如新增一个 provider capability,明确它只改变 ResponsesApiRequest 的可选字段,再为 ordinary、Lite、WS full、WS incremental 各加一条 wire contract。不要在这里直接修改 ContextManager 或 RegularTask,那会跨过第 9 章和第 11 章的 ownership 边界。
交给下一章
| 交付对象 | 第 10 章已经封口 | 第 11 章是否继续 |
|---|---|---|
| request shape | ordinary 与 Lite 的字段映射、序列化和 provider/model gate | 否,不重讲 request shape |
| transport | HTTP 与 WebSocket 的选择、full/delta 局部分支 | 否;完整恢复矩阵统一交给第 13 章 |
| ResponseStream | HTTP / 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 的结论。网络只交付事件,下一章才处理这些事件怎样改变任务状态。