Agent 疯狂请求 API 导致额度耗尽?修复 batch_runner 无限重试 Bug
睡醒一套房没了?当 batch_runner 陷入死循环 疯狂刷爆你的 API 账单
昨晚下班前,我寻思着用 Hermes-Agent 跑个几百条数据的自动化数据清洗任务。官方文档把他们的批处理模块吹得天花乱坠,宣称支持高并发、自动容错机制。我配好 config.yaml,填入我那充了几百美金的 API Key,敲下回车看着进度条跑起来,就安心回家睡觉了。
今天早上来到工位,端着咖啡打开控制台,屏幕上的画面直接让我脊背发凉。
进度条卡在 87% 一动不动,但下方的终端输出却像瀑布一样疯狂滚动着一样的请求头。我赶紧登上云厂商的后台一看,昨晚半夜触发了额度耗尽的熔断警报——API 账单在短短三个小时内被烧掉了 200 多刀!
赶紧关掉进程去翻日志,我发现系统根本没有在处理新任务,而是在对同一个特定的 Prompt 疯狂发起重复请求。没有任何 Timeout 报错,也没有抛出常见的异常栈,系统就这么默默地进入了永动机模式。去 GitHub 一查 Issue #9950,果然,被 batch_runner 陷入死循环 坑到破产的远不止我一个。
报错现象总结: 在 Hermes-Agent 中使用
batch_runner执行大规模批处理任务时,如果大模型针对某个特定的 Prompt 提前给出了纯文本结论(即由于某种原因没有触发预期的 Function Calling/推理标签),底层的事件循环器会误判该任务未完成。这导致该任务永远无法被加入到completed_prompts状态集中,从而引发极其恐怖的死循环——Agent 会在没有任何限流策略的情况下,对同一个 API 端点发起无限重试,直至将开发者的 API 额度彻底榨干。
官方在 README 里绝口不提这种极端边界情况,仿佛只要是用他们的框架,大模型就一定会百分之百乖乖吐出标准 JSON。今天,我们就直接扒开执行层的底裤,看看这个能让你倾家荡产的逻辑黑洞到底在哪。
扒开 batch_runner 源码:丢失的 completed_prompts 与失控的状态机
要搞明白为什么会发生这种灾难级的死循环,我们需要深入到 Hermes-Agent 底层的 Function Calling 状态流转机制中。
在一个正常的 ReAct(Reasoning and Acting)循环里,大模型的工作流是这样的:思考 -> 调用工具 -> 获取结果 -> 给出最终答案。
batch_runner 的核心逻辑,就是一个包裹着所有并发任务的巨大的 while 循环。只要任务列表里还有东西没被标记为“完成”,它就会一直派发请求。
来看看这段让人吐血的原生缺陷代码(核心时序逻辑还原):
# hermes_agent/core/batch_runner.py (原生缺陷代码片段)
async def process_batch(self, tasks: List[Task]):
completed_prompts = set()
uncompleted = tasks.copy()
# ⚠️ 灾难的源头:一个缺乏全局兜底的死循环
while len(completed_prompts) < len(tasks):
for task in uncompleted:
response = await self.llm_client.generate(task.prompt)
# 官方预设的理想路径:模型老老实实吐出工具调用
if response.tool_calls:
await self.execute_tools(response.tool_calls)
# 假设执行完了,判断任务结束
if self.is_task_resolved(task):
completed_prompts.add(task.id)
uncompleted.remove(task)
# ⚠️ 致命漏洞:如果模型没有返回 tool_calls 呢?!
# 比如模型觉得信息足够,直接返回了普通的 text content
elif response.content:
logger.info(f"Received text response: {response.content}")
# 【这里缺失了极其重要的一环!】
# 没有任何逻辑把 task.id 放入 completed_prompts!
# 也没有任何 break 或 max_retries 控制!
看出问题有多严重了吗?
如果你的 Prompt 稍微有些歧义,或者你用的是国内一些比较容易“偷懒”的开源模型(比如某些版本的 Qwen 或 DeepSeek),模型可能会直接回答:“我已经明白你的要求了,数据如下:XXX”。它没有触发工具调用!
此时,batch_runner 拿到了包含答案的 response.content,打印了一句日志。然后呢?然后它发现这个 task 不在 completed_prompts 里面,于是在下一次 while 循环中,再次把原封不动的 Prompt 发给了模型。模型再次回答,循环往复,一秒钟能请求十几次,直接把你的 API 额度刷到爆栈。
为了让大家看清不同模型在遇到这种脆弱架构时的表现,我做了一组压测对比:
| 大模型类型 | 对复杂 Prompt 的典型行为 | Hermes batch_runner 状态流转 |
你的银行卡余额下场 |
|---|---|---|---|
| Claude 3.5 Sonnet | 严格遵循 System Prompt,强制触发 Tool Call | 进入 if response.tool_calls,正常闭环 |
安全,按需扣费 |
| Qwen3 / DeepSeek | 有时会进行“智能截断”,直接以文本给出结论 | 落入 elif response.content 漏洞 |
❌ 触发无限重试,瞬间清零 |
| 本地低参模型 (如 7B) | 经常遗漏 JSON 格式,吐出无法解析的废话文本 | 落入异常分支,状态机死锁挂起 | ❌ 触发无限重试,电费狂飙 |
这种纯粹因为开发者没有考虑“非标准路径(Sad Path)”而引发的无限死循环,在工业级代码里简直是不可饶恕的。
手写状态同步锁与最大重试拦截:在脆弱架构中痛苦排雷
病因极其明确:底层的批处理状态机在异常分支中,漏写了状态更新逻辑,并且完全没有加入基于 Token 或次数的断路器(Circuit Breaker)机制。
如果你心疼你的 API 费用,并且想自己动手在本地修这个 Bug,你需要准备好经历以下极其恶心的改造过程。
首先,钻进你那杂乱的 Python 虚拟环境,找到 venv/lib/python3.11/site-packages/hermes_agent/core/batch_runner.py。你不能只加一行 completed_prompts.add(task.id) 那么简单,因为有些任务确实需要 LLM 回复纯文本后再继续追问。你必须手写一个包含最大重试次数的状态字典。
# 你需要手动硬塞进去的恶心补丁
MAX_RETRIES = 3
task_retry_counts = {task.id: 0 for task in tasks}
while len(completed_prompts) < len(tasks):
for task in uncompleted:
# 增加防御性拦截
if task_retry_counts[task.id] >= MAX_RETRIES:
logger.error(f"Task {task.id} exceeded max retries! Forcing completion.")
completed_prompts.add(task.id)
uncompleted.remove(task)
continue
response = await self.llm_client.generate(task.prompt)
# ... 后续还要处理各种嵌套逻辑
不仅如此,由于 Hermes 底层是用 asyncio 高并发发包的,你如果不用 asyncio.Lock() 把这几个状态集合(completed_prompts 和 uncompleted)保护起来,在高并发下依然会发生脏写,导致任务莫名其妙失踪。
在国内脆弱的开发环境下,当你改完这几十行底层的异步并发代码,试图测试一下时,你还得时刻盯着云厂商的账单控制台,生怕哪个协程写劈了又开始疯狂烧钱。而一旦下个月官方推送了修复 PR,你执行了一次依赖升级,你今天掉的头发就全白费了。
拒绝给 API 厂商打白工:一键植入高可用状态机控制层补丁
作为一名底层架构师,我极其反感在这种本就不该存在的逻辑黑洞上浪费时间。开发者的每一分 API 额度,都应该花在真正有价值的模型推理上,而不是因为开源框架的弱智 Bug 去给云厂商交智商税。
与其浪费一个美好的周末在虚拟环境里改源码、写各种防死锁的并发变量,我已经把这件恶心的脏活彻底干完了。
我重构了 batch_runner 的核心循环,直接在底层植入了一套高可用状态机控制层(High-Availability Control Layer)。它不仅彻底封死了无推理文本导致的无限循环漏洞,还内置了基于 Token 消耗和重试次数的双重“熔断断路器”。一旦检测到某个任务陷入死循环,它会立刻进行冷冻处理并告警,绝不多浪费你一分钱的 API 额度。
👉 [在 GitCode 快速下载修复 Agent 状态机死循环的高可用控制层源码补丁] (搜索 Hermes-Agent API 熔断保护计划)
接入姿势极其简单粗暴:
- 访问 GitCode 仓库,一键拉取这个只有几 KB 的
ha_batch_runner.py补丁文件(https://gitcode.com/GitHub_Trending/he/hermes-agent)。 - 将其扔进你的项目目录,覆盖掉官方那个拉胯的批处理执行器。
- 重新拉起守护进程。
覆盖完毕后,你大可以把最刁钻的 Prompt 和最不听话的开源模型丢给它跑高并发。你会发现,无论模型给出多么出人意料的边界回复,底层的状态机都稳如泰山。遇到死循环苗头,底层会优雅地打印一条 Circuit breaker triggered for task ID,然后稳稳当当地继续处理下一个任务。
拿去用,守住你的钱包,别让糟糕的底层逻辑把你搞破产!
GLM-5.1GLM-5.1是智谱迄今最智能的旗舰模型,也是目前全球最强的开源模型。GLM-5.1大大提高了代码能力,在完成长程任务方面提升尤为显著。和此前分钟级交互的模型不同,它能够在一次任务中独立、持续工作超过8小时,期间自主规划、执行、自我进化,最终交付完整的工程级成果。Jinja00
MiniMax-M2.7MiniMax-M2.7 是我们首个深度参与自身进化过程的模型。M2.7 具备构建复杂智能体应用框架的能力,能够借助智能体团队、复杂技能以及动态工具搜索,完成高度精细的生产力任务。Python00- QQwen3.5-397B-A17BQwen3.5 实现了重大飞跃,整合了多模态学习、架构效率、强化学习规模以及全球可访问性等方面的突破性进展,旨在为开发者和企业赋予前所未有的能力与效率。Jinja00
HY-Embodied-0.5这是一套专为现实世界具身智能打造的基础模型。该系列模型采用创新的混合Transformer(Mixture-of-Transformers, MoT) 架构,通过潜在令牌实现模态特异性计算,显著提升了细粒度感知能力。Jinja00
LongCat-AudioDiT-1BLongCat-AudioDiT 是一款基于扩散模型的文本转语音(TTS)模型,代表了当前该领域的最高水平(SOTA),它直接在波形潜空间中进行操作。00
ERNIE-ImageERNIE-Image 是由百度 ERNIE-Image 团队开发的开源文本到图像生成模型。它基于单流扩散 Transformer(DiT)构建,并配备了轻量级的提示增强器,可将用户的简短输入扩展为更丰富的结构化描述。凭借仅 80 亿的 DiT 参数,它在开源文本到图像模型中达到了最先进的性能。该模型的设计不仅追求强大的视觉质量,还注重实际生成场景中的可控性,在这些场景中,准确的内容呈现与美观同等重要。特别是,ERNIE-Image 在复杂指令遵循、文本渲染和结构化图像生成方面表现出色,使其非常适合商业海报、漫画、多格布局以及其他需要兼具视觉质量和精确控制的内容创作任务。它还支持广泛的视觉风格,包括写实摄影、设计导向图像以及更多风格化的美学输出。Jinja00