跳转至

04. 会话循环:一次轮次如何推进

会话循环是 Codex 智能体的主执行流程。它处理的不是一个普通循环,而是一个持续变化的开发现场:模型在流式输出,工具在本地运行,用户可能中途追加限制,审批可能悬而未决,压缩可能重写历史,客户端还要实时收到可解释的事件。

Codex 将异步、可中断、可恢复的任务收束到一个边界清楚的轮次中。提交循环负责会话级顺序,活跃轮次(ActiveTurn)保存当前轮次状态,轮次上下文(TurnContext)固定运行现场,普通任务(RegularTask)管理生命周期,轮次运行(run_turn)管理模型与工具反馈循环。

stateDiagram-v2 [*] --> Idle Idle --> Submission: 收到操作 Submission --> NewTurn: 无活跃轮次 Submission --> SteeredInput: 活跃普通轮次可注入 SteeredInput --> PendingInput: 写入轮次状态 NewTurn --> TurnContext: 解析本轮配置 TurnContext --> RegularTask: 启动任务 RegularTask --> RunTurn: 发出轮次开始 RunTurn --> ContextBuild: 记录上下文差异 / 钩子 / 技能 ContextBuild --> Sampling: 模型流式采样 Sampling --> ToolDispatch: 工具调用条目完成 ToolDispatch --> ToolResult: 权限 / 沙箱 / 处理器 ToolResult --> History: 工具输出写回 History --> Sampling: 需要后续 Sampling --> StopHooks: 无工具且无待处理输入 StopHooks --> TurnComplete: 收束事件和指标 TurnComplete --> Idle Sampling --> Interrupted: 中断 ToolResult --> Interrupted: 中断 Interrupted --> Idle

职责边界

会话负责“当前有哪些事还活着”,包括活跃轮次、待处理输入、审批状态、消息邮箱、持久化记录和事件通道。普通任务负责“一次用户任务从开始到结束”,包括发出轮次开始、进入模型循环、处理待处理输入和完成收束。轮次运行负责“模型下一步要做什么”,包括构造上下文、采样、分发工具、回填结果、压缩和停止钩子。

这三个边界保持分离后,用户中途输入、工具输出、审批响应和轮次完成都能归属到明确位置。用户追加输入不会被误当成下一轮任务;工具输出进入历史后才会影响后续采样;轮次完成发出时,历史和事件已经尽量收束到可恢复状态。

会话操作队列

Codex 的外部入口不是直接调用轮次运行,而是把操作提交到会话入口。用户输入、中断、审批响应、配置覆盖、压缩、回滚、MCP 刷新、动态工具响应、用户输入请求响应都是操作的不同分支。

会话内有很多共享状态,不能让多个入口同时修改。活跃轮次、待审批项、历史、消息邮箱、持久化记录、智能体状态和客户端事件必须按同一个顺序推进。否则会出现很难复现的状态错位:用户刚点了停止,审批响应却先写入;工具结果还没进历史,轮次完成已经发出;新的用户输入被当作下一轮任务,但模型仍在上一轮继续采样。

Codex 的会话最多只有一个运行中任务,并且可以被用户输入中断。这个约束牺牲了同一会话内的并行轮次,但换来一个清晰的不变量:只要存在活跃轮次,所有会话级操作都围绕它决策;没有活跃轮次,新的用户输入才能启动新的普通任务。

抽象成伪代码就是:

while session is alive:
  op = receive_submission()
  match op:
    用户输入 -> 尝试引导活跃轮次;失败则创建新轮次
    中断 -> 取消活跃任务
    审批响应 -> 唤醒待审批项
    动态工具响应 -> 唤醒待处理动态工具
    压缩/评审/undo -> 作为特殊任务进入生命周期
    回滚/配置更新 -> 更新会话状态或历史

这个循环本身不决定模型下一步。它只保证所有入口先经过同一条门,再交给当前状态能接受的分支。

输入注入与新任务启动

用户输入进入后,会先根据请求里的当前目录、权限、模型、推理强度、输出结构等信息创建本轮轮次上下文。随后运行时尝试把输入引导进当前活跃轮次。

steer_input 只接受三类情况:当前存在活跃轮次、活跃轮次里有任务、任务类型是 RegularReviewCompact 这类任务会被拒绝注入,因为它们不是普通协作开发任务,中途混入用户新需求会破坏任务语义。调用方还可以传 expected_turn_id,防止客户端把输入注入到已经切换过的轮次。

