Learning AI Quality 返回 KuthorX Blog II博客首页

第 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 架构地图与一次提交的流转 在新标签页打开

为什么读 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 那种只数代码行),再按目录拆开。

语言文件数行数都在哪
Rust5,0571,985,6775,040 个在 codex-rs/,其余是 Bazel 测试规则和自研 lint
TypeScript76111,524737 个文件(8,168 行)是 app-server-protocol/schema/typescript/ 下由 ts-rs 从 Rust 类型生成的绑定;手写的只有 sdk/typescript(24 个文件,3,356 行,其中 src/ 1,051 行)
JavaScript3(另有 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)的另一个脚本
Python19662,029sdk/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、execcli 是 codex 这个多子命令二进制;tui 是交互界面;exec 是非交互模式
协议边界app-server-protocol、app-server、app-server-client、app-server-transport对外协议的类型、服务端、进程内客户端,以及 stdio / unix socket / websocket 传输
核心core、protocolcore 里是会话、turn、agent loop;protocol 定义 Op、EventMsg 等内部协议类型
模型与工具codex-api、codex-client、tools、apply-patch、codex-mcp、rmcp-client、hooksResponses API 流式解析与重试、工具契约、补丁、MCP 客户端、钩子
安全sandboxing、linux-sandbox、execpolicy、shell-commandOS 沙箱、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/interruptcommon.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/updatedcommon.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 execInProcessAppServerClient::start(...) 进程内嵌入;--json 时把通知再投影成 JSONLexec/src/lib.rs:986
TS SDKspawn 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-RPCsdk/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 ):

  1. 查清单:读 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:: 还会被第二步抓到;改成别的名字,两步都抓不到。
  2. 查源码:扫 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.completeditem 的生命周期。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.failedturn 失败,带 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 ,有四条写测试时必须知道的行为:

  1. agent_message 和 reasoning 没有 item.started,只有 item.completed(:343-351)。app-server 的 item/agentMessage/delta 文字增量在这里直接丢掉,exec 的 JSONL 里看不到流式文字。
  2. turn 结束时会补齐:收到 turn/completed(含 interrupted / failed)时,先给 started 了但还没 completed 的 item 补发 item.completed(:370-385、:524)。补齐在 match status 之前,所以被中断的 turn 也有这些 item.completed。
  3. 被中断的 turn 没有结束事件:TurnStatus::Interrupted 分支只清掉最终消息,不输出 turn.completed,也不输出 turn.failed(:559-563)。中断和失败的退出码都是 1( exec/src/lib.rs:1267-1276 、 :1320-1323 ),从退出码分不出来,只能看有没有 turn.failed。
  4. 警告不受 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,以及工具为什么在流还没结束时就开跑。