ARTICLE / 开源项目

补充账单余额耗尽提醒特性的研究

PR 地址NousResearch/hermes-agent#45004 · provider 余额耗尽后的每日温和账单提醒

研究摘要

对话中途 provider 账号余额耗尽时,Hermes 的 fallback 链会逐级尝试、逐级失败,用户最后得到的是失败或降级的回复——真正的原因"钱用完了"被埋在一堆错误之下,用户往往要反复尝试几次才意识到需要充值。本研究向 Hermes 上游补充了账单余额耗尽提醒:当 provider 账号在对话中余额耗尽,次日起每天在第一条回复后温和追加一条提醒,绝不单独成文、绝不进入群聊。新增 agent/billing_notice.py 的 BillingNoticeManager,以文件状态(~/.hermes/.billing_notice)跨会话、跨天持久记录;触发、展示、清除三个环节分别接入 fallback 耗尽记录、最终回复组装与账单恢复路径。改动规模 +294/-5,涉及 11 个文件。

提醒默认关闭(enabled: false),且带有一组约束参数(仅私聊、仅附加、每日最多一次),把打扰降到最低。这篇研究拆解提醒的状态机设计、接入点选择与边界条件。

一、问题背景:余额耗尽为什么总是"看不见"

1.1 失败信息被 fallback 链吞掉

Hermes 的 provider 配置通常带有多级 fallback:主 provider 失败后依次尝试备用 provider。这条链路的设计目标是容错,但它有一个副作用——错误信息逐级被消化,最终只留下结果。余额耗尽发生时,链路上一级的错误(余额不足、账单结算失败、配额超限)往往只在内部日志里出现,用户感知到的是最终失败或降级的结果。

对用户而言,这两类问题的处理方式完全不同:余额问题需要去充值或更换密钥,是"行动";临时故障只需要重试,是"等待"。错误被链路吞掉,意味着用户拿到的行动指引是错的——在等待中反复重试一个注定失败的操作,直到某次偶然的日志查看或客服排查才发现根因。账单问题的特殊性在于它不会自行恢复:不会因为重试而消失,只会持续到用户主动处理。

账单类错误的边界还常常模糊:部分 provider 欠费后不是立即拒绝请求,而是降级到慢速或受限服务,错误信号更弱,连“失败”都不明显,用户感知到的只是“变慢了”“时好时坏”。这类场景下,明确的账单提醒反而提供了稀缺的确定性。

1.2 提醒的粒度问题

如果每失败一次就提醒一次,余额耗尽的场景会在每次对话中都轰炸用户——提醒变成噪音,用户反而会忽略真正需要行动的那一次。如果完全不提醒,用户可能连续多天不知道为什么服务时好时坏,把可用性误判为质量问题。合理的形态介于两者之间:以"天"为频率上限,把提醒附加在正常回复之后,让用户知道原因而不打断体验。这正是本特性选择的形态——日频、附加、静默。它传递的信息是"系统知道问题在哪、给你一次知晓的机会",而不是"系统催你去充值"。

频率上限还有一层语义:若用户在耗尽当天就完成充值,次日提醒不会出现(状态已被清除);若提醒次日出现,本身就传递了“故障仍在”的信号。提醒的出现与消失,构成账单状态的可见指示。

二、特性设计:文件状态 + 三个接入点

2.1 状态管理:BillingNoticeManager

新增 agent/billing_notice.py 实现 BillingNoticeManager,状态以文件形式落在 ~/.hermes/.billing_notice。选文件而非内存状态,是因为余额耗尽跨越会话边界:用户在晚上耗尽余额,次日清晨才再次使用,提醒必须能跨进程、跨天存活。内存状态在进程重启后即丢失,数据库状态对单机 CLI 工具过重,文件状态在三者之间取平衡——持久、轻量、可读。文件内容天然可审计:用户或开发者可以直接查看提醒状态,理解"为什么今天多了一条提醒"。

文件状态的选择也意味着状态与 ~/.hermes 目录的生命周期绑定:目录被清理、迁移或重建时,提醒状态随之消失——这对单机个人使用影响极小,但多设备同步 ~/.hermes 的用户需要注意状态文件的传播行为。

2.2 三个接入点

