开源自托管 Agent 项目 Hermes Agent 的并行派活机制
本文基于 hermes-agent 仓库 commit 2d40494(2026-07-29)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/NousResearch/hermes-agent 最新代码与文档为准。
**这个项目的委派机制里,真正值钱的不是并行,而是「父 Agent 的上下文只看得到委派调用和最终摘要」这一条约束。**并行只是顺带的结果:一旦子 Agent 的中间工具输出永远进不了父 Agent 的对话,你才敢一次派三个出去。这里说的 Hermes 是 Nous Research 开源的自托管常驻 Agent 项目 hermes-agent(MIT 许可证,LICENSE 署名 Nous Research),不是同名的开源模型系列,也不是任何同名商标或库。
站内已有的 并发派遣的方法论、多 Agent 并发编排、角色分工设计 讲的是通用方法论或者别的项目的做法;本篇不重复那些结论,只回答一件事:这套想法在 hermes-agent 这个具体仓库里落成了哪些文件、哪些配置项、哪些可以当场打开核对的行为。
一、委派工具先解决的是上下文污染,不是算力
委派入口是 tools/delegate_tool.py 里的 delegate_task。它对模型暴露两种形状:单任务给 goal(再加可选的 context 和 role),批量给 tasks 数组,数组里每一项还能各自带 goal / context / role。
每个子 Agent 拿到的是一份全新会话,不是父 Agent 历史的副本。构造子 Agent 时传的参数很直白地写着这件事:skip_context_files=True、skip_memory=True、quiet_mode=True、platform="subagent"、clarify_callback=None,系统提示走 ephemeral_system_prompt,由 _build_child_system_prompt 用委派目标现拼一份,末尾还明确要求子 Agent 的最终回复要短——原话的意思是过长的摘要会挤占父 Agent 的上下文窗口。
代价是子 Agent 对你们之前聊的一切一无所知。工具描述里把这条写成了硬提醒:文件路径、报错原文、约束条件都得靠 context 字段手动喂过去;如果用户在用非英语交流,也要在 context 里说清语言要求,否则子 Agent 默认按英语作答,摘要回到父 Agent 那里就把最终回复的语言带偏了。这是我见过最实在的一条工程注解,因为它描述的是真实翻车过的场景,而不是设计意图。
工具权限是减法。DELEGATE_BLOCKED_TOOLS 明确列了五个子 Agent 永远拿不到的工具:delegate_task(默认不许递归派活)、clarify(不许找用户追问)、memory(不许写共享记忆文件)、send_message(不许产生跨平台副作用)、cronjob(不许用父 Agent 的名义再安排任务)。除此之外 kanban 相关的那组也被剥掉。剥离分两层做:只含被禁工具的工具集整组去掉,而像 hermes-cli 这种混装了有用工具的组合工具集必须保留,于是再用一份「拒绝工具集」清单传给子 Agent,让工具解析在组合展开之后再把被禁的名字减掉。这个两层设计的动机在注释里写得很清楚——单靠名字过滤会让被禁工具从组合包里漏进去。
想让某个子 Agent 自己再派活,得把 role 写成 orchestrator。但这条能否生效受两道闸门约束:全局开关 delegation.orchestrator_enabled,以及深度上限 delegation.max_spawn_depth。代码里的默认回退常量 MAX_DEPTH = 1,注释解释这是「扁平」形态:父 Agent 在深度 0,孩子在深度 1,孙子被拒。两者任一不满足时,orchestrator 会静默降级成 leaf,不报错。想知道你这台机器上真实的上限是多少,别去读散落的文档,读工具描述本身——_build_dynamic_schema_overrides 每次生成工具定义都会把当前的并发上限和深度上限重新写进描述,注释里说这么做的原因是:否则模型会照着框架默认值自我设限。
二、异步委派:结果不是「返回值」,是下一轮的输入
tools/async_delegation.py 管的是「派出去之后不等」。它没有把结果塞回正在跑的那一轮,而是把完成事件推进共享的 process_registry.completion_queue,事件带 type="async_delegation",由 CLI 与网关在 Agent 空闲时排空队列,据此伪造一轮新的输入。
先把「什么时候真走后台」这件事钉死,否则后面几段容易读串。走不走后台不由模型决定:run_agent.py 里那个唯一的派发入口 _dispatch_delegate_task 按深度算——深度 0(顶层模型发起的委派)一律传 background=True,单任务和批量都一样;深度大于 0,也就是编排子 Agent 自己派的活,留在同步路径上,注释给的理由是编排者必须在自己这一轮里拿到工人结果才能汇总,而且子 Agent 并不拥有异步结果要回投的那个网关会话。除此之外还有两处会从后台退回同步:后台池满了,以及当前会话运行时压根没法在这一轮结束之后接收脱离的结果——一次性运行器、定时任务起的工作进程、无状态的 HTTP 端点都属于这一类,代码会直接内联跑完并在结果里附一句说明。所以「同步」在这套代码里不是一个可选模式,而是三种兜底情形。
模块顶部把这个选择的理由写出来了:完成事件只会在 Agent 空闲时作为一轮新消息出现,永远不会插在某个工具结果和助手消息之间,这样消息角色的严格交替不被破坏、提示缓存也不失效——「绝不修改过去的上下文」被写成了硬不变量。这一条比任何架构图都值得抄走,尤其当你自己在做 上下文预算 相关的设计。
完成事件里带的不是一句「成功」,而是一整块自描述的任务来源信息:原始目标、父 Agent 当时给的 context、工具集、模型、派发时间、状态、完整摘要。理由同样写在注释里:结果回到对话时父 Agent 可能已经在聊完全无关的东西,根本不记得这个子 Agent 为什么存在,所以事件必须自带背景,让它能判断是直接用还是重派。
派发是有容量的,而且满了就拒绝,不排队。dispatch_async_delegation 与 dispatch_async_delegation_batch 都在同一把锁里做容量检查和登记,注释说明分开检查会让两个并发派发同时通过。满了返回的是 {"status": "rejected"},调用方退回同步执行;设计意图是防止跑飞的模型堆积无限量后台任务。一次批量扇出算作一个后台单元占一个槽位,批内并行另由并发上限约束——所以单个批量任务不会自己把后台池吃光。
跨进程的部分落在 SQLite。state.db 里的 async_delegations 表记着每条委派的状态、归属会话、投递状态与投递次数。delivery_state 的取值是 pending、delivered、dropped 三态;claim_completion_delivery 让多个消费者竞争认领同一条完成结果,认领失败的不重复投;反复投递失败的行在耗尽投递次数预算后被标成终态 dropped,注释解释了原因:否则一条投不出去的结果会在每次重启时被恢复重放。进程崩了的场景由 recover_abandoned_delegations 兜底,它检查记录的属主进程还在不在、启动时间对不对,不在就把这条判成 unknown,错误信息直说「属主进程在记录终态结果前就退出了,结果未知」——它不假装知道。
三、生命周期服务:给插件的那道边界
agent/subagent_lifecycle.py 是另一条路径,模块开头就声明它有意只暴露不可变契约,不暴露 Agent 对象本身,插件必须从 PluginContext.subagent_lifecycle 拿到它。
SubagentLifecycleService 提供 launch / status / wait / cancel / result / reconnect。请求体 SubagentLaunchRequest 与句柄 SubagentHandle 都是冻结的数据类,状态机是 SubagentState 那九个取值(PENDING、STARTING、RUNNING、SUCCEEDED、FAILED、INTERRUPTED、CANCEL_REQUESTED、CANCELLED、UNKNOWN)。句柄里带一个 capability 字段,是用进程内随机密钥对子 Agent id、父会话 id、创建时间做的 HMAC,取记录时用 hmac.compare_digest 校验,并且要求当前活跃父会话与句柄里的父会话一致——伪造或串会话的句柄拿不到东西。
它明确拒绝的东西比它支持的更有信息量:timeout_seconds 不支持,理由是超时该在委派配置里显式设;working_directory 不支持,理由是委派子 Agent 用的是隔离的任务环境;blocked_tools 不支持,理由是禁用工具由项目自己一律处理,插件只能通过 allowed_toolsets 收窄,而且收窄后的集合必须是父 Agent 已启用集合的子集,否则直接抛错——不给扩权留缝。这套写法本身就是一份 最小权限设计 的样本。
reconnect 的行为值得单独说:跑着的子 Agent 只存在于本进程内,完成结果保留到进程退出,终态快照按一小时的保留窗口清理。序列化过的句柄在进程重启后拿不回来,reconnect 会如实报告连不上,而不是「顺手」再跑一遍任务。
四、四个文件各管什么
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
委派工具 delegate_task | 建子 Agent、剥工具、并行跑、裁剪并汇总摘要 | tools/delegate_tool.py | 模型每次派活,以及所有 delegation.* 配置项 |
| 异步委派登记处 | 后台派发、容量拒绝、完成事件、SQLite 投递状态、停滞监控 | tools/async_delegation.py | 结果隔一段时间以新消息回到对话时 |
| 子 Agent 生命周期服务 | 插件侧的 launch/status/wait/cancel/result 契约与句柄鉴权 | agent/subagent_lifecycle.py | 你自己写插件去监管子 Agent |
| 委派上下文标记 | 标记「当前正在为委派子 Agent 执行」,并在起子进程时洗掉调度器专用环境变量 | agent/delegation_context.py | 子 Agent 要拉起子进程、或你在排查环境变量泄漏 |
| 实时转写日志 | 每个子任务一份可追加、可 tail 的人类可读日志 | tools/delegation_live_log.py | 想看某个子 Agent 此刻在干什么 |
agent/delegation_context.py 这个小文件容易被忽略,但它解决的是一类很具体的串味问题:父进程本身可能是看板调度器起的工作进程,环境里带着 HERMES_KANBAN_* 那组变量;委派子 Agent 跑在同一个 Python 进程里,却并不是调度器拥有的工作进程。于是它用一个 ContextVar 标记「我在为委派子 Agent 执行」,scrub_kanban_env 在往子进程传环境变量时把那组键删掉、补上一个委派血缘标记键,而 delegated_child_subprocess_env 只在血缘真的需要跨进程传递时才物化一份清洗过的环境,其余情况保持原来的默认语义不变。这种「只在必要时改行为」的克制,比一刀切覆盖环境变量安全得多。
五、什么任务值得派,派出去之后怎么盯
值不值得派,工具描述给了判据,而且正反两面都给了。适合派的是:推理密集的子任务(调试、代码评审、资料综合)、会把父 Agent 上下文灌满中间数据的任务、彼此独立可并行的工作流。不该派的是:不需要推理的机械多步操作(用代码执行工具)、一次工具调用就能完成的事、需要跟用户来回确认的事(子 Agent 拿不到 clarify)、以及必须活过当前这一轮的持久任务——后者要用定时任务或后台终端,描述里直说后台委派不具备持久性。
盯进度有三层可用的东西。
最直接的是实时转写日志。每次派发都会在 cache/delegation/live/<delegation_id>/ 下按 task-<n>.log 给每个子任务建一份追加日志,派发时就写好文件头,这样 tail -f 能立刻挂上;之后子 Agent 的助手文本、思考、工具调用与工具结果一行行流进去。这套日志的设计约束写得很硬:绝不把异常抛回 Agent 主循环,第一次写失败就停用该写手并降级为调试日志;每次写都以追加模式重开文件,不留长生命周期句柄,子 Agent 崩了也不丢已写的行;它只是侧信道,不碰消息内容,因此不影响提示缓存;保留期是模块常量七天,在后续派发时顺手清理。
其次是进程内的活动登记。list_active_subagents 给出当前子 Agent 树的快照(含 id、父 id、深度、目标、模型、开始时间、工具调用计数、状态),interrupt_subagent 按 id 请求某个子 Agent 在下一次迭代边界停下——注释诚实地说明这不是硬杀线程,Python 做不到,它设的是中断标志并向下递归传播。还有一个全局暂停开关 set_spawn_paused,打开后已在跑的孩子继续跑,只有新的派发会快速失败。
第三层是停滞判定,两条路径各有一套阈值,而且都刻意不设通用墙钟超时。同步路径靠心跳:每 30 秒读一次子 Agent 的活动摘要,迭代计数与当前工具都不前进才算一个停滞周期;空闲态 15 个周期、正在工具里 40 个周期才判停滞。后台路径靠一个监控线程采样进度令牌:空闲态 450 秒、在工具里 1200 秒无进展则判为停滞,先发中断给子 Agent 一个 120 秒的宽限窗口,让它有机会按正常路径交付部分结果,实在不回来才强制终结成一条 stalled 事件。为什么不设默认超时?注释给的理由是:深度评审、大规模资料扇出、慢的推理模型本来就该跑很久,失败该来自子 Agent 真正在做的事,而不是一个通用秒表。
摘要本身也会被裁。批量扇出时 N 份摘要一起进父 Agent 上下文是真实的溢出源,所以每份摘要的额度取两个上限的较小值:一个是父 Agent 剩余上下文余量的一半再按批量份数均分(有下限),另一个是静态字符上限 delegation.max_summary_chars。超额的摘要会被切成头尾两段留在上下文里,全文另存到委派缓存目录的文件,并在页脚给出确切的读取偏移,告诉父 Agent 怎么翻到被省掉的中段。
六、边界与代价:它明确不管什么
后台委派不持久。 这是最需要提前知道的一条。它脱离了当前这一轮,但仍然是进程内的:父会话被显式重置、或者进程在子 Agent 完成前退出,那个子 Agent 的工作就被丢掉;停止指令会取消所有在跑的后台子 Agent。要跨重启存活,得换成定时任务或带完成通知的后台终端。
子 Agent 摘要是自报,不是核实过的事实。 工具描述把这条写成了硬要求:凡是有外部副作用的操作(远端写入、发布、在共享路径建文件、HTTP 写请求),必须要求子 Agent 返回可验证的凭据——URL、ID、绝对路径、HTTP 状态码——然后由父 Agent 自己去取、去 stat、去读回内容确认,再告诉用户成功。这和 失败重试设计 是同一个思路的另一面:先假定报告不可信。
危险命令在子 Agent 线程里默认被拒。 原因是接口层面的死锁:子 Agent 跑在工作线程里,交互式审批回调存在线程本地存储里不会被继承,一旦回落到从工作线程读标准输入,就会和父 Agent 占着终端的界面互锁。项目的处理是给每个子 Agent 工作线程装一个非交互回调,默认自动拒绝并打审计日志,让子 Agent 看到一次可恢复的拒绝;把 delegation.subagent_auto_approve 打开才改成自动放行,注释直接把它标成需要主动选择的高风险选项。代价你要认:默认配置下,需要危险命令的子任务会直接失败,而不是等你点同意。
并行是线性乘上去的花费。 并发上限超过一定值时代码会打一条一次性告警,措辞是每个子 Agent 独立消耗接口调用量、高并发线性放大花费;深度上限也在注释里被反复提醒每加一层都在乘。委派没有让工作变少,只是把它搬到别处并行做。具体价格与配额各家规则不同且会调整,以官方最新说明为准。
它不管跨机分布。 跑着的子 Agent 只活在本进程;生命周期服务的重连如实报告重启后连不上。也不管人机协同的追问(clarify 被禁),不管让子 Agent 写共享记忆(memory 被禁),不管让子 Agent 替父 Agent 对外发消息或排新任务(send_message、cronjob 被禁)。这些不是漏掉,是明确划出去的。
还有一条属于自托管本身的代价,跟委派叠加后会放大:这类项目会常驻在你的机器上、开终端执行命令、往磁盘写文件、访问外部服务。委派把这些能力复制给了若干个你没有逐条批准的子 Agent,它们各自有独立终端会话与独立工作目录。要装,就按「它能做的事等于你自己在这台机器上能做的事」来准备隔离环境,别装在放着生产凭据的机器上。
七、上手与避坑清单
别把上下文当成可选项。 会踩是因为单任务模式下 goal 是必填、context 是可选,模型很容易只给一句目标就派出去;而子 Agent 完全没有你们的对话历史。避法是把「路径、报错原文、约束、语言要求」当成派活的四件必填项写进 context,缺一样就当这次派活没准备好。
批量任务数不要超过并发上限。 会踩是因为超了不是排队而是直接报错:任务数大于并发上限时 delegate_task 返回一条明确的错误,让你减任务、拆成多次调用,或者去调配置项。模型看到的是一次失败的工具调用,白花一轮。避法是要么按当前上限拆批,要么先把 delegation.max_concurrent_children 调到位再派。
别指望 orchestrator 一写就生效。 会踩是因为深度上限或全局开关不满足时它是静默降级成 leaf,没有报错也没有警告级提示。避法是把嵌套能力当成需要先确认的前置条件:看当前生成的工具描述里那句嵌套说明写的是启用还是关闭,它是按你这台机器的实际配置重新拼的。
别在模型调用里传 max_iterations 或 background。 会踩是因为它们看着像参数其实不起作用:调用方给的迭代上限会被丢掉并记一条调试日志,配置里的值才是权威;background 在工具定义里被标注为已废弃且被忽略,仅为向后兼容保留。避法是想改预算就去改 delegation.max_iterations 这一类配置项,别在提示里跟模型商量。
别用 orchestrator 做纯转发。 会踩是因为把整个目标原封不动再派给一个工人看起来很像编排,实际是没有增值的穿透——编排角色的系统提示里明确把这种做法列为不该委派的情形,同列的还有单步机械工作和一两次工具调用就能完成的事。避法是编排角色只在目标能拆成两个以上真正独立的子任务时才用,并且由它负责汇总,不是由工人各说各话。
子 Agent 改过的文件,父 Agent 要重读。 会踩是因为父 Agent 之前读过的文件被子 Agent 改了,父 Agent 手里的内容已经过期,直接编辑就会覆盖。项目做了兜底:委派结束时会比对父 Agent 读过的路径与这段时间内发生的写入,命中就在摘要末尾追加一条「子 Agent 修改了父 Agent 先前读过的文件,编辑前请重读」的提示,并列出路径。避法是把这条提示当硬指令执行,不要因为摘要说「已完成」就跳过重读。
0 次接口调用就超时的那种卡死,去翻诊断文件。 会踩是因为这种失败最没有线索:子 Agent 连第一次模型请求都没发出去。仅在配了硬超时且接口调用数为 0 时,项目会往家目录下的日志目录写一份诊断文件,名字以 subagent-timeout- 开头,里面记着子 Agent 的配置、系统提示与工具模式的体积、活动摘要、以及工作线程和其它线程在超时那一刻的 Python 调用栈。避法是先看这份文件再猜,注释列出的常见原因是提示过大被服务端拒、传输层挂住、凭据解析卡住。
收束:先读哪三段
真要动手,按这个顺序核对最省时间:先在 tools/delegate_tool.py 里找那份被禁工具集合,确认你打算派的活在剥离后还有工具可用;再看 _build_top_level_description 生成的那段工具描述,那是你这台机器上真实的并发与深度上限;最后打开一次派发产生的 cache/delegation/live/<delegation_id>/task-<n>.log,用它校准你对「子 Agent 到底在干什么」的想象——多数人对派出去那几分钟发生了什么的猜测,和日志里写的并不一样。
派活之前问自己四句:这个任务的全部前提我是否都写进了 context;它产出的东西我打算怎么验证而不是相信;它失败或卡死时我从哪个文件看到;以及最朴素的一句——这活我自己顺手做掉是不是更快。前三句有一句答不上来,就先别派。
想继续往下挖,tools/async_delegation.py 里那张表和它的三态投递状态是整套后台机制最值得抄的部分;agent/subagent_lifecycle.py 那份拒绝清单则是一份现成的接口边界写法参考。顺带一说,这个仓库 tests/ 下有 2499 个以 test_ 开头的测试文件,委派相关的行为大多能在里面找到对应的用例,比读注释更能确认某个行为是不是有意为之。
本篇属于一个把开源常驻自托管 Agent 项目 Hermes Agent逐层拆开讲的系列,整体地图见 开源自托管 Agent 项目 Hermes Agent 是什么;沿着这条线往下,还可以看 开源自托管 Agent 项目 Hermes Agent 的会话检索链路 和 开源自托管 Agent 项目 Hermes Agent 的语音链路与代价。