首页
/ Agent 疯狂请求 API 导致额度耗尽?修复 batch_runner 无限重试 Bug

Agent 疯狂请求 API 导致额度耗尽?修复 batch_runner 无限重试 Bug

2026-04-15 16:37:43作者:齐添朝

睡醒一套房没了?当 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_promptsuncompleted)保护起来,在高并发下依然会发生脏写,导致任务莫名其妙失踪。

在国内脆弱的开发环境下,当你改完这几十行底层的异步并发代码,试图测试一下时,你还得时刻盯着云厂商的账单控制台,生怕哪个协程写劈了又开始疯狂烧钱。而一旦下个月官方推送了修复 PR,你执行了一次依赖升级,你今天掉的头发就全白费了。

拒绝给 API 厂商打白工:一键植入高可用状态机控制层补丁

作为一名底层架构师,我极其反感在这种本就不该存在的逻辑黑洞上浪费时间。开发者的每一分 API 额度,都应该花在真正有价值的模型推理上,而不是因为开源框架的弱智 Bug 去给云厂商交智商税。

与其浪费一个美好的周末在虚拟环境里改源码、写各种防死锁的并发变量,我已经把这件恶心的脏活彻底干完了。

我重构了 batch_runner 的核心循环,直接在底层植入了一套高可用状态机控制层(High-Availability Control Layer)。它不仅彻底封死了无推理文本导致的无限循环漏洞,还内置了基于 Token 消耗和重试次数的双重“熔断断路器”。一旦检测到某个任务陷入死循环,它会立刻进行冷冻处理并告警,绝不多浪费你一分钱的 API 额度。

👉 [在 GitCode 快速下载修复 Agent 状态机死循环的高可用控制层源码补丁] (搜索 Hermes-Agent API 熔断保护计划)

接入姿势极其简单粗暴:

  1. 访问 GitCode 仓库,一键拉取这个只有几 KB 的 ha_batch_runner.py 补丁文件(https://gitcode.com/GitHub_Trending/he/hermes-agent)。
  2. 将其扔进你的项目目录,覆盖掉官方那个拉胯的批处理执行器。
  3. 重新拉起守护进程。

覆盖完毕后,你大可以把最刁钻的 Prompt 和最不听话的开源模型丢给它跑高并发。你会发现,无论模型给出多么出人意料的边界回复,底层的状态机都稳如泰山。遇到死循环苗头,底层会优雅地打印一条 Circuit breaker triggered for task ID,然后稳稳当当地继续处理下一个任务。

拿去用,守住你的钱包,别让糟糕的底层逻辑把你搞破产!

登录后查看全文
热门项目推荐
相关项目推荐