用聊天软件指挥开源自托管 Agent 项目 Hermes Agent

2026-07-30

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

**把 Agent 接到聊天软件上,真正吃工作量的不是收发消息,而是三件事:这条消息算谁发的、允不允许它进来、以及它该落进哪个会话。**先说清楚对象:这里讲的是 NousResearch/hermes-agent,一个常驻在自己机器上、MIT 许可证(LICENSE 署名 Nous Research)的开源 Agent 项目,不是 Nous Research 同名的 Hermes 开源模型系列,也不是任何同名商标或库。它的仓库里 gateway/platforms/ 放着内置适配器(微信、Signal、QQ、WhatsApp 官方云 API 都在这里),plugins/platforms/ 下是随仓库捆绑的平台插件目录(Telegram、飞书、企业微信、Discord、Slack 这些走的是插件路)。

站内已经有几篇相邻的文章,分工不同:跨平台扩展的三体对照比的是几套方案的取向差异,把 Agent 嵌进现有程序讲的是另一个项目的嵌入方式,Agent 日常运维是不挑项目的通用做法。本篇只干一件事:把这个具体项目的适配层拆开,看它把上面那三件事分别放在了哪个文件的哪个位置。

一、这一层要解决的问题:把「消息」翻译成「会话事件」

每个聊天平台的入口协议都不一样,而且差得很远。

微信这条走的是腾讯 iLink Bot API:gateway/platforms/weixin.py 里定义了 ILINK_BASE_URL 指向 https://ilinkai.weixin.qq.com,收消息靠长轮询 ilink/bot/getupdates(常量 EP_GET_UPDATES),发消息走 ilink/bot/sendmessage。它还有个别处没有的约束,文件开头的设计注记写得很直白:每条外发回复都要带上这个对话方最新的 context_token,所以项目专门做了一个磁盘落地的 ContextTokenStore 按「账号 + 对话方」缓存这个值。媒体文件不是直接下载,而是过一层 AES-128-ECB 加密的 CDN 协议。登录方式是扫码——qr_login() 把二维码打到终端里,扫完确认拿到 bot 身份和 token 写进本地文件。

Signal 完全是另一副长相。gateway/platforms/signal.py 不直接连 Signal 服务器,而是连一个你自己起在本地的 signal-cli 守护进程(文件头部写明的启动方式是 signal-cli daemon --http 127.0.0.1:8080)。入站靠 SSE 流式订阅 /api/v1/events,出站是 JSON-RPC 2.0 打到 /api/v1/rpc。连接前先 GET /api/v1/check 做健康检查,连上之后还有一个 _health_monitor() 后台任务盯着:SSE 长时间没动静就回头探一下守护进程,不健康就强制重连,重连用的是指数退避加随机抖动(注释给的理由是避免一群客户端同时回来把守护进程冲垮)。

QQ 又不一样。gateway/platforms/qqbot/adapter.py 走的是官方 QQ Bot API v2,入站是 WebSocket 网关,出站是 REST(api.sgroup.qq.com),配置用 app_idclient_secret。WhatsApp 则被拆成了两个适配器共用一个行为层:gateway/platforms/whatsapp_common.py 里的 WhatsAppBehaviorMixin 负责准入、@ 提及识别、广播过滤和 WhatsApp 风味的 markdown 转换,传输细节留给各自的适配器,官方云 API 那个是 whatsapp_cloud.py

差别这么大,收口却只有一个。所有适配器都继承 gateway/platforms/base.py 里的 BasePlatformAdapter,把平台原始报文整理成 MessageEvent,用 self.build_source(...) 拼出描述来源的 SessionSource,最后调 self.handle_message(event) 交给网关。ADDING_A_PLATFORM.md 把这几条列成了「必须遵守的模式」,同一份清单里还有:过滤自己发的消息以防回复自激、过滤平台的同步回声、日志里必须脱敏手机号和 token、流式连接必须做带抖动的指数退避重连。

