开源自托管 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,MIT 许可证,署名 Nous Research)——不是 Nous Research 那套同名的开源模型系列,也不是其它叫 Hermes 的商标与库。它的网关(gateway)以守护进程形态长期驻留,开终端执行命令、接聊天平台连接器、跑定时任务,所以”它半夜干了什么”这个问题在它身上是硬需求。

站内已经有几篇相邻的文章:Agent 可观测与日志该记什么 讲的是通用方法论,pi 的 hooks 可观测机制 讲另一个项目怎么用挂钩点做观测,日志里的敏感信息怎么处理 讲脱敏的底线在哪。本篇不重复这些,只干一件事:把 agent/monitoring/ 这个具体目录读透,看这个项目把方法论落到了什么程度、代价压在哪里。

一、先看它把”可查”这件事切成了几块

打开 agent/monitoring/,文件不多,但分工很干净:emitter.py 是进程内的事件总线,events.py 定义了允许存在的事件形状,gateway_health.pycron_health.py 负责把运行时状态读成有界的指标与事件,otlp_exporter.pygateway_health_export.py 负责往外送,redaction.py 是所有出站字符串的唯一擦洗口,policy.py 只管一件小事——实例身份。

这个切法背后有一条明确取向,写在 agent/monitoring/__init__.py 的模块说明里:监控是一条出站路径,不是本地存储。没有订阅者的时候,事件就在环形缓冲里老化掉,不落盘、不留档。这决定了你后面所有的排查手段都得在外部收集器里做,而不是在这台机器上翻文件。

组成部分它负责什么对应仓库位置你什么时候会碰到它
事件发射器非阻塞入队 + 后台线程分发给订阅者agent/monitoring/emitter.py想加一个新的事件生产者,或怀疑事件被丢了
事件类型定义规定只有哪几种事件形状能存在agent/monitoring/events.py想往事件里加字段时
网关健康快照把运行时状态读成有界指标与生命周期事件agent/monitoring/gateway_health.py做仪表盘、判断进程与连接器状态
定时任务健康投影调度器心跳、逾期任务数、执行生命周期agent/monitoring/cron_health.py定时任务没按时跑、或跑失败要追因
导出运行时起指标提供者、日志流、快照线程,管关停顺序agent/monitoring/gateway_health_export.py配好了却什么都没收到时
跨度导出把事件映射成 OTel span,按类型白名单挑属性agent/monitoring/otlp_exporter.py想让某个字段出现在 span 上
脱敏所有出站字符串的唯一擦洗口agent/monitoring/redaction.py复核”到底会不会漏内容”
实例身份稳定、可重置的安装标识agent/monitoring/policy.py需要在集群视图里分清是哪台机器

二、事件发射:这条路上写了一条不许违反的规矩

emitter.py 的文件注释把契约写在最前面:emit() 必须在微秒级返回,不许在磁盘或网络上阻塞,也绝不许把异常抛回调用方。监控失败就是本地记一条 debug 日志然后丢掉,不允许影响网关或任何会话。

落到代码上是三个具体动作。第一,emit()queue.put_nowait,整段包在一个兜底的 except 里。第二,队列满了不是阻塞等待,而是先把最老的一条取出来丢掉、给新事件腾位置,并把丢弃数计入 _dropped——队列深度写死在模块常量 _MAX_QUEUE 上,代码注释直接把它称作环形缓冲深度,满了就丢最老的,这是内存有界的代价:极端拥塞时你丢的是历史,不是当下。第三,一个名为 hermes-monitoring-dispatch 的守护线程把队列排空成批(单批上限由另一个模块常量 _DRAIN_BATCH 定死),扇出给每个订阅者,每个订阅者单独 try/except——一个订阅者慢或抛异常,既碰不到热路径,也影响不到同伴。

有个细节值得单独说:get_emitter() 返回的单例默认是 enabled=False。也就是说,采集是选择性开启的,第一个订阅者通过 subscribe() 挂上来时才把开关打开;最后一个订阅者退订时又关回去。没配导出的用户,生产者那边就是空转,不会有任何队列开销。

调试时能用上的是 stats(),它返回 queued / dispatched / dropped / subscribers 四个数——排查”我明明发了怎么没到”的时候,先看这四个数在哪一环断掉,比盯网络抓包快得多。flush() 则用一个单独的等待线程去 join 队列,外面只等一个有界超时,不会因为分发卡住而把关停流程一起拖死。

三、健康快照与指标导出:数据是被”拉”出来的

指标这块的做法和很多项目不一样:它不是在业务代码里到处 counter.add(),而是每次采集时回读一次运行时状态。

gateway_health.py 里的 build_gateway_health_snapshot() 接受一份与网关状态文件兼容的运行时字典,输出一组 GatewayMetric。所有状态字符串都经过 _bounded_state() 收敛到闭集合里——_KNOWN_GATEWAY_STATES_KNOWN_PLATFORM_STATES 就是那两个集合,不在集合里的值统一变成 unknown。错误文本则走 classify_gateway_error(),用关键词归到 auth_failed / rate_limited / timeout / network_error / invalid_config 这类可操作的桶里。这一步是整套设计的关键:出站的不是原始错误,是错误的类别。

