夜雨聆风学习资料网

ARTICLE · 1286456

Codex 源码-多智能体 Spawn 与角色系统——槽位预留、LRU 驱逐与上下文过滤

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

维度
V1 ThreadSpawn
V2 常驻 / fork
容量超限行为
直接 AgentLimitReached 报错
先 LRU 驱逐最闲子代理,无可驱逐才报错
槽位机制
reserve_spawn_slot(原子计数)
V2ResidencySlot(LRU 队列 + pending 槽位)
上下文继承
仅环境快照 + exec policy
fork:过滤后的父历史(洗掉审批证据/用量)
完成通知
watcher 推送 SubagentNotification
InterAgentCommunication 邮箱(Result 类型)
驱逐保护条件
不适用(无驱逐)
终态 + 无活跃 turn + 邮箱空

九、数据流:一次 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技术推荐官」获取更多源码解析内容

回首页,搜索你想要的文档

在首页搜索框输入关键词,快速定位资料

文库首页搜索指引 立即搜索

相关学习资料