开源 Agent 项目 Hermes Agent 的消息分档路由怎么做

2026-07-30

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

分档路由真正要解决的问题,不是”一个 Agent 同时服务多个人”,而是”同一份代码在不同来源下必须整套换掉身份、记忆、模型、密钥和工具”——只换系统提示词是不够的。 这里说的 Hermes Agent 是 NousResearch/hermes-agent 这个常驻自托管的开源 Agent 项目(MIT 许可证,LICENSE 署名 Nous Research),不是 Nous Research 同名的那套开源模型系列,也不是其它同名商标或库。它把”分档”这件事做得很实:一档就是磁盘上一个完整目录,选哪一档由一张按位加权的匹配表决定,全过程不经过模型。

站内已有几篇相邻的文章,分工先说清楚:模型路由策略讲的是同一个请求怎么在多个模型之间选,Agent 角色分工讲的是多个 Agent 之间怎么切职责,模型分层调度讲的是分层派活的通用方法论。这三篇是方法论层面的,本篇只做一件事:把这个具体开源项目的分档实现读到能改、能抄的程度。

一、它先要解决什么:一个 bot token 服务多个社区

默认情况下,一次 gateway 运行只用一份配置:一份记忆、一个人格、一套工具。项目文档里把这份东西叫 profile,而 profile 在磁盘上就是 ~/.hermes/profiles/<name>/ 这一整个目录,里面有 config.yaml(模型与提供方、MCP 服务器、启用的技能)、skills/.envSOUL.mdUSER.md,加上各自独立的 MEMORY.md 与会话状态。设计文档里那句话说得比任何抽象都清楚:一个 profile 就是一个完整的 HERMES_HOME 目录。

在一台机器上跑多个 Agent,有两种做法。一种是一个进程一个 profile,各自装成托管服务,互不打扰——这是默认,也是文档推荐给大多数人的方式。另一种是让默认 profile 的 gateway 变成唯一的入站进程,替所有 profile 收消息,这就是复用(multiplexing)。复用模式下,选哪一档有三条路径:

  • 按凭据选:每个 profile 用自己的 bot token,谁的 token 收到的消息就归谁;
  • 按 URL 前缀选:HTTP 入站的平台走同一个监听端口,用 /p/<profile>/ 这样的路径前缀区分;
  • 按来源选:多个社区共用同一个 bot token 时(典型是一个 Discord bot 进了很多服务器),用 profile_routes 把具体的服务器、频道、线程指到不同 profile。

第三条就是本篇的主角。它填的是前两条填不上的洞:一个 token 一个入口,来的消息本身没有任何”我属于哪档”的标记,判断只能靠消息的出处。

二、匹配规则:全部合取,再按权重取最具体的一条

路由规则写在 config.yaml 里,顶层的 profile_routes 和嵌套的 gateway.profile_routes 两种写法都收(hermes config set gateway.profile_routes ... 写出来的是嵌套那种)。docs/profile-routing.md 给了四条示例规则,这里取前三条:

profile_routes:
  # Route an entire Discord server (guild) to one profile.
  - name: server-default
    platform: discord
    guild_id: "1234567890"
    profile: server-profile

  # Override a specific channel within that server with a different profile.
  - name: support-channel
    platform: discord
    guild_id: "1234567890"
    chat_id: "9876543210"
    profile: support-profile

  # Pin a Telegram group to a profile (Telegram has no guild_id — chat_id only).
  - name: tg-group
    platform: telegram
    chat_id: "-1001234567890"
    profile: tg-profile

一条规则里必填的是 nameplatformprofile,可选的判别字段是 guild_idchat_idthread_id,另有一个 enabled 默认为真。这里有一处文档与代码的宽严差异值得先记下:文档的字段表把 name 也标成必填,而解析函数只在 platformprofile 缺失时才丢弃条目,name 空着照样会被装进规则列表——代价是它只在日志里出现,规则一多,你会看到一条无名规则命中、根本对不上是配置文件里哪一行。所以 name 该按必填来写,理由不是解析器逼你,是排查时你自己需要它。判别逻辑是合取:规则声明了的每一项都必须成立,没声明的忽略。这一点有个容易反直觉的推论——同时写了 guild_idchat_id 的规则,光频道对上不算命中,服务器也得对上。仓库的测试文件里专门为它留了回归守卫,注释写明这是修过的行为:早先 chat_id 先判并直接返回真,guild_id 根本没被看过。