组成部分它负责什么对应仓库位置你什么时候会碰到它
适配器基类定义 connect/send/send_typing 等接口,提供 build_sourcehandle_messagegateway/platforms/base.py自己写新平台,或者想知道某个行为是平台特例还是通用默认
微信适配器iLink 长轮询、context_token 缓存、加密 CDN 媒体、扫码登录gateway/platforms/weixin.py接微信号,或排查「消息没进来」「发出去被限频」
Signal 适配器连 signal-cli 守护进程,SSE 入站 + JSON-RPC 出站,表情态进度提示gateway/platforms/signal.py接 Signal,或守护进程掉线后追查重连
WhatsApp 行为混入两种传输模式共享的准入、提及、markdown 转换gateway/platforms/whatsapp_common.py一个平台要同时支持两种接入方式时的参考结构
会话键构造把来源算成一个确定的会话标识gateway/session.pybuild_session_key()出现「群里串味」或「同一个人被拆成两段记忆」
准入判定允许名单、allow-all 开关、配对授权的并集判断gateway/authz_mixin.py_is_user_authorized()陌生人给你的号发消息时想知道会发生什么
配对授权一次性配对码的生成、过期、限速与锁定gateway/pairing.py想让别人能用,但不想手抄一串用户 ID
适配器注册表平台条目的元数据与工厂,含延迟加载gateway/platform_registry.py用插件加平台,或想知道为什么启动没变慢

二、消息怎么被算进同一个会话

gateway/session.py 里的 build_session_key() 是这件事的唯一出处,函数注释自己就写着「single source of truth」。它拼出来的键长这样:agent:main:<platform>:<chat_type>:...,其中 main 是命名空间字面量,不是分支名。

有几个结论值得记住。

第一,平台标识在键里,所以不同平台的消息不会落进同一个会话。你在微信里说的话,Agent 换到 Signal 上不会当成上文——这是设计上的隔离,不是缺陷。

第二,私聊优先用 chat_id;如果某个来源没有 chat_id(非标准适配器或者合成来源),它会退到 user_id_altuser_id。函数注释解释了为什么必须有这层兜底:否则所有无 chat_id 的私聊会塌进同一个 <命名空间>:<平台>:dm 键里,一个缓存的 Agent 实例同时服务好几个人的对话,历史会互相渗透。

第三,群和线程的默认取向相反。group_sessions_per_user 默认为真,也就是群里每个人各自一个会话;thread_sessions_per_user 默认为假,也就是同一个线程里所有人共享一个会话。注释里给的理由是线程式对话的用户预期本来就是共享的。这两个开关都能从平台配置的 extra 里读到。

第四,同一个网关跑多套 profile(仓库里另有 gateway/profile_routing.py 管这件事)时,命名空间那一格会换成 profile 名,位置布局不动。默认 profile 仍然是 agent:main,注释里特意强调这是与历史上每一个键「字节一致」的,位置解析器也不受影响。

微信这条还多一步。iLink 是逐条投递的,用户转发一批消息或者手快连发几条,每条都会各自触发一次 Agent 调用。适配器为此做了防抖合并:_enqueue_text_event() 把文本先攒起来,_flush_text_batch() 等一段安静期再合并派发,而攒消息用的桶键就是 _text_batch_key()build_session_key() 算出来的同一个键——批处理边界和会话边界严格对齐。安静期有一个很短的默认值,另外还有一条特殊处理:如果最后一段文本长得像是被平台自己切开的分片,就换用一个更长的等待,免得把一句话拆成两次调用。这两个等待时长都能在平台配置的 text_batch_delay_secondstext_batch_split_delay_seconds 里改。

三、准入:三道门,不是一道

不少人以为「填个允许名单」就完事了,这个项目里实际是分层的。

第一道在适配器入口。 微信适配器有 dm_policygroup_policy 两个策略,默认值分别是 pairingdisabled——群聊默认根本不收。私聊侧还分成两个方法:_is_dm_intake_allowed() 决定这条消息要不要往下走(pairing 策略下放行,因为后面要跑配对流程),_is_dm_allowed() 才是真正的放行判断。open 策略不是白填的,它还要求 WEIXIN_ALLOW_ALL_USERSGATEWAY_ALLOW_ALL_USERS 显式打开,也就是「开放」这件事必须有人主动按两次。适配器还会用属性 enforces_own_access_policy 告诉上层:我自己在入口把门看住了。

