源码对照(一):OpenAI Codex 是怎么做 Agent 的

专栏:Agent 工程 · 第 16 / 18 篇
AgentCodex源码学习

Agent 专栏进入实战对照阶段。第一个解剖对象是 OpenAI Codex(Apache-2.0 开源)——一个以 Rust 写成的生产级 coding agent。这篇先看它的架构分层与核心循环;工具、沙箱与扩展生态留给下一篇。

仓库概览:145 个 crate 的分层

codex-rs/ 是一个 145 成员的 Cargo workspace,但分层非常清晰:

图表(codex-agent-architecture.md)

几个先立住的硬事实: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-agent-architecture.md)

与其他模块的联系

  • 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 的设计几乎可以逐词互译。

← 返回文章列表