chat_id 还有一层父链匹配。线程消息里,来源的 chat_id 携带的是线程本身的 id,父频道则由 parent_chat_id 携带。判定时 chat_id 允许匹配来源的频道它的直接父级,所以一条挂在频道上的规则会自动覆盖这个频道下的线程和论坛帖,不用为每个线程各写一条。反过来不成立:只声明 thread_id 的规则,父频道命中不了它。

多条规则同时命中时取最具体的一条。具体度是加权求和,权重分别是 thread_id 8、chat_id 4、guild_id 2,只有 platform 的规则是 0。代码里就是这么算的:

    @property
    def specificity(self) -> int:
        """Higher value = more specific match."""
        s = 0
        if self.guild_id:
            s += 2
        if self.chat_id:
            s += 4
        if self.thread_id:
            s += 8
        return s

这里有个读代码的小提醒。gateway/profile_routing.py 模块顶部的注释也列了一份优先级表,标注的数字是 14 / 6 / 2,但那三个数字对应的是”三项全填 / 两项全填 / 只填服务器”的和,与它自己在同一行列出的字段组合并不严格对齐。要判断谁赢,按上面那个属性的加权自己算,或者看 docs/profile-routing.md 里那张权重表——那张表和代码是一致的。一条线程规则赢过频道规则,频道规则赢过服务器规则,一条都没命中就落回默认/当前激活的那档。

还有一处更值得抄走的细节:排序发生在解析阶段,而不是匹配阶段。parse_profile_routes 读完配置后按具体度倒序排好,match_profile_route 只是顺序遍历、返回第一个命中的。也就是说”最具体者胜”这个保证挂在解析函数身上;如果你把这套结构搬过去、却自己拼装规则列表再丢给匹配函数,保证就没了,行为退化成”配置文件里谁写在前面谁赢”。

三、运行时是怎么串起来的

从一条消息进来到落进某个目录,链路上的角色分得很干净:

组成部分它负责什么仓库位置你什么时候会碰到它
ProfileRoute一条规则的不可变载体,带 matches 判定与 specificity 权重gateway/profile_routing.py写规则、调试为什么没命中
parse_profile_routes解析配置、校验并规范化 profile 名、跳过非法条目、按具体度倒序gateway/profile_routing.py启动读配置、规则莫名消失时
match_profile_route顺序遍历返回第一个命中的规则,无命中返回 Nonegateway/profile_routing.py想把这套分档搬到自己项目里
profile_routes 配置字段挂在网关配置对象上的列表,顶层与嵌套两种写法都接gateway/config.pyconfig.yaml、排查配置没生效
BasePlatformAdapter.build_source构造这条消息的来源对象,并在这里就把选中的档名盖上去gateway/platforms/base.py自己写平台适配器
gateway_runner 反向引用由网关注入给每个适配器的回指,路由靠它才是平台通用的gateway/platforms/base.py(声明)、gateway/run.py(注入)新增平台却发现路由不生效
_profile_name_for_source前置开关判断、调用匹配、异常兜底回落,只回档名不碰目录gateway/run.py读日志里”没有规则命中”那行
_resolve_profile_home_for_source把档名换成真实目录:来源已盖章 → 重跑一次路由 → 当前激活档 → defaultgateway/run.py排查记忆串档、目录选错
路由生效的前置开关复用模式的总开关,关着的时候规则被整体忽略gateway/config.pygateway/run.py第一次配路由(多半栽在这)

顺序上,适配器收到消息后先构造来源对象,这时就通过回指问网关”这条消息归哪档”,把结果盖在来源上;再往下,解析目录的那一步按上表的顺序挑出 HERMES_HOME,然后整个回合被包在一个按 profile 划定的运行时作用域里跑——配置、技能、记忆、人格文件都从这一档解析,密钥也从这一档的作用域取,不会被并进共享的进程环境。文档里明确写了这一点比分进程时更严:MCP 服务器这类子进程只看得到自己那一档的密钥。

注意解析目录那一步会再跑一次路由。这不是冗余代码,注释说得很直白:给那些绕过了构造来源那条路径的来源做防御性兜底。设计上”决策点尽量早,但关键路径上补一次重算”,这个取舍值得记下来。

四、边界与代价:它明确不管什么

这套设计的取舍相当明确,写出来比夸它有用。

