开源自托管项目 Hermes Agent 的 MoA 多模型合议:怎么组织,何时值得付

2026-07-30

本文基于 hermes-agent 仓库 commit 2d40494(2026-07-29)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/NousResearch/hermes-agent 最新代码与文档为准。

开源自托管 Agent 项目 hermes-agent(NousResearch/hermes-agent)里的这套 MoA 不是”多个模型投票选答案”,它更像给你原来的 agent 循环外挂了一圈顾问:顾问不碰工具、不执行任何东西,只对当前状态出判断,然后这段判断被贴到真正干活那个模型的 prompt 末尾。 想清楚这件事,后面所有取舍(成本乘在哪儿、哪些场景白花钱、追踪文件为什么不进消息表)都顺着推得出来。

先做个消歧:Hermes 这个名字还对应 Nous Research 的开源模型系列,以及若干同名商标和同名库。本文说的是 NousResearch/hermes-agent 这个常驻自托管的开源 Agent 项目——装在你自己机器上、会开终端执行命令、往磁盘写文件、还能接你聊天账号的那个进程(skills/ 下 14 个分类目录共 70 份 SKILL.md,plugins/ 18 个顶层插件目录,MoA 只是其中一块)。许可证 MIT,署名 Nous Research。

站内已有三篇讲方法论的:多模型混答怎么合 讲”合”本身的通用做法,多方案判决 讲怎么给多份方案定裁决规则,对手验证 讲让另一个模型专职挑错。本文不重复方法论,只做一件事:把这个具体项目怎么把”多模型各答一遍再合”落到代码里讲清楚,包括它刻意没做的那部分(比如它根本没有打分和投票)。

一、先把角色搞清楚:aggregator 才是干活的那个

MoA 在这个项目里是一个虚拟 provider。你配一个预设(preset),这个预设就以 moa 这个 provider 下的一个”模型”身份出现在模型选择器里;选中它之后,预设里的 aggregator 槽位才是真正的行动模型——它写回复、它发工具调用、它的输出就是这一轮的助手消息。reference_models 里那些槽位是顾问,它们拿不到工具 schema,也拿不到 Hermes 自己的系统提示。

顾问这个角色是硬写进提示里的。agent/moa_loop.py 里那段发给每个顾问的固定 system prompt,第一句先把它定位成 MoA 流程里的”参考顾问”,紧接着就是 You are NOT the acting agent and you do NOT execute anything,后面还专门列了正反例:

- Bad: "I ran curl and got 404."
- Good: "Based on the error pattern, a curl request to that URL would likely return 404."

这段防御性文案是被真实故障逼出来的。代码注释写得很直白:不加这层框定,顾问收到一段光秃秃的对话会以为自己就是行动方,于是道歉说”我没法访问仓库/URL”,或者试图调用它根本没有的工具。另一个方向的限制同样明确:顾问槽位不能再指向一个 MoA 预设,递归 MoA 树被直接封掉——顾问侧是跳过并留说明,aggregator 侧直接抛错。

二、一次回合的实际路径

主循环每做一次模型调用都走 MoA 的 facade。facade 除了常规入口还暴露一个”只准备不发送”的方法:先跑扇出、把最终要发给 aggregator 的请求原样返回,让主循环能在自己的上下文压缩门之前测量这段被撑大的 prompt;压缩真发生了也不重跑扇出,而是把已拿到的顾问文本重新贴到压缩后的新记录上。扇出是全流程最贵的一步,能不重跑就不重跑。

扇出是线程池不是 asyncio,因为底层模型调用是同步阻塞的。并发上限取槽位数和常量 8 里的小者,注释说这只是防一个病态大预设一次开几十条连接。工作线程显式继承父上下文(审批回调和会话标签),否则顾问调用挂在空上下文里、归属信息就丢了。等待不是逐个死等,而是每 5 秒轮询一次,为的是让用户中断能打断等待。但已经发出去的顾问请求杀不掉:中断只能停止”等”,在飞的调用会跑完、会计费,所以专门有一条迟到账单回收路径把这些钱折回本会话的待结算里。

顾问看到的对话,跟你看到的不是同一份:

  • Hermes 自己的系统提示被整段丢掉(注释称其为 8K 样板文,没有顾问价值);
  • 助手轮里的工具调用被摊平成 [called tool: 名字(参数)] 这样的文本行;
  • 工具结果不丢,但会被折进前一条助手轮,包成 [tool result: ...],并且按 4000 字符做头尾预览、中间标注省略了多少字符;
  • 整份视图不产生任何 tool 角色消息、也不产生任何 tool_calls 数组——这是为了绕开那些会因为”顾问没产生过的 tool_calls”而直接报 400 的严格服务商;
  • 视图必须以 user 轮结尾。如果自然结尾是助手轮,就追加一条合成的 user 轮请顾问对上面的状态下判断,而不是删掉助手的最新上下文来凑格式。