Signal 的取向略有不同。群消息的开关直接由 SIGNAL_GROUP_ALLOWED_USERS 是否设置推导——没设就是群功能关闭,设了 * 是全开,填具体群 ID 就是白名单;还可以用 SIGNAL_REQUIRE_MENTION 要求群里必须 @ 到机器人账号才响应,并且不管有没有开这个要求,它都会把自己的 @ 从正文里剥掉,注释给的理由很具体:@+155****4567 say hello 这种文本会被模型误读成「联系这个号码」。

第二道在网关。 gateway/authz_mixin.py_is_user_authorized() 按顺序查:平台级 allow-all 开关、环境变量允许名单、配对已批准列表、全局 allow-all,最后默认拒绝。注意 Signal 适配器里那个 SIGNAL_ALLOWED_USERS 默认值是 *,但代码注释说得很清楚,它在适配器里只用来决定要不要给发信人回一个表情进度提示(因为表情是在网关鉴权之前就发出去的,不加这道判断,任何联系人发消息都会看到眼睛表情,等于暴露了「这里有个机器人在听」),真正的授权仍在网关侧单独判定。把适配器层的这个默认值当成授权配置,是很容易踩的误解。

第三道是配对。 gateway/pairing.py 提供的是「陌生人拿到一次性码、机器主人在 CLI 里批准」这套流程,替代手抄用户 ID。它的安全参数写在文件头,一条条都能对上具体的攻击面:短码取自一个去掉了 0/O 和 1/I 的无歧义字母表(防抄错),随机数走 secrets.choice()(不是普通伪随机),码有过期时间(防翻旧聊天记录捡码),每个平台的待批数量有上限(防刷屏把真实请求淹掉),同一用户要码有冷却(限速),批准失败到一定次数会锁定一段时间(防暴力猜码),数据文件 chmod 0600,码永不写进标准输出。批准之后如果这个平台本来就配了允许名单,批准动作会顺手把人写进那个名单,让运维看到的列表仍然是唯一可编辑的事实来源,而不是和一个不透明的存储各说各话。

四、加一个新平台:插件路与内置路

ADDING_A_PLATFORM.md 开门就分了两条路,而且明确推荐第三方走插件路。

插件路的做法是在 ~/.hermes/plugins/(或仓库内的 plugins/platforms/)建一个目录,放 plugin.yamladapter.py,适配器继承 BasePlatformAdapter,在 register(ctx) 入口里调 ctx.register_platform()。文档承诺这条路「零核心代码改动」,适配器创建、配置解析、用户授权、定时任务投递、发消息路由、系统提示词提示、状态展示、网关设置向导都由插件系统接过去。

注册表的形状在 gateway/platform_registry.py 里。PlatformEntry 这个数据类把一个平台需要交代的事情摆成了字段:adapter_factory 是工厂而不是裸类,注释说明了理由——让插件能做自定义初始化;check_fn 回答依赖装没装,install_hint 是装不上时给的提示;validate_config 回答配没配对,允许为空,那样就让适配器在 connect() 时报一个更具体的错。它的文档字符串里给的注册示例是这样:

from gateway.platform_registry import platform_registry, PlatformEntry

platform_registry.register(PlatformEntry(
    name="irc",
    label="IRC",
    adapter_factory=lambda cfg: IRCAdapter(cfg),
    check_fn=check_requirements,
    validate_config=lambda cfg: bool(cfg.extra.get("server")),
    required_env=["IRC_SERVER"],
    install_hint="pip install irc",
))

更值得学的是那些「边角字段」,每一个都对应一类真实的静默失效:allowed_users_envallow_all_env 让新平台接进授权判断;cron_deliver_env_var 让定时任务的投递目标不用去改调度器里的硬编码集合;standalone_sender_fn 处理定时任务与网关不在同一进程时的投递,文档直说没有它的话任务会正常触发但发送返回「没有活着的适配器」;apply_yaml_config_fn 把配置文件到环境变量的翻译交给插件自己,免得核心配置模块被每个平台的字段撑大;env_enablement_fn 让只配了环境变量的平台在状态命令里也能显示出来;还有 platform_hint,注入系统提示词告诉模型自己现在在哪个平台——文档提醒缺了这条,模型可能在不渲染 markdown 的平台上照样输出 markdown。