如果没有活跃轮次,会话就启动新的普通任务。如果有活跃普通轮次,输入会被转成响应输入条目,写入轮次状态里的待处理输入,并重新打开消息邮箱的当前轮次投递。模型不会立刻被打断;它会在下一个安全采样点看到这段待处理输入。

这个设计解决的是同轮次引导:用户看到智能体正在跑测试时追加“只改文档,不要碰代码”,这应该影响当前任务,而不是排成下一次全新对话。反过来,若当前任务是评审或压缩,注入会被拒绝,避免把普通需求塞进非交互型后台任务。

活跃轮次与轮次状态

活跃轮次是会话当前正在处理的轮次。它里面有两类状态:任务集合和轮次状态。

任务集合保存正在运行的任务。每个任务带有任务类型、取消令牌、任务句柄、轮次上下文和完成通知。当前普通用户路径通常只有一个普通任务,但结构上允许活跃轮次挂载辅助任务,例如活跃轮次内执行用户 shell 命令。

turn_state 保存这一轮可变的共享状态:待审批项、待处理权限请求、待处理用户输入、待处理 MCP 交互、待处理动态工具、待处理输入、已授予的权限、工具调用计数、记忆引用标记和本轮起点 token 用量。

这一层把“轮次生命周期”与“模型循环局部变量”分开。审批响应可能从客户端入口进来,工具处理器正在等待它;用户输入可能从界面进来,轮次运行下一次采样才消费它;中断可能从外部进来,需要清空所有等待中的请求。这些状态如果只放在轮次运行栈上,外部入口就无法可靠唤醒或取消;如果都放在会话状态里,又会和跨轮次的持久状态混在一起。

轮次状态还保证待处理输入的顺序。被钩子阻塞后未处理的待处理输入会放回队首,避免新的输入插队到旧输入前面。这个细节很小,但对交互体验很重要:用户连续发两条追加要求时,模型看到的顺序必须和用户提交顺序一致。

轮次上下文固定运行现场

轮次上下文是本轮任务的运行快照。它包含当前目录、模型信息、模型提供方、审批策略、权限配置、沙箱、网络策略、环境选择、技能、插件、动态工具、输出结构、会话来源、客户端信息和遥测状态。

它不是普通参数对象,而是“本轮规则基线”。同一个轮次内可能发生多次模型采样和多次工具调用,如果每次都从会话状态读取最新配置,任务会变得不可解释:第一轮采样看到工作区可写,第二轮工具执行突然读到只读;模型请求某个工具时它可见,执行时工具列表却变了;当前目录变化导致相对路径解析前后不一致。

固定轮次上下文后,模型可见能力和本地执行边界共享同一份依据。模型根据它看到工具和权限说明,处理器根据它做权限、沙箱、环境和路径判断。配置仍然可以在会话级更新,但会影响后续轮次,而不是悄悄改写当前轮次的执行现场。

轮次运行早期会把本轮上下文变化写入历史和持久化记录,使恢复或分叉后不只恢复聊天文本,还能恢复“当时模型是在什么规则下继续工作的”。

普通任务生命周期

普通任务是普通用户轮次的外壳。它先发送轮次开始事件,再消费启动预热出来的模型客户端会话,然后进入轮次运行。轮次运行返回后,普通任务不一定马上结束;它会检查会话是否还有待处理输入,有就再次调用轮次运行。

这个外壳层解决了两个问题。

第一,生命周期事件需要稳定。客户端不能等到模型预热完成后才知道轮次开始,所以普通任务在进入模型主循环前就发出轮次开始。完成事件则统一由任务启动方发出,保证普通、评审、压缩等任务共享同一套完成收束逻辑。

第二,待处理输入需要在同一个用户任务里继续推进。用户中途追加输入时,轮次运行可能已经走到某次采样尾部。普通任务外层的循环让这类输入不会因为一次轮次运行返回就被落到下一轮;只要活跃轮次里还有待处理输入,它就继续让当前普通任务消费。

普通任务运行:
  发出轮次开始
  可能使用预热模型会话
  loop:
    last_message = 轮次运行(input)
    if session.has_pending_input():
      input = []
      continue
    return last_message

普通任务不理解用户意图,也不执行工具。它只维护“这还是不是同一个轮次”的外层边界。

轮次运行的入口阶段

轮次运行开头先做两个判断:如果没有显式输入,也没有待处理输入,直接返回;否则先检查是否需要采样前压缩。

