tools/delegate_tool.py,5,071 行。这是 Hermes 里最长的单个工具文件 —— 比整个审批系统的核心还长。这一章讲一个智能体怎么派另一个智能体去干活。
委派的本质是「上下文分区」。
它不是为了「并行更快」(虽然确实更快),核心价值是:让每个子任务在一个干净、专注、不会被无关信息污染的上下文里执行,而主智能体只承担协调成本。
MAX_DEPTH = 1
这一行是整个多智能体系统里最重要的一个常量。它的意思是:主智能体可以派子智能体,但子智能体不能再派孙智能体。
这是一个「用最简单的手段消灭一整类问题」的典型例子。
想做「智能地限制递归」很难:要估算成本、要判断任务复杂度、要有熔断机制、要有预算传递……
而 MAX_DEPTH = 1 一行代码就让整类问题不存在了。代价是失去了「深层任务分解」的能力 —— 但实践中,一层委派已经覆盖了绝大多数场景,而两层带来的复杂度是指数级的。
在做架构设计时,先问「能不能用一个硬限制消灭这类问题」,再考虑「怎么智能地处理这类问题」。
_DEFAULT_MAX_CONCURRENT_CHILDREN = 10
_RECENT_SUBAGENTS_CAP = 200
| 常量 | 作用 |
|---|---|
_DEFAULT_MAX_CONCURRENT_CHILDREN = 10 |
同时最多跑 10 个子智能体。第 11 个要排队。防止一次性打爆模型供应商的速率限制,也防止本机内存和文件句柄耗尽 |
_RECENT_SUBAGENTS_CAP = 200 |
「最近的子智能体」记录最多保留 200 条。这是给「查看子智能体状态」这类功能用的历史缓冲,超出就丢弃最老的。防止长时间运行的会话把内存吃光 |
这两个数值配合 MAX_DEPTH = 1,把整个多智能体系统的资源占用锁在一个可预测的范围内:任意时刻最多 1 + 10 = 11 个智能体在跑,历史记录最多 200 条。
DELEGATE_BLOCKED_TOOLS
子智能体不能使用某些工具。最重要的一条是:子智能体不能再调用 delegate 工具 —— 这是 MAX_DEPTH = 1 在工具层面的强制实现。
注意这是「双重保险」:
· 逻辑层:MAX_DEPTH = 1 在委派时检查深度
· 能力层:DELEGATE_BLOCKED_TOOLS 让子智能体根本看不到 delegate 这个工具
第二层更彻底 —— 模型连「我可以委派」这个念头都不会有,因为工具列表里没有。不给能力,比给了能力再拦截更可靠。这和第 4 章「webhook 只投放 4 个只读工具」是同一个思路。
def _subagent_auto_deny(...)
def _subagent_auto_approve(...)
这里有一个必须解决的问题:子智能体跑起来后要求审批,谁来批?
| 函数 | 语义 |
|---|---|
_subagent_auto_deny |
自动拒绝。子智能体收到「被拒绝」的结果,它可以换个方式做,或者报告失败。安全但可能卡住任务 |
_subagent_auto_approve |
自动批准。不问人直接放行。能跑通但风险大 |
这是自动化系统里最难的一个权衡。
自动拒绝是安全的默认,但会让很多合法任务失败 —— 而且失败方式很隐蔽(子智能体报告「我做不到」,但真实原因是权限被拒)。
自动批准能跑通,但意味着红线之外的所有操作在无人监督下执行。第 5 章那 12 条红线依然生效(那是绝对禁止的),但「需要确认」这一档就被跳过了。
正确的做法是:让主智能体在委派时显式声明子智能体的权限档位,而不是有一个全局默认。做「只读分析」的子智能体应该自动拒绝一切写操作;做「批量重构」的子智能体则需要预先授权写文件。
子智能体不是「发出去就不管了」。有三个控制接口:
def interrupt_subagent(...) # 中断某个子智能体
def steer_subagent(...) # 向运行中的子智能体注入指令
def set_spawn_paused(...) # 暂停/恢复新子智能体的派生
| 接口 | 用途 |
|---|---|
interrupt_subagent |
发现某个子智能体走偏了 / 卡住了 / 在烧钱,单独把它停掉,不影响其他 9 个 |
steer_subagent |
不打断,但插一句话。对应第 3 章讲过的 /steer 机制 —— 把新指令注入到最后一条工具消息里,让智能体在下一轮就能看到。比如:「顺便也检查一下类型注解」 |
set_spawn_paused |
暂停派生新的子智能体,但已在跑的继续。用途:发现整批任务方向不对时,先止血 —— 不再派新的,让已经跑起来的自然结束,然后重新规划 |
set_spawn_paused 这个设计值得单独说。
最朴素的做法只有「全部继续」和「全部杀掉」两种。但实际场景里最常见的是第三种:「别再开新的了,让手上的跑完」。
这在运维上叫「排空」(drain)—— 优雅停机、滚动更新、限流降级都是这个模式。一个成熟的并发系统必须区分「停止接受新工作」和「终止现有工作」。
def _is_descendant_of(..., max_hops: int = 8)
「判断智能体 A 是不是智能体 B 的后代」。用途:
那个 max_hops = 8 是防御性的。既然 MAX_DEPTH = 1,理论上最多只需要查 1 跳。设成 8 是为了:
这是「即使不变量被破坏,程序也不能挂死」的写法。
正常情况下永远不会走到第 8 跳。但如果某个 bug 导致父子关系成了环,有这个上限的版本会返回一个(可能错误的)答案并继续跑,没有上限的版本会把整个进程挂死。
在遍历任何「理论上应该是树,但数据由运行时构造」的结构时,都要加这样一个跳数上限。
plugins/kanban/ 提供了另一种多智能体模式 —— 不是「派下去等结果」,而是「共享一块任务板」。
kanban_create_task 创建任务
kanban_claim_task 认领任务
kanban_update_task 更新进度
kanban_complete_task 完成任务
kanban_list_tasks 查看任务列表
kanban_heartbeat ★ 心跳
| 委派模式 | 看板模式 | |
|---|---|---|
| 关系 | 父子 —— 主智能体明确指派 | 对等 —— 谁有空谁认领 |
| 谁决定做什么 | 主智能体 | 各个智能体自己 |
| 生命周期 | 子智能体做完就结束 | 智能体长期存在,持续认领新任务 |
| 适合 | 已知的、可分解的批量任务 | 持续的、来源不定的工作流 |
kanban_heartbeat 为什么必须存在任何「认领 - 执行 - 完成」的分布式任务系统,都必须有心跳或租约(lease)机制。
否则「认领了但没做完就死掉」的任务会永久卡住。这在消息队列、任务调度器、分布式锁里是同一个问题,解法也一样:认领是有时效的,需要持续续期。
回到开头那个数字。真正做「派一个子智能体」的核心逻辑可能只要 200 行。剩下 4,800 行在做什么?
| 类别 | 内容 |
|---|---|
| 生命周期管理 | 创建、启动、监控、中断、清理、超时、僵尸回收 |
| 并发控制 | 并发上限、排队、暂停派生、优先级 |
| 结果聚合 | 收集 10 个子智能体的结果、处理部分失败、超时的怎么算 |
| 状态查询 | 「现在有几个在跑」「花了多少钱」「卡在哪一步」 |
| 控制通道 | 中断、注入指令、暂停 —— 每个都要跨进程/跨线程安全地送达 |
| 安全边界 | 工具屏蔽、审批策略、深度检查、亲缘关系 |
| 可观测性 | 每个子智能体的日志、成本、耗时都要单独记录并能关联回父任务 |
| 失败处理 | 子智能体崩溃、模型报错、上下文爆炸、无限循环 —— 每种都要有对策 |
「让智能体调用智能体」的 demo 是 20 行,生产系统是 5,000 行。
这个 250 倍的差距全部来自「出问题时怎么办」。多智能体系统的难点从来不是「怎么派」,而是「派出去的东西失控了怎么收场」。
面试里如果被问到多智能体,能说清楚这一点,比能画出漂亮的架构图有用得多。