它必须挂在复用模式下。 前置开关关着的时候,规则被完整忽略,行为和单档网关逐字节一致。代码注释解释了为什么要加这道门:路由的动作是给来源盖档名,而档名决定会话键与批处理键的命名空间;如果开关关着、路由却照样盖章,会话键按档分了家,实际的 Agent 运行却还在原来的命名空间里,两边各说各话。这是一个”半开状态比全关更危险”的典型,所以干脆不给半开。

它放弃了进程级隔离。 复用模式是一个进程收所有消息,换来的是一份 PID、一份锁、一个要盯的状态面。代价是没有独立的崩溃域,也没法只重启其中一档。文档自己给了反向建议:要硬隔离就回到一个进程一个 profile。

共享监听端口带来一串约束。 端口绑定类的平台只能配在默认那一档,次级 profile 自己配了会被判为配置错误,整档跳过(其余档继续跑);按凭据分档时,同一个 bot token 不能被两个 profile 同时轮询,冲突会在启动时直接失败并点名两档。这些都不是路由本身的规则,但你一旦开了复用就必须一起吃下。

它只看来源,不看别的。 判别字段只有平台、服务器、频道、线程。谁在说话、说的是什么、什么时间说的,一概不参与。想”按用户分档""按意图分档""按敏感度升档”,这套机制不管——这也正是它确定性高、可单测、零 token 开销的原因。要按内容分流,那是另一层的事,别指望这套机制替你做。

错配是降级而不是报错。 解析阶段只校验档名的格式(顺带做小写规范化,并挡住路径穿越那类名字),非法条目跳过、留一行警告;档名格式合法但磁盘上没有这个目录,要到解析目录那一步才被发现,行为是记一条点名了档名与来源的警告,然后回落到全局的 HERMES_HOME。两种情况都不会让网关起不来。好处是不会因为一条规则写错就全线停摆,代价是症状表现为”记忆串档”而不是”启动失败”,你得主动看日志。

常驻本身的代价不会因为分档而消失。 这个项目会常驻在你的机器上、开终端执行命令、连你的聊天软件账号、往磁盘写文件、访问外部服务。分档只是把爆炸半径切小:一档的密钥、记忆、技能不流向另一档。它不解决”这台机器上跑着一个能执行命令的常驻进程”这件事本身的风险,权限与工作区边界还得单独设,可以参考工作区隔离那篇的思路。

还有一件容易误读的事。 仓库里 docs/design/profile-builder.md 描述的那套分步创建 profile 的界面,文件开头就标着状态为设计提案、尚未实现。读设计文档很有价值(下一节会用到它的结论),但别把它当成已有功能来规划。

五、上手与避坑清单

每条都写清为什么会踩、怎么避。

  1. 只写了规则、没开复用开关。 这是第一大坑,因为配置改了、重启了、日志里也没有报错,规则就是不生效——因为它被整体忽略了。避法:开关设在默认 profile 上(复用器归它),设完重启默认 profile 的网关,再发一条测试消息看日志里有没有匹配记录。
  2. 配置只查了一处。 顶层 profile_routes 和嵌套 gateway.profile_routes 都被接受,命令行写出来的是嵌套那种,手写文档抄来的常是顶层那种。避法:排查时两处都看,别看到一处为空就下结论。
  3. 把服务器和频道当成”或”。 两个字段一起写就是”两个都得对”,只想按频道分档却顺手补了个服务器 id,结果是范围被收窄、跨服务器的同名频道全不命中。避法:想按频道就只写频道;确实要限定在某个服务器内的某个频道,才两个都写。
  4. 为每个线程各写一条规则。 频道规则本来就通过父级覆盖它下面的线程和论坛帖,逐条写只会让规则表膨胀、还容易漏。避法:默认写到频道粒度,只在某个线程确实要用另一档时才加线程规则。反过来也要记住:只写 thread_id 的规则不会被父频道触发。
  5. 规则指向了不存在的目录。 档名拼错、profile 还没建、或者建在了另一台机器上,症状不是报错而是回落到全局目录,看起来像”两个社区共用了记忆”。避法:规则上线前先确认目录真的在;上线后第一件事是翻日志找那条点名档名与来源的警告。
  6. 档名用了非法字符或保留名。 解析会把名字规范化成小写,格式不合规的条目直接跳过——你的规则就这么无声消失了。避法:档名守住小写字母数字加下划线连字符的形状,避开那几个会和安装目录或系统命令撞车的保留名。
  7. 搬这套结构时把排序丢了。 匹配函数本身不排序,“最具体者胜”是解析阶段排好的结果。自己拼列表再匹配,就退化成书写顺序决定胜负,而且这种 bug 只在规则重叠时才现形,很难查。避法:要么照抄”解析时排序”这个约定,要么在匹配时取权重最大的命中项,别依赖调用方的书写顺序。
  8. 把端口绑定类平台配到了次级 profile。 后果是那一整档被跳过,而其它档照常工作,很容易被误判成”只有这一档的路由坏了”。避法:这类平台只配在默认那一档,其余档通过路径前缀触达。
  9. 想临时停一条规则就把它删掉。 删掉意味着下次要恢复得重新拼 id,而 id 这种东西一旦从配置里消失就很难再找回。避法:用 enabled 置假保留规则本体。