采样前压缩的目的不是优化,而是避免第一次采样就撞上下文窗口。它还处理一个特殊场景:用户从大上下文模型切到小上下文模型时,旧历史可能在旧模型里合法,在新模型里超限。Codex 会在旧模型上下文下先压缩,再用新模型继续。

如果压缩改写了历史,预热的 WebSocket 会话会被重置。模型客户端会话可能缓存了粘性路由或上下文相关状态,历史发生结构性重写后继续复用会让采样链路变得不干净。

入口阶段随后会处理技能、插件、连接器、显式提及、MCP 依赖提示、会话启动钩子、用户提示提交钩子,并把用户输入记录到历史。这里看起来像“上下文准备”,实际是在建立本轮模型可见事实:用户输入先作为正式用户条目进入历史,技能/插件注入再作为上下文条目进入历史,后面的采样统一从历史生成提示词。

上下文准备在采样前重算

每次模型采样前,Codex 都会从历史生成采样输入。它不会把第一次构造好的提示词缓存在轮次里反复使用,因为工具输出、待处理输入、压缩、钩子和能力列表都可能改变下一次采样。

这一步至少包含几类变化:

  • 历史中新增了模型文本、工具调用和工具输出。
  • 待处理输入被钩子接受后记录为新的用户输入。
  • 轮次中压缩可能把旧历史替换成摘要。
  • 工具、技能、插件、连接器的可见列表可能因为本轮提及或配置而变化。
  • 上下文基线可能需要注入差异,或者在压缩/恢复后重新建立。

所以提示词不是“用户输入 + 固定系统提示词”,而是当前会话状态的一次规范化快照。模型只能基于这份快照做下一步,本地状态后续发生的变化必须等下一次采样才能进入模型视野。

这让因果关系更清楚:模型请求工具 A,本地执行得到结果 B,B 写入历史,下一次采样才允许模型基于 B 决策。不会出现模型在同一次请求里“看见”请求发出后才发生的本地状态。

模型采样阶段

每次采样前,运行时都会构造工具路由器(ToolRouter,用于同时提供模型可见工具规范和本地处理器路由),再把路由器里的模型可见规范、基础指令、人设、输出结构和历史输入组装成提示词。

工具路由器每次重建,是因为工具可见性属于当前轮次和当前采样。显式提到应用、技能触发、动态工具、MCP 状态、功能开关、模型能力都会影响本次可见工具。如果工具列表只在会话启动时确定,模型会看不到刚触发的能力,也可能继续使用已经不可用的工具。

模型返回的是流式响应事件。运行时不把流式增量直接当最终历史。文本增量会先转成客户端事件,让界面实时显示;等输出项完成后,完整条目才被记录。推理、计划、工具参数和普通文本也分开处理,避免把内部状态混入用户可见文本。

工具调用尤其需要等条目完成。模型可能逐步流出函数名和 JSON 参数,参数没闭合前不能执行本地动作。Codex 只有在工具调用条目完成后才分发,这保证处理器拿到的是完整、可解析、可审计的调用,而不是半截流式片段。

采样阶段还带有重试逻辑。网络错误、流中断、上下文窗口超限、用量限制等错误需要不同处理:有些可以重试,有些要触发压缩,有些要发错误事件后允许用户继续对话。错误处理放在采样层,而不是工具层,是因为它们影响的是模型请求本身。

工具执行阶段

工具调用完成后,工具调用运行时根据名称和参数交给对应处理器。处理器做参数解析和语义校验,再进入权限、审批、沙箱和具体执行。shell、补丁、MCP、动态工具、权限请求、用户输入请求都是同一条模型工具调用反馈链上的不同处理器。

工具执行不是旁路副作用。它会产生两类输出:

第一类是客户端事件。例如命令开始、stdout/stderr 增量、命令完成、补丁差异、审批请求。这些事件让界面能显示进度,但它们不是模型下一步的完整输入。

第二类是模型可读工具输出。它会和原工具调用保持对应关系写入历史。下一次采样时,模型看到的是“我请求了这个工具,本地运行时返回了这个结果”。这条对应关系比纯文本日志重要得多,因为模型协议通常要求工具调用和工具输出成对出现,连续工具调用也需要保持可归因。

权限拒绝、沙箱拒绝、审批失败、参数错误也会转成工具结果。这样模型可以根据拒绝原因改方案,例如改用只读检查、请求更小权限,或者向用户说明无法继续。拒绝不是异常吞掉,而是智能体循环的反馈信号。

后续采样

一次采样结束后,轮次运行会判断两个信号:模型是否因为工具调用需要后续,以及会话是否还有待处理输入。任一为真,就继续采样。

