这是本教程最核心的一章。Codex 的所有架构特征——多前端、可远程、可嵌入、可测试——都是同一个决定的后果:内核与界面之间只有两条队列,没有函数调用。 本章拆这个决定的实现与代价。
先数一下 Codex 需要支撑的前端形态:
| 形态 | 进程关系 | 语言 | 位置 |
|---|---|---|---|
| TUI(codex) | 同进程 | Rust | 本机终端 |
| 无头执行(codex exec) | 同进程 | Rust | 本机 / CI |
| IDE 插件(VS Code / Cursor / Windsurf) | 跨进程 | TypeScript | 本机 |
| 桌面 App(codex app) | 跨进程 | 非 Rust | 本机 |
| MCP 客户端(codex mcp-server) | 跨进程 | 任意 | 本机 |
| 云端任务(Codex Web) | 跨机器 | —— | 远端 |
如果内核暴露的是 Rust 函数,那么后面四种全都做不了。所以 Codex 从一开始就把内核的对外接口定义成消息而不是调用。
官方规范里对这个定位讲得很直白 [源码 codex-rs/docs/protocol_v1.md]:
The term "UI" is used to refer to the application driving Codex. This may be the CLI / TUI chat-like interface that users operate, or it may be a GUI interface like a VSCode extension. The UI is external to Codex, as Codex is intended to be operated by arbitrary UI implementations.
("UI"指驱动 Codex 的应用程序。它可能是用户操作的 CLI / TUI 聊天界面,也可能是 VSCode 扩展这样的 GUI 界面。UI 对 Codex 而言是外部的,因为 Codex 的设计目标就是被任意 UI 实现所驱动。)
对 Rust 侧调用者,入口就一个方法 [源码 core/src/codex_thread.rs:247]:
丢一个 Op 进去,拿一个 sub_id 回来,剩下的从事件流里读。
这是本章第一个值得记住的数字 [源码 protocol/src/protocol.rs]:
| 枚举 | 变体数 | 位置 |
|---|---|---|
| Op(UI → Codex) | 约 30 | protocol.rs:545 |
| EventMsg(Codex → UI) | 80 | protocol.rs:1298 起 |
输入 30、输出 80 这个不对称非常说明问题:agent 的复杂度不在"用户能让它做什么",而在"它做的过程中有多少种状态需要被外界看见"。
Op 的主要变体(节选):
| Op | 作用 |
|---|---|
| TurnInput { … } | 发起一轮(携带完整的 per-turn 上下文:cwd、模型、沙箱、审批策略) |
| Interrupt | 打断当前轮 |
| ExecApproval / PatchApproval | 批准或拒绝一次命令执行 / 补丁应用 |
| UserInputAnswer | 回答 request_user_input 工具的提问 |
| RequestPermissionsResponse | 回应权限升级请求 |
| ApproveGuardianDeniedAction | 覆盖 Guardian 的拒绝决定(第 11 章) |
| Compact | 手动触发压缩 |
| ThreadRollback { num_turns } | 回滚 N 轮 |
| Review { review_request } | 进入代码复核模式 |
| RefreshMcpServers / ReloadUserConfig | 热重载 |
| SuspendTurnAndShutdown | 挂起当前轮并退出(可恢复) |
| RealtimeConversation*(6 个) | 语音实时会话 |
EventMsg 的 80 个变体按语义分组:
| 组 | 代表变体 | 数量级 |
|---|---|---|
| 生命周期 | TurnStarted / TurnComplete / TurnAborted / SessionConfigured | ~8 |
| 助手输出 | AgentMessage / AgentMessageContentDelta / AgentReasoning / AgentReasoningRawContent | ~8 |
| 命令执行 | ExecCommandBegin / ExecCommandOutputDelta / ExecCommandEnd / TerminalInteraction | ~5 |
| 补丁 | PatchApplyBegin / PatchApplyUpdated / PatchApplyEnd / TurnDiff | 4 |
| 审批请求 | ExecApprovalRequest / ApplyPatchApprovalRequest / RequestPermissions / RequestUserInput / ElicitationRequest | 5 |
| MCP | McpStartupUpdate / McpToolCallBegin / McpToolCallEnd | ~5 |
| 内置工具 | WebSearchBegin/End / ImageGenerationBegin/End / ViewImageToolCall | ~5 |
| 上下文 | TokenCount / ContextCompacted / ThreadRolledBack | 3 |
| 安全 | GuardianWarning / GuardianAssessment / SafetyBuffering | 3 |
| 复核模式 | EnteredReviewMode / ExitedReviewMode | 2 |
| 实时语音 | RealtimeConversation* | 5 |
| 其它 | Error / Warning / StreamError / DeprecationNotice / ModelReroute / PlanUpdate / RawResponseItem … | ~25 |
可迁移的判断 ② Begin / Delta / End 三段式贯穿了每一类长耗时操作。 命令执行、补丁应用、MCP 调用、Web 搜索、图片生成,全是这个模式。
好处是 UI 可以对任何长操作套同一套渲染逻辑:开始时画个占位、delta 到了就追加、结束时定型。如果你在设计自己的 agent 事件流,先把这个三段式定死,比事后给每种操作单独设计事件要省太多。
协议规范定义了本教程后续都会用到的四个词 [源码 docs/protocol_v1.md]:
| 术语 | 定义 | 关键约束 |
|---|---|---|
| Session | Codex 当前的配置与状态 | 一个 Codex 实例一个 Session |
| Task | Codex 响应用户输入而执行的一次工作 | 一个 Session 同时只能有一个 Task |
| Turn | Task 里的一次迭代 | 一次模型请求 + SSE 收集 + 工具执行 |
| Model | Responses REST API | 见第 5 章 |
Task 的终止条件规范里列了五条:
规范与代码已经分叉 规范开头自己就写了:"NOTE: The code might not completely match this spec."
实际读代码会发现术语已经演进:现在的三级是 Task → Turn → Sampling Request(采样请求),代码里也叫 "step"。规范里的 "Turn" 大致对应今天的一次采样请求,而今天的 run_turn 是一个包含多次采样的循环。第 4 章会用代码里的术语。
这不是文档失职,而是一个真实项目的常态。读架构时,规范给你概念地图,代码给你真相。
还有一条向后兼容的痕迹值得注意:
Note: For v1 wire compatibility, EventMsg::TurnStarted and EventMsg::TurnComplete serialize as task_started / task_complete. The deserializer accepts both task_* and turn_* tags.
内部改名了,线上格式不能改,于是序列化标签保留旧名,反序列化两个都收。这是有真实下游用户的项目才会有的负担。
SQ/EQ 本身是进程内的 Rust 通道。要跨进程用,需要一层包装——这就是 app-server(连同协议和传输,共 199943 行)。
app-server 把队列语义翻译成 JSON-RPC 2.0 的方法调用与通知 [源码 docs/codex_mcp_interface.md]:
v2 主要 RPC:
| 分组 | 方法 |
|---|---|
| 线程 | thread/start、thread/resume、thread/fork、thread/read、thread/list |
| 轮次 | turn/start、turn/steer、turn/interrupt |
| 账号 | account/read、account/login/start、account/login/cancel、account/logout、account/rateLimits/read |
| 配置 | config/read、config/value/write、config/batchWrite |
| 元数据 | model/list、app/list、collaborationMode/list |
通知(Codex → 客户端): thread/started、turn/completed、account/login/completed,加上 codex/event/* 这一族——后者就是 EQ 事件的直通管道。
反向请求(Codex → 客户端): applyPatchApproval、execCommandApproval。注意这是服务端向客户端发起请求,因为审批必须由人做决定。JSON-RPC 的双向性在这里是刚需。
注意 turn/steer 这个方法:它不是"打断"也不是"新开一轮",而是往正在跑的轮次里插话。这个能力在协议层就存在,说明"用户中途改主意"被当成一等公民处理,而不是事后打补丁。
app-server-transport 提供了四条路 [源码 app-server-transport/src/transport/]:
| 传输 | 文件 | 用途 |
|---|---|---|
| stdio | stdio.rs | MCP 标准、IDE 插件 |
| Unix socket | unix_socket.rs | 本机守护进程(app-server-daemon) |
| WebSocket | websocket.rs | 远程控制 |
| remote control | remote_control/ | 云端接入 |
外加 auth.rs——跨进程了就需要认证。
协议规范对传输的态度是刻意开放的:
Can operate over any transport that supports bi-directional streaming: cross-thread channels, IPC channels, stdin/stdout, TCP, HTTP2, gRPC. Events still serialize cleanly to newline-delimited JSON for non-framed transports.
codex mcp-server 这条命令的含义值得单独说:它把整个 Codex 变成一个 MCP 服务器,任何 MCP 客户端(包括 Claude Code、包括另一个 Codex)都能把它当工具调用。
这是协议先行的直接红利。因为内核本来就只认消息,多加一种 wire 格式就是多写一个适配器,内核一行不用改。
这是最容易被低估的收益。因为内核的全部行为都表现为"喂进 N 个 Op,吐出 M 个 EventMsg",测试可以完全不碰 UI:
对比一下:如果 agent 内核直接调 UI 的渲染函数,你就得 mock 渲染层才能测循环逻辑。
WebSocket 传输 + 认证 = 内核跑在别处。云端 Codex 和本地 Codex 共用同一份内核代码,这在架构上是免费的。
IDE 插件是 TypeScript,桌面 App 是另一套技术栈,它们都不需要 FFI,只需要能收发 JSON。
第 12 章会讲到,会话持久化(rollout)记录的就是这条事件流。"给 UI 看的"和"存下来的"是同一批数据,不需要维护两套。这和 dsh 的"事件溯源 + surface 投影"(dsh 教程第 8 章)思路一致——两个独立项目走到同一个答案,通常说明这个答案是对的。
模型想问用户一个问题?不能弹窗,得走 EventMsg::RequestUserInput → UI 渲染 → Op::UserInputAnswer 回来。审批、权限升级、MCP elicitation、动态工具调用,全是这套往返。
EventMsg 里有 5 个变体专门用于"向用户要东西",Op 里有对应的 5 个回应变体。这 10 个变体的存在本身就是解耦的账单。
前面提到的 task_started / turn_started 双标签只是冰山一角。app-server-protocol 分了 v1 / v2 两套类型,v1 里还留着 getConversationSummary、gitDiffToRemote、fuzzyFileSearch 这些兼容 RPC。
协议一旦有外部消费者就冻结了。 这是内部函数调用没有的成本。
规范写死:一个 Session 同时只能跑一个 Task。要并行怎么办?
Since only 1 Task can be run at a time, for parallel tasks it is recommended that a single Codex be run for each thread of work.
(由于同时只能跑一个 Task,并行任务建议为每条工作线索各跑一个 Codex 实例。)
并行的粒度是"整个引擎实例",不是"一个 Session 内的多个任务"。 第 14 章讲多 agent 时会看到,spawn_agent 本质上就是起一个新的内核实例。这个约束大幅简化了状态管理(一个 Session 的状态永远只被一个 Task 改),代价是并发模型比较粗。
| 维度 | Pi | dsh | Codex |
|---|---|---|---|
| 内核与 UI 的关系 | 库调用 | 插件树内的一批插件 | 进程间协议 |
| 事件机制 | 事件驱动(Pi 教程第 7 章) | 事件溯源 + surface 投影 | SQ/EQ 双队列 |
| 换前端的成本 | 写一个新的 TS 前端 | 写一批 UI 插件 | 写一个 JSON 客户端,任意语言 |
| 远程运行 | 不支持 | 需要 host 层配合 | 传输层换一个即可 |
| 版本兼容负担 | 低 | 中 | 高 |
dsh 的"事件日志是真源,消息历史是派生物"和 Codex 的"EQ 是唯一出口"讲的是同一件事的两个侧面。差别在边界画在哪:dsh 把边界画在插件树内部(同进程),Codex 画在进程外。
实际看一眼事件流(把 Codex 当 MCP 服务器起来,然后手工发 JSON-RPC):
下一章进入引擎内部:run_turn 这个 400 行的循环里,到底发生了什么。