注册表里还有一处值得单独说:register_deferred() 的延迟加载。注释解释得很实在——平台适配器模块在模块级导入各家重量级 SDK,早期把所有捆绑的平台插件都在发现阶段加载,给每一次命令行调用都加了好几秒,包括根本不碰任何平台的纯本地对话。现在发现阶段只登记一个便宜的加载器,真正的模块等到有人查这个平台时才导入。这是个可以直接搬走的模式:注册表存名字,实体等用到再说。

内置路是另一副光景。同一份文档为核心贡献者列了 16 步清单:适配器本体、平台枚举、工厂分支、授权映射的两个字典、会话来源字段、系统提示词提示、工具集、定时投递映射、发消息工具的映射与路由、定时任务工具的参数描述、频道目录、状态展示、设置向导、日志脱敏、五处文档、测试。文档在好几处点名了漏掉的后果,比如少了定时投递映射,创建带该投递目标的任务会「静默失败」。这份清单本身就是一个提醒:内置一个平台的成本远大于写完那个适配器。

五、边界与代价:它放弃了什么

这套设计有明确不管的事,也有明确要你自己承担的代价。

它不承诺你的号能干普通账号能干的所有事。 微信适配器的 connect() 里有一段很长的警告:扫码登录连上的是一个 iLink bot 身份,通常没法被拉进普通微信群,iLink 一般也不给这类账号投递普通群事件,所以就算你把群策略打开,群消息也可能永远到不了。日志里那句话最后一行是「限制在 iLink 一侧,不在 Hermes 里」——这是维护者对能力边界的诚实交代,不是可以靠改配置绕过的东西。

它不承诺消息能改。 微信和 Signal 的适配器都把 SUPPORTS_MESSAGE_EDITING 显式设成假,注释说明了后果:流式输出只能走「只发终稿」的兜底路径,否则那个进度光标会永久留在对话里。落到体验上就是——Agent 说错了话,你只能再发一条,删不掉也改不了。

它需要一台一直开着的机器。 微信靠长轮询,Signal 靠常驻的 SSE 流,QQ 靠 WebSocket。这类连接一断,消息就只能等重连;Signal 那条还额外依赖 signal-cli 守护进程活着,它挂了适配器只能看着健康检查失败并重连。常驻这件事本身的代价,Agent 长期常驻与检查点那篇讲的是通用做法,这里的具体形态是:你多了一个必须监控的进程。

它要往磁盘写凭据。 微信扫码成功后 save_weixin_account() 把 token 和账号写进本机文件,随后尝试 chmod 0600——注意代码里那个 except OSError: pass,权限设置是尽力而为,失败了不报错。同一个目录下还会落 context_token 缓存和长轮询的同步游标。也就是说,拿到这台机器上这个目录的人,就拿到了你的会话凭据。

它对外部下载做了防护,但你要知道防的是什么。 微信媒体下载路径上有一个 CDN 主机白名单 _WEIXIN_CDN_ALLOWLIST_assert_weixin_cdn_url() 在拉取前校验协议和主机,不在名单里就拒绝,注释直接写了目的:防 SSRF。这条防护的存在本身说明了一件事——平台报文里的 URL 不能当可信输入。

它不替你处理平台账号的合规与风控。 把一个自动化程序挂到个人聊天账号上,会不会违反某个平台的用户协议、会不会触发风控,各家规则不同且会调整,以官方最新说明为准。仓库里能读到的只是技术路径,读不到任何关于「这样做安全」的承诺。

最后是最该说明白的一条:这个东西的权限半径远大于聊天。 它常驻在你的机器上、能开终端执行命令、能往磁盘写文件、能访问外部服务,仓库里 skills/ 有 14 个分类目录共 70 份 SKILL.md,optional-skills/ 有 21 个分类目录共 111 份 SKILL.md,另有 optional-mcps/ 6 个可选扩展。接上聊天软件之后,任何能给你发消息的人都站在这条链路的入口。准入不是可选项,最小权限设计在这里不是理论洁癖。

