这一章讲两件相关但不同的事:一条消息属于「哪个智能体身份」,以及属于「哪一场对话」。
「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 记忆数据库 |
| 网关进程 | 可以有自己的网关实例 |
| 定时任务 | 自己的任务清单 |
典型场景:
关键在于「一个进程」。如果每个身份都要跑一个独立进程,那么内存占用、部署复杂度、定时任务的协调都会变成问题。
路由规则写在配置文件里:
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).」
译:所有配置的判别条件都是「与」关系:路由声明的每一个条件都必须成立。……一条同时声明了服务器和频道的路由,要求两者都匹配(光频道匹配不能满足服务器约束)。
那个 parent_chat_id 分支处理的是 Discord 特有的层级结构:
这个细节体现的是「抽象要贴合真实世界的结构」。
如果只做扁平的「频道 ID 精确匹配」,用户会遇到一个非常困惑的行为:在频道里说话是一个身份,在这个频道开个线程说话就换成默认身份了。而用户的心智模型里,线程明显属于那个频道。
抽象和用户心智模型不一致时,用户会认为是 bug —— 即使代码完全按设计工作。
class ProfileRouteRejected(RuntimeError):
"""An explicit route matched a profile this gateway does not serve."""
译:一条显式路由匹配到了一个「本网关不服务」的身份。
场景是:你可以跑多个网关进程,每个只服务一部分身份(比如为了资源隔离)。这时一条消息可能匹配到一个「不归我管」的身份 —— 这不是错误配置,只是这条消息该由另一个网关处理。
用一个专门的异常类型而不是「静默回落到默认身份」,是正确的选择 —— 因为静默回落会导致消息被错误的身份处理,而用户很难发现。
每个身份有一个 SOUL.md。它和系统提示词的关系是:
这个分层和 Claude Code 的 CLAUDE.md 是同类东西,但有一个重要区别:Hermes 把「人格」和「记忆」「用户认知」拆成了三个独立文件。
| 文件 | 谁写的 | 内容性质 |
|---|---|---|
SOUL.md | 人写的 | 身份设定。基本不变,改动是刻意的 |
MEMORY.md | 智能体自己写的 | 它学到的事实。持续增长 |
USER.md | 智能体自己写的 | 对用户的建模。持续修正 |
这个拆分对「自我改进」是必要的。
如果人格和记忆混在一个文件里,那么智能体在写记忆时就有可能改到人格设定 —— 而人格是不该由智能体自己修改的。
拆开之后:智能体只往 MEMORY.md 和 USER.md 里写,SOUL.md 是只读的。「可自我修改的部分」和「不可自我修改的部分」有了物理边界。
身份路由决定「用哪个人格」,会话路由决定「接哪场对话」。
这是网关最有价值的能力之一:你在电脑上用 Slack 聊到一半,出门换成手机上的 Telegram 继续聊,对话上下文完全连续。
相关的模块:
gateway/channel_directory.py 频道目录
gateway/pairing.py ★ 配对(把不同平台的账号关联到同一个用户)
gateway/mirror.py 镜像
gateway/profile_routing.py 身份路由
pairing.py(配对)是这套机制的基础:系统需要知道「Slack 上的 @alice」和「Telegram 上的 alice_w」是同一个人。这通常通过一次性验证码之类的方式建立关联。
网关会缓存 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 个文件的绝大部分。