法不净空,觉无性也。

第5章 子代理与协作式取消

2026.07.29

Agent 可以创建子 agent——但子 agent 不能写文件、不能跑命令、不能无限递归。这不是限制,是设计。


模式层

5.1 三种并发模式

Agent 系统中"并发"不是一个单一的概念。至少有三种不同的并发模式,各自有不同的适用场景:

子代理投递(spawn → join)。 父 agent 创建一个新的 agent 实例,分配给一个独立的任务(如代码审查、方案评估),等待它返回结果。子 agent 有自己的上下文、自己的工具集、自己的 LLM 调用。父 agent 在等待期间可以处理其他事情(如果有并行能力),但通常是阻塞等待。

子代理投递适合的场景是:需要从不同的角度评估同一个问题,或者需要一个"独立的第二意见"。子 agent 的独立性是核心——它不是父 agent 的一个分支,它是一个独立的推理实例。

独立 agent 运行。 两个完全独立的 agent 实例,各自有自己的 session、自己的权限 allowlist、自己的工具集。它们之间没有父子关系,没有共享状态。独立 agent 适合的场景是:不同的用户同时使用系统、headless runner 和交互式 session 同时运行。

独立 agent 在 CodeCoder 中是通过 daemon 管理多个 AgentLoop 实例来实现的。每个 agent 实例运行在自己的 OS 线程中,通过 cmd_tx / event_rx 与 daemon 通信。

并行工具执行。 同一个 turn 内并行执行多个工具调用。如果 LLM 一次输出了两个 ToolCall——比如同时读两个文件——系统可以并行执行它们,减少总等待时间。

CodeCoder 不支持并行工具执行。原因是有意的设计选择:串行工具执行保持了一个 turn 的确定性。如果两个工具并行执行,它们的执行顺序不确定——工具 A 可能修改了工具 B 正在读取的文件。串行执行意味着执行顺序完全由 LLM 输出的顺序决定,没有竞争条件。

三种模式的决策树:

需要独立的推理实例?
    ├→ 需要修改系统状态?→ 独立 agent(不共享权限/上下文)
    └→ 只读分析?→ 子代理投递(spawn → join,只读工具集)
不需要独立推理实例,但需要加快工具执行?
    → 并行工具执行(CodeCoder 不支持,有意选择)

5.2 协作式取消 vs 强杀

当用户说"取消"时,系统应该怎么做?

强杀是最直接的做法:终止 agent 的进程或线程。在 Rust 中,std::thread::spawn 创建的子线程可以通过 Thread::join() 等待,但没有"杀死"线程的 API——Rust 标准库没有提供。你可以通过 std::process::exit() 杀死整个进程,但这会跳过所有析构函数。

强杀的问题在于资源泄漏。如果 agent 正在执行 write_file,强杀可能导致文件写入不完整——部分写入的文件留在磁盘上。如果 agent 正在执行 run_command,强杀可能导致子进程变成孤儿进程。

协作式取消是另一种做法:系统不强制终止 agent,而是通知 agent "用户想取消",agent 在安全点检查这个通知,并优雅地终止当前操作。

CodeCoder 的协作式取消通过 CancelToken 实现:

struct CancelToken {
    cancelled: AtomicBool,
}

impl CancelToken {
    fn cancel(&self) {
        self.cancelled.store(true, Ordering::SeqCst);
    }

    fn is_cancelled(&self) -> bool {
        self.cancelled.load(Ordering::SeqCst)
    }
}

当用户按 Ctrl+C 时,客户端(cc)翻转共享的 CancelToken,不是发送一个 Kill 信号。Agent 的工具执行循环(如第 3 章 process_turn 伪代码中的步骤 5)在每次迭代中检查 is_cancelled()。如果被翻转,它:

  1. 终止当前正在执行的工具(如 kill 子进程)
  2. 返回一个"已取消"状态
  3. 等待下一轮用户输入

协作式取消的关键设计点是:取消不是在任意时刻强制发生的——它在 agent 的工具执行循环中,在安全点检查时发生。 如果 agent 正在执行一个不可中断的操作(如一个已经写入一半的文件),取消请求会在操作完成后、下一次迭代开始时生效。

5.3 结构化验收 vs 自由文本回复

