ARTICLE · 1286456
Codex 源码-多智能体 Spawn 与角色系统——槽位预留、LRU 驱逐与上下文过滤
Codex 源码解析系列
第 37 讲:多智能体 Spawn 与角色系统——槽位预留、LRU 驱逐与上下文过滤
基于 OpenAI Codex 源码 · 2026-10-31
💡 本讲一句话:Codex 的多智能体不是"无脑 fork 线程"——每次 spawn 都要过四道闸门:容量检查(V2 还会 LRU 驱逐最闲的子代理腾位置)、RAII 槽位预留(失败自动回滚)、角色降权(子代理只能比父会话更弱、不能更强)、上下文过滤(fork 时把父级的审批证据和用量提示全部洗掉)。读完你能说清一个子代理从出生到被驱逐的完整生命周期。
一、spawn_agent_internal:一次派生的完整流水线
多智能体系统的入口在 core/src/agent/control/spawn.rs(全文件 1324 行)。三个公开入口 spawn_agent / spawn_agent_with_metadata / spawn_agent_with_communication 最终都汇入同一个私有函数 spawn_agent_internal。它做的事比"创建线程"多得多:先定版本、再查容量、预留槽位、继承环境、准备元数据、建线程、投递初始输入——任何一步失败,RAII 保证已占的资源自动归还:
📄 codex-rs/core/src/agent/control/spawn.rs (第 614-730 行)
async fn spawn_agent_internal( // 所有派生入口的汇聚点:一次子代理出生要过的全部闸门都在这里 &self, config: Config, // 父会话配置:容量上限、角色、feature flag 都从这里取 initial_input: SpawnInitialInput, // 初始输入二选一:用户 prompt,或 agent 间通信消息(V2) session_source: Option<SessionSource>, // 会话来源:ThreadSpawn 才走子代理分支,None 表示普通新线程 options: SpawnAgentOptions, // 可选参数:fork 模式、父 turn id、环境选择等) -> CodexResult<LiveAgent> { let state = self.upgrade()?; // 弱引用升级成强引用,thread manager 已销毁就直接报错退出 let multi_agent_version = state // 先定版本:V1/V2/Disabled 决定后面走哪套容量与通知机制 .effective_multi_agent_version_for_spawn( &InitialHistory::New, session_source.as_ref(), options.parent_thread_id, /*forked_from_thread_id*/ None, // fork 场景由 spawn_forked_thread 单独处理,这里传空 &config, ) .await; if let Some(session_source) = session_source.as_ref() { self.ensure_execution_capacity(multi_agent_version, session_source)?; // 执行容量硬检查:V2 子代理超限直接 AgentLimitReached,fail loud } let agent_max_threads = config.effective_agent_max_threads(multi_agent_version); // V2 上限=配置值-1(给父会话留一个位置),V1 用默认常量 let spawn_uses_v2_residency = multi_agent_version == MultiAgentVersion::V2 // 只有"V2 + SubAgent 来源"才占用 LRU 常驻槽位 && session_source.as_ref().is_some_and(is_v2_resident_session_source); let residency_slot = if spawn_uses_v2_residency { Some( self.reserve_v2_residency_slot(&state, &config, /*protected_thread_id*/ None) // 预留常驻槽位:满了会先尝试 LRU 驱逐一个闲子代理(第三节) .await?, ) } else { None }; let reservation_max_threads = if spawn_uses_v2_residency { None // V2 走常驻槽位后不再重复计数,避免双重限流 } else { agent_max_threads }; let mut reservation = self.state.reserve_spawn_slot(reservation_max_threads)?; // RAII 预留:函数中途 return/panic 时 Drop 自动归还(第二节) let inheritance = SpawnAgentThreadInheritance { // 继承清单:子代理默认复用父会话的运行环境与安全策略 environments: self.inherited_environments_for_source(&state, session_source.as_ref()).await, // 快照父线程的 turn 环境(工作目录、工具配置) exec_policy: self.inherited_exec_policy_for_source(&state, session_source.as_ref(), &config).await, // 复用父级 ExecPolicyManager,避免子代理重新解析策略文件 }; let (session_source, mut agent_metadata) = match session_source { Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth, agent_path, agent_role, .. })) => { // 只有 ThreadSpawn 来源才需要登记父子关系与元数据 let (session_source, agent_metadata) = self.prepare_thread_spawn( // 预留 agent path + 昵称,构造带 depth/role 的 SessionSource &mut reservation, &config, parent_thread_id, depth, agent_path, agent_role, /*preferred_agent_nickname*/ None, )?; (Some(session_source), agent_metadata) } other => (other, AgentMetadata::default()), // 非子代理来源:不登记元数据,走普通线程路径 }; let notification_source = session_source.clone(); // 留一份副本给后面的完成通知与埋点用 // The same `AgentControl` is sent to spawn the thread. // 注释原文:同一个 AgentControl 会被注入新线程——子代理共享同一套控制面 let new_thread = match (session_source, options.fork_mode.as_ref(), inheritance) { (Some(session_source), Some(_), inheritance) => { Box::pin(self.spawn_forked_thread(&state, config, session_source, &options, inheritance, multi_agent_version)).await? // fork 模式:复制父级历史再过滤(第四节) } (Some(session_source), None, inheritance) => { let history_mode = if let Some(parent_thread_id) = options.parent_thread_id // 父线程是分页历史模式时,子线程也沿用——保证 resume 路径一致 && let Ok(parent_thread) = state.get_thread(parent_thread_id).await { matches!(parent_thread.config_snapshot().await.history_mode, ThreadHistoryMode::Paginated).then_some(ThreadHistoryMode::Paginated) } else { None }; Box::pin(state.spawn_new_thread_with_source( // 常规路径:新建线程并注入继承的环境与策略 config.clone(), self.clone(), session_source, history_mode, options.parent_thread_id, /*forked_from_thread_id*/ None, /*thread_source*/ Some(ThreadSource::Subagent), /*metrics_service_name*/ None, inheritance.environments, inheritance.exec_policy, options.environments.clone(), )).await? } (None, _, _) => Box::pin(state.spawn_new_thread(config.clone(), self.clone())).await?, // 无来源:最普通的裸线程创建 }; agent_metadata.agent_id = Some(new_thread.thread_id); // 元数据回填真实 thread id,之后按 id 可反查角色/昵称 reservation.commit(agent_metadata.clone()); // 提交预留:active=false,Drop 不再回滚——资源正式归属新线程 if let Some(residency_slot) = residency_slot { residency_slot.commit(new_thread.thread_id); // 常驻槽位转正:pending-1、进入 LRU 队列尾部(最近使用) }为什么这样设计:注意整个函数里没有任何一处"手动释放资源"——reservation 和 residency_slot 都是 RAII 守卫,中途任何 ? 提前返回都会触发 Drop 自动归还槽位。这是 Rust 写并发资源管理的典型姿势:把"清理"绑定到作用域而不是靠人记得调用。另一个细节是 V2 的容量上限要减 1(effective_agent_max_threads,config/mod.rs 第 1583-1596 行)——因为父会话自己也要占一个执行位,子代理池的上限必须给父级留位置:
📄 codex-rs/core/src/config/mod.rs (第 1583-1596 行)
pub(crate) fn effective_agent_max_threads( // 按多智能体版本返回子代理并发上限 &self, multi_agent_version: MultiAgentVersion,) -> Option<usize> { match multi_agent_version { MultiAgentVersion::V2 => Some( self.multi_agent_v2.max_concurrent_threads_per_session.saturating_sub(1), // V2:配置值减 1——父会话自己占一个执行位,子代理池必须让出位置 ), MultiAgentVersion::Disabled | MultiAgentVersion::V1 => { self.agent_max_threads.or(DEFAULT_AGENT_MAX_THREADS) // V1/关闭:用户显式配置优先,否则用默认常量兜底 } }}二、槽位预留:RAII 守卫与"昵称第 N 世"
容量检查在 core/src/agent/registry.rs。核心是一个原子计数器 + 一个 RAII 预留对象:reserve 成功返回 SpawnReservation,commit 才转正;不 commit 就 Drop,自动回滚。同一文件里还有个有趣的细节——子代理昵称冲突时会自动加"第 N 世"后缀(format_agent_nickname,registry.rs 第 60-77 行),所以日志里出现 "Alice the 2nd" 不是 bug:
📄 codex-rs/core/src/agent/registry.rs (第 96-135 行)
pub(crate) fn reserve_spawn_slot( // 预留一个派生槽位:先查上限,再返回 RAII 守卫 self: &Arc<Self>, max_threads: Option<usize>, // None 表示不限(V2 常驻路径走这里)) -> Result<SpawnReservation> { if let Some(max_threads) = max_threads { if !self.try_increment_spawned(max_threads) { // CAS 自增:当前计数已达上限则失败,不排队等待——快速失败 return Err(CodexErr::new(CodexErrorDetails::AgentLimitReached { max_threads })); // 超限直接报错,调用方(模型)能看到明确原因并调整策略 } } else { self.total_count.fetch_add(1, Ordering::AcqRel); // 无上限路径:只计数不检查,用于 V2 常驻槽位之外的场景 } Ok(SpawnReservation { state: Arc::clone(self), active: true, reserved_agent_nickname: None, reserved_agent_path: None }) // active=true 标记"未提交",Drop 时据此决定是否回滚}impl Drop for SpawnReservation { // RAII:预留对象离开作用域且未 commit → 自动归还资源 fn drop(&mut self) { if self.active { // 已 commit 的守卫 active=false,跳过——避免二次释放 if let Some(agent_path) = self.reserved_agent_path.take() { self.state.release_reserved_agent_path(&agent_path); // 归还预留的 agent path(如 /root/worker-1),防止路径泄漏被永久占用 } self.state.total_count.fetch_sub(1, Ordering::AcqRel); // 计数回退,槽位重新可用 } }}为什么这样设计:"reserve → commit / drop"两段式把检查与占用原子化:两个并发 spawn 同时通过容量检查的竞态,被 try_increment_spawned 的 CAS 挡掉;而 spawn 流程中间任何一步失败(建线程、投递输入),Drop 都会把计数和路径还回去——不需要在每条错误分支里手写清理代码。对比 V2 常驻槽位(下一节):它同样用 V2ResidencySlot + Drop 实现,但多了一个"驱逐换容量"的能力。
三、V2 常驻槽位:满了不报错,先 LRU 驱逐最闲的子代理
这是 V1 和 V2 最本质的区别。V1 超限直接 AgentLimitReached;V2 的 reserve_slot(residency.rs 第 81-103 行)会先进入一个循环:占不到槽位就主动卸载一个"可驱逐"的常驻线程,腾出位置再重试。谁能被驱逐?is_unloadable(第 233-239 行)给了三个条件:状态是终态、没有进行中的 turn、邮箱里没有未读消息——正在干活的子代理永远不会被踢:
📄 codex-rs/core/src/agent/control/residency.rs (第 81-158 行)
async fn reserve_slot( // V2 常驻槽位预留:占不到就驱逐最闲的常驻线程,循环重试 self: Arc<Self>, manager: &Arc<ThreadManagerState>, capacity: usize, // 容量上限(配置值减 1) protected_thread_id: Option<ThreadId>, // 受保护线程:本轮驱逐扫描中跳过它(resume 自己的场景)) -> CodexResult<V2ResidencySlot> { loop { if self.try_reserve_pending_slot(capacity) { // 先尝试占一个 pending 槽位:residents+pending < capacity 才成功 return Ok(V2ResidencySlot { residency: self, active: true }); // 拿到守卫,等 commit 转正;失败路径 Drop 自动释放 } if !self.try_unload_one_resident(manager, protected_thread_id).await { // 满了:尝试驱逐一个可卸载的常驻线程 return Err(CodexErr::new(CodexErrorDetails::AgentLimitReached { max_threads: capacity })); // 没有可驱逐对象(都在干活)才真正报错 } }}async fn try_unload_one_resident( // LRU 扫描:从最久未使用的常驻线程开始找"可卸载者" &self, manager: &Arc<ThreadManagerState>, protected_thread_id: Option<ThreadId>,) -> bool { let candidates_to_scan = self.resident_count(); // 最多扫一轮,防止死循环 for _ in 0..candidates_to_scan { let Some(candidate_thread_id) = self.pop_lru_candidate(protected_thread_id) else { return false }; // 从队头(最久未用)弹出一个候选;受保护线程会被放回队尾跳过 let Some(candidate_thread) = manager.get_thread(candidate_thread_id).await.ok() // 线程可能已消失,取不到就继续下一个 .filter(|thread| is_resident_candidate(thread)) else { continue }; // 只处理 V2 + SubAgent 来源的常驻线程 if !is_unloadable(candidate_thread.as_ref()).await { // 终态 + 无活跃 turn + 邮箱空,三者缺一不可(第 233-239 行) self.touch(candidate_thread_id); // 还在干活:刷新 LRU 位置到队尾,本轮不碰它 continue; } candidate_thread.ensure_rollout_materialized().await; // 先把未落盘的 rollout 刷出来——驱逐前保证历史完整持久化 if let Err(err) = candidate_thread.shutdown_and_wait().await { // 优雅关闭并等待退出;失败则刷新 LRU、换下一个候选 warn!("failed to shut down v2 resident thread before unloading {candidate_thread_id}: {err}"); self.touch(candidate_thread_id); continue; } let environments = candidate_thread.environment_selections().await; // 记下它的环境选择,驱逐后 resume 时能原样恢复 candidate_thread.session.services.agent_control.state.save_evicted_environments(candidate_thread_id, environments); // 环境快照存入 registry(evicted_environments) let _ = manager.remove_thread(&candidate_thread_id).await; // 从线程管理器移除,槽位释放 return true; } false // 扫完一轮没有可卸载者:调用方据此返回 AgentLimitReached}为什么这样设计:多智能体场景里"子代理池满了"是常态而不是异常——父 agent 经常并行派生多个 worker。V2 的策略是把容量压力转化为 LRU 驱逐:最久没被 touch、且已经干完活的子代理先走,它的 rollout 已落盘、环境快照已保存,之后 ensure_v2_agent_loaded(spawn.rs 第 315 行起)可以凭 rollout 文件把它"复活"。这本质上是把子代理当成可换页的进程:内存不够就 swap out,需要时再 swap in——和操作系统管理物理内存的思路完全同构。而 is_unloadable 的三个条件(终态、无活跃 turn、邮箱空)保证了驱逐不会打断任何进行中的工作。
四、角色系统:子代理只能"降权",不能"扩权"
派生时可以指定 agent_type(角色)。内置三个:default、explorer(快速回答代码库问题)、worker(执行与生产工作,role.rs 第 343-404 行)。角色配置的核心约束写在 role.rs 文件头注释里:"Roles may customize the child or reduce its capabilities, but never replace the parent session's authority"——角色可以定制子代理、削弱其能力,但绝不能替代父会话的权限。代码层面这个约束是结构性的:feature flag 只允许关、不允许开:
📄 codex-rs/core/src/agent/role.rs (第 91-127 行)
if let Some(features) = role_config.features { // 角色 toml 里声明的 feature 开关 for (key, enabled) in features.entries() { if !enabled // 只处理"关闭"方向的声明——开启方向直接忽略,这是降权约束的第一道闸 && let Some( feature @ (Feature::ShellTool | Feature::Apps | Feature::Personality // 白名单:只有这几个敏感能力允许被角色关掉 | Feature::Plugins | Feature::MemoryTool | Feature::RequestPermissionsTool), ) = feature_for_key(&key) { overrides.features.insert(feature.key().to_string(), false); // 记入覆盖表,后面统一 disable——白名单外的 key 不生效 } }}if let Some(mut skills) = role_config.skills { // 技能同理:只保留"禁用"声明,启用声明被过滤掉 skills.config.retain(|skill| !skill.enabled); // 自定义技能列表里只留 enabled=false 的条目 skills.bundled = skills.bundled.filter(|bundled| !bundled.enabled); // 内置技能同理:只保留禁用项 skills.include_instructions = skills.include_instructions.filter(|enabled| !enabled); // include_instructions 为 false 才保留(即"不要注入说明") skills.max_context_tokens = None; // 角色不允许调整技能上下文预算——防止子代理用更大窗口绕过限制}let role_layer_toml = TomlValue::try_from(&overrides)?; // 把覆盖表序列化成 toml,作为新的配置层if role_layer_toml.as_table().is_some_and(toml::map::Map::is_empty) { return Ok(()); // 没有任何有效覆盖:原样返回父配置,不产生多余配置层}*config = role_overrides::build_next_config(config, role_layer_toml, &overrides)?; // 生成新 Config:角色层插入配置栈 + 逐项应用覆盖为什么这样设计:注意 build_next_config(role.rs 第 213-217 行)里对 features 的处理只有一个方向——next_config.features.disable(feature)?,整个文件没有任何 enable 路径。这意味着即使角色 toml 里写了 shell_tool = true,子代理也拿不到 shell——安全边界由代码结构保证,而不是靠"希望模型不写错配置"。另外角色层是作为新的配置层插入 layer stack(第 240-263 行)而不是直接改字段:这样下游所有按层读取配置的消费者(exec policy、工具注册)看到的都是一致的层级视图,且父级 requirements 约束依然生效。
五、fork 模式:复制父历史时,哪些东西必须洗掉
spawn_forked_thread(第 821-1136 行)是 spawn.rs 里最长的函数:它把父线程的 rollout 历史复制给子线程,但不是原样复制——keep_forked_rollout_item(第 65-107 行)是一个精细的过滤器,决定哪些历史条目能进子线程:
📄 codex-rs/core/src/agent/control/spawn.rs (第 65-107 行)
fn keep_forked_rollout_item(item: &RolloutItem, preserve_reference_context_item: bool) -> bool { // fork 过滤器:逐条决定父历史能否进入子线程 match item { RolloutItem::ResponseItem(envelope) => match &envelope.item { ResponseItem::Message { role, phase, .. } => match role.as_str() { "system" | "developer" | "user" => true, // 指令与用户消息保留:子代理需要同样的任务上下文 "assistant" => *phase == Some(MessagePhase::FinalAnswer), // assistant 只留最终答案——中间推理对子代理是噪音且浪费 token _ => false, }, ResponseItem::FunctionCallOutput { call_id: None, .. } | ResponseItem::ConfigurationUpdate { .. } => true, // 无配对调用的输出与配置变更保留:它们承载环境事实 ResponseItem::AdditionalTools { .. } | ResponseItem::AgentMessage { .. } | ResponseItem::Reasoning { .. } // 工具清单、agent 间消息、推理过程——全部丢弃 | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::ToolSearchCall { .. } | ResponseItem::FunctionCallOutput { call_id: Some(_), .. } | ResponseItem::CustomToolCall { .. } // 工具调用与配对输出成对丢弃:子代理会自己重新执行需要的操作 | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::ToolSearchOutput { .. } | ResponseItem::WebSearchCall { .. } | ResponseItem::ImageGenerationCall { .. } | ResponseItem::Compaction { .. } | ResponseItem::CompactionTrigger { .. } | ResponseItem::ContextCompaction { .. } // 压缩痕迹不继承:子线程有自己的上下文预算与压缩策略 | ResponseItem::Other => false, }, RolloutItem::RealtimeItem(_) | RolloutItem::InterAgentCommunication(_) // 实时流、agent 间通信——父级协作过程对子代理无意义 | RolloutItem::InterAgentCommunicationMetadata { .. } | RolloutItem::RetainedContext(_) | RolloutItem::SecurityRiskScore(_) => false, // Full-history forks preserve the cached prompt prefix and can keep diffing from the parent's durable baseline. // 注释原文:全量 fork 保留缓存的 prompt 前缀,可继续基于父级基线做增量 // Truncated forks drop part of that prompt, so they must rebuild context on their first child turn. // 截断 fork 丢了一部分 prompt,必须在首个子轮次重建上下文 RolloutItem::TurnContext(_) | RolloutItem::WorldState(_) => preserve_reference_context_item, // 参考上下文条目:全量 fork 保留、LastNTurns 截断 fork 丢弃——由调用方按 fork 模式决定 // Child threads inherit model context, not the parent's cumulative usage state. // 注释原文:子线程继承模型上下文,不继承父级累计用量状态 RolloutItem::TokenUsageRecord(_) => false, // token 用量是父会话的账本,子代理从零开始计数 RolloutItem::Compacted(_) | RolloutItem::EventMsg(_) | RolloutItem::SessionMeta(_) => true, // 压缩检查点、事件流、会话元数据保留:resume 与审计依赖它们 }}为什么这样设计:fork 的语义是"让子代理站在父级肩膀上",但不是克隆整个进程。过滤规则背后有三条原则:① 指令保留、过程丢弃——system/developer/user 消息定义任务,工具调用与推理是父级的解题过程,子代理应该自己解;② 安全证据不继承——第 1039-1049 行还会把压缩检查点里的 guardian_history(Guardian 审批证据)和 retained_context 清空,注释写得很直白:"Parent-local review evidence must not become the child's authorization"——父级被批准过的操作,不能自动变成子级的授权;③ 账本不继承——token 用量、安全分都是会话私有状态。这三条合起来保证 fork 出来的子代理是"知情但清白"的:知道任务背景,但不继承任何特权。
六、完成通知:V1 推消息,V2 走邮箱
子代理干完活怎么告诉父级?maybe_start_completion_watcher(control.rs 第 626-716 行)为 V1 路径启动一个脱离式 watcher:订阅子线程状态,等到终态后把结果注入父线程。V2 则不走这条路——spawn_agent_internal 末尾的 if multi_agent_version != MultiAgentVersion::V2(第 800-812 行)明确把 V2 排除在外,因为 V2 有 agent 间通信邮箱(InterAgentCommunication),结果直接走 mailbox:
📄 codex-rs/core/src/agent/control.rs (第 639-715 行)
let control = self.clone(); // 克隆控制面句柄,watcher 是独立任务tokio::spawn(async move { // 脱离式任务:不阻塞 spawn 调用方,父线程无需等待子代理结束 let status = match control.subscribe_status(child_thread_id).await { // 订阅子线程状态流(broadcast) Ok(mut status_rx) => { let mut status = status_rx.borrow().clone(); while !is_final(&status) { // 轮询直到终态:Completed / Errored / Interrupted if status_rx.changed().await.is_err() { // 订阅断开(线程被驱逐/销毁)→ 降级为主动查询一次 status = control.get_status(child_thread_id).await; break; } status = status_rx.borrow().clone(); } status } Err(_) => control.get_status(child_thread_id).await, // 订阅失败兜底:直接查当前状态 }; if !is_final(&status) { return; } // 非终态(如线程已消失):放弃通知,避免注入脏数据 let Ok(state) = control.upgrade() else { return; }; // manager 已销毁则退出 let child_thread = state.get_thread(child_thread_id).await.ok(); let child_uses_multi_agent_v2 = match child_thread.as_ref() { Some(child_thread) => child_thread.multi_agent_version() == Some(MultiAgentVersion::V2), // 子线程实际版本以运行时为准(配置可能中途变化) None => true, // 查不到时按 V2 处理:走通信路径更安全 }; if child_agent_path.is_some() && child_uses_multi_agent_v2 { // V2 + 有 agent path → 构造 InterAgentCommunication 发给父级邮箱(trigger_turn=false,不强制开新轮次) let Some(parent_agent_path) = child_agent_path.as_str().rsplit_once('/').and_then(|(parent, _)| AgentPath::try_from(parent).ok()) else { return; }; // 从子路径 /root/worker-1 反推父路径 /root let Some(message) = format_inter_agent_completion_message(parent_agent_path.clone(), child_agent_path.clone(), &status) else { return; }; // 按终态格式化结果消息(成功/失败文案不同) let communication = InterAgentCommunication::new(child_agent_path, parent_agent_path, Vec::new(), message, /*trigger_turn*/ false); let context = AgentCommunicationContext::new(AgentCommunicationKind::Result, child_thread_id); // 标记为 Result 类型通信,父级可区分"结果"与"指令" let _ = control.send_inter_agent_communication(parent_thread_id, communication, context, TurnStartOptions::default()).await; return; } let Ok(parent_thread) = state.get_thread(parent_thread_id).await else { return; }; // V1 路径:父线程必须还活着才能注入 parent_thread.inject_fragment_without_turn(SubagentNotification::new(child_reference.as_str(), status)).await; // 以"无 turn 片段"形式注入子代理完成通知,父级下一轮自然看到});为什么这样设计:V1 的 watcher 是推送式——子代理终态后主动把通知塞进父线程上下文,父级不需要轮询。V2 改成邮箱式(InterAgentCommunication):通信有类型(Result/指令)、有来源路径、可被 is_unloadable 的"邮箱空"条件感知——未读消息成为驱逐保护的一部分,这是 V1 推送模型做不到的。两条路径并存是渐进演进的痕迹:V2 上线时 V1 会话仍在运行,watcher 保留兼容。
七、执行限流器:turn 级别的第二道闸
spawn 时的容量检查管"能派生几个",AgentExecutionLimiter(execution.rs)管的是"同一时刻几个子代理在跑 turn"——这是更细粒度的并发闸,同样用 RAII guard:
📄 codex-rs/core/src/agent/control/execution.rs (第 13-97 行)
#[derive(Default)]pub(super) struct AgentExecutionLimiter { // 执行限流器:只统计"正在跑 turn"的子代理,不是总线程数 active: AtomicUsize, // 当前活跃执行计数(原子操作,无锁) max_threads: OnceLock<usize>, // 上限惰性初始化:第一次 spawn 时按配置写入,之后不可变}pub(crate) struct AgentExecutionGuard { // RAII guard:持有期间 active+1,drop 时自动 -1 limiter: Arc<AgentExecutionLimiter>,}impl Drop for AgentExecutionGuard { fn drop(&mut self) { self.limiter.active.fetch_sub(1, Ordering::AcqRel); } // turn 结束(无论成败)归还执行位——作用域即租期}fn is_execution_limited(multi_agent_version: MultiAgentVersion, session_source: &SessionSource) -> bool { // 限流只作用于 V2 + SubAgent:根会话与 V1 不受此闸约束 multi_agent_version == MultiAgentVersion::V2 && matches!(session_source, SessionSource::SubAgent(_))}impl AgentExecutionLimiter { fn has_capacity(&self) -> bool { self.active.load(Ordering::Acquire) < self.max_threads() } // 容量判断:活跃数严格小于上限才放行 fn guard(self: Arc<Self>) -> AgentExecutionGuard { // 获取执行位:先自增再返回守卫(调用方需先 has_capacity 检查) self.active.fetch_add(1, Ordering::AcqRel); AgentExecutionGuard { limiter: self } }}为什么这样设计:"线程存在"和"正在执行"是两回事——被 LRU 驱逐前、等待输入时,子代理都占着常驻槽位但不烧 token。ensure_execution_capacity_for_turn_start(第 30-42 行)在每个 turn 启动时检查:已有活跃 turn 的线程直接放行,否则要拿执行位。这样即使常驻槽位满了,只要没有子代理真正在跑模型推理,新派生的子代理仍能开工——限的是算力并发,不是线程数量。
八、三种模式对照:V1 / V2 / fork
| 容量超限行为 | ||
| 槽位机制 | ||
| 上下文继承 | ||
| 完成通知 | ||
| 驱逐保护条件 |
九、数据流:一次 spawn_agent 的完整路径
spawn_agent_internal 流水线(V2 ThreadSpawn)
① 定版本:effective_multi_agent_version_for_spawn
按配置 + 父线程状态解析 V1/V2,决定后续走哪套容量与通知机制。
▼
② 容量闸门:ensure_execution_capacity + reserve_v2_residency_slot
🔹 V1:超限直接 AgentLimitReached🔹 V2:满了先 LRU 驱逐最闲子代理(终态+无 turn+邮箱空),无可驱逐才报错
▼
③ RAII 预留:reserve_spawn_slot → SpawnReservation
计数 + agent path/昵称占位;中途失败 Drop 自动回滚,commit 才转正。
▼
④ 继承与元数据:environments + exec_policy + prepare_thread_spawn
🔹 环境快照/执行策略从父线程复用🔹 depth+1、agent path、昵称(冲突加"第 N 世")、角色 apply_role_to_config
▼
⑤ 建线程:spawn_new_thread_with_source / spawn_forked_thread
🔹 常规:空历史 + 继承环境🔹 fork:复制父 rollout → keep_forked_rollout_item 过滤(洗掉审批证据/用量)
▼
⑥ 投递初始输入:send_input / send_inter_agent_communication
🔹 V1/用户派生:UserInput prompt🔹 V2 agent 间通信:InterAgentCommunication + context 成对投递
▼
⑦ 收尾:notify_thread_created + persist_thread_spawn_edge
🔹 V1:maybe_start_completion_watcher 挂脱离式 watcher🔹 agent graph store 落盘父子边——restore_v2_agent_metadata 靠它恢复身份
十、小结
这一讲拆完,Codex 多智能体的"不失控"可以归结为四句话:容量是租约不是门票(RAII 预留 + commit 转正);驱逐是换页不是杀进程(LRU + rollout 落盘 + 环境快照,随时可 resume);降权是结构性的(角色只能关 feature、不能开,代码里没有 enable 路径);继承是有边界的(fork 洗掉审批证据与用量账本——知情但清白)。下一讲进入 Code Mode 运行时:cell actor 模型如何把"执行代码"变成一种可恢复的会话状态。
📚 系列导航
← 第 36 讲:记忆系统——两阶段 LLM 蒸馏与 git baseline 工作区
→ 第 38 讲:Code Mode 运行时——cell actor 与远程会话
关注公众号「AI技术推荐官」获取更多源码解析内容














































