这几条背后都对应过真实报错,注释里连”某些服务商接受空 user 轮、某些直接拒”这种不一致都记下来了。行动方那边正好相反:aggregator 永远拿到完整、未裁剪的记录,被裁的只是顾问那份一次性副本。

窗口差异也单独处理:每个顾问按自己模型的窗口裁一遍,预留一段输出余量,再留 10% 给”字符数除四”这种粗估的误差;从最老的对话帧开始丢,同时守住”系统提示永远留着”和”第一条非系统消息必须是 user 轮”两条不变式,窗口解析不出来就原样返回、不瞎裁。为什么专门做?因为窗口不够时服务商返回的是硬 400,而 400 会被外层捕获成一条 [failed: ...] 文本,回合照常继续,你只是悄悄少了一个顾问

拼装位置比看起来重要。顾问块贴在 aggregator prompt 的最末尾,而不是并进”最近那条 user 消息”——在 agentic 循环里最近的 user 消息其实躺在上下文顶部(后面全是助手轮和工具轮),往它里面塞每轮都在变的顾问文本,等于让 prompt 前缀在很靠前的位置就发散,整段对话每步都要重新预填。贴尾部则保住前缀稳定。对应地还有一个精确的剥离函数逆向对应三种贴法,失效切换要重做缓存标记时必须先剥干净,否则缓存断点落在那段每轮都变的顾问块上,等于白缓存。

组成部分它负责什么对应仓库位置你什么时候会碰到它
MoA facade 的 create / prepare解析预设、跑扇出、拼出 aggregator 请求agent/moa_loop.py选了 moa 预设之后的每一次模型调用
_run_references_parallel线程池并发跑顾问、按槽位顺序收结果、处理中断与迟到账单agent/moa_loop.py顾问多于一个,或你中途按了中断
_reference_messages / _trim_messages_for_reference把对话压成顾问能吃的纯文本视图,并按各顾问窗口裁剪agent/moa_loop.py某个顾问报 400,或它抱怨”看不到你的请求”
_attach_reference_guidance 与其逆操作顾问块贴在 aggregator prompt 尾部、以及精确剥离agent/moa_loop.py排查缓存命中、排查失效切换
save_moa_turn / _slot_trace把整回合谁看到什么、谁说了什么、花了多少落成一行 JSONagent/moa_trace.py打开追踪做离线复盘
call_llm所有槽位共用的出口:认证、请求成形、按模型调参、超时agent/auxiliary_client.py某个槽位的服务商行为异常
预设归一化与取值收敛把手改的配置收敛成运行时唯一形状hermes_cli/moa_config.py手写配置后行为跟你以为的不一样
顾问任务的默认项(含超时默认 900 秒)顾问调用的任务级默认值hermes_cli/config_defaults.py顾问长时间不返回

三、成本闸门:扇出节奏、缓存与分账

MoA 最容易失控的地方不是单次调用贵,而是乘法:一个用户回合里 aggregator 可能连着调十几次工具,如果每次迭代都重跑一遍顾问扇出,账单就乘以工具迭代数。项目为此提供了三档扇出节奏,配置项在预设上:

  • 默认是”每个用户回合跑一次”。实现很聪明:只对”到最后一条真实 user 消息为止”的前缀做哈希当缓存键,于是回合中途上下文长胖也不改签名,第 2 次及以后的迭代自然命中缓存。判断”最后一条真实 user 消息”时还得排除那条合成 user 轮,否则签名每轮都变、这档节奏直接失效。
  • fanout: per_iteration 是每个工具迭代都刷新顾问意见,代价就是上面那个乘法。文档注明这一档曾是默认值,后来改成了最省的那一档。
  • fanout: every_n:3 之类是中间档(N 不小于 2):每个用户回合第一次迭代跑,之后每 N 次跑一次,中间的迭代复用上次建议。计数器按用户回合作用域、新消息归零;状态签名没变的重复调用(比如流式重试)不会白吃掉一个节拍位。

缓存键是(预设名,视图签名,槽位标签元组)三元组,槽位一改就自动失效。有个反直觉但正确的细节:被中断的扇出不进缓存——它是半成品,一旦缓存住,那些占位说明会在整个回合的后续迭代里被反复复读。