LLM 的输出是不可预测的——这是 agent 系统需要面对的根本不确定性。一个重要的设计策略是:让 LLM 返回结构化结果而不是自由文本。

子 agent 的典型用途是执行分析性任务(代码审查、风险评估)。如果子 agent 返回自由文本——"我觉得这个代码还可以,但有几个地方需要调整"——父 agent 需要再次用 LLM 解析这个结果。这引入了不确定性放大:第一次 LLM 调用的不确定性被第二次 LLM 调用叠加。

结构化验收要求子 agent 返回一个固定格式的结果:

enum Verdict {
    Pass,           // 验收通过
    NeedsFix,       // 需要修改(附原因)
    Rebuild,        // 需要重建(附原因)
}

父 agent 不需要理解自然语言——它只需要检查 Verdict 枚举值。如果 NeedsFix,读取原因字段;如果 Rebuild,重新规划。

这种"结构化的下游消费"模式是 CodeCoder 中一个贯穿性的设计原则。从工具调用的 ToolResult 到验收门的 Verdict,LLM 的输出在尽可能早的环节被结构化为枚举或固定格式的 JSON。非结构化文本只在用户界面层出现。


案例层

5.4 agent 工具的 spawn → join 流程

agent 工具的执行流程如下:

fn execute_agent(args: AgentArgs, context: &Context) -> Result<AgentResult> {
    // 1. 检查子 agent 深度
    if context.agent_depth >= MAX_AGENT_DEPTH {
        return Err("sub-agent depth exceeded");
    }

    // 2. 创建只读工具集
    let tools = Toolbox::read_only_child();

    // 3. 创建子 AgentLoop 实例
    let child_loop = AgentLoop::new(
        tools,
        context.provider.clone(),
        context.cancel_token.clone(),
        context.agent_depth + 1,
    );

    // 4. 在新线程中运行子 agent
    let handle = thread::spawn(move || {
        child_loop.process_turn()
    });

    // 5. 等待结果(阻塞在 join handle 上)
    match handle.join() {
        Ok(Ok(result)) => Ok(result),
        Ok(Err(e)) => Err(e),
        Err(_) => Err("sub-agent panicked"),
    }
}

关键步骤:

  • 步骤 2:子 agent 只读工具集的强制——子 agent 不能写文件、不能跑命令。只读工具集的范围(9 个工具)在第 4 章已完整列出,这里不再重复
  • 步骤 3:子 agent 共享父 agent 的 CancelToken——父 agent 被取消时,子 agent 也会被取消。协作式取消的完整论述见 5.2 节

Toolbox::read_only_child() 返回的工具集包括:read_fileglobgrepdiffweb_searchweb_fetchgithub_searchagent(深度锁定)、reason

5.5 review 工具与四信号漂移评分

review 工具是一个特殊的子 agent——它使用只读子 agent 的实例,但为子 agent 注入了一个特殊的 system prompt,要求它按结构化 rubric 评审输出质量。关于 ReviewVerdict(评审报告)和 Verdict(三值裁决)的区分——ReviewVerdict 是评审工具的完整返回结构(含信号和摘要),Verdict 是其中的裁决枚举(Pass / NeedsFix / Rebuild)。四信号 rubric 的详细评分标准和合并规则在第 11 章展开,本章仅给出结构定义。

review 工具的 rubric 包括四个信号:

struct ReviewVerdict {
    verdict: Verdict,           // Pass / NeedsFix / Rebuild
    signals: ReviewSignals,
    summary: String,            // 评审摘要
}

struct ReviewSignals {
    foundation: f32,            // 基础结构完整性 0.0-1.0
    over_engineering: f32,      // 过度设计程度 0.0-1.0
    volume: f32,                // 变更规模评分 0.0-1.0
    terminology: f32,           // 术语一致性 0.0-1.0
}

四个信号的含义:

  • foundation:基础结构是否完整?模块是否缺失?类型声明是否完整?
  • over_engineering:是否引入了不必要的抽象?是否过早优化?
  • volume:单次变更的范围是否与任务匹配?
  • terminology:新代码是否遵循了项目的术语约定?

四个信号合并为一个 Verdict 的规则见第 11 章 11.6 节。review 工具是只读的——评审 agent 不能写文件、不能跑命令、不能修改任何东西。这意味着即使评审 agent 的判断有误,它造成的最大危害是误判(Pass 应该 NeedsFix、或反过来),不会破坏代码。

