2 · 身份与会话路由

这一章讲两件相关但不同的事:一条消息属于「哪个智能体身份」,以及属于「哪一场对话」

2.1 Profile:一个进程承载多个身份

「Profile」在 Hermes 里指一个完整的智能体身份。文档第一句定义得很清楚:

「Allows a single Hermes instance to route specific Discord guilds/channels/threads to different profiles — each with their own model, tools, memory, and persona.」

译:让单个 Hermes 实例把特定的 Discord 服务器 / 频道 / 会话线程路由到不同的身份 —— 每个身份有自己的模型、工具、记忆和人格。

一个 Profile 拥有独立的:

组成部分说明
config.yaml自己的配置:用哪个模型、开哪些工具集、上下文阈值多少
SOUL.md人格与行为准则文件。这个身份是什么风格、遵循什么原则
MEMORY.md / USER.md这个身份的长期记忆和对用户的认知
memory_store.db独立的 SQLite 记忆数据库
网关进程可以有自己的网关实例
定时任务自己的任务清单
为什么需要多身份

典型场景:

关键在于「一个进程」。如果每个身份都要跑一个独立进程,那么内存占用、部署复杂度、定时任务的协调都会变成问题。

2.2 四级路由与「具体度」打分

路由规则写在配置文件里:

gateway:
  profile_routes:
    - name: server-default          # 规则名
      platform: discord
      guild_id: "服务器ID"
      profile: server-profile       # 路由到哪个身份

    - name: special-channel
      platform: discord
      guild_id: "服务器ID"
      chat_id: "频道ID"
      profile: channel-profile

    - name: thread-route
      platform: discord
      chat_id: "频道ID"
      thread_id: "线程ID"
      profile: thread-profile

具体度打分

@dataclass(frozen=True)
class ProfileRoute:
    name: str
    platform: str
    profile: str
    guild_id:  Optional[str] = None      # 服务器
    chat_id:   Optional[str] = None      # 频道
    thread_id: Optional[str] = None      # 会话线程
    enabled: bool = True

    @property
    def specificity(self) -> int:
        """Higher value = more specific match."""
        s = 0
        if self.guild_id:  s += 2        # 服务器  权重 2
        if self.chat_id:   s += 4        # 频道    权重 4
        if self.thread_id: s += 8        # 线程    权重 8
        return s

hermes-agent/gateway/profile_routing.py

这个打分用的是二进制位权重(2、4、8),所以四级优先级刚好对应文档里那张表:

优先级匹配条件具体度含义
1平台 + 频道 + 线程14 = 2+4+8精确到某个会话线程
2平台 + 频道6 = 2+4整个频道
3平台 + 服务器2整个服务器
4都不匹配回落到默认身份

(严格说打分只是排序依据,实际优先级 14 > 6 > 2 是位权重的自然结果。用 2/4/8 而不是 1/2/3 的好处是:任意组合的分数都不重复,排序永远确定。)

匹配是「合取」的

def matches(self, platform, guild_id=None, chat_id=None,
            thread_id=None, parent_chat_id=None) -> bool:
    if not self.enabled:                                       return False
    if self.platform != platform:                              return False
    if self.thread_id and self.thread_id != thread_id:         return False
    if self.chat_id and self.chat_id != chat_id \
                    and self.chat_id != parent_chat_id:        return False    # ★
    if self.guild_id and self.guild_id != guild_id:            return False
    return True

文档明确了语义:

「All configured discriminators are matched conjunctively (AND): every discriminator that the route declares must hold. …A route declaring both guild_id and chat_id requires both to match (a chat match alone does not satisfy a guild constraint).」

译:所有配置的判别条件都是「与」关系:路由声明的每一个条件都必须成立。……一条同时声明了服务器和频道的路由,要求两者都匹配(光频道匹配不能满足服务器约束)。

父链匹配:Discord 论坛与线程

那个 parent_chat_id 分支处理的是 Discord 特有的层级结构:

Discord 的结构: 服务器 (guild) └─ 频道 (channel) ├─ 直接发在频道里的消息 └─ 线程 (thread) / 论坛帖 (forum post) └─ 发在线程里的消息 问题:你为"某个频道"配了一条路由规则, 那么发在这个频道的"线程"里的消息,该不该匹配? 答案:应该。所以匹配时同时检查: chat_id == route.chat_id (直接发在频道里) 或 parent_chat_id == route.chat_id (发在这个频道的线程里) 文档原话: 「Routes keyed on a channel match both direct messages and messages in any thread/post whose parent is that channel.」

这个细节体现的是「抽象要贴合真实世界的结构」。

如果只做扁平的「频道 ID 精确匹配」,用户会遇到一个非常困惑的行为:在频道里说话是一个身份,在这个频道开个线程说话就换成默认身份了。而用户的心智模型里,线程明显属于那个频道。

抽象和用户心智模型不一致时,用户会认为是 bug —— 即使代码完全按设计工作。

2.3 显式路由被拒绝的情况

class ProfileRouteRejected(RuntimeError):
    """An explicit route matched a profile this gateway does not serve."""

