第5章 子代理与协作式取消
2026.07.29Agent 可以创建子 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()。如果被翻转,它:
- 终止当前正在执行的工具(如 kill 子进程)
- 返回一个"已取消"状态
- 等待下一轮用户输入
协作式取消的关键设计点是:取消不是在任意时刻强制发生的——它在 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_file、glob、grep、diff、web_search、web_fetch、github_search、agent(深度锁定)、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);
}
双路径的取消设计覆盖了两种场景:
- agent 正在执行工具(如
run_command跑构建)→CancelToken被翻转 → 工具执行循环在安全点检查 → 优雅终止 - agent 正在等待用户输入(如
ask_user弹窗)→cmd_tx发送Cancel→ agent 从等待中唤醒 → 返回"已取消"状态
CancelToken 的共享意味着:当父 agent 被取消时,它创建的子 agent 也会被取消——因为它们共享同一个 CancelToken 引用。这确保了取消的传递性:取消父 agent 不会留下孤立运行的子 agent。
5.7 Review Verdict 的三层裁决
review 工具返回的 Verdict 有三层:
- Pass:输出质量符合预期,所有信号高于阈值
- NeedsFix:输出质量有缺陷但可修复。父 agent 应该在修复后重新提交审查
- Rebuild:输出质量有严重缺陷,需要完全重建。父 agent 应该重新规划方案
Rebuild 和 NeedsFix 的区别在于问题的严重程度。如果 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。