5.6 CancelToken 的共享翻转

CancelToken 在多个 agent 实例之间共享:

// 在 cc 客户端中
fn handle_ctrl_c() {
    // 直接翻转 CancelToken——不走 cmd_tx
    cancel_token.cancel();
    // 同时向 cmd_tx 发送 Cancel 命令(确保 agent 在等待用户输入时也能收到)
    cmd_tx.send(AgentCommand::Cancel);
}

双路径的取消设计覆盖了两种场景:

  1. agent 正在执行工具(如 run_command 跑构建)→ CancelToken 被翻转 → 工具执行循环在安全点检查 → 优雅终止
  2. agent 正在等待用户输入(如 ask_user 弹窗)→ cmd_tx 发送 Cancel → agent 从等待中唤醒 → 返回"已取消"状态

CancelToken 的共享意味着:当父 agent 被取消时,它创建的子 agent 也会被取消——因为它们共享同一个 CancelToken 引用。这确保了取消的传递性:取消父 agent 不会留下孤立运行的子 agent。

5.7 Review Verdict 的三层裁决

review 工具返回的 Verdict 有三层:

  • Pass:输出质量符合预期,所有信号高于阈值
  • NeedsFix:输出质量有缺陷但可修复。父 agent 应该在修复后重新提交审查
  • Rebuild:输出质量有严重缺陷,需要完全重建。父 agent 应该重新规划方案

RebuildNeedsFix 的区别在于问题的严重程度。如果 foundation 信号低于 0.3——基础结构缺失、类型不完整——修复成本可能接近重写,不如直接重建。如果只有 over_engineering 信号偏高——过度设计但功能完整——修复成本低,适合 NeedsFix。

5.8 Sub-agent 深度锁定为 1

子 agent 深度锁定为 1 意味着:父 agent 可以创建子 agent,子 agent 不能再创建子 agent。

const MAX_AGENT_DEPTH: u32 = 1;

fn execute_agent(..., context: &Context) -> ... {
    if context.agent_depth >= MAX_AGENT_DEPTH {
        return Err("sub-agent depth exceeded");
    }
    // ... 创建子 agent
}

为什么是 1,不是 0 也不是 3?

深度 0 意味着 agent 工具不可用——子 agent 不存在。这排除了所有需要独立推理实例的场景(代码审查、方案评估)。

深度 3 以上意味着递归子 agent——子 agent 可以创建子 agent,子 agent 还可以再创建子 agent。这引入了两个问题:第一,递归深度的 LLM 调用成本呈指数级增长;第二,深递归的子 agent 链使得"谁对输出负责"变得模糊。

深度 1 是一个实用的折中。父 agent 可以创建一个独立推理实例来执行审查,但审查子 agent 不能再创建自己的子 agent——它只能读文件、搜索、返回结果。审查的结论是审查 agent 自己的判断,不是递归调用的多个 agent 的合成。


ADR 深度阅读

子代理权限从无限制到只读(ADR 0019)

ADR 0019 记录了子代理权限从"继承父代理的全部权限"到"强制只读"的演进。

最初的设计中,子 agent 继承父 agent 的权限集。理由是:子 agent 应该能做父 agent 能做的任何事情——因为子 agent 是父 agent 的"助手"。

问题在第一个使用子 agent 做代码审查的场景中暴露。审查子 agent 在评审过程中决定"这个文件的代码风格需要调整",然后调用了 write_file 修改了代码——而不是通过 review 工具的 verdict 返回建议。父 agent 在收到子 agent 的结果时,发现代码已经被改过了,无法判断"这是审查过程中的修改还是子 agent 自主做的修改"。

修复方案是:子 agent 强制使用只读工具集。不能写文件、不能跑命令、不能提交 git、不能生成 skill/capability。如果子 agent 需要"写"什么,它只能通过结构化的结果返回——父 agent 决定是否执行。

这条规则后来扩展到:子 agent 的只读工具集在 Toolbox::read_only_child() 中硬编码,不是通过配置控制的。这意味着即使子 agent 的系统 prompt 被注入恶意指令,它也无法获得写权限。


下一章进入第三编——能力增长的三种模式:Tool、Skill、Capability。