大部分 agent 框架把"支持多种模型 API"当卖点。Codex 走了相反的路:只支持一种 wire 协议,然后把省下的复杂度预算全部投到传输层和模型元数据上。本章拆这个取舍。
先看这个 [源码 model-provider-info/src/lib.rs:64]:
一个枚举,一个变体。
这不是笔误。全仓库 grep -rn "chat_completions\|ChatCompletions\|chat/completions" --include=*.rs 的结果是零 [源码,实测]。Chat Completions 支持被完整移除了。
对比一下 Pi 教程第 4 章讲的"一行代码驾驭多个模型"——那是把不同 provider 的差异抹平在一个抽象层里。Codex 的选择是:差异不抹平,直接不支持。
仓库里确实有 ollama 和 lmstudio 两个 crate,codex --oss 会拉起本地 Ollama 跑 gpt-oss:20b [源码 ollama/src/lib.rs:16]。它们走的是这些服务的 OpenAI 兼容端点——必须是 Responses 形态的兼容端点。这条路是"支持",但显然不是一等公民。
两个原因:
这条判断不可无脑迁移 如果你在做的是一个需要接多家模型的产品,Codex 的做法就是错误示范。
但反过来的教训成立:"支持所有模型"这个目标本身是有代价的——你只能用到所有 API 的交集能力。Codex 用放弃兼容换来的是服务端压缩、有状态会话、WebSocket 流式这些交集里没有的东西。第 7 章会看到这个交换值不值。
模型调用被拆成两个对象 [源码 core/src/client.rs:256, 276]:
ModelClientSession 的文档注释把设计约束写得非常清楚,值得完整引用:
A turn-scoped streaming session created from a ModelClient. The session establishes a Responses WebSocket connection lazily and reuses it across multiple requests within the turn. It also caches per-turn state:
- The last full request, so subsequent calls can reuse incremental websocket request payloads only when the current request is an incremental extension of the previous one.
- The x-codex-turn-state sticky-routing token, which must be replayed for all requests within the same turn.
Create a fresh ModelClientSession for each Codex turn. Reusing it across turns would replay the previous turn's sticky-routing token into the next turn, which violates the client/server contract and can cause routing bugs.
三件事都很有信息量:
不是每次请求建连,也不是全程一条连接,而是一轮一条。因为一轮之内可能有多次采样(第 4 章:工具调用后模型继续),建连成本不该重复付。
"只有当前请求是上一个请求的增量扩展时,才复用增量 websocket 请求载荷"——也就是说,第二次采样不用把整个历史重发一遍,只发新增的部分。
这对长会话是巨大的带宽和延迟节省。但它有个前提:连接得是同一条,服务端得记得上一次发了什么。这就是为什么要有 WebSocket,也是为什么要有下面这个。
服务端是分布式的。一轮之内的多次请求必须打到同一个后端实例(否则增量状态就丢了),所以服务端在轮次开始时发一个 token,客户端后续原样带回。
用 OnceLock 而不是 Mutex<Option<String>> 是个恰当的类型选择:这个值一旦设定就不能改,类型系统直接保证了这一点。
可迁移的判断 ⑤ 把"会话级"和"轮次级"的客户端状态分成两个类型,并在文档里写清楚跨界复用会出什么问题。
注释里那句 "Reusing it across turns would replay the previous turn's sticky-routing token into the next turn, which violates the client/server contract and can cause routing bugs" 是典型的代价前置写法——它没有依赖 reviewer 记得这条规则,而是把违反后果写在了类型定义上。
stream() 的主体是一个降级链 [源码 core/src/client.rs:1878]:
降级是永久性的 [源码 core/src/client.rs:1935]:
Permanently disables WebSockets for this Codex session and resets WebSocket state. This is used after exhausting the provider retry budget, to force subsequent requests onto the HTTP transport.
一旦 WebSocket 用尽重试预算,整个会话余下时间都走 HTTP。不做反复试探——因为在一个用户面前反复卡顿比一直慢一点更糟。
ModelClientSession 有两个预热方法 [源码 core/src/client.rs:1320, 1817]:
配合 core/src/session_startup_prewarm.rs(会话启动时就把 base instructions 送过去预热),这套机制的目的是:用户敲下第一个字符之前,连接和 KV cache 都已经就绪。
这是纯粹的产品化投入,对架构没有贡献,但对"感觉快不快"贡献极大。dsh 教程里反复出现的"KV Cache 影响"视角,在 Codex 这里变成了具体的预热代码。
models-manager/models.json 是 424117 字节,内含 10 个模型的完整元数据 [源码,实测]:
| slug | 上下文窗口 | 指令模板大小 |
|---|---|---|
| gpt-5.6-sol | 272000 | 17730 B |
| gpt-5.6-terra | 272000 | 17730 B |
| gpt-5.6-luna | 272000 | 17730 B |
| gpt-daybreak-blue-latest | 272000 | 17298 B |
| gpt-daybreak-red-latest | 372000 | 17297 B |
| gpt-5.5 | 272000 | 19754 B |
| gpt-5.4 | 272000 | 12896 B |
| gpt-5.4-mini | 272000 | 11114 B |
| gpt-5.2 | 272000 | 21544 B |
| codex-auto-review | 272000 | 17298 B |
注意最后一列:系统提示词是模型元数据的一个字段。
这是本教程认为 Codex 在架构上最值得注意的一个决定,第 6 章会展开讲它对上下文工程的意义。这里先说它对模型层的意义。
ModelInfo 结构体有 40 多个字段 [源码 protocol/src/openai_models.rs:389]。分类看:
| 类别 | 字段举例 |
|---|---|
| 身份 | slug / display_name / description / priority / visibility |
| 推理 | default_reasoning_level / supported_reasoning_levels / supports_reasoning_summary_parameter / default_reasoning_summary |
| 上下文 | context_window / max_context_window / auto_compact_token_limit / effective_context_window_percent / comp_hash |
| 工具能力 | shell_type / apply_patch_tool_type / web_search_tool_type / experimental_supported_tools / supports_search_tool / tool_mode |
| 输入能力 | input_modalities / supports_image_detail_original |
| 提示词 | model_messages(含 instructions_template 与变量) |
| 提示词开关 | include_skills_usage_instructions / include_plugin_usage_instructions / include_apps_usage_instructions |
| 服务层级 | service_tiers / default_service_tier / additional_speed_tiers |
| 行为策略 | truncation_policy / node_repl_disabled / node_repl_auto_review_required / auto_review_model_override / multi_agent_version |
| 生命周期 | upgrade(含 migration_markdown 与退役时间)/ availability_nux |
几个特别值得看的:
effective_context_window_percent —— 注释写道:
Percentage of the context window considered usable for inputs, after reserving headroom for system prompts, tool overhead, and model output.
(在为系统提示词、工具开销和模型输出预留余量后,认为可用于输入的上下文窗口百分比。)
上下文窗口不等于可用窗口。 这个显而易见但经常被忽略的事实,在这里被做成了一个可下发的参数。
auto_compact_token_limit 的计算逻辑很有意思 [源码 protocol/src/openai_models.rs:487]:
默认取上下文窗口的 90%,用户配置只能更严,不能更松(min)。这是一个防御性设计:用户把阈值配大了,系统不会跟着他跳崖。
comp_hash —— "Opaque identifier for compaction-compatible model configurations."(压缩兼容的模型配置的不透明标识符。)第 7 章会看到:模型切换时如果 comp_hash 变了,之前的压缩结果就不能复用了。
upgrade 带 migration_markdown 和退役时间 —— 模型的淘汰路径被写进了元数据,客户端可以直接把迁移说明展示给用户。
models.json 只是 bundled 的兜底 [源码 models-manager/src/lib.rs:12]:
真正的目录从服务端拉,带完整的缓存语义 [源码 models-manager/src/cache.rs:63]:
etag + TTL + client_version 三件套。client_version 尤其关键:同一个模型对不同版本的客户端可以下发不同的元数据——新客户端拿到新的工具集和新的提示词,老客户端拿到兼容版本。
可迁移的判断 ⑥ 把模型的能力、限制、提示词、工具偏好全部收进一份可远程刷新的元数据,而不是散在客户端代码的 match model_name 里。
这是本章最重要的一条。绝大多数 agent 项目的模型差异处理长这样:
每加一个模型就改一次代码,每改一次就要发一次版本。Codex 的做法是把这些判断变成数据,改数据不改代码,改完立刻对所有已安装客户端生效。
对本仓的启示:如果我们的 AI 项目要同时支持多个模型,模型差异应该从第一天就设计成配置而不是分支。
虽然只支持 Responses 一种 wire 协议,ModelProviderInfo 还是留了相当完整的自定义空间 [源码 model-provider-info/src/lib.rs:96]:
| 字段 | 用途 |
|---|---|
| base_url | 换端点(企业网关、代理) |
| env_key / env_key_instructions | API key 从哪个环境变量读,以及读不到时怎么提示用户 |
| auth | 命令行式的 bearer token 获取(跑一条命令拿 token) |
| aws | AWS SigV4 签名 |
| query_params / http_headers / env_http_headers | 自定义查询参数与头 |
| request_max_retries / stream_max_retries / stream_idle_timeout_ms / websocket_connect_timeout_ms | 四个超时与重试旋钮 |
两个细节:
① experimental_bearer_token 的注释是一句安全提醒:
Use of this config is discouraged in favor of env_key for security reasons, but this may be necessary when using this programmatically.
配置项本身就带着"别这么用"的说明。类型是 RedactedString——日志里不会打印出来。
② 重试次数有硬上限 [源码 model-provider-info/src/lib.rs:32]:
用户可以调,但调不过天花板。和 auto_compact_token_limit 的 min 是同一种思路:给用户旋钮,但不给他打穿系统的权限。
第 4 章讲过重试的分层。这里补充模型层的两个细节:
stream_idle_timeout_ms 的注释:
Idle timeout (in milliseconds) to wait for activity on a streaming response before treating the connection as lost.
(在把连接视为丢失之前,等待流式响应上出现活动的空闲超时。)
流式请求的"卡住"和"结束"在协议层没有区别——TCP 连接还在,就是不来数据。必须有一个客户端侧的空闲判定。这是所有做流式的人迟早会踩的坑(本仓 memory 里记的 aigateway.variflight.com 吊死问题,本质就是这个:成功 ~1s,失败不报错只吊死,而客户端没有空闲超时配置)。
ContextWindowExceeded 与 UsageLimitReached 被显式排除在重试之外(第 4 章已述)。后者还会顺手更新速率限制状态:
错误响应里带的配额信息被提取出来喂给 UI,用户能看到自己还剩多少。这类细节决定了产品观感。
下一章接着这条线索往下:既然系统提示词是模型元数据的一个字段,Codex 的上下文到底是怎么装配出来的?