故障形态
我们的 agent 运行时允许任意 agent 通过 Agent 工具派生子 agent,子 agent 又能派生它自己的。这棵树在结构上无界。两条生产 trace 把代价摆上台面:一个任务产生了 129 个 agent,之后一个 "deep research" 请求产生了 38 个。这些 agent 大多干的有用活很少——每个收到一份模糊的父任务切片,判断它仍太大,于是再次细分。每个节点都细分时级联委派指数增长,叶子 agent 在没有共享上下文的情况下从零探索。
朴素修法是设硬递归上限:深度超过 N 就拒绝派生。第一次尝试(#86)反其道而行:把 depth 沿完整 IPC 链路传播,在 system prompt 里渲染为 {{ agent_depth }},并移除原有的硬 max_depth 闸门,让深度变成信息而非一堵墙。理由是硬上限拦得住递归,却对广度只字未提——一个 depth 0 的 agent 收到任务瞬间派生 5 个盲目子 agent 仍然病态,哪怕它从未越过 depth 1。prompt 改成按深度条件化,把更深的 agent 推向直接用工具。
但指导改变的是倾向而非能力,模型仍能不顾建议去派生,负载下它确实会。所以 #100 在指导之下叠了三个机制,分别打击级联的三个成因。
机制一:深度降级工具
越过配置的深度后,派生相关的工具从 agent 的工具集里物理消失。这不是调用时拒绝;它们在模型看到的 schema 里就被过滤掉了。
const SPAWN_TOOLS: &[&str] = &["Agent", "SendMessage", "ListHubs"];
pub fn build_depth_tool_filter(depth: u32, max_depth: u32) -> Option<HashSet<String>> {
if depth < max_depth {
return None; // 派生工具仍然可用
}
let mut allowed: HashSet<String> = /* 所有工具名 */;
for name in SPAWN_TOOLS {
allowed.remove(*name);
}
Some(allowed)
}
agent_max_depth 默认 2,于是 depth 0 和 1 能派生,depth 2 及以上不能。常见情况下 filter 返回 None(无限制),快路径上没有分配。depth ≥ 2 的 system prompt 被改成与能力一致:「你在此深度的派生能力已被移除。直接用你的工具完成任务。」移除工具而非劝阻使用,关掉了「告诉模型什么」与「模型能做什么」之间的缝隙。
机制二:原子化 hub 预算
深度约束的是树的高度,对宽度毫无办法:一个 depth-1 的 agent 仍能派生很多同辈,每个同辈也能,直到深度上限。Hub——持有每个 agent 连接的进程——对子 agent 总数强制一个全局上限。agent_max_total 默认 16,只计派生出来的子 agent,不含根 agent:
pub fn sub_agent_count(&self) -> usize {
self.agents.values()
.filter(|a| a.info.parent.is_some())
.count()
}
这个检查在并发下必须正确,因为一个 turn 里的多个 Agent 调用并行跑、会并发到达 hub。两处守护它。预检在 fork 操作系统进程之前跑,超预算的请求快速失败、不留孤儿。权威检查随后在注册 agent 的同一把锁内跑,于是计数和注册之间不存在 TOCTOU 窗口:
// 预算检查(与注册原子,无 TOCTOU)。
if parent.is_some() {
let sub_count = h.registry.sub_agent_count();
if sub_count >= h.max_total_agents as usize {
return Err(format!(
"Spawn budget exhausted ({sub_count}/{} sub-agents). \
Complete the task with your own tools.",
h.max_total_agents
));
}
}
这个错误以 tool result 返回给调用方 agent,而非作为故障抛出——agent 读到「预算耗尽,用你自己的工具完成任务」后继续推进。若进程派生成功但随后注册失败,那个孤儿进程会被杀掉而非泄漏。预算刻意放在 hub 而非每个 agent 上:每个 agent 只认识自己的子 agent,所以 per-agent 上限无法约束全局总数,只有共享的 registry 才看得见整棵树。
机制三:fork context
深度和预算封住级联,fork context 打击的是它的动机。一个只带一行任务被派生的子 agent 别无选择只能从零探索,而探索恰恰诱使它再次委派。把父 agent 的会话 fork 进子 agent,就卸掉这个压力:子 agent 一上来就已经知道父 agent 知道的东西。父会话在注入前会被压缩——它是背景,不是子 agent 自己的工作集:
const FORK_MAX_TOKENS: u32 = 50_000;
const TOOL_RESULT_CAP: usize = 200;
pub fn compress_for_fork(messages: &[Message]) -> Vec<Message> {
// 剥离 Thinking / Image / ServerTool 块(临时性、不可迁移)
// 每个 tool result 截断到 TOOL_RESULT_CAP 字符 + "…[truncated]"
// 丢掉结尾未完成的 assistant turn(悬空的 tool_use)
// 从最旧开始丢,直到落在 FORK_MAX_TOKENS 以内
// 然后把开头推进到第一条 User 消息(API 要求)
}
Thinking、image、server-tool 块被剥离——它们无法有意义地迁移到另一个进程。Tool result 每个截到 200 字符,因为子 agent 需要的是父 agent 找到了什么的形状,不是完整 dump。结尾以 tool_use 收尾的 assistant turn 被丢弃,因为配对的 tool result 还不存在。整体从最旧裁到 50k token,再把头部推进到第一条 User 消息让 transcript 对 API 合法。子 agent 第一条消息前拼上一段 boilerplate 把契约显式写死:
STOP. READ THIS FIRST.
You are a forked worker process. The conversation above is background
context from your parent agent.
RULES:
1. Do NOT spawn sub-agents — execute directly with your tools.
2. Stay within your assigned scope.
3. Use tools silently, then report findings once at the end.
4. Keep your report under 500 words. Be factual and concise.
5. Your response MUST begin with "Scope:" — no preamble.
这里三个独立约束彼此加固:prompt 说别派生,深度 filter 最终拿掉派生能力,继承的上下文拿掉派生的理由。
这些控制各自落在哪一层
三个机制刻意分布在不同层,没有单点绕过能同时击穿它们。
| 机制 | 约束的是 | 所在层 | 强制力 |
|---|---|---|---|
| 深度降级工具 | 树高 | per-agent 工具 filter | 硬——工具不在 schema 里 |
| Hub 预算(16) | agent 总数 | hub registry,锁内 | 硬——注册时原子检查 |
| fork context | 递归的动机 | 注入的会话 | 软——移除诱因 |
| 深度条件 prompt | 每层的广度 | system prompt | 软——仅指导 |
结论
当 agent 能派生 agent,软指导约束的是递归的倾向而非能力,负载下模型会动用那个能力。指导和强制不冗余:指导让硬上限很少被撞到,硬上限是为指导被无视的时刻准备。如果一条限制必须成立,就把它强制在共享状态所在之处——深度落在 per-agent 工具 filter,总数落在 hub registry 且与注册同锁——并让 prompt 与工具实际允许的一致。默认值(深度 2、总数 16)是配置项 agent_max_depth / agent_max_total 而非常量,放宽 fan-out 不必改代码。最后,移除驱动递归的诱因:继承了父上下文的 agent,从零探索的理由少得多,把探索委派出去的理由也少得多。