cron_health.py 是同一套模式的第二个实例,也是官方文档里推荐的”加新子系统就照这个抄”的样板:一个 build_cron_health_snapshot(),读调度器心跳年龄、上次成功年龄、追赶发生次数、启用与运行中的任务数,以及按调度自身的宽限规则算出的逾期任务数。任务身份不外发原值,_job_key() 取 sha256 前 24 位十六进制,加 sha256: 前缀。执行生命周期用 emit_execution_state() 发,状态收敛在 claimed / running / completed / failed / unknown 里;终端状态会顺手做一次一秒上限的 flush 尝试,代价是关停可能被多拖一秒,换来的是终局状态不易丢。

往外送的那一头在 gateway_health_export.py_start_metric_provider() 用 OTel 的可观测量表(observable gauge)注册一批指标名,每次采集回调都重新读一次快照、按名字挑出对应的观测值。指标名是一份显式列表,hermes.gateway.uphermes.gateway.active_agentshermes.platform.uphermes.cron.jobs.overdue 之类都在里面。这里有个坑后面单独讲。诊断事件走 GatewayDiagnosticLogStreamer,作为发射器的订阅者把 gateway_diagnostic 事件发成 OTLP 日志;日志正文是一个常量字符串 gateway diagnostic,信息全在有界属性上——渲染后的日志消息不导出,这是写在代码注释里的明确决定。端点也只需要配一个:_metric_endpoint()_logs_endpoint() 会从 /v1/traces 推出 /v1/metrics/v1/logs

otlp_exporter.py 里的 _span_attrs() 是 span 侧的最后一道闸:按事件类型查 keep_by_kind 白名单,没列进去的键直接丢;列进去的键,只要是字符串就先过脱敏、再按一个固定的长度上限截断(同一套「脱敏加截断」在 gateway_health.py 的诊断文本、gateway_health_export.py 的日志属性上是一样的写法,上限值都在各自模块的默认参数里写死)。凭据的处理也值得学一手——配置里存的是环境变量名,不是密钥值,_resolve_headers() 在导出时才从环境读取,值不写日志也不落配置。

四、脱敏:一次无条件的擦洗,失败时向”不发”倒

redaction.py 只有一个对外函数 redact_for_export(),模块注释开头一句话就把设计取向说清了:一次无条件的擦洗,没有模式,没有开关。

顺序是先秘密后 PII。秘密这一层包了 agent/redact.pyredact_sensitive_text(force=True)——force 的意思是用户配置无法把它关掉——再叠上 bearer 与几种令牌形状的正则兜底。最关键的一行是它的失败方向:如果这个脱敏器压根跑不起来(except 分支),返回的不是原文,而是 [redaction-unavailable]。这叫向关闭方向失败,跟发射器”失败就丢弃、绝不影响主流程”是一对互补的取向:一个宁可丢数据不肯拖慢你,另一个宁可丢内容不肯漏出去。PII 那一层把邮箱、UUID 形状的长标识、电话号码分别换成 [email][id][phone],电话的正则注释里写明是刻意保守的,为的是别把代码片段和普通 ID 一起误伤。

同一个函数被反复复用:gateway_health.pyredact_gateway_message()_safe_metric_value()gateway_health_export.py_redact_string()otlp_exporter.py_span_attrs() 都往这里收。这种”只留一个出口”的写法值得抄——脱敏最怕的不是规则不够狠,是有第二条不过闸的路。

资源属性那一层还多加了一道校验:_safe_resource_attributes() 只认一份键名白名单,值必须匹配一个保守的字符集正则,而且——这一条很聪明——如果一个值经过脱敏后发生了变化,说明它本身长得像敏感数据,那就整个丢掉,不用脱敏后的版本。实例身份则统一走 _safe_instance_id(),对安装标识做单向哈希后取前 24 位,你能在集群视图里分清是哪台机器,但拿不到原始标识。

五、边界与代价:它明确不管的那些事

这套东西的定位窄得很自觉,docs/observability/monitoring.md 里写得很直白。

它不管执行轨迹。 提示词、消息、工具参数与结果、会话历史、任务名、投递目标、调度表、原始报错、用量分析、审计日志,全都不在这个平面上。所以你别指望用它回答”那条改文件的命令具体改了什么”——它只能告诉你有一次工具调用失败了、失败归在哪个类别。想要轨迹级别的东西,得走另一条路:docs/observability/README.md 描述的观察者挂钩契约(pre_api_request / post_api_request / pre_tool_call / post_tool_call 这一组,注入字段 telemetry_schema_version = "hermes.observer.v1"),仓库里 plugins/observability/ 下的 langfuse 与 nemo_relay 就是这条路上的两个消费者。两个平面的隐私策略完全不同,别混着谈。

它不管数据落地。 前面说过,监控不做本地存储。没有配好收集器就等于什么都没有,事后想补也补不出来。

