雨天小六

读懂 Codex(三):一项任务的一生——Thread、Turn 与 Step

· 更新于 2026-08-02 · 专栏:读懂 Codex

#Codex#Agent Runtime#生命周期#异步编程#软件架构

Codex 正在修复资料页的保存按钮。它已经找到组件代码,准备运行测试。此时用户又补充了一句:

不要改接口,只修前端。

这句话应该加入正在执行的任务,还是创建一项新任务?如果用户紧接着点击中断,正在等待的模型请求、已经启动的测试进程和尚未答复的审批又该由谁停止?测试停下以后,之前的对话历史是否还保留?

这些问题不能只靠一个反复调用模型的循环解决。Runtime 必须知道哪些状态属于整段会话,哪些只属于当前请求,哪些在一次模型采样结束后就应失效。Codex 用 Thread、Session、Turn、Task 和 Step 把这些时间尺度分开。

第二章沿着 turn/start 追踪了完整执行路径。本章把这条路径拆开,重点检查状态所有权、生命周期切换、运行中追加输入以及协作式取消。

本章细节导航

本篇负责建立全局地图,源码级细节已继续拆成 22 个单元:

五个名称对应五种时间尺度

先把最容易混淆的几个名称放在同一张表里。

名称它表示什么典型生命周期
Thread一段可以继续、恢复和持久化的会话身份从创建开始,可跨多次 Turn,停止后还可以恢复
Session一个已加载 Thread 的内存 Runtime从 Thread 被加载到关闭或进程退出
Turn一次用户请求及为完成它开展的全部工作TurnStarted 到完成、中断或失败
Task执行某类 Turn 的运行策略从 Session 启动后台任务到清理结束
Step模型观察当前状态并采样一次的内部边界从捕获 StepContext 到本次模型输出处理完成

这五层不是五种写法不同的“任务”。Thread 回答“这段对话是谁”,Session 回答“现在由哪个活体 Runtime 管理它”,Turn 回答“用户这次要求做什么”,Task 回答“Runtime 用哪种策略执行”,Step 回答“模型这一次实际看到了什么”。

它们的所有权关系如下。

Codex 中 ThreadManager、CodexThread、Session、ActiveTurn、TurnContext 与 StepContext 的所有权关系
图 3-1:ThreadManager 管理多个可用 Thread;每个已加载 Thread 由一个 Session 承载。Session 同时至多有一个活跃主 Task,而同一 Turn 可以先后捕获多份 StepContext。移动端可横向滑动,点击可查看 SVG 原图。

Thread 是身份,Session 是活体 Runtime

ThreadManager 负责创建、恢复和查找 Thread。它在进程内维护一张从 Thread ID 到 CodexThread 的表,并共享模型管理器、环境管理器、MCP、Skills、Plugins 和存储等服务。

CodexThread 是外部调用者接触 Core 的双向接口。它包装一个 Session 和一组输入输出通道:调用者通过它提交 Op,再读取 Core 发出的 Event。它本身不运行模型循环,真正持有会话状态的是 Session。

Session 中既有跨 Turn 保留的状态,也有只在运行期间存在的资源。前者包括当前配置、对话历史、Token 统计和附加上下文;后者包括事件发送端、输入队列、工具与扩展服务以及当前活跃 Turn。

Thread 和 Session 的区别在恢复场景中最清楚。一个正在运行的 Thread 已经有 Session,再次请求恢复时可以直接复用。如果 Thread 只剩持久化记录,Runtime 会从历史重新构造一个 Session。Thread ID 和历史仍然延续,但原来的内存对象、异步任务和网络连接不会被“复活”。

因此,不能把 Session 当成 Thread 的另一个名字。Thread 是可恢复的会话身份,Session 是该身份当前加载出来的运行实例。

Session 串行处理控制操作

Session 启动时会创建一条 Submission 通道,并运行长期存在的 submission_loop。用户输入、中断、审批答复、设置变更、上下文压缩和关闭请求都会先成为 Submission,再由这条循环按接收顺序分派。

串行分派解决的是控制顺序,不代表模型和工具只能串行执行。循环收到用户输入后,会把实际工作放进独立的异步 Task,自己继续接收后续操作。因此,模型仍在流式返回内容时,Session 依然能够处理审批答复或 Interrupt

这里还有另一条容易与 Submission 通道混淆的队列:InputQueue

队列保存什么什么时候消费
Submission 通道UserInputInterrupt、审批答复、设置变更等控制操作Session 主循环逐项分派
Turn InputQueue运行中追加的用户输入、附加上下文和 Agent Mailbox 消息当前 RegularTask 在模型采样边界读取