六、上手清单:会踩在哪,怎么绕

用一个不重要的账号先试。 会踩的原因是发出去改不掉:既没有编辑能力,Agent 又可能把内部推理或半成品直接说出来。怎么避——先拿测试号或「给自己发消息」这类自聊对话跑通,确认输出形态可接受再换常用号。

群聊没反应先别调代码。 会踩的原因是微信侧群策略默认关闭,而且打开之后还可能撞上 iLink 不投递普通群事件的限制。怎么避——先看启动日志里有没有那条群策略警告,它已经把结论说了;确认是平台侧限制就别再排查自己的配置。

别把适配器里的默认值当授权配置。 会踩的原因是 Signal 那个允许名单在适配器层默认 *,看上去像「全开」。怎么避——授权只认网关侧 _is_user_authorized() 的判断顺序,配置前把那段注释读完,分清「表情提示门」和「授权门」。

群里串味或记忆被切两半,先看会话键。 会踩的原因是群默认每人一个会话、线程默认全员共享,两个默认方向相反。怎么避——改开关之前先想清楚「谁应该看到谁的历史」,然后去 build_session_key() 的注释里核对你要的组合。

觉得它抢答,调防抖不调提示词。 会踩的原因是逐条投递遇上连发消息,你还没说完它就开始答。怎么避——调那两个批处理延迟;反过来觉得它反应迟钝,也是调这里。

长回复被切成好几条不是 bug。 会踩的原因是微信单条消息有长度上限(适配器用 MAX_MESSAGE_LENGTH 声明了这个上限,splits_long_messages 标记自己会拆),默认走的是紧凑模式,只有超限才按块打包,且拆分逻辑刻意保证围栏代码块不被切成两半。怎么避——想要旧的逐行拆分行为就用 split_multiline_messages 这一项显式打开,别指望默认行为符合直觉。

Signal 的语音转写可能悄悄失效。 会踩的原因是安卓端语音是裸 ADTS AAC,多数转写服务不收,项目用 _remux_aac_to_m4a() 做无损重封装,但机器上没有 ffmpeg 时它会安静地跳过,且注释明说下游没有兜底重封装。怎么避——接 Signal 之前确认 ffmpeg 在。

加平台走插件路,别改核心。 会踩的原因是内置路那 16 步里漏掉任何一步,症状往往是静默失效而不是报错。怎么避——先按插件路做,需要的边角能力用注册表里那几个可选钩子补齐。

动完就跑测试。 会踩的原因是适配层的改动会牵动授权、会话、投递多条链路,人工点几下试不出来。怎么避——仓库 tests/ 下有 2499 个 test_ 开头的测试文件,文档给的验证方式就是全量跑一遍,再用关键词全仓搜一遍找漏掉的接入点。

收束:接下来读哪个文件

如果你要判断这套东西值不值得接进自己的日常,按这个顺序读最省时间:先 gateway/platforms/ADDING_A_PLATFORM.md,它是这一层的目录和契约;再 gateway/session.pybuild_session_key(),把会话归属的规则一次看完;再 gateway/authz_mixin.py_is_user_authorized()gateway/pairing.py,把准入的三道门看清楚;最后挑你真要接的那个平台的适配器读,微信读 weixin.py,Signal 读 signal.py

接之前给自己过三个问题:这台机器出事时,Agent 手上的凭据和文件访问范围有多大;除了你自己,还有谁能给这个号发消息、他们的消息会走到哪一步;以及最坏情况下 Agent 说错了话、你既删不掉也改不了时,损失可不可以承受。前两个问题能在上面那几个文件里找到确切答案,第三个只能你自己回答。

本篇属于一个把开源常驻自托管 Agent 项目 Hermes Agent逐层拆开讲的系列,整体地图见 开源自托管 Agent 项目 Hermes Agent 是什么;沿着这条线往下,还可以看 开源自托管 Agent 项目 Hermes Agent 常驻部署与缩容开源自托管 Agent 项目 Hermes Agent 的定时任务怎么跑

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