主循环:Submission 驱动的 Turn 系统
〇、引言
上一篇文章我们从宏观上俯瞰了 Codex 的整体架构:三个二进制入口、100+ 个 crate、Rust 工程哲学。但你可能会问——用户敲下 codex 之后到底发生了什么?
这篇文章深入 Codex 的主循环,回答三个问题:
- Codex 的主循环长什么样?——不是轮询,是事件驱动的 reactor
- 一次 Turn 的生命周期是什么?——从 User Input 到模型回复,经过了哪些阶段
- 和 CC 的主循环比有什么区别?——两种工程哲学的碰撞

一、Codex 没有「主循环」
1.1 这不是一个玩笑
这是一个有点反直觉的说法,但我先说出来:Codex 没有一个类似 CC 中 queryLoop() 那样的函数。
CC 的主循环在 query.ts 的第 200-1677 行,是一个约 1,477 行的巨型 async function* queryLoop()。它用 while-true 驱动一切——等待用户输入、调模型、执行工具、压缩上下文,全都在同一个函数里按顺序跑。
Codex 完全不是这样。
它的「主循环」严格来说不是一个循环,而是一个事件分发器:submission_loop(handlers.rs:738)。
// handlers.rs:738-887(简化)
pub(super) async fn submission_loop(
sess: Arc<Session>,
config: Arc<Config>,
rx_sub: Receiver<Submission>,
) {
while let Ok(sub) = rx_sub.recv().await {
match sub.op {
Op::UserInput { .. } => user_input_or_turn(&sess, ...).await,
Op::Compact => compact(&sess, ...).await,
Op::Interrupt => interrupt(&sess).await,
Op::Shutdown => shutdown(&sess, ...).await,
// ... 共 20+ 种 Op 类型
}
}
}它不主动"拉"工作,而是被动响应 Receiver<Submission> 上的消息。每个 Submission 包装了一个 Op 枚举值,告诉分发器"该干什么"。
有 20+ 种 Op 类型:
| Op 类型 | 用途 |
|---|---|
UserInput | 用户发消息,启动一轮对话 |
Compact | 触发上下文压缩 |
Interrupt | 中断当前任务 |
ExecApproval | 执行策略审批 |
PatchApproval | 代码修改审批 |
ThreadRollback | 回滚对话 |
ThreadSettings | 运行时设置变更 |
Shutdown | 关闭会话 |
RunUserShellCommand | 执行 shell 命令 |
RealtimeConversationStart/Audio/Text/Close | 实时语音对话控制 |
InterAgentCommunication | 子 Agent 消息传递 |
| 等等 |
1.2 消息从哪来
Codex 的 Submission 通过 async_channel(Tokio 的 MPSC 变体)发送。这意味着:
- Producer 可以在任意协程中发送 Submission
- Consumer(
submission_loop)在单协程中串行处理
产生 Submission 的场景包括:
- 用户输入:TUI 或 App Server 收到用户消息,发送
Op::UserInput - 子 Agent 通信:Agent 需要给父/子 Agent 发消息,通过
Op::InterAgentCommunication - 自动触发的任务:定时检查 token 超限,自动发送
Op::Compact - 审批响应:用户批准/拒绝一个 shell 命令执行,通过
Op::ExecApproval
每个 Submission 的完整结构(protocol.rs:127):
pub struct Submission {
pub id: String, // 关联事件 ID
pub op: Op, // 干什么
pub client_user_message_id: Option<String>, // 客户端消息 ID
pub trace: Option<W3cTraceContext>, // 分布式追踪
}复用同一个 channel 让所有操作自然地排队——用户输入不会打断审批,审批不会打断压缩。串行化是自动的,不需要锁。
二、一次性 Turn 的生命周期
当 submission_loop 收到 Op::UserInput 时,它调用 user_input_or_turn(handlers.rs:88),最终触发 run_turn(turn.rs:136)。
run_turn 的完整签名:
pub(crate) async fn run_turn(
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
turn_extension_data: Arc<codex_extension_api::ExtensionData>,
input: Vec<TurnInput>,
prewarmed_client_session: Option<ModelClientSession>,
cancellation_token: CancellationToken,
) -> Option<String>一次 Turn 的生命周期可以概括为 8 个阶段:

pre-sampling compact
↓
记录上下文更新 + 注入 skills/plugins/hooks
↓
set_previous_turn_settings
↓
↓ → 检查 pending_input(用户在中途发的新消息)
↓ → clone_history() 组装 prompt
↓ → run_sampling_request() 调模型
↓ → 判断 token_limit_reached && needs_follow_up
↓ → 是 → run_auto_compact() → back to loop
↓ → 否 → 返回结果
↓ ← loop
工具执行 + 结果回写
↓
stop hooks
↓
结束2.1 Pre-sampling Compact
每次 sampling 前(turn.rs:150),Codex 会先检查是否需要压缩上下文。如果当前 token 使用量超过阈值,会先触发 compact 再采样。
这里调用的函数是 run_pre_sampling_compact(turn.rs:784),它会检查 auto_compact_token_status() 返回的 token 状态,决定是否以及用什么方式压缩(Local / Remote v1 / Remote v2)。
// turn.rs:150
if let Err(err) = run_pre_sampling_compact(&sess, &turn_context, &mut client_session).await {
// 如果 usage limit 超了,返回 None
return None;
}2.2 上下文注入
压缩检查通过后,Codex 记录上下文变更并构建注入:
// turn.rs:167
sess.record_context_updates_and_set_reference_context_item(turn_context.as_ref()).await;
let (injection_items, explicitly_enabled_connectors) =
build_skills_and_plugins(&sess, turn_context.as_ref(), &input, &cancellation_token).await?;这个过程包括:
- 上下文 diffing(仅发送变更段,复用 prompt cache)
- Skills/Plugins 注入
- Hooks 执行(
run_pending_session_start_hooks)
2.3 Sampling 请求
提交给模型的 prompt 通过 clone_history().for_prompt() 组装(turn.rs:237):
let sampling_request_input: Vec<ResponseItem> = {
sess.clone_history()
.await
.for_prompt(&turn_context.model_info.input_modalities)
};然后调用 run_sampling_request,这对应实际的模型 API 调用:
match run_sampling_request(
Arc::clone(&sess), Arc::clone(&turn_context),
Arc::clone(&turn_extension_data), Arc::clone(&turn_diff_tracker),
&mut client_session, turn_metadata_header.as_deref(),
sampling_request_input.clone(), cancellation_token.child_token(),
)2.4 Mid-turn Compact
Sampling 返回后(turn.rs:259),Codex 进入一个判断逻辑:
if token_limit_reached && needs_follow_up {
if let Err(err) = run_auto_compact(
&sess, &turn_context, &mut client_session,
InitialContextInjection::BeforeLastUserMessage,
CompactionReason::ContextLimit,
CompactionPhase::MidTurn,
)这是 mid-turn compact:模型本轮返回时说"我还要继续调用工具",但 token 已经超限了,那就在工具执行前先压缩。压缩完成后回到 loop 顶部重新 sampling。
前一篇说过 Compact 的细节,这里不展开——第四篇会单独讲。

2.5 Loop + Stop Hooks
loop {
检查 pending_input(用户中途发的新消息)
执行 hooks + 记录输入
clone_history() 组装 prompt
run_sampling_request() 调模型
判断是否需要 mid-turn compact
需要 → compact → continue
不需要 → break
}
执行 stop hooks
返回最终消息Stop hooks(run_turn_stop_hooks)在 turn 结束前执行,和 CC 的 stop hooks 概念类似——允许用户注册在对话结束后运行的逻辑。
三、SessionTask 抽象
Codex 有不同的任务类型。不是每一次"模型回复"都是标准的用户对话:
| Task 类型 | 定义位置 | 用途 | 触发器 |
|---|---|---|---|
RegularTask | tasks/regular.rs | 标准用户-模型对话 | Op::UserInput |
CompactTask | tasks/compact.rs | 上下文压缩 | Op::Compact |
ReviewTask | tasks/review.rs | Code Review | review() 在 handlers.rs:702 |
UserShellCommandTask | tasks/user_shell.rs | codex exec 一次性命令 | Op::RunUserShellCommand |
它们都实现 SessionTask trait(tasks/mod.rs:207):
pub(crate) trait SessionTask: Send + Sync + 'static {
fn kind(&self) -> TaskKind;
fn span_name(&self) -> &'static str;
fn run(
self: Arc<Self>,
session: Arc<SessionTaskContext>,
ctx: Arc<TurnContext>,
input: Vec<TurnInput>,
cancellation_token: CancellationToken,
) -> impl Future<Output = Option<String>> + Send;
fn abort(
&self,
session: Arc<SessionTaskContext>,
ctx: Arc<TurnContext>,
) -> impl Future<Output = ()> + Send;
}Session::spawn_task(tasks/mod.rs:305)会先 abort 所有之前的任务,再 spawn 新任务。任何时候只有一个活跃的 SessionTask。