后续不是把工具输出拼到提示词末尾。Codex 会重新从历史生成提示词,清理和规范化条目,重建可见工具列表,并加入新的上下文变化。这样每次采样都是基于完整因果链,而不是基于一段临时字符串。

sequenceDiagram participant R as 轮次运行 participant H as 历史 participant M as 模型 participant T as 工具运行时 R->>H: 生成采样输入 R->>M: 流式请求 M-->>R: 工具调用条目完成 R->>T: 分发 T-->>R: 工具输出 R->>H: 记录工具调用/输出 R->>H: 重新生成采样输入 R->>M: 后续流式请求

轮次中压缩也发生在这个阶段。若 token 用量达到自动压缩阈值,且仍需要后续,Codex 会在当前轮次内压缩,然后重置 WebSocket 会话,再继续采样。测试失败、文件内容和用户目标会被摘要保留下来,当前任务能继续推进。

当既没有工具后续,也没有待处理输入时,Codex 才进入停止钩子和智能体后置钩子。停止钩子可以要求模型继续,例如补充检查或生成收尾说明;智能体后置钩子可以做最终审计。也就是说,“模型说完了”还不等于轮次完了,运行时还要给本地策略最后一次介入机会。

待处理输入合并

待处理输入是运行中追加的用户输入。Codex 不会在任意时刻打断模型流把输入塞进去,而是等当前采样或工具链来到可恢复边界后再合并。

轮次运行里有一个“可取出待处理输入”标记。刚进入轮次时它通常是关闭状态,因为当前用户提示词应先被采样;第一次采样完成后才允许取出待处理输入。自动压缩后也会谨慎处理:如果模型还有未完成后续,就先让工具链恢复,再决定是否消费用户追加输入。

待处理输入还会经过钩子检查。钩子可以接受输入,也可以阻塞输入并提供额外上下文。若某条待处理输入被阻塞,后面的未处理输入会被放回队首,下一轮仍按原顺序处理。这保证钩子的治理逻辑不会打乱用户输入顺序。

消息邮箱也通过同一套待处理输入机制进入模型上下文。子智能体消息或智能体间通信可以选择是否触发轮次;如果当前轮次已经输出了用户可见终局文本,消息邮箱可能被推迟到下一轮次,避免较晚到达的子任务消息把一个已经展示结束的回答重新拉长。

中断处理

中断是运行时级取消,不是给模型发一条“请停止”。会话收到中断后会取出活跃轮次,遍历其中任务,取消令牌,等待一个很短的优雅退出窗口,然后中止任务句柄。

这个顺序有两个目的。

第一,让正在等待审批或工具输出的任务先观察到取消。系统会在清理待审批项前给任务一个取消窗口,否则等待审批的工具可能把通道关闭误判成“审批拒绝”,并把拒绝作为模型可见结果写入历史。

第二,保证客户端和持久化看到一致的中断边界。真正中断时,Codex 会把中断标记写入历史和持久化记录,并刷新存储,然后再发轮次中止事件。这样客户端收到中止事件后同步重读持久化记录,不会读到缺少中断标记的半截历史。

中断不销毁线程,也不清空会话历史。它只是终止当前活跃任务、清理等待中的请求、发出轮次中止事件,并在存在已排队待处理工作时尝试启动下一轮。用户后续输入仍在同一线程里继续。

轮次完成收束

轮次完成不是“模型最后一个 token 到达”。它是运行时的提交点。

任务运行结束后,启动方先刷新持久化记录,失败会发警告。随后任务完成处理从活跃轮次移除任务,取出待处理输入,更新 token 用量、工具调用数、记忆引用、网络代理、目标运行时和轮次耗时。最后才发送轮次完成。

任务完成处理清理活跃轮次时还会检查当前活跃轮次是否仍然是同一个轮次状态,并且任务已空。这避免竞态:如果旧任务完成的同时新任务已经启动,旧任务不能把新活跃轮次清掉。

轮次完成发出后,客户端可以把本轮视为结束,因为几个条件已经同时成立:模型流结束,工具后续收束,待处理输入已处理或留给后续,历史和持久化记录已尽量写入,指标和状态已经更新,活跃轮次也被安全清理。

会话循环的调度层较重,状态边界较细。开发型智能体不是一次问答,它会长时间运行、被用户打断、等待审批、执行本地工具、压缩上下文、恢复历史。Codex 把这些变化分层收束,使每个输入、工具结果、权限决定、事件和历史记录都能归属到明确的轮次。