Codex 源码导读这套系列,上一部分我们把整体架构和工作台摸了一遍,从入口到工具注册到 session 管理都过了一遍。当时留了个尾巴:一次用户请求进来之后,系统内部到底是怎么一步一步把这个请求"处理完"的?这次就专门把这个闭环——一个 Turn 从出生到结束的完整生命周期——拉到源码层面过一遍。
如果你是第一次接触 Codex 源码,建议先把上一部分提到的目录结构、EventBus 和 Session 基本概念过一遍,否则下面这些东西接不住。这里我先假定你已经能定位到codex-rs相关模块,知道哪些 crate 是干什么用的。如果还没到这个程度,也没关系,跟着这一篇走完,再把源码打开对着看,效果一样。
1. Turn 是什么,为什么要单独剖析它
1.1 先给 Turn 一个准确定义
在 Codex 里,Turn 不是"用户发一条消息"这么简单。它实际上是系统内部一个完整的执行闭环:从接收用户输入开始,经过系统提示词组装、模型推理、工具调用、结果回填、再次推理,一直到最终把答案输出并结算状态为止。你可以把它理解为一次"从问题到交付"的完整事务,中间哪怕经历了十几次工具调用和模型请求,也全都算在同一个 Turn 里。
这一点和常见的 LLM 应用框架不太一样。很多框架里,一次请求就是一次 completion 调用,拿到结果就算完。Codex 的 Turn 是一个可以持续很久、内部状态不断变换的长任务。它的生命周期覆盖了整个 agentic loop,也就是智能体循环。所以在源码里,跟 Turn 相关的类型几乎贯穿了所有核心模块,理解了 Turn 的生命周期,就等于理解了 Codex 运行时的一半。
打个比方:Turn 就像一个客服工单。用户提交一个问题之后,客服可能内部开会、查资料、请示领导,最后给用户一个答复。整个过程对外只有一个工单号,但对内包含大量动作。Codex 的 Turn 就是这个工单,工单号就是turn_id,而中间那些会议纪要、资料查询记录,就是 Turn 内部不断累积的事件流。
1.2 源码里 Turn 的核心数据结构
在源码中,最直接的结构体是Turn和TurnRecord。先看一眼TurnRecord:
pub struct TurnRecord { pub uuid: Uuid, pub events: Vec<Event>, pub status: TurnStatus, pub correlation_id: Uuid, pub platform_fields: Vec<PlatformField>, pub usage: Option<TurnUsage>, // response_format 等字段省略 }这里的关键点在于:Turn 的所有中间痕迹都保存在events里。事件流是这个数据结构的骨架,而不是"当前状态"这种瞬时值。为什么要这么设计?因为智能体执行的过程不是线性的,中间有工具调用、有分支、有并行 subturn,把每个动作都记成事件,才能在系统异常时重放现场。
我在读代码时最常干的一件事,就是把 Turn 的 events 打出来看。比如你问它"帮我写一个 Python 脚本读取 CSV 文件",你会在事件流里看到UserText、SystemText、FunctionCall、FunctionCallOutput等一长串记录。每一类事件在状态机里都有不同的处理分支。理解了事件类型,后面分析生命周期时会轻松很多。
还有一点值得注意:源码里的Turn在大部分时候是"不可变"的,真正可变的是它内部的执行上下文。简单说,Turn 对外是一个身份标识,对内是一串不断追加的记录。Status 字段控制着它能不能继续跑、要不要做总结,而 Events 记录着它跑过的每一步。
2. 生命周期状态机:Turn 从诞生到终结的每一步
2.1 一次编写代码任务的状态流转
Turn 的生命周期状态定义非常清晰,源码里基本就是这几个:
| 状态 | 含义 | 进入时机 |
|---|---|---|
TurnStatus::Init | 初始化完成,尚未开始执行 | TurnRunner 创建 Turn 后 |
TurnStatus::Running | 进入主执行循环 | ExecutionLoop 开始驱动 |
TurnStatus::Summarizing | 执行结束,进入总结阶段 | 工具调用到达上限或任务完成 |
TurnStatus::Completed | 正常结束,总结产出 | summarize 流程完成后 |
TurnStatus::Error | 出现不可恢复错误 | 任意环节抛出致命异常 |
TurnStatus::Cancelled | 被用户或其他逻辑取消 | 收到取消信号 |
TurnStatus::Replaced | 被新 Turn 取代 | 新请求抢占当前执行上下文 |
给你一个具体场景:用户说"帮我写一个冒泡排序的 Python 函数"。Turn 从Init进入Running,系统先把用户消息包装成UserText事件,把它和系统提示词一起发给模型。模型返回一个UserFunctionCall,比如调用shell工具去写文件。工具执行完,结果作为新事件回填,模型再次被调用,直到回答完用户问题或达到max_tool_calls上限。这时状态变成Summarizing,系统会再跑一次总结请求,把整个执行过程浓缩成一份最终回答,最后落成Completed。
这里有个容易忽略的地方:Error状态并不是所有异常都会进。很多业务上的异常,比如工具返回错误码,那只是作为事件记录下来,并不改变 Turn 的状态。只有那些系统层面无法恢复的异常,比如底层请求连续失败、内部数据结构被破坏,才会把 Turn 打成Error。所以你在排查问题时,看到Error状态的 Turn,基本可以直接定位到系统级故障,而不是业务逻辑问题。
2.2 状态变更时机为什么值得关注
状态变更的时机,直接决定了系统的可观测性和恢复能力。比如Cancelled状态,它不只是在 UI 上点了个停止按钮才触发。当用户发起新请求、抢占同一个 session 上下文时,旧的 Turn 会被标记为Replaced。这种"取代"机制很重要:它保证了同一时刻一个 session 里只有一个活跃 Turn,避免两个执行循环同时往同一个上下文里写事件,产生数据竞争。
源码里这些状态变更基本都发生在确定的临界点:
- Turn 创建后立即设为
Init - 进入主循环前设为
Running - 工具调用计数到达上限后的下一次事件写入前,切到
Summarizing - 总结流程完全结束、最终事件写入完毕后,切到
Completed
为什么要卡得这么准?因为事件流本身是 Turn 状态的"日志",而状态是"标记"。如果标记切换比事件写入早,可能出现事件还没写完、状态已经 Completed 的情况;如果切换得太晚,又可能导致总结请求已经发出,状态还停留在 Running,让外部观察者误以为任务还在执行。源码里的exec_event逻辑就是为了校准两边的顺序,你后面排查"事件不刷新"这类问题时,十有八九是这里出的毛病。
3. 一次 Turn 是怎么被启动的
3.1 从用户消息到 LaunchInput
Turn 的启动入口在TurnRunner的run_turn(_)方法,它接收一个Frame,这个 Frame 可能是一条用户消息(Ack)或一个提问(Question)。源码里会先对 Frame 做一次匹配分发,再根据 session 和 assistant 的信息组装出LaunchInput:
let input = LaunchInput { objective: RolloutObjective::CodexTask, input_model: Default::default(), ephemeral_store: EphemeralStore::from_ephemeral( session.ephemeral(), assistant.clone(), &turns_map, ), options: session.options.clone(), input_events, };注意input_events拼接了两个部分:一是session.ephemeral()里的历史上下文,二是当前 Frame 转换出来的事件。UserText就是在这里被塞进输入流的。模型不会直接看到"原始字符串",它看到的永远是一串事件,这个设计贯穿整个生命周期。
有一点我觉得特别值得展开:EphemeralStore。这个 store 不是简单把历史消息拼在一起,它会根据当前 assistant 的配置,把历史事件重新筛选、整理。比如有些中间过程(如上一步工具输出的原始日志)在对后续模型请求没有帮助时,会被压缩甚至剔除。这就是为什么你看到 Codex 处理长任务时,上下文不会无限膨胀。它是在每一步启动时,用EphemeralStore重新构建了一份"对当前任务最有用"的历史视图。
3.2 Rollout 的创建与输入过滤器
LaunchInput准备好之后,系统会调用Rollout::new(input)创建一个 rollout。这个 rollout 是整个执行过程的"沙盘":它管理着当前所有事件、输入模型、输出通道。Rollout 创建时并不急着调用模型,它只是把LaunchInput里的事件灌进内部存储,并初始化好执行所需的环境。
这块代码里有个InputFilter的概念,可能不太好理解。它是用来决定哪些输入事件会真正被送入模型的东西。比如 Codex 在开启并行工具调用时,多个 subturn 共享同一个 rollout,但各自的InputModel可能不同。有的 subturn 只需要看到某个特定工具的输出,而不是整个上下文。代码里是通过filter来做这件事的,具体实现在ephemeral_store和rollout的协作逻辑中。
调试时如果你想确认"为什么模型没有看到某条消息",十有八九是 InputFilter 把它滤掉了。它不是 bug,是设计:每个 subturn 只需聚焦自己的局部上下文,否则并行时上下文互相污染,模型反而会乱。
4. 核心驱动循环:ExecutionLoop 是怎么跑起来的
4.1 循环任务、pre_turn 与事件出队
rollout 创建好后,系统会把 rollout 交给ExecutionLoop,由一个异步循环开始驱动。这个循环不是简单地"调用一次模型就结束",它是一个持续监听的消费者:
while let Ok(Some(ephemeral)) = self.ephemeral_rx.recv().await { let events = ephemeral.into_parts().0; self.exec_event(events).await?; // 判断是否继续、是否需要总结等 }这段代码对应的流程是:每一轮模型推理完成后,模型返回的事件会通过 channel 传回循环体,循环体再调用exec_event把事件写入 Turn 的事件流,并根据事件类型决定下一步动作。exec_event里有个重要的方法叫run_pre_turn,它会在每一轮推理前执行一些准备操作,比如处理 Question、更新状态、执行 hook 等。
如果你以前写过事件驱动代码,会对这个模式很熟悉。整个 ExecutionLoop 本质上就是一个while recv -> process -> send的状态机。它不关心外层 UI 是什么状态,只要有事件进来,就执行对应逻辑,执行完再根据逻辑决定是否继续循环。它坚持"事件驱动"而不是"指令驱动",这使得同一套执行循环既能接 CLI,也能接 IDE 插件,而内部逻辑完全不用改。
这里还有个很重要的工具调用上限判断。源码里用max_tool_calls和当前已执行的 FunctionCall 数量做比较,一旦达到上限,系统会把事件里某种对应状态置为invalidated,并停止继续产生新的工具调用。我在第一遍看的时候,死活找不到"为什么不继续调工具了",后来才发现是这个上限在起作用。默认的上限值每个版本可能不同,具体配置会体现在 session 的 options 里。
4.2 工具调用的执行路径
每条 FunctionCall 从事件流里出来之后,会进入工具匹配逻辑。Codex 的事件流里,模型返回的是一个UserText,内部包装着UserFunctionCall,而UserFunctionCall里又嵌套着具体的函数调用声明和回调 ID。执行时,系统会用这个声明去工具注册表里查找对应的Tool,找到后执行其call方法。
一个典型的工具执行逻辑如下:
let output = tool .call(arguments, tool_ctx) .await .map_err(ToolError::from)?; let output_text = ToolOutput::from(output);工具返回的ToolOutput会再次被包装成UserText,并作为结果事件推回事件流。这整个"模型发请求 -> 工具执行 -> 结果回填 -> 模型再看结果"的循环,就是 agentic loop 最核心的部分。Codex 里管它叫Request的来回过程,但实际上每次循环都会产生若干新事件,而不是简单复用旧请求。
这里我想特别提示一个细节:工具调用的结果回填,不是直接追加到原始事件列表末端,而是会经过一次"重新组装"。原因是模型下一轮请求需要的是一个完整上下文,而不是零散的工具调用片段。源代码里会用上一轮的事件加上本轮工具输出,重新构建出一个新的输入序列。这个组装逻辑如果不仔细看,很容易误解成"直接把所有事件拼一起发给模型"。
5. 并行工具调用:一次 Turn 内部的多个 Subturn
5.1 为什么要并行,以及 par_iter 的分岔逻辑
Codex 源码里有一个很有特色的机制:并行工具调用。当模型在一次请求中返回多个工具调用,而且这些调用之间没有依赖时,系统会尝试并行执行它们。这里用的是 Rust 的par_iter或类似的多线程分岔机制,每个分支各自生成一个ExecutionLoop实例,处理各自的工具执行链路。
我读源码时的反应是:"好家伙,原来不是所有工具都是排队跑的。" 具体实现上,当检测到多个函数调用可以并行时,代码会为每个函数调用创建一个subturn。你可以把 subturn 理解为"大 Turn 里的小 Turn",每个 subturn 有自己的执行状态、输入过滤器和输出通道,但它们共享大 Turn 的上下文和最终目标。
这里有个非常关键的设计细节:并行分支创建 rollout 时,并不会再次调用模型的输入处理器(即不会重新组装上下文)。因为如果每个并行分支都重新组装一次,会互相覆盖,产生数据竞争。源码里专门开了一个create_spin_out相关的逻辑来避免这种情况,所有分支共享同一个基础上下文,各自只追加自己的局部动作。
5.2 Subturn 结束后的事件流如何汇聚
并行执行最麻烦的问题,不是并行本身,而是结果怎么汇聚回主事件流。Codex 的处理方式是:每个 subturn 的事件被标记了执行边界(exec_event),在写入主 Turn 的事件流之前,会先经过一个合并逻辑,确保事件顺序是确定性的。
源码里用append和prepend来管理事件的插入位置。分支完成的事件会 append 到主事件流,而分支内部产生的状态标记则通过其他机制同步回主 rollout。这样做的目的是:即使多个工具的输出到达顺序不一致,最终写在事件流里的顺序也是稳定可复现的。
这一点对排查问题特别重要。你会发现某些 bug 在开启并行工具调用时才出现,关了并行就消失。不是工具本身有问题,而是事件合并时序不确定导致模型读到的上下文顺序乱了。遇到这种问题,别急着改工具代码,先检查 subturn 的事件合并逻辑是不是把顺序搞错了。
6. 结束阶段的总结请求与最终输出
6.1 summarize 并非每次都有,它什么时候被触发
当工具调用到达上限,或者模型判断任务已经完成时,Turn 会进入Summarizing状态。这个阶段的核心任务,是让模型回顾整个执行过程,产出一份面向用户的最终总结。
源码里有一段逻辑在 Turn 的 counts 到达时触发summarize。就是说,系统在完成最后一条工具输出后,会检查是否满足总结条件,满足则发起一次专门的推理请求。这次请求与前面的正常循环不同:它不会被用于继续调工具,而是要求模型"根据当前全部上下文,生成最终答案"。
看完源码你会发现,summarize不是每个 Turn 都会走。如果整个 Turn 没有任何工具调用,模型直接给出文本回答,那系统就直接进入完成状态,压根不会触发总结。总结是给那些经过多轮工具调用的复杂任务准备的,目的是把冗长的执行过程浓缩成用户可读的最终交付物。
6.2 munch 与事件重放
进入总结阶段后,有个比较冷门的函数叫munch,它的作用是"吃掉"(消费)之前所有未处理的事件,并把它们塞回 Turn 的输入流。这有点像电影剪辑:主循环跑完时,可能还有少量事件滞留在 channel 里没被处理,总结请求前需要把它们全部捞出来,确保模型看到的上下文是完整的。
代码里大概是这样的逻辑:
let mut munch = MunchHandler::default(); result = exec_event(&mut munch, event)?; // 汇总所有事件并转成总结输入munch并不仅仅是把事件消费掉,它还会对事件做一次"裁决",判断哪些事件应该保留、哪些可以丢弃。比如重复的中间状态、已经被后续事件覆盖的旧版本,都会被抛弃。这样模型在总结时面对的事件流是干净、精炼的,而不是把所有调试信息都糊上去。
最终输出的事件流会通过 Turn 的transcript或EventBus广播出去,包含被标记为final的总结文本。CLI 界面就是靠订阅这些事件来刷新终端显示的,所以你在终端看到的那段总结,本质上也是事件流的一部分,而不是特殊通道里的数据。
7. 事件总线:Turn 是事件流的组装与广播
7.1 EventBus / Transcript 的分发逻辑
前面我们反复提到事件流,那 Turn 是怎么把事件送给 UI 层或插件的呢?答案是通过 EventBus 和 Transcript 机制。Codex 里的事件总线以 actor 模型实现,简单说,就是有一个独立的任务(actor)负责接收所有事件并分发给不同的订阅者。
Turn 本身不直接持有 UI 组件,它只是不断把事件推到transcript上,而transcript内部再通过 SubscriptionChannel 分发给注册过的订阅者。这就实现了"核心逻辑与界面解耦":CLI 可以订阅事件刷新终端,IDE 插件也可以订阅同一批事件绘制界面,双方互不干扰。
如果你自己写扩展或插件,一定要遵循这个规则:不要在工具内部直接操作界面,而是发事件,让界面层自己去响应。Codex 官方插件的很多功能,比如流式显示、进度条、聊天记录持久化,本质上都是监听 Turn 的事件流做出来的。顺着事件总线去理解,比翻插件 UI 代码要高效得多。
7.2 调试排障脚本怎么写
理解了事件总线后,我建议你写一个小脚本,专门用来 dump 事件流。做法很简单:订阅 EventBus,把每个 Turn 的所有事件按顺序打印到日志文件。我实测下来,很多难以定位的状态问题,打开事件流日志一看就明白了。
bus.subscribe(|event| { if let Some(record) = event.get_turn_events() { for e in record.iter() { println!("[{}] {}", record.uuid, e.kind()); } } });这类 dump 脚本对处理并行 subturn 尤其有用。当多个分支同时跑的时候,单步断点调试基本没用,因为断哪个线程都不对。事件流日志能记录全局视图,让你看到哪个事件先到了、哪个事件后到了、有没有重复、有没有缺失。
8. 沿着代码走一遍之后的常见问题速查
8.1 排查实例一:请求被中断或被替换
现象是:用户连续发了两条消息,前一条任务还没跑完,后一条进来了。你会发现前一个 Turn 的状态变成了Replaced,而且它的后续事件不再写入主流程。原因是系统检测到同一个 session 的新 Turn 需要立即介入,旧 Turn 被抢占。
排查思路很简单:先看新 Turn 的correlation_id是否和旧 Turn 不同,再看旧 Turn 的状态是否变成了Replaced。如果是,说明这是抢占逻辑在正常工作,不是 bug。如果你希望旧任务继续跑完,就得从 Session 策略层面改动,而不是在执行循环里打补丁。
8.2 排查实例二:循环跑了几百轮不收敛
现象是:任务迟迟不结束,模型一直在调工具,事件流里全是重复的工具调用。这种情况十有八九是max_tool_calls被配置得过大,或者模型始终没有收到"任务已完成"的信号。源码里,每次执行完工具调用后都会检查是否需要 summarize,但如果模型每轮都返回新的 FunctionCall,而代码又认为这些调用必须执行,循环就会一直持续。
快速定位方法:在exec_event入口加一条日志,打印当前的函数调用计数和上限值。只要计数器没到上限,循环就会继续。如果你发现计数增长但任务逻辑没有进展,那基本可以判断是提示词或工具返回内容让模型陷入了死循环,需要从工具输出格式上找突破口。
8.3 排查实例三:事件不刷新
现象是:UI 上消息一直转圈,但 Turn 状态已经 Completed。这类问题往往是transcript的事件广播出了问题,可能是某类事件没有被标记为可渲染类型,也可能是final标志没有正确写入。优先检查事件流末尾有没有final事件,再检查 UI 端订阅的过滤器是否把所有事件都接收到了。
一个比较容易踩的坑是:在并行 subturn 场景下,某个 subturn 的事件没有通过合并逻辑回到主事件流,导致 UI 等不到这条事件就卡住了。这时需要去翻合并逻辑里的事件去重和顺序处理代码,看看是不是把某些事件标记成"仅内部消费"了。
| 问题 | 优先排查点 | 常见原因 |
|---|---|---|
| 任务卡住不结束 | 工具调用计数与上限 | 提示词让模型陷入重复调用 |
| Turn 提前结束 | summarize 触发条件 | max_tool_calls 配置过小 |
| UI 不刷新 | final 事件是否写入 | 事件广播过滤器问题 |
| 并行工具乱序 | subturn 事件合并逻辑 | 事件未按确定性顺序合并 |
写在最后的一点实操体会
我把 Turn 生命周期的源码来回读了三遍,最强烈的一个感受是:Codex 对"可追溯"这个要求贯彻得非常彻底。所有东西都是事件,所有状态变更都有明确的触发点,所有执行过程都可以重放。这也是它能在复杂 agent 场景下保持相对稳定调试体验的根本原因。
如果你要上手读这一块代码,我的建议是别急着从头读到尾,先盯住一条具体的用户请求,比如"帮我把这个目录下的文本文件合并成一个",然后顺着TurnRunner -> Rollout -> ExecutionLoop -> summarize这条主线一步步走,每次都把当前事件流打出来看。等你把这条主线跑顺了,再去研究并行 subturn 和事件总线,会快很多。源码分析这事,急不得,但只要你把"事件"和"状态"这两个抓手握稳了,Codex 的源码就会从一堆抽象类型变成一条清晰可走的路。