第 13 章
第 3 周:一个生产级 Agent 长什么样?Codex 的仓库地图
读 OpenAI Codex 源码的第一讲:仓库里九成六是 Rust,155 个 crate 只看主线;核心 + 协议 + 多客户端的分层,core 内部的 Op / EventMsg 队列、对外的 thread / turn / item 协议,CI 怎么守住分层,以及 codex exec --json 的事件流契约。一段讲解视频,一个可点击的架构地图和一次提交的单步动画。
上一周我们从零写了一个 agent loop:一个 run_agent() 函数,发请求、看 stop_reason、执行工具、追加 messages,直到模型说做完了。它能跑,但离一个真正有人在用的 Agent 还差很远:界面要实时显示流式文字,用户要能中途打断、补一句话、审批一条命令,终端、CI、桌面 App、Python 脚本都要用同一个 Agent。这一周读 OpenAI 开源的 Codex,看生产级 Agent 怎么处理这些事。第一讲先不看 loop 的细节,先把地图画出来:仓库里有什么,协议分几层,一次用户提交要经过哪些文件。
本周所有源码引用都基于 openai/codex commit
7993248
(2026-10-01),链接都固定到这个 commit。本机装的 codex CLI 是 0.159.2,命令行参数以本机 --help 为准。
讲解视频
互动演示
两个演示。第一个是可点击的架构地图:点一个 crate,看它的职责、关键文件(都链到源码),以及它依赖谁、谁依赖它;依赖数据取自这个 commit 里 155 个 crate 的 Cargo.toml。下面还能模拟"给 TUI 加一条 codex-core 依赖",看 CI 的边界检查怎么报错。第二个是一次提交的单步动画:turn/start 怎么变成 core 提交队列里的 Op::TurnInput,core 推出的 EventMsg 又怎么变成 app-server 通知和 codex exec --json 的 JSONL。可以切换"正常完成 / 中途中断 / 运行中又提交一次"三个场景,拖动文字增量个数,看三层各自收到多少条消息。页面底部有自动判分的练习。
为什么读 Codex
拿上一周的 loop 对照一下,生产环境会多出这些需求:
| 第 2 周的写法 | 生产里会遇到的问题 | Codex 的做法(本讲只看位置) |
|---|---|---|
run_agent(task) 阻塞到结束才返回 | 界面要实时显示流式文字、命令输出 | core 往事件队列推 EventMsg,客户端边收边画 |
| 没法中途插话、取消 | 用户要中断、补一句话、审批命令 | 客户端往提交队列塞 Op::Interrupt、Op::TurnInput、Op::ExecApproval |
| 只有一个调用方 | 终端、无人值守的 CI、桌面 App、Python 脚本都要用 | 对外只暴露 app-server 协议,各客户端各自实现 |
| messages 列表就是全部状态 | 要恢复会话、要回放、要给 UI 一个稳定的数据模型 | thread / turn / item 三层模型;会话写进 rollout 文件 |
后面几讲(生产级 loop、工具与 MCP、上下文压缩、沙箱与审批、测试工程)讲的每段代码,都能在这张地图上找到位置。
仓库现状:几乎全是 Rust
统计方法:在这个 commit 的浅克隆里,用 git ls-files '*.rs' | xargs cat | wc -l 这类命令按扩展名数物理行数(含空行、注释和测试,不是 tokei 那种只数代码行),再按目录拆开。
| 语言 | 文件数 | 行数 | 都在哪 |
|---|---|---|---|
| Rust | 5,057 | 1,985,677 | 5,040 个在 codex-rs/,其余是 Bazel 测试规则和自研 lint |
| TypeScript | 761 | 11,524 | 737 个文件(8,168 行)是 app-server-protocol/schema/typescript/ 下由 ts-rs 从 Rust 类型生成的绑定;手写的只有 sdk/typescript(24 个文件,3,356 行,其中 src/ 1,051 行) |
| JavaScript | 3(另有 2 个 .cjs、1 个 .mjs) | 413(.cjs 197,.mjs 598) | npm 启动器 codex-cli/bin/codex.js 295 行;其余 .js 是 ESLint 配置和一个代理的 npm 入口;.cjs 是 TS SDK 的 Jest 配置和一个示例技能脚本,.mjs 是同一个示例技能(codex-rs/skills 里的 openai-docs)的另一个脚本 |
| Python | 196 | 62,029 | sdk/python 77 个文件 27,839 行(其中 generated/ 13,412 行);scripts/ 39 个文件 16,897 行(含 MCP 一致性测试 scripts/mcp_conformance 9 个文件 11,491 行和打包脚本);.github/ 的 CI 脚本 33 个 6,421 行;third_party/voice 26 个 5,346 行;codex-rs/skills 的示例技能脚本 9 个 2,764 行;其余零散 |
codex-rs/Cargo.toml 的 [workspace] members 一共 155 项。npm 包里的
codex.js
按平台挑一个可选依赖里的原生二进制,然后
spawn(binaryPath, process.argv.slice(2), ...)
。npm i -g @openai/codex 装到的其实是一个 Rust 程序。
TS 文件里七成多是生成的,这本身就是分层的证据:协议类型只在 Rust 里定义一次(app-server-protocol),TS 绑定由 ts-rs 生成,Python SDK 的 generated/v2_all.py 由 datamodel-codegen 从协议的 JSON Schema 生成(文件头注释写明了来源)。例外是 TS SDK 的 events.ts:它对应的是 exec 的 JSONL,是手写的,第一行注释写着 “based on event types from codex-rs/exec/src/exec_events.rs”。
155 个 crate,只看主线
不用逐个看。按"一次请求会经过谁"分成六组:
| 分组 | crate(目录名) | 一句话 |
|---|---|---|
| 入口 / 客户端 | cli、tui、exec | cli 是 codex 这个多子命令二进制;tui 是交互界面;exec 是非交互模式 |
| 协议边界 | app-server-protocol、app-server、app-server-client、app-server-transport | 对外协议的类型、服务端、进程内客户端,以及 stdio / unix socket / websocket 传输 |
| 核心 | core、protocol | core 里是会话、turn、agent loop;protocol 定义 Op、EventMsg 等内部协议类型 |
| 模型与工具 | codex-api、codex-client、tools、apply-patch、codex-mcp、rmcp-client、hooks | Responses API 流式解析与重试、工具契约、补丁、MCP 客户端、钩子 |
| 安全 | sandboxing、linux-sandbox、execpolicy、shell-command | OS 沙箱、Starlark 命令策略、危险命令判定 |
| 状态与可观测 | rollout、state、thread-store、config、otel | 会话记录、SQLite、线程存储、分层配置、OpenTelemetry |
几个从依赖图里读出来的数字(只算 [dependencies] 和 [target.*.dependencies] 里的 workspace crate,不含 dev-dependencies):protocol 被 76 个 crate 直接依赖,是整个仓库的"公共语言";core 直接依赖 69 个 crate;execpolicy 只依赖 1 个,几乎是独立的库。
三层协议
第一层:core 内部的 SQ / EQ
protocol/src/protocol.rs:1-4
的模块注释写明:用 SQ(Submission Queue)/ EQ(Event Queue)模式在用户和 agent 之间异步通信。两个队列在会话创建时建好(
core/src/session/mod.rs:591-592
):
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
let (tx_event, rx_event) = async_channel::unbounded();
tx_sub / rx_sub是提交队列,有界,容量SUBMISSION_CHANNEL_CAPACITY = 512( mod.rs:504 )。客户端提交太快时发送方会等,形成背压。tx_event / rx_event是事件队列,无界。core 推事件时不会因为 UI 读得慢而卡住 agent loop。
客户端往 SQ 里放 Submission { id, op, ... },op 的类型是 Op 枚举(
protocol.rs:586-766
,29 个变体);core 从 EQ 里吐出 Event { id, msg },msg 是 EventMsg(
protocol.rs:1352-1575
,1352 行起是文档注释,枚举本体在 1358-1575 行,83 个变体)。提交的入口是
SessionIo::submit_with_id
:
pub(crate) async fn submit_with_id(&self, mut sub: Submission) -> CodexResult<()> {
if sub.trace.is_none() {
sub.trace = current_span_w3c_trace_context();
}
self.tx_sub
.send(sub)
.await
.map_err(|_| CodexErr::InternalAgentDied)?;
Ok(())
}
逐行看:
pub(crate) async fn:只在 core 这个 crate 内可见的异步函数。外面拿不到它,只能通过CodexThread的公开方法间接调用。if sub.trace.is_none():没带 trace 上下文就补上当前 span 的 W3C trace,提交和之后的事件能串成一条链路,是上一周 trace 那一章的生产版。self.tx_sub.send(sub).await:塞进提交队列,队列满了就在这里等。.map_err(|_| CodexErr::InternalAgentDied)?:发送失败只有一种可能,接收端(core 的循环)已经退出,于是翻译成"agent 死了"这个明确的错误;?把错误往上抛。
另一头是
handlers.rs:422 的 submission_loop
:一个 loop 不停地 rx_sub.recv(),对 sub.op 做 match,Op::Interrupt 就中断,Op::TurnInput 就交给 turn_input::handle。客户端只提交意图,怎么执行由 core 决定。
两个细节值得记住:
- 提交 id 就是 turn id。提交 id 用
Uuid::now_v7()生成,注释写明 app-server 把"开启 turn 的那次提交的 id"当作对外的 turn id( mod.rs:1086-1092 );TurnStartedEvent.turn_id取自turn_context.sub_id( mod.rs:2232-2235 )。从日志里拿到一个 turn id,就能回溯到是哪次提交触发的。 Op不能过网络。它只 derive 了Debug,没有Serialize,里面还带着oneshot::Sender这种进程内回调通道。对外需要另一层能序列化的协议,这就是 app-server。
第二层:app-server 的 thread / turn / item
app-server 的消息格式是简化版的 JSON-RPC:
rpc.rs:1-2
的注释原文是 “We do not do true JSON-RPC 2.0, as we neither send nor expect the "jsonrpc": "2.0" field."。方法都在 app-server-protocol/src/protocol/common.rs 里用宏声明:
| 方向 | 例子 | 位置 |
|---|---|---|
| 客户端 → 服务端请求 | initialize、thread/start、thread/resume、turn/start、turn/steer、turn/interrupt | common.rs:499、551、557、1038、1050、1056 |
| 服务端 → 客户端请求 | item/commandExecution/requestApproval(审批) | common.rs:1778 |
| 服务端 → 客户端通知 | thread/started、turn/started、turn/completed、item/started、item/completed、item/agentMessage/delta、thread/tokenUsage/updated | common.rs:1932-1975 |
thread 是一次会话;turn 是用户发起的一次请求,对应上一周的一次 run_agent;item 是 turn 里的一个单元:一条 agent 消息、一次命令执行、一次文件修改、一次 MCP 调用。TurnStatus 只有 completed / interrupted / failed / inProgress 四种(
v2/turn.rs:33-38
)。传输可以是 stdio://、unix://、ws://IP:PORT(
transport/mod.rs:81-86
)。这个三层模型也很适合直接拿来当 trace 的 span 层级。
从第一层到第二层的翻译在 app-server 里:每个 thread 有一个监听任务循环调用 conversation.next_event()(
thread_lifecycle.rs:302
),拿到 EventMsg 后交给
apply_bespoke_event_handling
,变成 ServerNotification,发给订阅了这个 thread 的所有连接。文字增量、item 开始和结束这类一一对应的映射集中在
event_mapping.rs:362-419
。
反方向,turn/start 进来后的调用链是:
turn_processor.rs:174 的 turn_start
→
CodexThread::start_or_steer_turn
→
SessionIo::submit_turn_input
,最后变成 SQ 里的一个 Op::TurnInput。名字里的 “steer” 说明了一件事:thread 正忙时再发 turn/start,core 不会排队开新 turn,而是先尝试把输入并入正在跑的 turn(
turn_input.rs:299-339
),回复 Steered,app-server 返回的 turn id 是正在跑的那个,也不会再发一次 turn/started。steer 也可能被拒:正在跑的是 review 或 compact turn(ActiveTurnNotSteerable)、输入为空(EmptyInput)、这次请求带的 output schema 和正在跑的 turn 不一致(ActiveTurnOutputSchemaMismatch)时,core 回复 NotSubmitted,app-server 直接返回 JSON-RPC 错误,既不排队也不开新 turn(
turn_input.rs:683-711
、
turn_processor.rs:673-684
)。另外,TUI 自己检测到有活动 turn 时发的是 turn/steer,只有没有活动 turn 时才发 turn/start(
thread_routing.rs:751-763
、
:879
);运行中直接发 turn/start 的是 Python SDK 或自己写的客户端。
第三层:客户端
| 客户端 | 怎么接 app-server | 依据 |
|---|---|---|
| TUI | 没指定远程时,先用 50ms 超时探测本机 app-server daemon 的控制 socket:连得上就用 LocalDaemon(允许回退到嵌入),连不上才进程内嵌入(Embedded);也能连远程(Remote)。部分启动参数会跳过探测,直接嵌入 | tui/src/lib.rs:317-326 、 :511-535 、 :1030-1037 、 startup_orchestration.rs:302-315 |
codex exec | InProcessAppServerClient::start(...) 进程内嵌入;--json 时把通知再投影成 JSONL | exec/src/lib.rs:986 |
| TS SDK | spawn codex exec --experimental-json,读 JSONL(--experimental-json 是 --json 的别名) | sdk/typescript/src/exec.ts:92 、 exec/src/cli.rs:58-65 |
| Python SDK | 启动 codex app-server --listen stdio://,走 JSON-RPC | sdk/python/src/openai_codex/client.py:256 |
两个 SDK 接入的层不一样:Python SDK 接的是完整的 app-server 协议,审批这类服务端请求、turn/steer、文字增量通知都在这一层;TS SDK 拿到的是 exec 再投影一次的 JSONL,粒度更粗,下面的实验会看到它连文字增量都没有。拿两个 SDK 做对比实验时,先确认差异是不是来自接入层。
CI 怎么强制分层
分层光靠约定,迟早会被一次图省事的 import 打破。Codex 把它写成了 CI 检查:
repo-checks.yml:23-24
运行 .github/scripts/verify_tui_core_boundary.py,repo-checks 又是阻塞门禁 blocking-ci.yml 的一环(
:33-36
),最后汇总到一个 if: always() 的 “CI required” job。
这个脚本只有 91 行,做两件事( :16-83 ):
- 查清单:读
codex-rs/tui/Cargo.toml,dependencies、dev-dependencies、build-dependencies以及各平台target.*下的同名段,查的是依赖的键名:FORBIDDEN_PACKAGE in dependencies( :47-48 ),键名是codex-core就算违规。所以靠package = "codex-core"改名引入能绕过这一步:键名写成codex_core的话,源码里的codex_core::还会被第二步抓到;改成别的名字,两步都抓不到。 - 查源码:扫
tui/**/*.rs的每一行,匹配codex_core::、use codex_core、extern crate codex_core三个正则。
我在本地跑了一遍。原样跑,退出码 0;复制一份 tui 目录,在 lib.rs 末尾加一行 use codex_core::CodexThread; 再跑:
codex-tui must not depend on or import codex-core directly.
Use the app-server protocol/client boundary instead; temporary embedded startup gaps belong behind codex_app_server_client::legacy_core.
- codex-rs/tui/src/lib.rs:3999 imports `codex_core`
退出码 1。这条规则管的是 API 边界,不是链接关系,有三个细节:
- TUI 的传递依赖里仍然有 core:
tui → app-server-client → app-server → core。嵌入模式下 core 就在同一个进程里,而且不管走哪种模式,core 都编进了同一个二进制。规则保证的是 TUI 的代码只能通过协议跟 core 说话。 - 官方留了一个过渡口子:
app-server-client的legacy_core模块只 re-export 了codex_core::config( lib.rs:70-83 ),注释说是为了在启动和配置路径迁到 RPC 之前先去掉直接依赖。TUI 源码里引用legacy_core::config的地方有 191 处。 - 只有 TUI 有这条检查。
exec的Cargo.toml直接依赖codex-core,源码里也在用codex_core::config等;core自己也依赖app-server-protocol(4 个文件用到,主要是把 rollout 投影成 turn 历史)。分层是"方向上大体如此”,不是严格的单向图。
同一个 workflow 里还有两条同类检查:所有 crate 的 Cargo.toml 必须继承 workspace 设置,Bazel 的 clippy 参数必须和 Cargo 的 lint 配置一致。思路都一样:把"大家都应该这样做"写成一个几十行的脚本,挂进阻塞门禁。对测开来说,这就是架构测试,Java 生态里 ArchUnit 做的也是这件事。
动手实验:codex exec --json 的事件流契约
先看本机 0.159.2 的帮助,这个命令不调模型:
$ codex exec --help
Run Codex non-interactively
Usage: codex exec [OPTIONS] [PROMPT]
codex exec [OPTIONS] <COMMAND> [ARGS]
...
-s, --sandbox <SANDBOX_MODE>
Select the sandbox policy to use when executing model-generated shell commands
[possible values: read-only, workspace-write, danger-full-access]
...
--json
Print events to stdout as JSONL
-o, --output-last-message <FILE>
Specifies file where the last message from the agent should be written
--json 输出的每一行是一个 ThreadEvent(
exec_events.rs:8-37
),只有 8 种:
| type | 何时出现 |
|---|---|
thread.started | 第一个事件,带 thread_id,可用于 resume |
turn.started | 提交 prompt 后 |
item.started / item.updated / item.completed | item 的生命周期。item.type 有 9 种:agent_message、reasoning、command_execution、file_change、mcp_tool_call、collab_tool_call、web_search、todo_list、error |
turn.completed | 正常结束,带 usage(5 个 token 字段) |
turn.failed | turn 失败,带 error.message |
error | 事件流上的错误通知。可重试的流错误(will_retry=true)也会输出这一行,之后 turn 仍可能 turn.completed;判断失败看 turn.failed 和退出码(
jsonl_output.rs:447-457
、
bespoke_event_handling.rs:1054-1070
、
exec/src/lib.rs:1261-1266
) |
读投影代码
event_processor_with_jsonl_output.rs
,有四条写测试时必须知道的行为:
- agent_message 和 reasoning 没有
item.started,只有item.completed(:343-351)。app-server 的item/agentMessage/delta文字增量在这里直接丢掉,exec 的 JSONL 里看不到流式文字。 - turn 结束时会补齐:收到
turn/completed(含 interrupted / failed)时,先给 started 了但还没 completed 的 item 补发item.completed(:370-385、:524)。补齐在match status之前,所以被中断的 turn 也有这些item.completed。 - 被中断的 turn 没有结束事件:
TurnStatus::Interrupted分支只清掉最终消息,不输出turn.completed,也不输出turn.failed(:559-563)。中断和失败的退出码都是 1( exec/src/lib.rs:1267-1276 、 :1320-1323 ),从退出码分不出来,只能看有没有turn.failed。 - 警告不受 turn 约束:
ConfigWarning、Warning、DeprecationNotice都被投影成 type 为error的item.completed(:409-419、:427-446、:459-473),exec 对ConfigWarning和DeprecationNotice不做 thread / turn 过滤( exec/src/lib.rs:1597 ),而且 exec 在发出turn/start之后才开始读事件( :1222-1250 ),所以这些行可能排在turn.started前面。
这就是一份可以写断言的契约。我写了一个只用标准库的检查脚本 exec_jsonl_contract.py(放在学习仓库的 week03_Codex源码/code/codex_map/),规则全部来自上面的源码:
- 第一个事件是带
thread_id的thread.started; item.started/item.updated只出现在 turn 内;type 为error的item.completed可能出现在turn.started之前(配置警告、弃用提示),其余item.completed也只出现在 turn 内;- 每个
item.started的 id,在turn.completed/turn.failed之前必须有对应的item.completed; turn.completed.usage的 5 个字段都是非负整数;- 流结束时 turn 没有收尾,要单独报出来(中断和失败退出码都是 1,没有
turn.failed才是中断)。
真实用法是 codex exec --json "..." | python3 exec_jsonl_contract.py,这会调用模型,我没有跑。不调模型时用 --selftest 跑 5 个按源码结构手写的样例,真实输出:
$ python3 exec_jsonl_contract.py --selftest
[通过] 正常:命令 + 回复
[违规] 违规:item 没收尾就 turn.completed
- 第 4 行:turn.completed 时 item item_0(第 3 行 started)还没 completed
[违规] 违规:item 在 turn.started 之前
- 第 2 行:item.completed 出现在 turn.started 之前或 turn 结束之后
[通过] 正常:turn.started 之前的配置警告
[违规] 中断:没有 turn.* 结束事件
- 流结束时 turn 没有 turn.completed / turn.failed(被中断的 turn 就是这样;中断和失败的退出码都是 1,靠有没有 turn.failed 区分)
样例里的事件结构照着 exec_events.rs 写,不是真实模型输出。拿到真实 JSONL 后,第一步应该把它存成 fixture,让契约检查在 CI 里对 fixture 跑,而不是每次都调模型。第 1 周说过,多跑几次真实模型证明不了"不会出错";本周最后一讲会看 Codex 自己怎么用 mock 模型服务器做到这一点。
面试怎么说
一句话:Codex 是"核心 + 协议 + 多客户端"。agent loop 在 core 里,core 内部用 SQ / EQ 两个队列,客户端提交 Op、core 推 EventMsg;对外由 app-server 翻译成能序列化的 thread / turn / item JSON-RPC 协议,TUI、exec、SDK 都是这个协议的客户端。分层用 CI 脚本守着,TUI 不准直接 import core。
可能的追问:
- 为什么要两层协议?
Op里带oneshot::Sender这种进程内回调,不能序列化,而且内部协议要能随便改。对外协议需要稳定、有 schema、能生成 TS / Python 类型,所以单独一层。 - **提交队列为什么有界、事件队列为什么无界?**有界的提交队列给客户端背压;无界的事件队列保证 UI 读得慢时不会反过来卡住 agent loop。代价是 UI 一直不读时内存会涨。
- **这种分层怎么测?**三层各测各的:core 用 mock 模型服务器断言事件序列;app-server 对协议做契约测试和 schema 快照;客户端对 JSONL 做契约检查。再加一条架构测试,防止有人绕过边界。
- **两个 SDK 的结果不一致,先查什么?**先查接入层:Python SDK 走 app-server,TS SDK 走 exec 的 JSONL 投影,后者没有文字增量,被中断的 turn 也没有结束事件。
常见错误说法
- “Codex CLI 是用 TypeScript 写的”:现在 npm 包只是一个 Node 启动器,真正运行的是 Rust 二进制;仓库里的 TS 七成多是从 Rust 类型生成的绑定。
- “TUI 不依赖 core,所以 TUI 的二进制里没有 core”:CI 禁止的是直接依赖和直接 import。core 通过 app-server-client → app-server 编进同一个二进制;没探测到本机 daemon 时走嵌入模式,core 就跑在 TUI 进程里。
- "
Op/EventMsg就是对外协议":那是 core 的内部协议,Op甚至不能序列化。对外协议是 app-server 的 thread / turn / item。 - “运行中再发一次 turn/start 会排队开新 turn”:core 先尝试 steer,把输入并入正在跑的 turn,返回的是旧 turn 的 id;正在跑的是 review / compact turn、输入为空或 output schema 不一致时,直接返回 JSON-RPC 错误,也不排队。(TUI 有活动 turn 时发的本来就是
turn/steer。) - “exec –json 的每个 turn 一定以 turn.completed 或 turn.failed 结束”:被中断的 turn 两个都没有。照字面写成断言会误报。
- “exec –json 的 error 事件就是失败”:可重试的流错误也会输出
error,turn 之后仍可能正常完成。判断失败看turn.failed和退出码。 - “item 事件只会出现在 turn 里”:配置警告和弃用提示是 type 为
error的item.completed,可能排在turn.started前面。 - “两个 SDK 拿到的事件一样”:Python SDK 走 app-server 协议,有文字增量;TS SDK 走 exec 的 JSONL,没有。
下一讲:生产级 agent loop。打开 core/src/session/turn.rs,看 run_turn 的两层循环、needs_follow_up,以及工具为什么在流还没结束时就开跑。