分账口径也值得抄。每个顾问的用量和费用单独记:token 按它自己的服务商用量格式归一化,钱按它自己模型的单价估,还带”定价状态”和”定价来源”,定不出价时为空。为什么不折进 aggregator 的用量一起算?因为顾问常跑在完全不同的模型上,合并等于拿 aggregator 的单价给顾问的 token 定价,每个顾问都算错。会话侧通过一次”弹出”把待结算取走并清零,避免流式重试重复计;缓存命中的迭代不再入账但也不清零,因为可能有一笔迟到的中断账单刚存进来。要系统地建成本视图,配合Agent 成本失控的排查路径看。

什么场景值得付这份乘法?收敛慢、错一次代价大、状态已经足够摊在对话里的判断型任务:卡了两小时的调试方向要不要换、跨栈迁移方案的取舍、改动范围大到评审都不敢拍板的设计。这类任务瓶颈是”想得对不对”,多一份独立视角确实能改判,工具迭代又不多、乘法不痛。仓库文档自报了一组 HermesBench 对照,说两模型 MoA 预设分数高于它的任一单模型组件——那是维护者自测口径,你的任务分布不一定复现。

纯属浪费的是另一类:机械改名、批量格式化、依赖升级、按既定清单跑脚本这种执行型长工具链。步骤本来就确定,瓶颈在执行不在判断,工具迭代动辄几十次,每次请几个顾问说一遍”请继续执行”,付的是纯乘法。一眼可判的小改动同理,顾问意见大概率彼此重复。

四、追踪:打开之后到底往磁盘写了什么

追踪是默认关闭的独立开关(配置在 moa 段下)。关着的时候唯一开销是每回合一次配置读,不落任何文件。打开之后,每个真正跑了扇出的回合(也就是缓存未命中的那次)会往按会话 id 命名的 JSONL 文件追加一行,默认目录在 Hermes 主目录下的 moa-traces/,也可以用配置项改到别处。

一行记录里有什么,看 agent/moa_trace.py 就一清二楚:时间戳、会话 id、预设名;每个顾问的标签、模型、服务商、温度、完整输入 messages完整输出、五项用量(输入、输出、缓存读、缓存写、推理)、费用与定价状态来源;以及 aggregator 的标签、模型、服务商、温度、它实际收到的完整输入(含被注入的顾问块)、输出、是否流式,还有一个”输出存在哪里”的三态标记——非流式直接内嵌,流式时调用方能回填就标成来自流的内嵌,回填不到就指向会话库里那条助手消息。

两个设计取向值得注意。一是它明确是侧信道:不进消息表、不参与回放,因为顾问是带自己系统提示的旁路调用,硬当对话轮持久化会破坏角色交替和重放。二是文件”可以随手删”,文档和注释都写明了这个定位。合起来就是:追踪给你离线审计用,不是产品状态的一部分。想把这类记录用起来,参考让 Agent 行为可复现与回放

配套还有隐私过滤开关,三档:关(默认)、只洗用户可见面(UI 里的顾问块和落盘记录)、连注入给 aggregator 的顾问文本一起洗。密钥类形状(API key 前缀、JWT、私钥、数据库连接串)由项目中央脱敏器负责,MoA 这层只额外加邮箱和”带明确分隔符的电话号码”。正则刻意保守:顾问文本常是代码评审形状的,满是行号、时间戳、git SHA、IP,所以裸数字串一律不匹配,只有 (555) 123-4567 这种带分隔符的形式才算电话。真实配置长这样:

moa:
  privacy_filter: display   # or: full

请注意默认值:这个开关是关着的。也就是说你一打开追踪,落到磁盘上的就是整段对话原文加上每个顾问的完整输出,明文 JSONL。

五、边界与代价:它明确不管的事

  • 它不做裁决。 没有打分、没有多数票、没有一致性投票,也没有让某个模型专职挑错。顾问输出被拼成带编号的参考块,贴在 aggregator prompt 尾部的那段合成提示只交代三件事:这是私有参考上下文、你才是行动模型、该直接答就答该调工具就调工具。至于哪条建议采纳、分歧怎么裁,提示里一个字都没规定,全凭 aggregator 自己。(“关注下一步、工具策略、风险与分歧”那几条要求写在顾问的系统提示里,是对顾问输出内容的要求,不是给 aggregator 的裁决规则——这两者别混。)你要”可复核的判决记录”,这套机制给不了:它甚至没有记录”某条建议被采纳/被驳回”的字段,追踪文件里能看到的只是每个顾问说了什么、aggregator 最终做了什么,中间的取舍是黑箱。
  • 顾问看的是副本,不是现场。 工具结果做过头尾预览、老帧可能被裁掉、系统提示整段不见,它可能恰好在被省略的那段中间下判断,而对此毫不知情。
  • 顾问不能执行,所以它的”下一步”可能不可执行。 它没有工具、没有权限、不知道哪些工具真的可用,建议里出现一个不存在的操作完全可能。
  • 降级默认不响。 单个顾问抛错就变成一条 [failed: ...] 文本,回合照常继续;全部失败时跳过合成、让 aggregator 独自行动,只带一条消毒过的不可用通知(刻意不透传原始服务商错误文本)。这条通知说不说给模型听,由预设上一个策略项控制,默认是”大声说”。
  • 中断不能真止损,在飞的调用会跑完并计费。
  • 调用数按槽位数乘。 文档自己就写着 MoA 会增加模型调用次数:一次模型迭代可能包含多次顾问调用加一次 aggregator 调用。
  • 最容易被忽略的代价在项目层面,不在 MoA 层面。 这是个常驻在你机器上、会开终端执行命令、会往磁盘写文件、可能连着你聊天账号和外部服务的进程。MoA 做的事是把同一段对话(里面可能有客户数据、内网结构、粘贴进来的凭据)额外发给 N 个第三方模型服务商:扇出等于把数据出口面乘以槽位数,而隐私过滤默认关闭、追踪默认落明文。这不是危言耸听,是配置默认值决定的事实。各家服务商的数据留存规则不同且会调整,以官方最新说明为准。