六、这套分档思路怎么搬到你自己的系统

抛开具体项目,可以复用的是四条判断。

第一,让”档”有一个物化载体,而且尽量选目录。 这个项目里一档就是一个目录,于是克隆、备份、diff、审计、打包迁移全都是免费的,也不需要为”这一档启用了哪些技能”再设计一层数据模型——设计文档里那句”按 profile 划分模型、MCP、技能是原生的,不需要改数据模型”,说的就是这个红利。反过来,如果你的档只是数据库里一行加一堆外键,每加一种可配置项都要动表。

第二,路由键只选入口处就能拿到的确定性字段。 平台、服务器、频道、线程这些字段在消息到达适配器的那一刻就是已知的,判定过程不查库、不调模型、不花 token,因此可以放在最热的路径上,也可以用纯函数单测覆盖。凡是需要模型先读一遍内容才能决定的分流,都要挪到后面一层去,别混进入口判定里——两者的失败模式完全不同。

第三,优先级用可加权的位,而不是 if-else 链。 每个判别字段给一个权重,命中的权重相加就是具体度,新增一个判别维度只要挑一个不冲突的权重值,不必重排整条判断链。这套写法的另一个好处是”谁赢”可以被直接断言,测试写起来是一行。

第四,决策点尽量早,但关键路径上补一次重算。 在构造来源对象时就把选中的档盖上,后面所有环节都读同一个结论,避免同一条消息在不同环节被判到不同档;同时在真正解析目录的那一步再跑一次匹配,兜住那些绕过了构造入口的来源。早决策保一致性,晚重算保覆盖率,两者不矛盾。

设计文档里还有两条工程经验,和分档没有直接关系但更普适。一条是:在模块导入时就把路径常量绑到全局变量上(文档里点名的例子是技能目录常量在导入时绑定),之后再用上下文变量去覆盖 HOME,是覆盖不回来的——已经绑好的模块全局不会因为上下文切换而重新求值,正确做法是起一个新的子进程,让它带着目标档的 HOME 从头导入一遍。任何”运行时切换根目录”的设计都会撞上这个坑。另一条是:同步的配置写入和异步的远程拉取不要放在同一个原子性承诺里——同步部分先提交并返回,异步部分返回进程 id 让界面轮询,并且这些补充步骤失败不该让整个创建请求失败,因为目录已经建好了,用户可以事后从对应页面补齐。

收束:三个自检问题

配完路由,回答这三个问题,能挡掉大部分线上事故:

  1. 复用开关是开的吗,而且是设在默认那一档上、重启过了吗?
  2. 规则里每个 profile 名,磁盘上真有对应目录吗(不是”我记得建过”)?
  3. 两条规则同时命中时,你能用权重口算出谁赢吗?算不出来,就说明规则表已经复杂到该拆了。

想继续往下读,顺序建议是:先看 docs/profile-routing.md 把字段和规则过一遍,再读 gateway/profile_routing.py 全文(不到两百行,看完就懂判定的全部真相),然后跳到 gateway/run.py 里解析档名与解析目录那两个方法看回落顺序,最后翻 tests/gateway/test_profile_routing.py——那里面每个测试类都对应一个真实踩过的坑,比文档更能说明边界在哪。至于会话与记忆按档隔离之后怎么组织,可以接着看记忆分层

本篇属于一个把开源常驻自托管 Agent 项目 Hermes Agent逐层拆开讲的系列,整体地图见 开源自托管 Agent 项目 Hermes Agent 是什么;沿着这条线往下,还可以看 开源自托管项目 Hermes Agent 的加平台清单开源自托管 Agent 项目 Hermes Agent 的学习链路拆解

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