译:一条显式路由匹配到了一个「本网关不服务」的身份。

场景是:你可以跑多个网关进程,每个只服务一部分身份(比如为了资源隔离)。这时一条消息可能匹配到一个「不归我管」的身份 —— 这不是错误配置,只是这条消息该由另一个网关处理。

用一个专门的异常类型而不是「静默回落到默认身份」,是正确的选择 —— 因为静默回落会导致消息被错误的身份处理,而用户很难发现。

2.4 SOUL.md:人格文件

每个身份有一个 SOUL.md。它和系统提示词的关系是:

系统提示词的组装(agent/prompt_builder.py): ① SOUL.md ← 这个身份的人格与行为准则 ② MEMORY.md ← 长期记忆索引 ③ USER.md ← 对用户的认知 ④ 已加载的技能 ← 技能的头部元数据(渐进式披露,见第 13 章) ⑤ 上下文文件 ← AGENTS.md / .hermes.md(项目级规范) ⑥ 工具说明 ← 当前工具集里每个工具的描述

这个分层和 Claude Code 的 CLAUDE.md 是同类东西,但有一个重要区别:Hermes 把「人格」和「记忆」「用户认知」拆成了三个独立文件。

文件谁写的内容性质
SOUL.md人写的身份设定。基本不变,改动是刻意的
MEMORY.md智能体自己写的它学到的事实。持续增长
USER.md智能体自己写的对用户的建模。持续修正

这个拆分对「自我改进」是必要的。

如果人格和记忆混在一个文件里,那么智能体在写记忆时就有可能改到人格设定 —— 而人格是不该由智能体自己修改的

拆开之后:智能体只往 MEMORY.mdUSER.md 里写,SOUL.md 是只读的。「可自我修改的部分」和「不可自我修改的部分」有了物理边界。

2.5 会话路由:跨平台的连续性

身份路由决定「用哪个人格」,会话路由决定「接哪场对话」。

这是网关最有价值的能力之一:你在电脑上用 Slack 聊到一半,出门换成手机上的 Telegram 继续聊,对话上下文完全连续。

它是怎么做到的

关键在于"会话"的定义不绑定平台: 会话键 (session key) 由这些因素决定: · 是哪个用户(跨平台的用户身份映射) · 是哪个身份 (profile) · 在什么范围(私聊 / 某个频道 / 某个线程) 而不是: · 平台是什么(× 这个不该影响会话归属) · 用哪个客户端(× 同上) ★ 所以从 Slack 私聊和从 Telegram 私聊,只要 "同一个用户 + 同一个身份 + 都是私聊", 就命中同一个会话键 → 同一个 AIAgent 实例 → 同一段历史

相关的模块:

gateway/channel_directory.py    频道目录
gateway/pairing.py              ★ 配对(把不同平台的账号关联到同一个用户)
gateway/mirror.py               镜像
gateway/profile_routing.py      身份路由

pairing.py(配对)是这套机制的基础:系统需要知道「Slack 上的 @alice」和「Telegram 上的 alice_w」是同一个人。这通常通过一次性验证码之类的方式建立关联。

2.6 智能体实例的缓存

网关会缓存 AIAgent 实例,同一场会话复用同一个。这带来两个后果:

好处:状态自然连续

消息历史、上下文引擎的记账、压缩计数器都在实例里,不需要每次从磁盘重建。

代价:跨轮次的状态污染风险

主循环开头有一段专门处理这个问题的代码,注释说得很清楚:

「The gateway caches agents across user turns. Compression state is per-turn: carrying a prior in-place boundary forward would make a later uncompressed result look like a compacted transcript to gateway writers.」

译:网关跨用户轮次缓存智能体。而压缩状态是每轮独立的:把上一轮的就地分界点带到下一轮,会让后面一个未压缩的结果在网关写入方看来像是一份已压缩的记录。

还有一段处理配置热更新:

「Adopt any ~/.hermes/.env credential/base-url edits made since the last turn — a Settings save updates .env but not this worker's client, which was built at agent init. No-op when .env is unchanged.」

译:采纳自上一轮以来对凭据 / 服务地址所做的任何修改 —— 用户在设置界面保存时会更新 .env 文件,但不会更新这个工作进程的客户端对象(那是在智能体初始化时创建的)。如果 .env 没变,这一步是空操作。

这是长驻进程特有的问题:用户在设置界面改了 API 密钥,期待立刻生效。但智能体实例是几小时前创建的,它手里的客户端对象还用着旧密钥。所以每轮开头要检查一次配置文件有没有变。

这一章的核心结论

「长驻 + 多入口 + 多身份」这三个特性一旦叠加,就会产生一整类命令行工具不会遇到的问题:

· 状态在轮次之间要不要清?哪些清哪些留?
· 配置改了怎么热更新?
· 同一个用户从不同入口进来,算不算同一场对话?
· 一条消息该由哪个身份处理?规则冲突怎么办?
· 进程重启后,进行到一半的会话怎么恢复?

这些问题的答案构成了 gateway/ 那 99 个文件的绝大部分。