源码对照(一):OpenAI Codex 是怎么做 Agent 的
专栏:Agent 工程 · 第 16 / 18 篇Agent 专栏进入实战对照阶段。第一个解剖对象是 OpenAI Codex(Apache-2.0 开源)——一个以 Rust 写成的生产级 coding agent。这篇先看它的架构分层与核心循环;工具、沙箱与扩展生态留给下一篇。
仓库概览:145 个 crate 的分层
codex-rs/ 是一个 145 成员的 Cargo workspace,但分层非常清晰:
几个先立住的硬事实:Rust + tokio + ratatui;模型通信只走 OpenAI Responses API(chat completions 的 wire 支持已从代码中移除,报错提示);配置在 ~/.codex/config.toml;会话录制在 ~/.codex/sessions/;默认沙箱模式 read-only、审批策略 on-request。
核心循环:Task → Turn → Sampling
Codex 的循环是三级结构,和第 2 篇要讲的”教科书循环”对照着看会非常清晰:
- Task:一次任务的壳。
RegularTask是常规任务,另有 Compact/Review/UserShell 等专用任务类型; - Turn(轮次):一个 task 内的 agent 循环,
core/src/session/turn.rs::run_turn(近 3000 行); - Sampling(采样请求):turn 内的每一次模型请求——每次工具调用后跟一次 follow-up 请求。
最外层的任务壳里藏着一个”轮次后续跑”循环——turn 结束后若输入队列还有活,继续开下一轮:
// codex-rs/core/src/tasks/regular.rs:40-99(节选)
loop {
let last_agent_message = run_turn(
Arc::clone(&sess),
Arc::clone(&ctx),
next_input,
&mut mcp_startup_requirements,
prewarmed_client_session.take(),
cancellation_token.child_token(),
).await?;
// 终态错误已上报;让任务完成时保留待处理输入,
// 而不是为同样的输入重启失败的 turn
if ctx.terminal_error.lock().await.is_some() {
return Ok(last_agent_message);
}
if !sess.input_queue.has_pending_input(&sess.active_turn).await {
return Ok(last_agent_message);
}
next_input = Vec::new();
}
run_turn 内部是真正的 step 循环。有两个细节体现工程功力——运行中转向(steer):用户可以在模型运行时追加输入,循环每步都会取走 pending input 并进上下文:
// codex-rs/core/src/session/turn.rs(节选)
loop {
// pending_input 是用户在模型运行时通过 UI 提交的消息。
// UI 可以支持这个,但模型未必。
let pending_input = if can_drain_pending_input {
sess.input_queue.get_pending_input(&sess.active_turn).await.0
} else {
Vec::new()
};
// 只捕获一次,让上下文、可用工具清单、工具调用共享同一个请求视图
let step_context = ...capture_step_context_with_required_mcp_servers(...)...;
以及工具并发:单次采样请求里,流式响应的读取与工具执行是并发的——模型一边继续吐 token,已确定的工具调用一边开跑:
// codex-rs/core/src/session/turn.rs:2292+(节选)
let mut stream = client_session.stream(prompt, &model_info, ...).await?;
let mut in_flight: FuturesOrdered<InFlightFuture<'static>> = FuturesOrdered::new();
// OutputItemDone 分支:工具调用 → in_flight.push_back(tool_future)
// 流结束后:drain_in_flight(&mut in_flight, ...) 逐个收齐结果回灌历史
// codex-rs/core/src/session/turn.rs:2213-2236(节选)
async fn drain_in_flight(...) -> CodexResult<()> {
while let Some(res) = in_flight.next().await {
match res {
Ok(envelope) => {
sess.record_annotated_conversation_items(&turn_context, vec![envelope]).await;
}
Err(err) => { /* 已启动的工具失败不炸轮次,记录后继续 */ }
}
}
Ok(())
}
「已启动的失败不中止整体、结果按序回灌历史」——这与 dsh 工具调度器的设计(第 9 篇)如出一辙。
会话录制:rollout,不只记消息
持久层是我认为 Codex 最值得学的设计。会话以 JSONL 录制在 ~/.codex/sessions/rollout-<时间戳>-<uuid>.jsonl,通过异步命令队列写入(独立 writer task,先缓冲、persist() 时才物化文件)。关键是录制内容远不止消息:
// codex-rs/history/src/lib.rs:118-140
pub enum RolloutItem {
SessionMeta(SessionMetaLine),
ResponseItem(ResponseItemEnvelope),
InterAgentCommunication(InterAgentCommunication),
Compacted(CompactedItem),
TurnContext(TurnContextItem),
TokenUsageRecord(TokenUsageRecord),
WorldState(WorldStateItem),
SecurityRiskScore(SecurityRiskScore),
EventMsg(EventMsg),
// 模型不可见的稀疏事实,用于重建实时呈现
RealtimeItem(RealtimeItem),
}
turn 上下文、token 用量、压缩事件、跨 agent 通信全部入档——resume、fork、审计、回放都从这一份日志派生。这与 dsh 的「session 仅追加事件日志 + 模型可见即已记录」(第 6 篇)是同一条设计哲学的两种实现。
上下文压缩:mid-turn 的一等公民
压缩不是”快满了才救火”的后备手段,而是 turn 循环内的常态分支。run_turn 每次采样后检查 token 状态,超限就在轮次中间触发自动压缩,然后把新上下文窗口接回当前轮次继续跑:
// codex-rs/core/src/session/turn.rs:470-545(节选)
let should_roll_over = needs_follow_up
&& (sess.take_new_context_window_request().await || token_limit_reached);
// 只要压缩能可靠地把 token 压到远低于上限,就不必担心死循环
if should_roll_over {
if let Err(err) = run_auto_compact(
&sess, step_context, ...,
InitialContextInjection::BeforeLastUserMessage { ... },
CompactionReason::ContextLimit,
CompactionPhase::MidTurn,
).await { ... }
continue;
}
压缩还分流派:手动 /compact 用提示词让模型做总结;token 预算型压缩直接跳过模型总结、安装一个全新的上下文窗口(世界状态重注入),仍然走同一套 compaction 生命周期以便 hooks 观测。这份”同一事件、多种实现”的抽象很值得抄。
一次输入的完整旅程
与其他模块的联系
- Codex 的 rollout 会话录制与 Agent 专栏第 6 篇「模型可见即已记录」互为印证——两个团队都把”请求是日志的纯函数”当作运行时不变量;
- **steer(运行中转向)**与本专栏第 7 篇 dsh 的 inbox 三入口(followup/steer/inject)语义逐词对应;
- mid-turn 压缩是第 7 篇(记忆与压缩)的 Codex 实现,
CompactionPhase::MidTurn说明压缩不是会话间隙的补救而是轮次内的常态分支; - 工具并发(FuturesOrdered + drain_in_flight)对照第 9 篇 dsh 的有界滚动池——“已启动的失败不中止、结果按模型序提交”是共同纪律。
与教科书循环的对照
把第 2 篇要讲的「while 循环 + 工具表」作为基线,Codex 的增量全在可靠性维度:
| 教科书循环 | Codex 的做法 | 解决的问题 |
|---|---|---|
| 一个 while 循环 | Task → Turn → Sampling 三级 | 压缩/审查等特殊任务复用同一生命周期 |
| 消息列表 | rollout JSONL 事件录制 | resume / fork / 审计 / 回放 |
| 串行工具调用 | FuturesOrdered 流式并发 | 延迟:边流边执行 |
| 用户等模型跑完 | start_or_steer_turn 运行中转向 | 长任务中途纠偏 |
| 上下文满了就报错 | mid-turn auto compact | 长任务不中断 |
| 工具直接执行 | execpolicy 规则 → 审批 → 沙箱 | 安全 |
下一篇拆 Codex 的工具系统、审批策略与沙箱——你会发现它的审批枚举与 dsh 的设计几乎可以逐词互译。