前者决定 Runtime 先处理哪个操作,后者决定哪些新信息应进入当前 Turn 的下一次模型请求。把它们合并成一条队列,会让“中断当前任务”和“把一句补充要求交给模型”失去清晰边界。

没有活跃任务时,UserInput 创建新 Turn

Session 收到 Op::UserInput 后,先根据 Thread 当前配置和本次覆盖项构造 TurnContext。它包含 Turn ID、模型、推理设置、协作模式、权限、环境选择、Skills 快照和终态元数据等信息。

TurnContext 是一次 Turn 的主要配置快照。这里的“主要”很重要:模型和权限等决策需要在本次任务中保持一致,但 MCP 连接、环境就绪状态和项目指令仍可能在后续采样前刷新。Codex 没有把所有运行状态一次复制后永久冻结。

如果 Session 此时没有活跃 Task,它会创建 RegularTask,并把两类对象登记进 ActiveTurn

  • RunningTask 保存 Task 类型、取消令牌、后台 Tokio Handle 和 TurnContext;
  • TurnState 保存待处理输入、审批等待者、动态工具响应、临时权限和本 Turn 统计。

Session 的 active_turn 是可选值,而且一个 Session 同时至多运行一个主 Task。这个约束让审批、追加输入和终态事件都能明确关联到当前 Turn。工具执行仍然可以在 Task 内并发,这与“只有一个主 Task”并不冲突。

Task 决定 Turn 怎样运行

Task 不是操作系统进程,也不是 Thread 的子会话。它是 Session 对一类工作的执行策略。统一接口的核心职责是报告任务类型、执行 run,并在需要时通过 abort 完成取消后的专用清理;追踪名称等运行信息也由 Task 提供。

Codex 当前把主要任务分成三类:

Task用途是否接受运行中追加输入
RegularTask普通对话、代码修改和工具反馈循环接受
ReviewTask运行受限制的代码审查流程并投影结果不接受
CompactTask压缩上下文,重建可继续使用的历史不接受

普通用户请求由 RegularTask 执行。它先发出 TurnStarted,取得启动阶段预热的模型传输会话(如果可用),然后进入 run_turn;同一次 run_turn 内的后续采样会复用这份会话。ReviewTask 和 CompactTask 仍然使用同一套 Session 生命周期设施,但它们各自实现不同的执行逻辑与清理动作。

把 Task 单独抽象出来有两个直接作用。第一,Session 不需要在主循环里堆叠普通对话、审查和压缩的全部分支。第二,所有 Task 共享启动、取消、统计、持久化和终态事件规则,客户端不必为每种内部实现重新理解生命周期。

代价也很明确:Task 的业务结束不等于生命周期已经结束。Runtime 仍要刷新执行记录、计算用量、发送终态事件并安全地清除 ActiveTurn。把这些动作散落到每个 Task 中,很容易产生重复完成事件或残留状态,因此统一收口在 Session 的完成处理里。

一个 Turn 为什么需要多个 StepContext

RegularTask 进入 run_turn 后,才开始第一次模型采样。采样前,Session 捕获一份 StepContext。它把这一次请求实际使用的动态状态绑定在一起,包括:

  • 当前环境是否已经就绪;
  • 此刻加载到的 AGENTS.md
  • 执行环境提供的能力发现结果;
  • 当前 MCP Binding 和模型可见工具列表;
  • 与工具列表对应的 ToolRouter。

模型看到的工具规格和 Runtime 随后使用的路由来自同一份 StepContext。即使 MCP 配置在采样期间刷新,已经发出的工具调用仍按产生它的那份执行视图解释,不会在半途中换到另一套路由。

当工具结果进入历史、用户追加输入到达或上下文发生变化时,下一次采样会捕获新的 StepContext。新的快照可以看见已经刷新的环境和工具状态,旧快照则保持不变。

因此,Step 不是一个长期保存的业务对象,也没有独立的客户端生命周期。它是一次模型采样的内部一致性边界。一个 Turn 可以只有一个 Step,也可以经历“搜索—读取—测试—修改—再测试—总结”等多次 Step。

把 RegularTask 和 run_turn 抽掉实现细节后,可以写成下面的伪代码:

async def run_regular_task(turn: TurnContext) -> str | None:
    while True:
        result = await run_turn(turn)
        if not input_queue.has_pending_input(turn.id):
            return result


async def run_turn(turn: TurnContext) -> str | None:
    while True:
        step = await capture_step_context(turn)
        outcome = await sample_model(step)
        await execute_and_record_tool_calls(outcome, step)

        if not outcome.needs_follow_up and not input_queue.has_pending_input(turn.id):
            return outcome.last_message

