run_turn 主循环 · 一轮对话是怎么转起来的
一轮对话是一个 RegularTask 反复调用 run_turn。run_turn 先做开轮准备,再进入循环:取出用户插话、捕获本次请求的 step、流式采样并边流边派发工具,最后按“还要不要继续”“上下文满没满”“Stop hook 放不放行”决定再来一次、压缩后再来,还是收尾;可重试的流错误在采样函数内部就地退避重试。
run_turn 主循环 · 一轮对话是怎么转起来的
一条消息的生命周期画过全景,上一篇讲了一轮开始时配置怎样冻结。这一篇钻进心脏:codex-rs/core/src/session/turn.rs 里的 run_turn。先分清两个词:**一轮(turn)**是用户一次输入引发的全部工作,对外有一个 turn ID;**一次采样请求(step)**是向模型发一次 Responses 请求。一轮里通常有好几个 step——模型每要一次工具,结果就得送回去再问一次。
用户看到的样子
模型干活时,你在 TUI 里直接按 Enter 发送,新指令会注入当前这一轮;按 Tab 则排到下一轮;按 Esc 或 Ctrl+C 中断这一轮(按键说明见斜杠命令)。网络抖动时状态栏会出现 Reconnecting... 2/5 这样的提示。通过 app-server 编程时,对应的是 turn/start、turn/steer、turn/interrupt,一轮以 turn/completed 通知结束(见 SDK 与 App Server)。
从任务到 run_turn
turn/start 最终在内核里变成一个 RegularTask(任务体系见任务类型与 Plan 模式)。它先发出 TurnStarted,领走会话启动时预热好的模型连接(见模型客户端),然后进入一个外层循环:
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(),
)
.instrument(run_turn_span.clone())
.await?;
// Terminal errors are already reported. Let task completion preserve pending
// input instead of restarting the failed turn for that same input.
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();
}(codex-rs/core/src/tasks/regular.rs:104)
外层循环兜的是一个竞态:run_turn 已经决定收尾,用户恰好又插了一句话。这时不开新的一轮,而是用空输入再跑一次 run_turn,由它从队列里取出那句话。若本轮已经报过终止性错误,就不再重跑,留在队列里的输入交给任务收尾时处理。
开轮准备
run_turn(codex-rs/core/src/session/turn.rs:163)进循环前要做五件事:
- 预采样压缩:
run_pre_sampling_compact先看上一轮是否换了模型:压缩兼容哈希comp_hash变了,或换到窗口更小、已装不下现有历史的模型,就用旧模型先压缩一次;再看 token 是否已到阈值(见上下文压缩)。压缩失败时,用户这次的输入照样写进历史,再报错结束,输入不会丢。 - 找出依赖、捕获首个 step:从用户输入里找出点名的插件、
mcp://提及和技能声明的 MCP 依赖,作为本轮必须启动的 MCP 服务器,然后捕获第一个StepContext。 - 记录上下文差异:
record_context_updates_and_set_reference_context_item在没有基准时(第一轮、或压缩清掉了基准)注入完整的初始上下文,否则只追加有变化的部分(见上下文与历史)。 - 技能与插件注入:用户点名的技能说明、插件能力摘要变成注入项;再跑积压的 SessionStart hooks。
- 写入用户输入:
run_hooks_and_record_inputs逐条过 UserPromptSubmit hook,被拦下的输入只留下 hook 附加的上下文;如果全部被拦,本轮直接结束。放行的输入先写进历史,随后记下本轮模型(previous_turn_settings,供下一轮判断是否换过模型),再写入技能与插件的注入项。
主循环
每一圈的顺序是:先看能不能取插话。带着新输入开轮时,第一圈不取,保证这次输入先被单独采样;中途压缩后如果模型还要继续,那一圈也不取,让它先把压缩前没做完的事续上。取出的插话同样要过 UserPromptSubmit hook 再写进历史。接着准备 step:第一圈没有插话时直接用开轮时捕获的那个,之后每圈重新捕获;有插话时还要先把其中点名的插件、MCP 服务器并进启动要求。然后追加世界状态的差异、必要时追加推理强度的配置项,从历史生成本次输入,交给 run_sampling_request。
采样成功后,决定走向的是这几行:
let needs_follow_up = model_needs_follow_up || has_pending_input;
let token_limit_reached = token_status.token_limit_reached;
// ...
let should_roll_over = needs_follow_up
&& (sess.take_new_context_window_request().await || token_limit_reached);(codex-rs/core/src/session/turn.rs:563)
needs_follow_up 为真有两个来源:模型发了工具调用(或服务端明确返回 end_turn: false),或者采样期间队列里来了新输入。要继续、而上下文又到了自动压缩阈值,就先 run_auto_compact 再 continue;阈值怎么算见上下文压缩。不需要继续时,Stop hook 还能把这一轮“拽回来”:它返回阻止并附上提示词,这条提示就作为新消息写进历史,循环接着转。都放行后,若配置了 model_post_turn_compact_threshold_percent(默认 0,即关闭)且用量越过这个百分比,会在收尾前再压缩一次,然后 break,返回最后一条助手消息,任务据此发出 TurnComplete。
错误分支同样在这个 match 里。被中断(TurnAborted)原样返回,由任务收尾;图片无效单独给出“请移除图片”的提示;其余错误发出 Error 事件后 break,注释写着“let the user continue the conversation”——这一轮失败了,线程还活着。
一次采样请求
run_sampling_request 组装 Prompt(历史、工具清单、基础指令、输出 schema),调用 try_run_sampling_request 消费模型流。流里的事件大致分三类:OutputItemAdded 与各种 delta 转成界面上的“条目开始”和增量事件;OutputItemDone 表示一个输出项完整了,交给 handle_output_item_done;Completed 记下 token 用量后结束这次请求。
if let Some(tool_future) = output_result.tool_future {
in_flight.push_back(tool_future);
}
if let Some(agent_message) = output_result.last_agent_message {
last_agent_message = Some(agent_message);
}
needs_follow_up |= output_result.needs_follow_up;
// ...
if let Some(false) = end_turn {
needs_follow_up = true;
}
break Ok(SamplingRequestResult {
needs_follow_up,
last_agent_message,
});(codex-rs/core/src/session/turn.rs:2700)
handle_output_item_done 先把完整的输出项写进历史,是工具调用就立刻派发、把 future 放进 FuturesOrdered,模型的后续输出与工具执行同时进行;流结束后 drain_in_flight 按调用顺序收齐结果写回历史,下一圈采样就能看到(并行规则见工具系统总览)。收尾时再补发 TokenCount 和本轮累计的 TurnDiff。还有一个提前结束的出口:多 agent 协作时,若模型刚输出一段 commentary 或推理,而收件箱里有别的 agent 发来的信件,就提前结束这次请求、标记需要继续,让下一次请求带上新信件(功能开关 defer_mailbox_preemption 可以关掉这个行为)。
出错了怎么办
try_run_sampling_request 返回错误后,run_sampling_request 先挑出不重试的:上下文超窗时把用量标记为已满,额度用尽时刷新限额快照,然后都直接返回。其余交给 handle_response_stream_error,它先看 CodexErr::retry_delay:认证、配额、策略类错误返回 None,终止;流中断、超时、限流、服务端错误、连接失败等给出延迟,优先用服务端建议的时间,否则从 200 毫秒起倍增、带 10% 抖动(codex-rs/async-utils/src/backoff.rs:12)。
if retry_state.retries >= max_retries
&& client_session.try_switch_fallback_transport(
&turn_context.session_telemetry,
turn_context.model_info(),
)
{
sess.send_event(
turn_context,
EventMsg::Warning(WarningEvent {
message: format!("Falling back from WebSockets to HTTPS transport. {err:#}"),
}),
)
.await;
retry_state.retries = 0;
return Ok(());
}(codex-rs/core/src/responses_retry.rs:99)
重试预算是提供方的 stream_max_retries,默认 5 次、上限 100(codex-rs/model-provider-info/src/lib.rs:64)。用完之后如果正在走 WebSocket,就退到 HTTPS、计数清零再来一轮;界面上的 Reconnecting... n/m 就是这里发的,发布版会隐藏 WebSocket 的第一次重试提示。连接直接失败(ConnectionFailed)另有一条路:默认开启的 unbounded_connection_retries 让普通会话无限等网络恢复,间隔从 5 秒倍增到 60 秒封顶,内部会话与 Amazon Bedrock 不走这条路。
重试时不会清空已经收到的内容。完整的输出项在 OutputItemDone 时已写进历史,已派发的工具在流出错后也会被 drain_in_flight 收齐,所以重试请求从历史重新生成输入,接着断点往下做;万一某个调用没有配对的输出,生成输入时的规范化会补一条内容为 aborted 的输出(codex-rs/core/src/context_manager/normalize.rs),历史始终合法。
打断与插话
插话走 Op::TurnInput 的 StartOrSteer 模式:有正在运行的普通任务,steer_input 就把输入连同受理顺序号压进本轮 TurnState 的 pending_input,下一圈循环顶部取出;审查任务和压缩任务不接受插话。中断走 Op::Interrupt:取消令牌一触发,流读取处的 or_cancel 立刻返回 TurnAborted,未完成的工具被 abort,历史里记一条 <turn_aborted> 提示(agents.interrupt_message 默认开启),再发出 TurnAborted 事件。具体的收尾顺序见任务类型与 Plan 模式。
和《从 LLM 到 Coding Agent》对照
Agent Loop 的最小实现是“问模型,有工具就执行,结果喂回去,再问”的 while 循环,Codex 的骨架一模一样:needs_follow_up 就是“这次回复里有没有工具调用”。区别在骨架外面的东西:边流边执行对应 FuturesOrdered 加 drain_in_flight;中断与转向区分的硬中断与转向,在这里是 Op::Interrupt 与 steer 两条独立的路。错误恢复提醒重试前要清理流了一半的“孤儿消息”,Codex 的做法是只把完整的输出项写进历史、缺配对的调用在生成输入时补上 aborted 输出,于是重试可以直接接着已有进度做。对照 Grok Build 的主循环:两者都在采样前查压缩,Grok Build 按上下文窗口的 85% 触发,Codex 默认按窗口的 90% 触发,还另有一道 95% 的硬上限。
上一篇:Session 与 TurnContext · 会话状态与每轮配置 · 下一篇:模型客户端 · Responses API 的流式调用