四、Session 状态的并发访问
Codex 的所有会话状态存储在 Session 结构体中,内部通过 Arc<Mutex<SessionState>> 保护:
// session.rs(简化)
pub struct Session {
state: Arc<tokio::sync::Mutex<SessionState>>,
// ...
}关键点:
- 短锁持有:访问 state 的操作都非常短,拿到锁 → 读/写 → 释放
- 事件是异步的:
Session::send_event()不会等 UI 渲染完,只发到 event channel - 历史是集成的:
Session内部持有关联的HistoryManager,通过clone_history()获取快照
这意味着 submission_loop 虽然是单协程处理,但协程内部通过 .await 暂停时,其他协程(如 UI 线程或监控协程)有机会执行。
五、Tool Calling 的处理
Tool calling 在 Codex 中不单独属于一个文件。它分散在几个层面:
- Sampling 阶段(
run_sampling_request):模型返回 tool call,由stream_events_utils解析 - Tool 路由(
tools/模块):ToolRouter根据 tool name 分派到具体 handler - 结果回写:工具结果写回 history,然后 loop 判断是否继续
这与 CC 的 tool calling 机制结构上是相似的(模型发 tool_call → 执行 → 结果写回),但 Codex 的抽象层级更多:
- Tool 定义和路由在独立的
codex-toolscrate - MCP 连接由
codex-mcp管理 - 执行权限由
codex-execpolicy控制 - 沙箱由
codex-sandboxing隔离
不过这些不在本文的范围内,第六篇会专门讲工具系统。
六、Codex vs Claude Code:主循环对比
6.1 核心差异
| 维度 | Codex | Claude Code |
|---|---|---|
| 主循环文件 | handlers.rs:738(~220 行) | query.ts:200-1677(~1,477 行) |
| 模式 | 事件驱动 reactor | continuation-driven polling |
| 并发 | tokio channel + 协程 | 单线程 async generator |
| 状态结构 | Arc<Mutex<SessionState>> | 显式 State 对象 + mutable |
| Task 抽象 | 4 种 SessionTask trait | 无(都塞在主循环里) |
| Op 枚举 | 20+ 种,enum dispatch | implicit(通过 yield/return 控制流) |
| Tool 循环 | 嵌在 run_turn 的 loop 里 | 嵌在 queryLoop 的 while true 里 |
6.2 为什么 Codex 选 reactor?
这和工程语言直接相关。
Rust 的所有权模型让显式状态管理更安全:Arc<Mutex<SessionState>> 在 Rust 里是惯用模式,编译器保证你不会误用。而在 TypeScript 里,同样的显式锁就需要更小心。
CC 的 queryLoop 选择 continuation-driven 是 TypeScript 自然的选择——async generator 让状态隐式保持在函数栈帧里,用 yield 退出再 next() 恢复,不需要额外的状态机。
两种模式没有绝对的优劣。但有一个观察:Codex 能天然支持并行子 Agent 和后台任务,因为它有 channel + 多协程的基础设施;CC 的 queryLoop 想要加并行子 Agent 就需要大规模重构。 这就是架构决策的长期影响。
6.3 一个有趣的类比
CC 的 queryLoop ≈ 单线程 event loop(如 Node.js 的事件循环) Codex 的 submission_loop ≈ 单消费者消息队列(如 Kafka consumer)
两者都串行处理,但串行的方式不同:
- CC:同协程内 cede control(yield),由外部调度器决定何时恢复
- Codex:总在新协程中处理每个 Op,channel 保证顺序
七、小结
| 你学到什么 | 对应文件 |
|---|---|
| submission_loop 事件分发 | handlers.rs:738-887 |
| Op 枚举(20+ 种操作) | protocol.rs:498 |
| Submission 结构 | protocol.rs:127 |
| run_turn 生命周期(8 阶段) | turn.rs:136 |
| Pre-sampling compact | turn.rs:150 + turn.rs:784 |
| Mid-turn auto compact | turn.rs:292-301 |
| SessionTask trait | tasks/mod.rs:207 |
| 4 种 Task 实现 | tasks/regular.rs, compact.rs, review.rs, user_shell.rs |
章节小测
本章 Quiz — 核心机制自检
1 Codex 的 submission_loop 与 Claude Code 的 queryLoop 核心设计差异是什么?
2 Codex 的 SessionTask trait 为什么设计了 abort 方法?
3 Mid-turn compact 的触发条件是什么?
4 Codex 选择事件驱动 reactor 模式而非 CC 的 continuation-driven polling,带来了什么长期影响?