它的词汇表是封闭的,扩展有摩擦。 官方文档管这叫”固定、枚举、无内容的词汇表”,并且专门警告过一句:新加的信号如果没在每一层都登记,看起来像代码 bug,其实是词汇表注册漏了——不报错,信号就是不到。要加一个指标,你得在快照构建函数里发出来,还得在导出模块的指标名列表里登记;要加一个 span 属性,得进 keep_by_kind 白名单;错误类别要扩,得同时改集合和分类函数,只改一个的话新值会被静默收成 unknown。这份严格是隐私的代价,也是你二次开发时的真实摩擦。

观察者挂钩是只读的、失败放行的。 挂钩回调抛异常,主循环照跑,只记一条 warning。这意味着你不能靠它做强制拦截;文档也点明了审批相关的挂钩是纯观察的,插件没法替用户预先回答或否决审批——真要挡住一次工具执行,得用 pre_tool_call 的阻断返回。这个取向对可观测是对的,但如果你想的是”用监控做安全网”,方向就错了。

它对不属于自己的东西不表态。 文档明确把另一个网关服务持有的共享连接状态划在范围外,那部分该由持有方自己导出。集群视图里因此会有拼不齐的地方,这是分工问题,不是漏做。

还有一件必须如实说的:这个进程会常驻、会开终端跑命令、会连你的聊天账号、会往磁盘写、会访问外部服务。监控平面做到内容无关,降低的是”遥测把内容带出去”的风险,它一点也没有降低”这个 Agent 本身能做什么”的风险。这两件事别混。

六、上手与避坑清单

一、只配了 OTLP 端点,却什么都没收到。 为什么会踩:健康导出有两个独立开关,gateway_health_export 的启用位和 export.otlp 的启用位,判定函数要求两个都开且端点非空才算启用。怎么避:照文档那段 YAML 两个都写上,然后跑 hermes monitoring status 看一眼它自己怎么说,别靠猜。

二、依赖没装,启动却一声不响。 为什么会踩:OTel SDK 是可选附加项(hermes-agent[otlp]),代码里是懒加载,非交互启动时不弹提示;装不上就记一条 warning 然后空转,绝不把异常抛进启动流程。怎么避:启用后先确认 hermes monitoring status 里 SDK 显示为已安装;机器上禁用了懒安装的话(那个开关在安全配置里),手动装。

三、指标发出来了,后端就是查不到。 为什么会踩:这条链上有好几个”不在白名单就静默丢”的关口——导出模块的指标名列表、span 属性白名单、以及你自己那台收集器上的过滤器。文档专门说这是新指标不出现最常见的原因。怎么避:别只验证发送端,用仓库自带的两个脚本在本地把整条链走一遍——scripts/observability/otel_capture_collector.py 起一个抓包收集器,scripts/observability/gateway_health_export_probe.py 驱动真实导出器跑一轮,然后解码抓到的载荷,既确认名字在、也确认没有内容泄出。

四、进程死了没有告警。 为什么会踩:一个已经死掉的进程发不出自己的零值。文档把这一条单独拎出来讲了。怎么避:显式状态告警和序列缺失告警都要有,缺失检测的窗口要长于你配的导出间隔加收集器重试余量。

五、把并发数看错。 为什么会踩:活跃计数和后台工作计数覆盖的是不同的东西——前者是网关关停时要排空的那部分,后者专门数它不含的那些后台工作,而且两个后台指标一个按任务粒度、一个按派发单元粒度。怎么避:读一遍文档里那段对照说明再建面板,别把两个数当同义词加在一起用;关于常驻 Agent 的日常巡检该看哪些数,可以对照 Agent 日常运维要做什么

六、以为脱敏能靠配置调松。 为什么会踩:习惯了别的项目有”详细模式”。怎么避:这里没有这个开关,代码注释里写明是刻意不给的。想要更详细的诊断文本,那属于另一个尚未开放、需要独立策略把关的方向,不要在生产上自己改代码绕过去。

收束:三个自检问题

把这套设计当镜子照自己的常驻 Agent,问三句就够了:一,你的监控路径在网络不通时会不会拖慢主流程,队列满了是阻塞还是丢弃、丢的是新的还是旧的?二,出站字符串有几条路,是不是每条都过同一个擦洗口,擦洗器自己挂了会怎么办?三,你的告警能不能发现”这台机器整个不见了”,而不是只能发现它主动报错?

想继续往下读,路线很清楚:先 agent/monitoring/emitter.py 建立契约感,再 agent/monitoring/redaction.py 看脱敏取向,然后 docs/observability/monitoring.md 那节讲维护与扩展的部分——它把这套词汇表为什么必须逐层登记讲得比代码更明白,也顺带解释了为什么”加个指标”在这个仓库里不是一行代码的事。

本篇属于一个把开源常驻自托管 Agent 项目 Hermes Agent逐层拆开讲的系列,整体地图见 开源自托管 Agent 项目 Hermes Agent 是什么;沿着这条线往下,还可以看 开源自托管 Agent 项目 Hermes Agent 的 LSP 集成开源自托管 Agent 项目 Hermes Agent 如何把密钥来源做成可插拔并划定作用域

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