内层循环处理模型与工具的反馈,外层检查完成边界是否又出现了输入。两层检查不是重复劳动:用户可能恰好在最后一次采样结束、run_turn 准备返回时补充要求,外层检查可以避免这条输入被遗漏。

运行中输入会 Steer 当前 Regular Turn

回到开头的场景。Codex 正在运行测试时,用户补充“不要改接口,只修前端”。Session 不会立即再启动一个并行 Turn,而是先检查当前 ActiveTurn。

如果活跃 Task 是 RegularTask,这条输入会作为 pending input 加入当前 TurnState。steer_input 返回当前 Turn ID,表明它属于原来的工作边界。run_turn 在下一次模型采样前把输入写入历史,模型随后可以根据新要求调整方案。

Steer 不是把一句话硬塞进正在传输的模型响应。已经发出的采样使用原 StepContext 完成;追加要求在安全的采样边界生效。这避免了同一请求的 Prompt 和工具视图在传输中途变化。

Runtime 还会检查调用者提供的 expected Turn ID。如果界面以为自己正在调整 Turn A,而 Session 实际已经进入 Turn B,输入会被拒绝,不会误写到新任务。ReviewTask 和 CompactTask 也不接受 Steer,因为把普通用户要求混入审查或压缩流程会破坏它们的输入契约。

如果根本没有 ActiveTurn,steer_input 会把输入原样退回给调用路径,Session 再用已经构造的 TurnContext 启动新的 RegularTask。同一种 UserInput 因此可以根据 Session 当前状态表现为“创建 Turn”或“调整当前 Turn”。

完成和中断都必须经过状态收口

下面的状态图把普通 Turn、追加输入和中断放在一起。

Codex Session 中普通 Turn 从空闲、运行到完成或中断的状态变化
图 3-2:Regular Turn 可以在工具反馈或 Steer 输入到达后继续采样。正常完成与中断都回到同一个 Session 空闲态,因此结束 Turn 不等于结束 Thread。移动端可横向滑动,点击可查看 SVG 原图。

正常完成时,Task 的 run 先返回结果,Runtime 刷新执行记录,再由 Session 统一完成以下动作:处理完成边界残留的输入,计算本 Turn 的 Token 和工具调用统计,发出 TurnComplete,清除 RunningTask,最后把 ActiveTurn 设回空闲。

清理 ActiveTurn 时还要确认它仍然对应刚完成的 TurnState。这个检查防止一个已经结束的异步任务误删后来建立的新状态。只有清理成功后,Session 才发出 Thread Idle 生命周期,并检查是否有排队工作需要唤醒新的 Turn。

中断走另一条路径。Op::Interrupt 到达 Session 主循环后,Runtime 先原子地取走 ActiveTurn,使新的 Steer 不再写入旧任务。随后取消 RunningTask 的 CancellationToken,给模型流、工具执行和审批等待一个协作退出的窗口;如果 Task 没有及时结束,再中止它的后台 Handle,并调用 Task 自己的 abort 清理。

对于用户中断,Codex 会先把模型可见的中断标记写入历史并完成持久化刷新,再发出 TurnAborted。这样恢复后的模型知道上一轮不是自然结束,客户端收到中断事件后重新读取记录时也不会看见缺失的历史。待审批者、待输入和其他 Turn 级等待项随后被清除。

无论正常完成还是中断,Session 和 Thread 都继续存在。用户下一句话会在同一个 Thread 中启动新 Turn,并继承此前已经提交到历史的内容。只有 Shutdown 或 Thread 被卸载,当前 Session 的生命周期才结束。

生命周期边界换来可控的并发

这套设计维持了几条关键不变量:

  1. 一个 Session 同时至多有一个主 Task,但 Task 内部可以运行并发工具和流式模型请求。
  2. TurnContext 固定一次 Turn 的主要决策,StepContext 固定一次采样真正使用的动态视图。
  3. 运行中的普通 Turn 可以吸收 Steer,特殊 Task 明确拒绝不兼容输入。
  4. 取消先切断 ActiveTurn,再通知异步工作退出,旧任务不能继续接收新输入。
  5. Turn 的完成、中断和持久化状态最终都由 Session 收口。

这些边界让 Codex 能在同一 Thread 中持续工作,同时避免多个主任务争抢历史、权限和终态事件。它们也增加了实现成本:输入可能落在采样、工具执行或完成检查之间;取消依赖每个异步组件配合;Turn 级配置和 Step 级动态状态必须维护清晰的刷新规则。

下一章将沿着 StepContext 继续向模型请求内部移动。重点不再是谁拥有状态,而是基础指令、对话历史、World State、项目规则和工具规格怎样在每次采样前组成模型真正看到的上下文。

延伸阅读

评论


← 返回文章列表