NeMo Relay 共享指标
在 Relay 发布了原生 wheel 的平台上,Hermes 将 NeMo Relay 作为常规运行时依赖引入。共享指标集成内置于 Hermes 中,不需要 Hermes 可观测性插件。在其他原生目标平台上,Hermes 仍可在没有 Relay 的情况下导入。这些目标平台使用一个显式的降级能力空操作宿主:
Hermes 执行仍然可用,但 Relay scopes、中间件、插件和订阅者不可用。hermes-agent[nemo-relay] extra 仍作为现有安装命令的空操作兼容别名保留。
[!WARNING] 这会移除 Hermes 的
observability/nemo_relay插件。现有用户必须从plugins.enabled中移除observability/nemo_relay(或其旧版nemo_relay别名),并将 exporter 配置迁移到由HERMES_NEMO_RELAY_PLUGINS_TOML选定的 Relayplugins.toml中。旧版HERMES_NEMO_RELAY_ATOF_*和HERMES_NEMO_RELAY_ATIF_*变量不再激活 exporter。若未设置新变量,Hermes 不会运行 Relay 插件发现、配置分层、中间件或 exporter。
Hermes 要求 0.8 发布线中的 NeMo Relay 0.8.3 或更高版本。该发布线提供了 Hermes 用于托管服务商和工具调用的 provider-codec 与规范化工具结果契约。
运行时依赖与数据边界
Hermes 从限定的 >=0.8.3,<0.9 依赖范围安装平台特定的 nemo-relay 原生 wheel。发布的软件包构建自 NVIDIA NeMo Relay 仓库。不受支持的平台使用上文所述的显式空操作运行时,而不是下载不同的实现。
运维人员提供的类型化原生插件必须针对 Relay 0.8 重新构建。当 grpc-v1 worker 使用工具回调、工具执行拦截或手动工具结束 API 时,必须重新生成并重新构建。
当 Relay 托管执行处于活动状态时,服务商请求和响应会在 Hermes 进程中经过该原生模块,以便配置的拦截器能够针对真实调用生效。这与共享指标数据契约是分开的。共享指标模式不会安装任何富可观测性网络 exporter,其订阅者
仅接受下文所述的带版本、经允许列表过滤的投影。附录 A 中描述的选入式包发送器是唯一的出站路径,除非用户同时启用 enabled 和 send,否则它不会传输任何内容,并且它发送的是整包,而非实时 span。启用单独配置的富可观测性或动态插件可能会创建不同的数据路径,需要其自身进行策略审查。
除非 Hermes 策略启用收集,否则收集保持关闭:
telemetry:
shared_metrics:
enabled: true
此选择从配置档自身的 config.yaml 读取。机器管理的配置覆盖层不能代表配置档启用或禁用共享指标。
Relay 插件的激活由原生运行时掌控,并且仍然是显式选入的。将 HERMES_NEMO_RELAY_PLUGINS_TOML 设置为选定的 plugins.toml 以激活配置的中间件、exporter 或动态插件。当该变量未设置时,Hermes 不会调用 Relay 的插件初始化器,因此 Relay 不会执行插件配置发现或分层。当它已设置并且所选文件加载成功时,Relay 会发现受支持的用户和系统 plugins.toml 文件,并将所选静态配置分层叠加其上。仓库本地的 .nemo-relay/plugins.toml 文件会被忽略。动态 [[plugins.dynamic]] 记录仅从所选文件加载。如果所选文件无法加载,Hermes 会报告该错误,并且不会调用 Relay 初始化,也不会回退到环境发现。
持续会话的会话 Span 分段
Relay 在其 scope 关闭时导出一个 span。持续运行的网关会话可能保持打开数天,因此其会话 span 即使每个轮次 span 正常导出也仍然保持打开。可选分段仅在轮次边界轮换会话 scope:
gateway:
telemetry:
session_segments:
on_compaction: false # 在上下文压缩后轮换
max_turns: 0 # 0 = 无限制;N = 每段轮次数
| 键 | 默认值 | 行为 |
|---|---|---|
on_compaction | false | 在压缩完成后、下一个轮次边界处轮换。 |
max_turns | 0 | 每完成 N 个轮次后轮换;0 禁用上限。 |
两个默认值都会在整个会话期间保留单个会话 scope。轮换后的 span 保留相同的 session_id,并添加 hermes.session.segment 以及 hermes.session.segment_reason(compaction 或 max_turns)。
进程级插件策略与配置档隔离
Relay 插件配置是进程级的部署选择,而不是 Hermes 配置档设置。第一个被托管的配置档会触发惰性初始化,该 Hermes 进程托管的每个后续配置档都会共享由此产生的静态中间件、动态插件、订阅者、导出器和护栏策略。初始化成功后,Hermes 会记录日志:
Relay plugins are active process-wide and apply to all profiles hosted by this Hermes process.
配置档作用域仍可在该共享策略内保持因果隔离。ATIF 按事件的顶层 Agent 作用域分组,因此并发的配置档会话会产生各自独立的轨迹,而不是混合为一条轨迹。ATOF 及其他全局订阅者会观察来自所有被托管配置档的事件。静态与动态中间件同样会为该进程中每个配置档的受管调用运行。
运行在独立工作进程中的 worker 插件不会创建按配置档划分的安全边界。一次进程级激活会将来自所有被托管配置档的调用分派给该 worker,同时保留发起调用的配置档的 Relay 作用域栈。原生动态插件会被加载到 Hermes 进程中,并共享同一策略边界。
当不同配置档需要不同的信任级别、插件凭据、导出器目标或护栏策略时,请将它们运行在各自的 Hermes 进程中。此进程级插件契约不会改变每个配置档各自独立的共享指标同意、本地 SQLite 状态或 ATIF 轨迹分组。
Hermes 核心为每个 Hermes 会话拥有一个 Relay 宿主和一个隔离的 Relay 会话作用域。核心生命周期生产者使用 agent.relay_runtime 获取共享会话句柄,或在该会话上下文中运行 Relay scope、LLM、tool 和 mark API。新的产品 mark 无需 Hermes 插件注册。共享指标 mark 仍必须只包含版本化允许列表批准的字段;这一硬依赖不改变采集或隐私策略。
当前切片
当前垂直切片记录化名化的配置档活动、逻辑模型调用、顶层任务运行、工具与审批结果,以及技能生命周期与复用:
Hermes turn, API, tool, and approval hooks
-> Relay session, task, LLM, tool, and mark lifecycle
-> Hermes shared-metrics subscriber
-> SQLite counters
-> immutable JSON delta package
Hermes 会向由 metrics 拥有的生命周期发送一个空的 LLMRequest。这并不描述上文已记录的原生运行时的独立受管执行调用。终端 metrics 事件包含 Hermes 用于该逻辑调用的模型标识符和服务商路由,例如通过 openrouter 使用 nvidia/nemotron-3-ultra。这些标识符会被转为小写并做结构化限界,但不会通过签入的模型目录进行规范化。定价和模型家族分类属于 metrics 后端。提示词、响应、端点、错误、会话 ID、任务 ID 和请求 ID 不会包含在 metrics 事件或包中。
New calls use hermes.model_route.count. The previous
hermes.model_call.count contract remains readable only so pending local
counters created by older builds can be exported without losing data.
首次获得同意的会话开始会发出一个空的 hermes.client.active Relay mark。按配置档作用域绑定的订阅者会创建一个随机 UUID 安装身份,并使用事务式比较并设置(compare-and-set),在任意滚动 24 小时窗口内至多记录一个客户端活跃计数器。该指标没有任何维度;Hermes 版本、操作系统家族、架构和安装方式保持在受限的包资源中。并发的 Hermes 进程共享 SQLite 闩锁,因此同时启动不会对同一次安装重复计数。后续会话或任务可以再次尝试该 mark,但订阅者会将其抑制,直到滚动窗口过期。
每次任务运行都是一个名为 hermes.task_run 的 Relay Function 作用域,其父级为拥有它的 Hermes 会话。起始计数器仅包含受限的执行表面和入口点值。终端计数器包含受限的结果、结束原因、终止状态、持续时间、逻辑模型调用次数、终端工具调用次数以及服务商重试次数分桶。重试是针对同一 Hermes API 请求 ID 的额外服务商尝试;它们不会抬高逻辑模型调用计数。工具调用在观察到终端工具结果后按 Hermes 工具调用 ID 去重。外层 AIAgent 执行边界会在正常返回、提前返回、异常和取消时关闭该任务。如果 Hermes 在上下文压缩期间轮换其对话会话,活跃任务归属会跟随任务 ID。
每次工具调用由一个名为 hermes.tool_call 的 Relay 工具生命周期表示。终端计数器仅包含受限的工具类别、结果、审批结果、延迟以及显式重试次数分桶。Hermes 从其运行时注册表中已声明的工具集推导类别;自定义和无法识别的工具集会折叠为 other,而不是导出工具或插件名称。Hermes 不会从重复的工具名称或相邻调用推断重试;当 hook 未提供显式的重试关系时,重试分桶为 unknown。审批决定会以 hermes.tool_approval mark 形式发出,并记录为归属于某个工具调用,或显式标记为 unattributed。工具名称、调用 ID、参数、结果、命令、描述和错误文本不会包含在共享指标事件或包中。已启动但在其任务终止时仍未关闭的工具会以失败、超时或取消状态关闭,并且仍计入该任务的工具计数分桶。
成功的技能变更会发出 hermes.skill.lifecycle mark,其中仅包含受限动作与来源。成功加载会发出 hermes.skill.load mark,其中包含受限来源、首次使用或复用状态、补丁后复用状态以及使用次数分桶。Hermes 会在其既有的 skills/.usage.json 状态中以事务方式推导复用与补丁生成连续性;技能名称以及精确次数或代次绝不会进入 Relay metrics 事件、SQLite 维度或包中。新补丁后的使用会计为一次 reused_after_patch;后续使用在再次打补丁前仍为普通复用。补丁后的任务结果归因仍待其窗口和多方技能语义定义后再处理。
本地状态写入位于以下路径:
$HERMES_HOME/telemetry/shared_metrics/metrics.sqlite3
$HERMES_HOME/telemetry/shared_metrics/outbox/*.json
数据库保存事务式聚合状态和包发件箱状态。包文件是不可变的增量文档,遵循封闭的 JSON schema,并以原子替换方式写入。每个包都会将 Hermes 版本、操作系统家族、架构和安装方式记录为受受限的客户端资源。无法识别的平台或安装值会导出为 unknown;原始平台字符串、主机名和路径绝不会包含在内。已完全打包的聚合行以及成功导出的包行和文件会在本地保留 30 天。具有未导出增量的待处理包行和计数器绝不会被清除。
Package schema v1 对现有发件箱文件保持不变。新包使用 v2,它同时接受已停用的 model-call 契约和当前的 model-route 契约,以便升级时能够安全清空待处理计数器。
每个包都包含一个作为随机 UUID 生成的 install_id。尽管 schema 字段名称如此,它当前的作用域是一个 HERMES_HOME,因此更准确地说,它是一个持久化的化名化配置档标识符。它不源自硬件、账户、主机、路径或凭据数据。它在该配置档产出的各包之间保持稳定,因此可以关联这些本地包。删除 $HERMES_HOME/telemetry/shared_metrics 会将该标识符连同所有聚合数据和包文件一起重置。
远程投递是可选的,默认关闭。在远程重用这一持久本地标识符,需要一项单独的产品与隐私决策,涵盖同意、身份作用域、重置行为、保留和删除——该决策已经作出。
这些决策记录在 附录 A 中,实施这些决策的导出器已经发布。仅采集本身仍然不会传输任何数据:只有同时启用
telemetry.shared_metrics.send时,发送端才会运行。每个传输的包都会按原样携带稳定的install_id(产品决策,2026-08-27——参见 A.2 的记录,其中包括已被取代的 HMAC-化名设计)。
安装身份的作用域为一个 HERMES_HOME。若要重置它,请停止 Hermes 进程并移除 $HERMES_HOME/telemetry/shared_metrics。这会故意将旧身份、聚合数据库和排队的本地包一并移除;下一个获得同意的会话会创建新的身份。禁用共享指标会停止新的采集,但不会静默删除先前已采集的本地状态。
冒烟测试
针对确定性的本地模型服务器,运行一次真实的 Hermes CLI 回合:
./.venv/bin/python scripts/smoke_nemo_relay_shared_metrics.py
该脚本默认使用已安装的 nemo-relay 依赖。只有在测试本地构建的 Relay 绑定时,才传入
--relay-python ../nemo-relay/python。
该冒烟测试会让本地模型在给出最终响应前发起一次真实的 read_file 工具调用,然后通过已安装的 Relay 绑定驱动创建、加载、复用、补丁、编辑、过期、归档、恢复和安装技能转换。它验证 SQLite 中的 model、provider、task、tool 和 skill 计数器,针对闭 schema 校验所有导出的 delta 包,验证化名客户端活跃计数器,并检查包中不存在 prompt、response、tool-call ID、tool-result 和 skill-name 金丝雀值。
附录 A:远程导出器决策(阶段 2)
状态:已实现。 本附录回答了“当前切片”会推迟到未来远程导出器的产品与隐私问题。它记录了已决定的内容及其原因,以便这些推理在实现中得以保留。
发送默认关闭,需要同时启用 telemetry.shared_metrics.enabled 和 telemetry.shared_metrics.send。
导出器将已写入 $HERMES_HOME/telemetry/shared_metrics/outbox/ 的包文件发送至 Hermes 遥测摄取服务。该服务仅校验信封(schema_version 加上 UUID package_id),并将正文原样存储到 S3。
A.1 同意
传输是与收集独立的 opt-in,由一个新的配置键控制:
telemetry:
shared_metrics:
enabled: false # 本地收集
send: false # NEW: 传输至 Nous 遥测服务
send默认值为 false。仅收集永远不会传输。send需要enabled。它并不隐含enabled:传输标志不得静默开启收集。send: true搭配enabled: false会发出警告且不执行任何操作。- 与
enabled一样,send由配置档所有,不会被 managed-scope 配置覆盖。
仅当一个包的整个周期都落在已记录的同意窗口内时,该包才会被发送。 同意以显式区间形式存储在 shared-metrics SQLite 存储中(send_consent_windows):当首次观察到 send: true 时,一个窗口开启;此后每次观察都正向确认该窗口;当观察到 send: false 时,窗口在最后一个已确认的时刻——而非墙钟时刻——关闭。单个 reconciler 在每次进程启动时根据配置派生此表,因此向导变更、对 config.yaml 的手工编辑以及回合中途撤销都走同一条路径,且任一种都无法遗漏任何转换。
任何周期早于首个窗口、落在窗口之间,或延续到最新已确认时刻之后的包都会被排除——该闸门失效关闭。因此一个全新包在其周期完成后,最多等待一次进程启动即可具备资格。
闸门针对的是周期,而不是包的创建时间。一个周期会被拆分到在不同日期创建的多个包中:当天的首个包在当天写入,同一周期的尾部包通常紧随次日出现。以创建时间作为闸门会发送某周期的尾部而丢弃其头部,从而报告出一个被静默低估的当天。以周期作为闸门可保持同意正向单向,并使每个传输的周期完整。
本地历史最多可有 30 天,且这些数据是在“绝不上传”的承诺下收集的。仅正向遵从同意最多损失我们从未获授权发送的 30 天积压数据。
A.2 身份范围——稳定的 install_id 按原样传输
决策记录。 此导出器的原始设计(以及本附录的修订版 1–8)传输的是键控化名而非标识符:HMAC-SHA256(key = 本地持有的轮换盐, message = install_id),盐每 30 天轮换一次。在 2026-08-27,在该功能发布之前(零同意用户、零生产传输),产品负责人判定分析需求是跨窗口的稳定身份——留存曲线、纵向安装行为——而轮换在设计上会将其破坏。化名层被整体移除,而非就地削弱。
现在传输的内容:
- 每个包按原样携带
install_id:即上文所述的持久化、配置档作用域的随机 UUID。 - 它在本地生成(
uuid4),不含硬件、账户、用户或机器派生信息,标识的是一个配置档,而非一个人。 - 它保持稳定,直到用户删除 shared-metrics 目录,此时会重新生成它(见 A.4)。
后果与其粉饰,不如直白陈述:
- 来自同一配置档的包会无限期关联,而非按窗口。一个安装的每日信封序列的长期可关联性现在是设计行为,而非残留。
- 旧设计的 A.3 残留分析(稳定的
resource元组 + 跨越轮换窗口的连续周期)已不再适用——不再有窗口边界需要跨越。 - 设置向导的同意文案明确陈述了这一身份模型;它已在移除派生的同一变更中更新,因此没有任何已发布构建曾在旧措辞下收集过同意。
字节级相同的重发仍然成立。 传输的 id 在包首次准备时记录在行上(sent_install_id),线缆正文始终由该记录值重建,因此重试会重建出相同的字节。契约要求如此:以不同内容重发同一个 package_id 属于未定义行为。(在稳定 id 下,记录副本不再是应对轮换的承重项——它保留作为审计列,以及作为对任何未来身份语义变化的廉价保险。)
A.3 轮换 —— 已移除(决策记录)
盐值轮换已随派生一并删除(产品决策,2026-08-27)。本节保留,作为对早期设计曾做什么、以及为何接受移除的记录:
- 轮换的存在是为了限制长期可关联性:每个 30 天窗口内一个身份,不同窗口之间身份互不相关。
- 已记录的残留问题(完整分析见 git 历史):信封中稳定、低熵的
resource元组,加上连续的每日周期,对于罕见配置而言,仍有可能合理地跨越窗口建立关联,因此这道边界只是抬高了成本,而并非一堵墙。 - 促使它被取消的产品需求:跨窗口连续性恰恰是留存分析所需要的。既然稳定的身份已成为一项要求,那么一道主要只是让诚实分析变得不便、而仅仅为决心已定的关联者抬高成本的边界,被判定为错误的取舍。
存储中没有盐值,没有轮换计划,整条流水线中也不存在任何派生标识符。
A.4 重置行为
删除 $HERMES_HOME/telemetry/shared_metrics 仍会重置本地身份、聚合数据和包文件,与上文所载完全一致。现在有两点诚实的限定:
- 重置会重新生成
install_id,因此后续包会传输一个新的身份。本地重置确实会带来新的远端身份。 - 重置无法撤回已发送的内容。已经传输的包仍会以发送时所用的标识符留在摄取服务的存储中。v1 合约中没有读回或删除 API。
设置 send: false 会立即停止传输:每个包发送前都会重新读取同意状态,因此一个已经在进行中的批处理会在当前正在发送的那个包之后停止,而不是把整批发送完。它不会删除先前已传输的包,也不会停止本地采集。
关闭发送还会关闭同意窗口 —— 时间点是最后一次实际观察到同意的时刻,而非墙上时钟时间。周期落在一个窗口与下一个窗口之间的包永远不会被传输,即使之后重新启用发送也是如此;这一点在任意数量的开/关循环下、在没有进程运行时的手工编辑下、以及在向任一方向跳变的时钟下都成立(窗口开启会被钳制为高于存储中已有的每个时间戳;观察标记每次调用只前进一个有界步长,因此一个故障性的向前采样不会把确认时间界线拖到数年之后;关闭时机永远不会落在关闭观察自身的时钟之后)。 与早先单一移动式选择加入日期不同,关闭后重新打开并不会丢弃上一个已同意窗口中仍未交付的积压 —— 那些包会留在它们各自的区间内,并且仍然符合发送条件。
一个有意为之的升级路径后果:在区间同意模型之前(send_consent_windows 存在之前)导出的包,早于第一个被记录的窗口,因此升级后永远不会被传输。这是失效即关闭的方向 —— 重新导入旧的移动式日戳以释放这些包,会重新引入经过五轮评审表明并不健全的语义 —— 而其代价至多是未交付的积压数据,绝不会是已采集的数据。
A.5 保留
- 本地: 不变 —— 成功导出的历史保留 30 天,待处理的增量会一直保留到被导出。发送状态不会延长本地的保留期限:一个永远无法发送的包仍会在 30 天时被清除。面对一个永久不可达的端点而让本地数据无界增长,比起从一个已经坏了一个月的安装中丢失指标,是更糟的失败。
- 远端: 原始包在生产环境中无期限地保留在 S3 中,在暂存环境中保留 30 天。
A.6 删除
v1 合约中没有远端删除路径,本附录也不臆造一条。用户可以做的事:
| 操作 | 效果 |
|---|---|
send: false | 不再有包离开这台机器 |
enabled: false | 采集停止;已有的本地状态保留 |
删除 .../shared_metrics | 本地身份、聚合数据和文件被重置;未来的发送使用新的 install_id |
| 删除已发送的数据 | 非自助操作 —— 需要操作者针对 S3 存储桶执行 |
如果将来承担了按要求删除的义务,查找路径现在是直接的:用户的 install_id(可从其本地存储读取)就是其数据存储所用的键。构建服务端的删除 API 仍然是一项新的产品决策,而不是实现细节。
A.7 outbox 目录是什么
记录于此,是因为它在 Phase 2 规划期间曾被误读一次,而那种理解本会删除用户数据。
该目录是本地历史,而非发送队列。package_outbox 是 SQLite 表;其 exported_at 列意味着“写入磁盘”,而非“已发送”。文件是不可变的,且仅按年龄清除。
摄取合约规定发送方应在收到 202 时从其 outbox 中删除包。导出器并不这样做。 收到确认即删除就会把用户 30 天的本地历史改作传输队列,并破坏他们曾被承诺保有的状态。发送状态改为保存在 package_outbox 表的新增列中;文件不受传输影响。
A.8 范围说明
包正文中的 install_id 字段按生成器写入的原样传输(它由该行冻结的 sent_install_id 重写而来,而后者记录的是同一个值)。没有其他负载字段发生变化,没有新增任何内容,服务将整个正文视为不透明。因此,负载模式的演进一如既往仍是发送方一侧的关注点。