特性的完整链路分布在三个修改点,各自承担状态机的一环:

  • 触发端(agent/agent_runtime_helpers.py):fallback 链耗尽时记录账单事件——只有"全部兜底都失败且原因指向账单"时才记,避免单次瞬时故障误触发;
  • 展示端(agent/conversation_loop.py):最终回复组装前调用 should_remind() 判断是否需要在本次回复后追加提醒。检查发生在回复组装这一环,保证提醒永远依附于真实回复存在,绝不单独发送;should_remind() 内部落实频率约束(每日一次)与场景约束(私聊、非群聊);
  • 清除端(agent/credential_pool.py):账单问题解决(余额恢复、密钥更换、provider 切换)后清除提醒状态,让提醒自然止息,不残留过期通知。

三个接入点分别落在"失败记录"“回复组装"“凭据管理"三处既有逻辑上,提醒状态机的读写都挂在现有生命周期事件上,不需要新增独立的后台任务或定时器。

完整事件流可以串成一条可追踪的时序:对话中 fallback 耗尽 → 触发端记录账单事件 → 当日剩余对话不再提醒(频率约束)→ 次日首条回复组装前 should_remind() 通过 → 回复末尾追加提醒 → 用户充值后凭据更新 → 清除端删除状态 → 后续对话不再提醒。每个环节都挂在既有生命周期事件上,状态的读写可审计。

2.3 配置项与约束语义

特性整体以独立配置段控制:

  • enabled: false(默认)——存量行为零变化;
  • ps_only: true——提醒只作为回复的附加段落,绝不单独成文;
  • dm_only: true——只在私聊中出现,群聊环境完全屏蔽;
  • max_frequency: daily——同一账单事件每天最多提醒一次。

三个约束合起来定义了提醒的"存在方式”:它在私聊的第一条回复末尾出现一次,然后当天不再出现。这种克制的设计把提醒定位为"知情工具"而非"催促手段”——对个人用户足够醒目,对共享场景(群聊)零打扰,对高频使用(每日多次)零轰炸。

三、实测结果

PR body 未提供测试套件数据或真机验证记录,本节记录设计意图与预期效果(明确标注,非实测):

  • 预期效果:余额耗尽后的次日第一条回复附带提醒,用户不再需要从失败堆里猜原因,行动路径(充值或换密钥)清晰;
  • 预期效果:同一事件每天只提醒一次,长期欠费场景不会变成每日轰炸,提醒的边际打扰趋近于零;
  • 预期效果:账单恢复后提醒自动清除,无需用户手动干预,状态机闭环;
  • 预期效果:群聊与独立消息两种形态被配置约束屏蔽,提醒不会造成公共场景的打扰;
  • 预期效果:文件状态跨进程存活,深夜耗尽、次日清晨使用的典型场景不漏报。

以上均为设计意图层面的预期。提醒的准确度取决于账单事件识别口径与 fallback 链行为的匹配程度,以及各家 provider 错误返回对"账单耗尽"的表述差异,需要在真实账单场景中验证。

提醒内容的构成(是否包含 provider 名、剩余可用 fallback 等信息)未被 PR body 记录——内容粒度直接影响用户行动的准确性,只提示“账单问题”与提示“哪个 provider 欠费”的行动效率完全不同。

四、能力边界

  • 提醒依赖 provider 侧返回的账单相关错误信号,不同 provider 的错误识别口径不一致时可能漏报(错误形态未被识别)或误报(非账单错误被归类为账单问题);
  • 特性只负责"告知",不解决余额问题本身,用户仍需自行充值或更换密钥;多次提醒后仍未处理时,提醒只是重复出现,不会升级处置;
  • 文件状态方案跨进程可靠,但多设备或多进程共用同一 ~/.hermes 目录时存在并发写竞争的可能;
  • 默认关闭,需要显式开启;dm_only 依赖平台对私聊/群聊语义的正确识别,平台语义不清时提醒可能在不该出现的地方出现;
  • 展示端只在"有回复可附加"时提醒——如果次日用户完全没有对话,提醒顺延,不补发。
  • 提醒状态文件与 ~/.hermes 目录生命周期绑定,目录迁移、清理或重建会重置提醒状态。
  • 与 API 密钥保存校验(#45003)、Ollama 本地兜底(#45005)三者构成配置错误的应对组合:校验负责在配置环节提示,账单提醒负责在运行环节告知原因,本地兜底负责在故障期间保底;账单提醒处于中间层,不阻止失败,但让失败原因可见。

五、PR 信息

  • PR 地址:https://github.com/NousResearch/hermes-agent/pull/45004
  • 改动规模:+294 / -5,11 个文件(新增 agent/billing_notice.py,修改 conversation_loop.py、credential_pool.py 等)
  • 状态:closed
  • 提交时间:2026-06-12

本文记录 x7peeps 向 Hermes Agent 上游贡献的特性研究,所有数据来自 PR 实测记录。