如果你正在维护三五套系统,跑着几个定时任务和爬虫,又不敢漏掉任何一条线上告警,那你大概率经历过这种场景:手机里塞满了钉钉群、企微群、Telegram 频道的消息,打开一看,一半是重复告警,一半是和自己无关的噪音,而真正关键的那条日志反而被淹没在信息流里。我做过一个叫 hermes-agent 的开源项目,就是为了解决这个“信息过载但关键消息永远难找”的痛点。简单说,它是一个自托管的智能消息采集、过滤与分发代理,把 RSS、GitHub 事件、监控告警、邮件等各种来源汇聚到一个入口,经过规则去重、优先级排序之后,再推送到你真正会看的即时通讯渠道。这篇文章会把从架构选型到落地部署的完整思路写出来,适合正在搭个人告警中枢或者团队消息管道的开发者参考。
1. 项目整体设计与思路拆解
1.1 hermes-agent 要解决的核心问题
在动手写第一行代码之前,我先把问题拆成了三块:来源碎片化、通知冗余化、接收被动化。来源碎片化很好理解,一个后端工程师日常要关注的东西太杂了——GitHub 上项目的 release、自己服务器的资源监控、第三方服务的状态页、测试环境的定时任务结果,这些东西分散在不同的后台和邮件里,人肉去刷不仅低效,而且一定会漏。
通知冗余化则体现在两个层面。第一是同一个事件多次推送,比如监控系统每 5 分钟探测一次,服务连续挂了半小时,告警系统可能已经刷了 6 条消息;第二是不同渠道重复触达,同一台服务器既接入了云监控又接了自建 Prometheus,两边同时告警。如果这些消息不做收敛直接推到群里,用不了半天这个群就废了。
接收被动化的意思是你只能等消息来,很难主动去查“现在系统处于什么状态”。hermes-agent 的设计目标不是做一个简单的消息转发器,而是做一层有状态的信息中枢:它要能主动轮询各类数据源,能对事件做去重和分级,能按用户定义的规则决定“这条消息值不值得打扰你”,甚至能缓存关键信息供随时查询。名字里的 Hermes 取的是希腊神话中信使的意思,agent 则强调它具备一定的自主决策能力。
1.2 架构选型:四层模型和插件化思路
整体架构我最终落地成了一个四层模型:Source(采集层)、Transformer(处理层)、Router(路由层)、Sink(出口层)。
Source 层负责连接各种外部数据源,不管是轮询式接口还是事件推送型 Webhook,统一转换成内部事件结构。Transformer 层拿到原始事件后,做清洗、格式归一、去重、富化。Router 层根据用户配置的规则决定事件的去向,可以丢弃、可以降级、可以立即推送。Sink 层则负责把最终事件渲染成不同平台的消息格式,并处理发送失败的重试。
这个模型不是拍脑袋定的,而是参考了数据管道里常见的 producer-processor-consumer 模式。在早期版本里,我试图把“过滤”和“推送”逻辑写死在同一个函数里,结果每加一个消息源就要动一遍核心代码,很快变得不可维护。改成四层模型之后,每一层只暴露一个统一的接口,Source 只管产事件,Sink 只管发消息,中间的处理逻辑完全由配置文件驱动。这意味着新增一个数据源通常只需要写一个新的 Source 插件,核心代码一行都不用改。
插件的发现机制我用了 Python 的 entry_points 方案。每个插件在自己包的 setup.py 里声明实现了哪个接口,hermes-agent 启动时通过 importlib.metadata 自动发现并加载,而不是在配置里手动写包名。这样做的好处是插件和主程序可以独立分发,团队内部可以各自维护自己的 Source 插件仓库。
1.3 技术栈选型的个人考量
技术栈选了 Python 3.10+,主要还是看中异步生态和 AI 相关库的集成便利性。核心事件循环用 asyncio 实现,所有 Source 的轮询和 Sink 的发送都是异步任务,互不阻塞。调度器用 APScheduler 的 AsyncIOScheduler,支持 cron 表达式和间隔两种模式。配置格式用 YAML,因为这类工具的用户习惯是拿 YAML 写规则,阅读门槛比 JSON 低很多。
也有人问我为什么不用 Go 或者 Node.js。Go 写这类工具确实性能和部署上有优势,但当时我计划后续要接大模型做智能摘要分类,Python 在 NLP 和 LLM 生态上几乎是零成本接驳,加上团队里其他成员更熟悉 Python,就定了这个方向。实际使用下来,单机跑几千个事件源的轮询任务,Python 的异步模型完全够用,瓶颈从来不在语言上。部署上直接用 Docker 打包,一条命令起服务,不需要在宿主机上装 Python 环境。
2. 核心细节解析与实操要点
2.1 接入层:Source 插件的设计与事件归一化
Source 插件是 hermes-agent 里最值得花时间设计的地方。我定义了一个统一的事件结构 SourceEvent,包含七个字段。
@dataclass class SourceEvent: source: str # 来源标识,比如 "github_release" event_type: str # 事件类型,比如 "release", "issue", "alert" title: str # 一句话标题,用于推送展示 content: str # 详细内容,支持 Markdown priority: int # 原始优先级 0-100,0 最低 occurred_at: datetime # 事件发生时间,UTC extra: dict # 扩展字段,各 Source 自行塞数据所有 Source 插件最终都要产出一个 SourceEvent 列表,而不是直接把原始数据丢给下游。这样做的目的很直接——Transformer 层处理逻辑不需要关心上游是 GitHub 还是企业微信机器人,只要按 SourceEvent 的字段做规则判断就行。
目前内置的 Source 覆盖了不少常见场景,我已经在测试环境跑了挺久,稳定性比较有保证:
| Source 类型 | 数据来源 | 典型用途 | 轮询方式 |
|---|---|---|---|
| RSS / Atom | 任意 RSS 订阅 | 博客更新、新闻、发行公告 | 定时轮询 |
| GitHub | GitHub API | Release、Issue、PR、Star 事件 | 定时轮询 |
| IMAP 邮件 | 任意邮件服务器 | 工单邮件、异常通知 | 定时轮询 |
| Prometheus | Alertmanager Webhook | 监控告警接入 | Webhook 推送 |
| HTTP 探针 | 自实现检测 | 站点存活、SSL 证书剩余天数 | 定时轮询 |
| 自定义脚本 | stdout 输出 | 内网监控、定时任务结果 | 定时执行 |
Webhook 类的 Source 要特别说明一下。它和轮询式 Source 的接口不一样,不是定时触发,而是暴露一个 HTTP 端点让外部系统主动推数据进来。我在框架层把这两类统一处理:Webhook 收到请求后把 payload 包装成 SourceEvent,然后塞进同一个事件管道。这样处理逻辑就可以完全复用。
实操里最容易忽略的是轮询去重。RSS 源每次拉取都会返回最近 N 条记录,如果不做游标管理,同一个条目会被重复处理。所以在每个轮询 Source 内部,我维护了一个 last_fetch_time,并且对每条记录计算 hash 存入 Redis 或者本地 sqlite,只有 hash 不存在的事件才会进入管道。这套机制不复杂,但能挡住 90% 的重复消息问题。
2.2 处理层:事件去重、规则过滤与优先级收敛
事件从 Source 出来之后,第一站是去重模块。除了刚才说的轮询去重,这里还有一层“全局事件去重”。什么意思?比如同一个 Prometheus 告警在 5 分钟内重复推送了三次,虽然每次时间戳不一样,但 alertname 和 instance 两个字段相同,就应该判定为同一条事件。我采用的方案是设计一个指纹函数:
def generate_fingerprint(event: SourceEvent) -> str: key = event.event_type + "|" + event.title # 对于告警类事件,用 title 加关键标签字段做指纹 if event.event_type in ("alert", "webhook"): labels = event.extra.get("labels", {}) sorted_items = sorted(labels.items()) key += "|" + json.dumps(sorted_items, ensure_ascii=False) return hashlib.sha256(key.encode("utf-8")).hexdigest()指纹算出来后,在 Redis 里存一个带过期时间的 key。默认过期时间是 6 小时,意味着同一个指纹 6 小时内不会重复进入后续管道。这个时间可以按场景调,比如故障告警希望收敛窗口长一点,普通 RSS 更新就不需要那么严格。
规则过滤是 Router 层的核心。我用的是“规则链”设计,每条规则有四个要素:条件、动作、优先级覆盖、目标 Sink。这里的条件语法我参考了类 PromQL 的写法,简单场景用关键词匹配和正则,复杂场景支持表达式组合。比如一条规则可以写成“如果 event_type 是 alert 且 priority 大于 70,则推送到线上告警群并标记为紧急”。
优先级收敛处理的是“告警风暴”问题。监控系统通常有 Warning 和 Critical 两个级别,如果某个服务在降级边缘反复横跳,Warning 级别的消息会不断产生。我加了一个状态机机制:当一个事件源在短时间内连续产生多条非 Critical 事件,自动进入“抑制模式”,在这期间只发第一条和最新的那一条,中间全部折叠。这个设计参考了 Prometheus Alertmanager 的 inhibition 逻辑,但实现上简化了很多。
2.3 出口层:Sink 多路推送和消息模板渲染
Sink 层要做的事情其实是“适配”。每个平台的消息格式、限制、认证方式都不同,如果在上游统一拼好 Markdown 再发,到了钉钉、企微、Telegram 上大概率会出现格式错乱。所以我在 Sink 层引入了模板渲染机制,每个 Sink 插件维护自己的一组 Jinja2 模板。
以发送一条告警为例,消息管道里流动的是一个结构化的 EventPayload,包含标题、内容、优先级、触达时间等字段。当 Router 决定把它发送到某个 Sink 时,Sink 插件会取出对应模板,用 payload 渲染出该平台专属的消息格式,然后调用 API 发送。比如钉钉机器人要求 Markdown 格式的 content,Telegram 的 sendMessage 则要求纯文本加 parse_mode。
sinks: - name: work_alert type: dingtalk webhook_url: "https://oapi.dingtalk.com/robot/send?access_token=xxx" secret: "SECxxx" template: | ### {{ payload.title }} **等级**: {{ payload.level }} **时间**: {{ payload.occurred_at }} {{ payload.content }} - name: tg_log type: telegram bot_token: "123456:ABC..." chat_id: "-1001234567890" template: | *{{ payload.title }}* {{ payload.content }}发送可靠性是 Sink 层容易被忽视的点。HTTP 推送有不确定性,网络抖动、平台限流、Token 过期都可能导致失败。我实现了一个带指数退避的重试机制,初始等待 1 秒,最大重试 5 次,间隔依次翻倍。超过重试次数后消息放入失败队列,由管理员手动处理或通过备用 Sink 发送。不同平台的重试策略也略有差异,钉钉和企微对调用频率有限制,Telegram 相对宽松,这些参数我都做成可配置项,默认值则是反复压测后调出来的。
3. 实操过程与核心环节实现
3.1 从零到跑通最小闭环
用官方 Docker 镜像部署 hermes-agent 只需要三步。第一步准备配置文件,最小配置只需要声明一个 Source 和一个 Sink,就能构成一条消息管道。第二步启动容器,第三步往 Source 端丢一条测试事件验证链路。
我的建议是从 HTTP 探针 Source 开始做第一个实验,因为它不需要依赖外部系统,能很快看到效果。假设你想监控自己的博客站点是否在线,配置如下:
sources: - name: blog_probe type: http_probe interval: 60 targets: - url: "https://example.com" expected_status: 200 rules: - name: blog_down when: 'event_type == "probe_fail"' action: push sink: tg_log sinks: - name: tg_log type: telegram bot_token: "123456:ABC..." chat_id: "-1001234567890"这个配置的意思是:每 60 秒请求一次 example.com,如果响应状态码不是 200,产生一个 probe_fail 事件,命中 blog_down 规则,推送到 Telegram 群。配置保存为 config.yaml 后执行:
docker run -d --name hermes-agent \ -v $(pwd)/config.yaml:/app/config.yaml \ -v hermes-data:/app/data \ -e TZ=Asia/Shanghai \ hermes-agent:latest容器跑起来之后,把 example.com 换成你真实的服务地址,或者临时停掉一个本地服务,看看 Telegram 能不能在 1 分钟内收到告警。这一步通了,整个项目的基本链路就走通了。
3.2 一个完整场景:监控 SSL 证书并通知钉钉群
证书过期是运维里最容易踩的坑,我把它做成一个完整 example。场景需求是:每天上午 10 点检查所有域名的 SSL 证书剩余天数,少于 30 天发提醒,少于 7 天发紧急告警。
这里用到一个新的 Source 类型 ssl_check。它内部会建立 TCP 连接并进行 TLS 握手,提取证书的 notAfter 字段,算出剩余天数,然后生成 SourceEvent。事件内容里带上剩余天数,规则层根据阈值判断优先级。
sources: - name: ssl_monitor type: ssl_check cron: "0 10 * * *" targets: - domain: "api.example.com" port: 443 - domain: "admin.example.org" port: 443 rules: - name: ssl_expire_urgent when: 'event_type == "ssl_expiring" and event.extra.days_left < 7' action: push priority_override: 90 sink: work_alert - name: ssl_expire_warning when: 'event_type == "ssl_expiring" and event.extra.days_left < 30' action: push priority_override: 60 sink: work_alert sinks: - name: work_alert type: dingtalk webhook_url: "https://oapi.dingtalk.com/robot/send?access_token=xxx" secret: "SECxxx"这里有三个实操要点。第一个是 cron 表达式的时区问题,我踩过坑。容器默认是 UTC 时间,如果你写0 10 * * *那么实际上是北京时间下午 6 点执行。解决方式是在 docker run 时加-e TZ=Asia/Shanghai,并且确保系统内所有时间操作基于同一时区。第二个是证书检测的超时设置,域名解析失败或者端口不通时不能卡住整个任务,我设了timeout: 10秒,超时则记录为错误事件但不阻塞后续域名检查。第三个是消息内容的可读性,不要只推送“证书即将过期”六个字,要把域名、剩余天数、到期日期都带上,否则收到消息后还要去查一遍是哪个域名,就失去了告警的意义。
3.3 五分钟写一个自定义插件
如果你觉得内置 Source 不够用,或者需要对接某个只有内部 API 的系统,自定义插件是绕不开的。hermes-agent 的插件接口设计得很克制,实现一个 Source 插件实际上只要求定义一个类和两个方法。
from hermes_agent.sdk import SourcePlugin, SourceEvent, register_source @register_source("my_status") class MyStatusSource(SourcePlugin): async def fetch(self, context): # 这里是核心采集逻辑,context 里包含了你在 yaml 里配的参数 endpoint = self.config.get("endpoint") async with context.http_session.get(endpoint) as resp: data = await resp.json() events = [] for item in data.get("items", []): events.append(SourceEvent( source="my_status", event_type=item["type"], title=item["title"], content=item.get("detail", ""), priority=item.get("priority", 50), occurred_at=datetime.now(timezone.utc), extra={"item_id": item["id"]}, )) return events写完这个类之后,把它放到plugins/目录下,再在配置文件的 plugins 节点里声明启用。注意 fetch 方法是异步的,如果插件里有耗时的 IO 操作,不要在方法内部用同步代码阻塞,要使用context.http_session这个复用连接池的会话对象。
自定义插件带来的灵活性非常大。你可以用它对接公司的工单系统、旧版监控平台的开放接口、甚至去查某个电商网站的库存状态。我在实际使用中写了大概 8 个内部插件,每写一个都是沿用同样的套路:请求接口、解析字段、组装 SourceEvent。骨架代码完全一样,只需要改数据解析和字段映射部分。
4. 常见问题与排查技巧实录
4.1 五个高频问题速查表
运行了一段时间之后,我整理了一份问题清单,这几个问题在 GitHub 的 issue 区也经常被问到。把它们列成一张表格方便对照:
| 问题现象 | 根因分析 | 解决方案 |
|---|---|---|
| 任务执行时间比预期晚 8 小时 | 容器内时区为 UTC,cron 按本地时间理解 | 设置TZ=Asia/Shanghai环境变量,或在 cron 里显式写时区 |
| 同一个告警反复推送 | 指纹去重未生效,Redis 未正确连接 | 检查 Redis 配置,确认去重 key 的过期时间设置 |
| 消息发不出去,报 429 错误 | 触发平台限流,发送频率太高 | 调整 Sink 的max_retries和退避间隔,或加全局发送限速 |
| 某个 Source 挂掉后所有任务停止 | 插件抛出的异常未捕获,导致事件循环退出 | 框架层增加兜底异常捕获,单个 Source 失败不影响其他任务 |
| 内存占用持续增长 | Health check 类任务频繁创建 HTTP 连接未释放 | 统一使用框架提供的连接池,禁止在插件里自己创建 Session |
第一个时区问题尤其隐蔽,因为你在本地跑没问题,一旦部署到 Docker 容器里就出现“延迟 8 小时”的假象。排查方法很简单,进容器执行date看系统时间,如果不是预期时区就在 docker run 命令里加环境变量。第二个问题需要先确认 Redis 是否连上了,有些部署方式下 Redis 和 hermes-agent 不在同一个网络,用的是 localhost 导致连接失败,去重功能会静默降级为“不去重”。
4.2 排查链路和调试技巧
消息没有按预期推送的时候,第一步不是改代码,而是看日志。hermes-agent 的日志分四个等级,默认配置会打印 INFO 级别以上。我强烈建议在调试阶段开启 DEBUG 模式,它会把每条事件的完整流转过程打出来——哪一步被哪个规则拦截了、为什么拦截、最终进了哪个 Sink,全部一目了然。
一个更高效的排查方式是配置 dry-run 模式。在这个模式下,事件会走完整个管道流程,但到 Sink 层只打印“将要发送到 XXX 的内容”,不真正调用外部 API。结合一个固定的示例事件,你可以在上线新规则之前快速验证规则逻辑是否正确。
docker exec -it hermes-agent hermes-agent --dry-run --event-file sample_event.json这个命令会用 sample_event.json 里的内容模拟一条用户指定的 SourceEvent,跑一遍 Transformer 和 Router,输出每条命中的规则和渲染后的推送结果。我在调整告警阈值和关键词过滤规则时几乎每次都先用 dry-run 验证,确认没有问题再重启服务更新正式配置。另外提一句,配置文件的 YAML 缩进错误也是高频低级错误,建议加了新配置之后用hermes-agent --check-config做一次语法验证。
4.3 健壮性设计上的三个建议
第一,所有外部依赖都要有降级方案。Redis 挂了不能让服务直接不可用,我在代码里实现了内存缓存作为降级,Redis 断开时去重功能退化为进程内去重,虽然跨实例效果差一点,但能保证消息不丢。
第二,不要把 Secret 写在配置里。GitHub 上搜索一下就会发现大量泄露的钉钉 token 和 Telegram bot token,一旦泄露,别人就能往你的群里发消息。正确做法是用环境变量或独立的.env文件管理凭证,配置文件里只写变量名。
第三,给每个 Sink 设置消息上限。某个瞬间如果产生了上千条事件,不要真的把上千条消息全部推到群里,这会直接被平台封禁。我在 Sink 层加了一个 aggregation 功能,超过一定数量时把多条事件合并成一条汇总消息,列出前 10 条和剩余条数。实际体验下来,这个设计比“全量推送”有用得多,因为人根本看不过来 1000 条告警。
5. 从 hermes-agent 到个人信息中台
5.1 进阶玩法:接入 LLM 做智能提炼
消息管道稳定跑起来之后,我开始考虑一个更“聪明”的需求:能不能让 agent 不只是转发消息,而是真正帮我理解消息的含义、判断哪些值得看?这就涉及 LLM 的接入。
我在 Transformer 层做了一个可选组件,叫llm_enhancer。它的工作方式是:当一条事件被判定为需要推送时,先不直接发,而是把事件内容交给 LLM 做一次浓缩摘要,再把摘要结果作为推送正文。比如一个 Kubernetes 集群异常事件,原始 Prometheus 告警信息可能又长又晦涩,里面有大量指标数值,人眼扫过去很难快速定位根因。LLM 可以把它提炼成“节点 node-2 内存使用率连续 15 分钟超过 95%,可能导致 Pod 被驱逐,建议检查内存占用最高的容器”。
当然这个能力有成本,不是每条消息都值得调用大模型。我在配置里加了策略控制,只有 priority 大于 80 的事件才触发 LLM 增强,其余事件直接走原始内容推送。接入方式也很常规,OpenAI 兼容接口都可以,通过环境变量配置 API 地址和 Key。预算上也可以完全不花这份钱,把 LLM 功能关掉,纯规则引擎已经足够覆盖绝大多数告警场景。
5.2 数据隐私和权限控制
自托管工具最大的优势就是数据不会经过第三方,所有消息都会直接推送到你自己的服务器和群。但仍有两个敏感点需要提醒:Source 插件在采集外部系统数据时,可能拿到一些本不应该被采集的字段,比如 GitHub 事件里联系人邮箱、内部工单系统的详情。我的做法是在配置里加了一个pii_filter选项,默认开启,对事件 payload 中的邮箱、手机号等个人信息做正则脱敏,确保这类数据不会通过 Sink 暴露到外部群组。
另一个是 Token 的权限最小化。创建机器人账号或者 API Token 时,只授予它必要的最小权限。比如只读仓库、只读告警、只发送消息,不要图省事直接赋予管理员权限。一旦 Token 泄露,攻击者能做的事情会小很多。配置文件的权限也要注意,包含 Token 的文件建议设置为仅当前用户可读写,不要提交到 Git 仓库。
5.3 后续扩展方向
目前 hermes-agent 的核心管线已经比较稳定,我还在持续打磨几个方向。一个是规则引擎的升级,计划支持更复杂的告警关联分析——把多个事件源的事件聚合在一起来分析根因,比如“API 网关 5xx 数量激增”和“数据库慢查询增多”两条事件可能出自同一个根因,把它们合并成一条复合告警推送给值班人员。另一个方向是支持多实例部署,通过共享 Redis 实现去重和规则的全局一致性,这样即使某个实例挂了,其他实例也能接管任务,不会丢失通知。
这个项目做到现在,最大的收获其实不是代码本身,而是把“消息管道”这件事想清楚了。告警和通知的本质不是“发得越多越好”,而是“到得越准确越好”,与其等消息来,不如设计一个能分辨轻重缓急的信使。这大概就是手写一个消息代理,和学习别人现成方案之间最大的差别——你踩过所有坑之后,才真正知道哪些设计是必要的,哪些是多余的。