六、上手与避坑清单

  1. 别一上手就开每迭代扇出。 会踩是因为”每一步都该拿最新建议”听起来更对;但顾问延迟和花费按这一回合的工具迭代数翻倍,长工具链任务上体感就是卡住。避法:先用默认档跑一周真实任务,确认建议真的过期了再上中间档。
  2. 顾问不封顶时,最慢那个决定整回合墙钟。 会踩是因为扇出没有”先到先用”,要等全部完成才交给 aggregator,而顾问输出默认不设上限,遇上爱写长文的模型就是干等。避法:给预设设顾问输出上限(文档举的例子是 reference_max_tokens: 600),它只封顾问、不封 aggregator,用户可见的答案不会被截。
  3. 别假设预设里写了三个顾问就真有三个。 会踩是因为顾问失败、窗口不够、递归被跳过,全都变成一条文本继续往下走,界面上不会红。避法:降级披露策略保持”大声说”,并定期从日志或追踪里数每回合实际成功几个顾问。
  4. 只看 aggregator 的账单会低估花费。 会踩是因为 MoA 路径上 agent 自身的 provider 和 model 是虚拟的(一个是 moa,一个是预设名),没有对应定价条目,顾问的钱又必须按各自模型单价算。避法:把顾问待结算的弹出口径接进成本看板,或从追踪逐回合累加费用字段,并留意定价状态为空(估不出价)的情况。
  5. 打开追踪之前先决定文件放哪。 会踩是因为它写的是顾问看到的完整输入(等于你整段对话)加完整输出,明文,一个会话一个文件,而隐私过滤默认是关的。避法:要么同开隐私过滤,要么把追踪目录指到受控盘并纳入清理策略;这些文件不参与回放,删掉不影响会话。
  6. 预设名只在模型选择这一层用,推理深度写在槽位上。 会踩是因为 moa 不是真实 HTTP 服务商,手改配置把预设名塞进别的路径就会拿到莫名其妙的报错(项目内部到处在把这个虚拟身份解包回 aggregator 的真实模型,正是为兜住这类误用);同理,顾问调用走共享调用出口、看上去像”辅助任务”,于是有人去辅助任务段设推理档位,项目对此明确拒绝并打日志。避法:深度差异用槽位表达,例如同一个模型开两个顾问槽、一浅一深。

动手前的自检就四个问题:这个任务瓶颈是判断还是执行(执行就别开);这一回合大概几次工具迭代(多就用默认或中间档);这段对话里有什么是你不愿意多发给 N 家服务商的(有就先开隐私过滤,或干脆不在这段会话里用);顾问那笔钱进没进你的成本口径(没进等于没在看账)。

想往下读代码,顺序建议:agent/moa_loop.py 先看构建顾问视图和裁剪的两个函数,再看扇出并发与中断处理,然后看顾问块贴到 prompt 尾部那段注释(整份文件信息密度最高的地方);agent/moa_trace.py 通篇不长,读完就知道追踪能支撑什么分析;预设取值怎么收敛看 hermes_cli/moa_config.py;顾问和 aggregator 最终怎么发出去看 agent/auxiliary_client.py 里共享调用出口的参数与超时解析。

本篇属于一个把开源常驻自托管 Agent 项目 Hermes Agent逐层拆开讲的系列,整体地图见 开源自托管 Agent 项目 Hermes Agent 是什么;沿着这条线往下,还可以看 开源自托管 Agent 项目 Hermes Agent 写盘前的四道关开源自托管 Agent 项目 Hermes Agent 怎么把运行轨迹变成训练与评测数据

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。