开源项目 Hermes Agent 里的四处成本旋钮各拦哪类失控
本文基于 hermes-agent 仓库 commit 2d40494(2026-07-29)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/NousResearch/hermes-agent 最新代码与文档为准。
这个项目里没有一个”这个月花到头就停”的总闸门。 你能拧的是四处各管一段的旋钮,它们拦的是四类互不相同的失控——进上下文的字符量、循环的轮数、账目的口径、外部接口的窗口——把它们当成同一件事去调,多半是两头落空:钱没省下来,任务反而被半路掐断。这里说的 Hermes Agent 指 NousResearch/hermes-agent 这个采用 MIT 许可证(LICENSE 署名 Nous Research)的自托管常驻 Agent 仓库,不是 Nous Research 的 Hermes 开源模型系列,也不是任何同名商标或同名库。
它为什么容易花超,看一眼仓库规模就明白:skills/ 下 14 个分类目录共 70 份 SKILL.md,optional-skills/ 下 21 个分类目录共 111 份,plugins/ 有 18 个顶层插件目录,optional-mcps/ 6 个。一个常驻进程、能开终端、能连聊天软件、能读写磁盘、能访问外部服务,可调用的面铺得越宽,单轮往上下文里塞的东西就越多,而单轮塞进去的东西才是账单的主体。
这里先说清一件不属于”旋钮”但比旋钮更该先看的事:可选技能与插件是要你自己启用的,启用一份就等于多一段会被送进上下文的说明、多一份会在你机器上真实执行的代码。第三方来源的技能和插件既抬成本也抬风险,装之前请自己读一遍它要做什么、要碰哪些文件和哪些外部地址,别把”仓库里带了”当成”已经审过了”。
站内已有几篇讲的是通用方法论或别的项目:Agent 成本失控的判断框架讲的是不依赖具体实现的通用思路,ECC 的成本流水做法与主流框架的 token 效率对比各自对着别的代码库。本篇不重复这些,只做一件事:把这个具体仓库里跟花费相关的几处代码摊开,指明每处拦什么、放弃什么。
一、先把四类失控分开
在动手改配置之前,先认清你要治的是哪一种病。四处机制分工如下。
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 工具结果字符预算 | 单条工具结果、单轮工具结果总量占用上下文的上限;超了就落盘换成预览 | tools/budget_config.py、tools/tool_result_storage.py | 一次搜索或一次终端命令吐出巨量文本,之后每轮都在为它付钱 |
| 迭代预算 | 一个 agent 实例在一轮任务里能做多少次工具调用循环,可消耗也可退还 | agent/iteration_budget.py、agent/conversation_loop.py | 任务卡在自我纠错里反复兜圈,或者任务还没做完就被中断 |
| 用量核算 | 把各家 API 回传的 usage 归一化成统一的 token 分桶,再折算成本并标注这笔数的可信度 | agent/usage_pricing.py、agent/aux_accounting.py、agent/insights.py | 你想知道钱花在哪个模型、哪类调用上,或者发现账目对不上 |
| 限流跟踪 | 解析响应头里的限流窗口状态并展示;被拒后跨会话记录,避免重试把额度打空 | agent/rate_limit_tracker.py、agent/nous_rate_guard.py | 任务开始大面积报错,或者你想知道还剩多少余量能跑 |
看这张表就能发现,前两处是”预防”,管的是往请求里塞什么、循环几次;后两处是”观测与自保”,管的是事后算账和被拒之后别越挫越勇。指望第三、四处帮你省钱,方向就错了。
二、字符预算:拦住一次巨量输出反复收费
一次工具调用返回几十万字符的日志,它不是只贵一次。它进了消息列表,后面每一轮都会被重新发一遍。这是常驻 Agent 最典型的成本泄漏方式,也是最容易被忽略的一种——你看到的是”任务变慢了”,账上体现的是每轮输入 token 都在涨。
tools/tool_result_storage.py 的模块注释把防御分成三层:工具自己在返回前先截断;单条结果超过该工具注册的阈值时,maybe_persist_tool_result 把全文写进沙箱临时目录,上下文里只留预览加一个文件路径,模型需要全文时自己去 read_file;一整轮的工具结果加起来超过总预算时,enforce_turn_budget 从最大的那条开始往磁盘溢出,直到总量落回预算内。
阈值怎么定,在 tools/budget_config.py 里。BudgetConfig.resolve_threshold 的优先级写得很直白:先看 PINNED_THRESHOLDS,再看 tool_overrides,再问工具注册表,最后落到默认值。这里有两个值得琢磨的设计。
一个是 PINNED_THRESHOLDS 里把 read_file 钉成 float("inf"),注释给的理由是防止 persist→read→persist 的无限循环——落盘产物本身要靠 read_file 取回,如果它也可能被落盘,就会自己咬住自己。这一项按代码逻辑是不接受覆盖的,你在配置里怎么写都不生效。
另一个是 budget_for_context_window。固定的默认值对大窗口模型是合适的,对小窗口模型则形同没有:一条结果按大模型的口径放行,本身就可能把整个窗口顶满。这个函数按模型窗口的固定比例算出单条与单轮的预算,再做双向夹逼——向上不超过原有默认值,所以大窗口模型的行为跟以前逐字节一致;向下不低于一个地板值,所以窗口很小的模型也还能拿到一份可用的预览而不是零。注释里明说了转换用的是与估算器一致的粗略字符换算比例,也就是说这是个刻意保守的近似,不是精算。
对你意味着什么:如果你把主模型换成一个窗口不大的自托管模型,这条缩放路径生效的前提是拿得到那个模型的窗口信息。agent/tool_executor.py 里 _budget_for_agent 的逻辑是拿到窗口就缩放、拿不到就退回默认配置。退回默认不会报错,只会安静地按大模型口径放行——这类”安静地不生效”比报错难查得多。
三、迭代预算:拦住兜圈子,但它数的不是钱
agent/iteration_budget.py 里的 IterationBudget 是个很小的类:一个上限、一个已用计数、一把 threading.Lock,对外只有 consume、refund、used、remaining。它数的是循环轮次,不是 token,更不是金额。
有意思的是 refund。在 agent/conversation_loop.py 里,主循环的继续条件同时看调用计数和 iteration_budget.remaining,预算耗尽时退出原因记为 budget_exhausted;但有相当多的情况会把这一轮还回去:只调用了 execute_code 的那一轮(注释的理由是这类程序化工具调用便宜,不该吃预算)、因为需要压缩而重跑的那一轮、因为切换到备用提供方而重发的那一轮、因为本地运行时上下文太小而直接放弃的那一轮。此外还有一个宽限标记,预算见底时允许模型再说一句话,然后无论结果如何都退出。
设计取向是清楚的:预算用来拦住无意义的兜圈,不用来惩罚基础设施抖动。代价也很清楚——日志里的 API 调用序号和预算的已用数不是一回事。你要判断”这个任务是不是在打转”,得看退出原因,不能拿调用次数当依据。关于打转本身怎么识别,站内另有一篇讲循环停不下来时的止损信号,这里不展开。
再一个容易踩的点是层级。文件注释说明,父 agent 与每个 subagent 各持一份独立的预算实例,subagent 那份由配置里 delegation 段下的 max_iterations 控制。注释自己也点明了后果:父加上派出去的那些,总轮次可以超过父的上限。也就是说这个数字不是全局上限,是每个执行体各自的上限。你把父的上限压到很小,只要它还能往外派活,总量照样上得去。
四、用量核算:它的价值在于诚实地说”我不知道”
agent/usage_pricing.py 是这四处里最厚的一处,但它一行都不阻断请求,只做两件事:把各家回传的 usage 归一化,以及给出一个带可信度标注的成本数。
归一化这块,CanonicalUsage 把用量拆成输入、输出、缓存读、缓存写、推理这几个桶,外加一个请求计数,还实现了相加——两份用量合并时,请求计数累加,而描述单次响应的原始字段会被丢掉,因为它没法合并。normalize_usage 处理三种 API 形状:Anthropic 的 Messages、Codex 的 Responses,以及最常见的 Chat Completions。这里的坑注释都留下了:几种形状对”输入 token 是否已包含缓存 token”的约定不同,需要减法还原;一些兼容代理只在顶层暴露缓存字段,不走嵌套结构,不做兜底就会把缓存写量记成零;推理 token 在两种形状里挂在不同的 details 对象上,只读一处的话,凡是走 Chat Completions 的推理模型,隐藏思考量在账上全是零。
折算这块,resolve_billing_route 先判断这次调用走的是哪条计费路线,产出的结构里带一个计费模式字段,取值区分”订阅内含""按提供方模型接口取价""按官方文档快照取价""未知”。仓库里那份文档快照是硬编码的条目,每条都带来源链接和一个版本标记。它会过期,代码注释里就写着某些条目在优惠窗口结束后需要更新——这一点你在读任何成本数字之前都该先想到。
真正值得学的是 estimate_usage_cost 的态度:如果某个 token 桶有用量但对应的单价缺失,它不会把这一桶悄悄按零加进去,而是整笔返回未知状态。CostResult 的状态取值本身就把”实际""估算""内含""未知”分开了,agent/insights.py 的汇总也把有价格的模型、没价格的模型、成本未知的会话、按内含计的会话分列。这就把一个很常见的误判堵掉了:看到成本显示不出来,别理解成”这次没花钱”。
还有一处盲区值得单说。agent/aux_accounting.py 的注释交代得很清楚:视觉识别、压缩、标题生成、网页正文抽取、会话检索这些辅助调用走的是另一个客户端,历史上它们的用量是被丢掉的,于是分析视图看不见这部分开销。现在的做法是用 ContextVar 发布当前会话的记账句柄,让辅助客户端在自己的响应校验点上记账;同一进程里并行的多个 agent 互不串账,按它提供的方式派生的工作线程会继承上下文。同时它显式排除了两类任务,因为主循环已经把那部分用量折进去了,再记一次就是重复计。这段代码本身就是一份提醒:你以为在看全账的时候,很可能有一整类调用不在账上。 想把这种分摊做细,可以参考站内按步骤拆解成本归属那篇的思路。
五、限流跟踪:省的不是单价,是被拒之后的空转
agent/rate_limit_tracker.py 做的事很收敛:从响应头里解析限流状态,存成四个桶——请求数的分钟窗口与小时窗口、token 的分钟窗口与小时窗口,每个桶记住上限、剩余、重置秒数和抓取时刻。因为记了抓取时刻,剩余时间可以按已流逝的时间往下推算,不至于把一份旧快照当成当下。解析时会把头名统一转小写,只要一个 x-ratelimit- 前缀的头都没有就直接返回空。展示有两种:带进度条的完整视图,任何一个桶用量偏高时追加一条警示;以及给状态栏用的一行式摘要。这两种输出都挂在 /usage 这条命令上,cli.py 与 gateway/slash_commands.py 各调一处。
采集点在 run_agent.py 的 _capture_rate_limits:每次流式调用之后从 HTTP 响应上取头,解析失败一律吞掉,注释写的是绝不让解头这件事打断 agent 循环。同一个文件里还有一处专门处理 Anthropic 的响应头,原因是那边 SDK 聚合后的消息对象会把 HTTP 头丢掉,同一族状态得另外捞。
真正能省钱的是隔壁 agent/nous_rate_guard.py。它的模块注释算了一笔账:一次被拒可以在单轮里放大成多次 API 调用,SDK 层重试乘上项目自己的重试,而每一次都照样计入小时窗口的请求数。做法是把被拒状态写进一个共享文件,放在 Hermes 主目录下的限流状态子目录里,让 CLI、网关、定时任务、辅助调用这些不同会话在发请求前先看一眼。恢复时间的取值优先小时窗口的重置头,其次分钟窗口,最后退到通用的重试头,都没有就退到一个默认冷却时长。
这段代码的取向要看清:跨会话共享的这一份只针对一家提供方的接口。rate_limit_tracker.py 本身不阻断任何东西,它只是把状态摆给你看。各家的限流规则不同且会调整,以官方最新说明为准——你能依赖的是”有头就有数、没头就没数”这个事实,而不是某个具体窗口的形状。
六、边界与代价:这四处明确不管的事
第一,它们合起来也不是钱包上限。没有任何一处会在累计花费达到某个数时停手:字符预算管的是进上下文的量,迭代预算管的是轮数,用量核算只算账不拦人,限流跟踪只读头不设限。你要”花到某个数就停”,得自己在外面做,或者在提供方那侧设。
第二,落盘溢出是拿磁盘换上下文。省下的是重复发送的费用,多出来的是一次取回往返,以及一个常驻场景里必须自己回答的问题:那些被写进临时目录的全文可能包含敏感内容,谁清理、留多久、机器上还有谁能读。这个项目会开终端、连你的聊天软件账号、往磁盘写文件、访问外部服务,落盘这件事只是这一整类风险里的一小块,别只当成性能优化来看。
第三,价格数据的时效性靠人维护。硬编码快照会旧,走提供方接口取价的路线依赖对方接口还在、字段没变,而本地部署与自定义地址那条路线的计费模式直接就是未知。你在这些路线上得到的成本数就是不可用,不是零。
第四,退还机制让轮次不再等于调用次数。这是有意为之,但它让”看日志估花费”这条粗糙路径失效了。要估花费就去看用量记账那张表,别用循环序号。
第五,迭代预算不区分贵活和便宜活。除了程序化调用那条特例,一轮就是一轮,无论这一轮调的是最便宜的模型还是最贵的。想按模型分层控成本,得走模型路由那条路径,不在这四处旋钮的职责里。
七、上手与避坑清单
别指望调迭代上限能省钱。 会踩是因为这个数字最好找、语义看着最像”限额”。但单轮请求里的 token 才是账单主体,把上限从大砍到小,通常只是让任务在做完之前被掐断,单轮该花的照样花。真要省,先去看进上下文的东西。
换小窗口模型时,先确认窗口信息拿得到。 会踩是因为缩放逻辑在拿不到窗口时会安静地退回默认配置,不报错、不告警,你以为的保护并没有发生。确认办法是顺着 agent/tool_executor.py 里解析预算那段往上查:它取的是上下文压缩器身上的窗口长度,而那个值是压缩器构造时探测出来的,探测不到就没有缩放。
别试图给读文件那类工具设覆盖值。 会踩是因为覆盖机制看着是通用的。但被钉住的那一项按代码逻辑不接受覆盖,理由是防止落盘与读取互相触发。要控制这类调用的开销,办法是别让它去读超大文件,不是改阈值。
定时任务那条路径的配置键名跟主路径不是同一个。 会踩是因为你在主配置里改了轮次上限,以为定时跑的任务也跟着变。cron/scheduler.py 里读的是配置中另一个键名,取不到才落到它自己的默认值。改之前先在这个文件里搜一遍你要改的键。
别拿注释里的默认值当事实。 会踩是因为 agent/iteration_budget.py 的文档字符串写的父级默认值,和 agent/agent_init.py 里那个形参的默认值并不一致,而不同入口(交互、批量、定时、后台复查)传进来的值各不相同。以你实际走的那个入口传的值为准。
看到成本显示不出来,别当成没花钱。 会踩是因为界面上的空值和零长得太像。这个项目在缺价时是整笔判未知而不是按零算,所以未知就是真未知。想先自查,用它提供的”这条路线有没有价格数据”那个判断,或者看汇总里没有价格的模型清单。
别只盯主循环的用量。 会踩是因为辅助调用走的是另一个客户端,不在你习惯看的那条链上。现在它们靠上下文变量把会话句柄传下去记账,所以你自己派生线程时要用项目提供的上下文传播方式,否则这部分记不上;同时注意有两类任务是被刻意排除的,别把它们的量再加一遍。
被拒之后不要靠重试硬扛。 会踩是因为重试是层层叠加的,你看到的一次失败在窗口计数上可能远不止一次。先看 /usage 输出里的限流那几行,判断该等多久,再决定是否重发。
收束:三个自检动作
拿到一台已经跑起来的实例,按这个顺序自查一遍,比通读文档快:先看 /usage 的输出——限流四个桶有没有数、成本状态是估算还是未知,这两条决定了你后面所有判断的可信度;再看会话用量那张表里各模型的分桶数据,缓存读写和推理这几桶的比例往往比总量更能说明问题出在哪;最后看退出原因,确认任务是正常收尾、被预算掐断,还是在兜圈。
接着往下读的顺序建议是:tools/tool_result_storage.py 的模块注释(三层防御的全貌)→ agent/conversation_loop.py 里主循环的继续条件与那几处退还调用(轮次到底怎么算)→ agent/insights.py 的汇总查询(账目最终以什么形状落库)。这三处读完,这个项目的花费从哪里来、被谁拦、以什么口径记账,就都有出处了。
本篇属于一个把开源常驻自托管 Agent 项目 Hermes Agent逐层拆开讲的系列,整体地图见 开源自托管 Agent 项目 Hermes Agent 是什么;沿着这条线往下,还可以看 开源自托管 Agent 项目 Hermes Agent 的语音链路与代价 和 开源自托管 Agent 项目 Hermes Agent 的三道闸门。