跳到正文
AIHOT · AI行业动· 来源· noreply@aihot.news (OpenRouter:Announcements)·· 18 小时前精选AI 评分78

OpenRouter 实测六模型并给出四层防护,解决 AI Agent 无限循环烧钱问题

OpenRouter 实测六模型并给出四层防护,解决 AI Agent 无限循环烧钱问题

引擎 / 框架
OpenRouter Agent SDK
模型
anthropic/claude-haiku-5.5、deepseek/deepseek-v4.1-flash、google/gemini-3.8-flash、mistralai/mistral-large-4-0、openai/gpt-6-luna、x-ai/grok-4.7
耗时
未说明
成本 / 额度
$0.29
产出
工具 / 流程
AI 用在
代码 · 测试
辅助工具
未说明
参与人数
未说明

字段由模型依据原文抽取,未说明不代表没有。请核对原文。字段有误?

推荐理由

实测六模型验证四层防护,提供可直接复用的 Agent 防死循环配置与代码。

正文

OpenRouter:Announcements·2026-11-12 08:00· 16 小时前精选AI 评分72

How to Stop an AI Agent Stuck in an Infinite Loop

AI 导读

OpenRouter 发布教程,讲解 AI Agent 因工具反复失败而陷入无限循环的成因,并给出四层防护:doom-loop 检测、loopKey 参数归一化、stopWhen 步数与成本上限、API key 信用额度。

推荐理由

原文用六个模型的实测数据给出防止 Agent 死循环的四层防护,含配置细节和成本对比,方法可直接迁移到自己的 Agent 代码。

当模型反复调用一个失败的工具、而且参数往往一模一样时,AI 智能体就会陷入死循环,因为智能体循环里没有任何机制叫它停下来。每次重试都会把整段对话重新发一遍,于是每一轮都比上一轮更贵,一个跑了一整夜的空转循环可能烧掉数千美元。

本指南用四道防线把循环拦住,我们在六个当前模型上做了测试。其中三道通过 OpenRouter Agent SDK(@openrouter/agent 0.11.0)运行在你的智能体内部:死循环检测、判定哪些调用算重复的规则,以及对步数和成本的上限。第四道是你 API 密钥上的额度上限。这道防线由我们这边执行,所以即使你的智能体代码有 bug 也依然有效。

TL;DR

  • 开启死循环检测后,我们测试的六个模型全部在第三次相同的工具调用时被拦下。不开启时,18 次运行中有 4 次一直重试到 40 步上限。开启后,18 次运行中有 16 次在两次真实的工具调用后就结束了。

  • 在 callModel 里设置 doomLoop: true,会拦下第三次相同的调用,并在第六次时结束运行。

  • 有些模型会稍微改动参数来绕过检测。用一个对参数做归一化的 loopKey 就能拦住它们。

  • stopWhen: [stepCountIs(n), maxCost(x)] 会给每次运行设上限,不管循环是什么原因引起的。

  • 密钥上的额度上限则封顶你的总花费。放不下的请求会失败,返回 402 和 limit_source: “openrouter_key_limit”。

为什么 AI 智能体会陷入死循环?

当模型不请求工具、直接给出回答时,智能体循环就结束了。而一个持续失败的工具,让模型没有理由这么做。错误被送回对话里,指令仍然要求完成任务,于是再试一次看起来就是正确的下一步。除非你加上代码去计数,否则循环里没有任何东西会统计尝试次数。

循环还会越跑越贵,因为每一轮都会把整段对话重新发一遍,包括之前每一次失败。在一次运行中我们逐轮做了记录,输入从 131 个 token 涨到 3,262 个,每轮成本几乎翻了两番。如果换成更贵的模型、同时跑很多会话,这种增长正是一夜之间累积到数千美元的原因。

为了亲眼看到它发生,我们做了一个账单智能体,它唯一的工具会像账单服务宕机那样失败。要运行它,把它保存为 agent.ts,执行 bun add @openrouter/agent zod,设置 OPENROUTER_API_KEY,然后运行 bun agent.ts:

import { OpenRouter, tool, stepCountIs } from '@openrouter/agent';
import { z } from 'zod';

const openrouter = new OpenRouter({ apiKey: process.env.OPENROUTER_API_KEY });

let attempts = 0;

const fetchInvoice = tool({
  name: 'fetch_invoice',
  description: 'Fetch an invoice from the billing service by ID.',
  inputSchema: z.object({ invoice_id: z.string() }),
  execute: async ({ invoice_id }) => {
    attempts++;
    console.log(`  fetch_invoice("${invoice_id}")`);
    throw new Error(`billing-service timeout after 30s fetching ${invoice_id}`);
  },
});

let cost = 0;

const result = openrouter.callModel({
  model: 'mistralai/mistral-large-4-0',
  instructions:
    'You are a billing agent. Complete the task. If a tool call fails, retry it. ' +
    'Do not stop until you have the invoice total.',
  input: 'Get the total of invoice INV-2291.',
  tools: [fetchInvoice] as const,
  stopWhen: stepCountIs(40), // so the demo ends
  hooks: {
    PostModelCall: [{ handler: ({ usage }) => { cost += usage?.cost ?? 0; } }],
    DoomLoopDetected: [{ handler: ({ action, streak }) =>
      console.log(`DoomLoopDetected: ${action} (streak ${streak})`) }],
    SessionEnd: [{ handler: ({ reason }) => console.log(`SessionEnd: ${reason}`) }],
  },
});

const text = await result.getText();
const verdict = await result.getDoomLoopVerdict(); // set only when detection stopped the run

console.log(`attempts: ${attempts}, cost: $${cost.toFixed(4)}`);
console.log(verdict ? `stopped: ${verdict.message}` : text.slice(0, 160));
  fetch_invoice("INV-2291")
  fetch_invoice("INV-2291")
  fetch_invoice("INV-2291")
  ...
SessionEnd: max_turns
attempts: 41, cost: $0.0309
I was unable to retrieve the total for invoice INV-2291. The billing service
consistently timed out after 30 seconds on all 41 attempts to fetch this invoice, r

全部 41 次调用完全相同,运行之所以结束,只是因为 stepCountIs(40)。多出来的那次调用是上限触发时模型已经请求过的,SDK 仍会执行它(第 3 步会讲到这一点)。如果没有 stopWhen,Agent SDK 会一直循环,直到模型不再请求工具,所以这次运行本来不会自己停下来。最终回答里说服务超时了,可见模型每一轮都知道这一点,却还是照样重试。

Line chart of the cumulative cost of the unguarded Mistral Large 4 run, rising from near $0 at turn 1 to $0.0309 after 41 turns.

我们在六个模型上各跑了同一个智能体三次,分别开启和关闭检测:

模型每次运行的工具调用数(无防护)触发 40 步上限每次运行的工具调用数(doomLoop: true)
anthropic/claude-haiku-5.56, 6, 60/32, 2, 2
deepseek/deepseek-v4.1-flash6, 5, 70/32, 2, 2
google/gemini-3.8-flash5, 6, 50/32, 2, 2
mistralai/mistral-large-4-011, 41, 412/32, 12, 2
openai/gpt-6-luna17, 37, 210/32, 2, 20
x-ai/grok-4.741, 12, 412/32, 2, 2

循环情况在不同运行之间也会变化。Grok 4.7 在同样的输入下分别调用了 41 次、12 次和 41 次,所以一次干净的测试并不能证明你的智能体不会循环。在全部 18 次运行中,检测把工具调用从 314 次降到 64 次,花费从 $0.29 降到 $0.05。

第 1 步:开启死循环检测

在 callModel 调用里加一个选项:

  doomLoop: true,
  fetch_invoice("INV-2291")
DoomLoopDetected: observe (streak 2)
  fetch_invoice("INV-2291")
DoomLoopDetected: block (streak 3)
DoomLoopDetected: block (streak 4)
DoomLoopDetected: block (streak 5)
DoomLoopDetected: stop (streak 6)
SessionEnd: doom_loop
attempts: 2, cost: $0.0034
stopped: Doom loop suspected: tool "fetch_invoice" was invoked in 6 consecutive rounds
with identical arguments (fingerprint 64be4614c5ea3e26…). Repeating the call will not
change the result. Take a different approach, or explain why repetition is required.

死循环检测会按工具名和参数为每次工具调用生成指纹。它先对参数做规范化处理,所以即使 JSON 键的顺序被打乱,也无法掩盖重复。只有模型已经看到上一次结果、却再次发起请求时,才算一次重复,因此同一轮里五次完全相同的调用只计为一次。随着连续重复次数增加,它会沿默认阶梯逐级升级:2 次时观察,3 次时拦截,6 次时终止。

调用被拦截时,SDK 会跳过 execute,并返回一个工具错误,告诉模型它正在重复自己。与超时不同,这个错误明确表示再试一次也没用。在大多数运行中,模型读到它后就直接写出最终答案,以 SessionEnd: complete 结束。在上面那次运行中,模型仍继续请求,于是 stop 以 reason: doom_loop 终止了运行。getDoomLoopVerdict() 只在检测导致运行结束时才返回消息,因此它是应当触发告警的信号。

想更早终止,就调低阈值。设置 doomLoop: { ladder: { observe: 2, block: 3, stop: 4 } } 后,一次 GPT-6 Luna 运行在连续重复 4 次时结束,花费 $0.0002。这个阶梯还有两级默认关闭。steer 会向模型发送一条提示,说明它在重复自己;escalate 会让你指定的更强模型来执行下一轮。

第 2 步:告诉检测器什么算同一次调用

默认情况下,只有参数完全一致,两次调用才算同一次。被拦截后,有些模型会稍微改动参数,让这次调用获得新指纹并开启新的连续计数:

 fetch_invoice("INV-2291")
DoomLoopDetected: observe (streak 2)
  fetch_invoice("INV-2291")
DoomLoopDetected: block (streak 3)
  fetch_invoice("2291")
  fetch_invoice("inv-2291")
  fetch_invoice("INV-2291")
DoomLoopDetected: observe (streak 2)
  fetch_invoice("INV-2291")
  fetch_invoice("INV2291")
  ...
SessionEnd: complete
attempts: 13, cost: $0.0125

在另外九次开启检测的 Mistral Large 4 运行中,模型这样做了五次,其中三次达到 40 步上限,主要是在 INV-2291 和 inv-2291 之间来回切换。GPT-6 Luna 则用尾随空格和换行做了同样的事。模型是在遵循拦截消息,那条消息让它们换一种做法。只有一个工具时,它们能换的只有输入。

工具上的 loopKey 告诉检测器输入中哪一部分用于标识这次调用。对于发票 ID,就是其中的数字:

 loopKey: ({ invoice_id }) => invoice_id.replace(/\D/g, ''), // "inv-2291 " and "INV-2291" are one call
 fetch_invoice("INV-2291")
DoomLoopDetected: observe (streak 2)
  fetch_invoice("INV-2291")
DoomLoopDetected: block (streak 3)
DoomLoopDetected: block (streak 4)
DoomLoopDetected: block (streak 5)
SessionEnd: complete
attempts: 2, cost: $0.0039

现在所有变体都映射到 2291,连续计数会继续增长,重复调用也持续被拦截。六次运行中有五次在两次真实调用后就停止了。第六次里,模型尝试了另一张发票 INV-0000,这正确地重置了连续计数。

要规范化大小写、空格、前缀,以及任何不影响工具取到哪条记录的差异。如果想完全忽略某个字段,比如模型每次重试都会调高的超时时间,就只列出真正重要的字段:loopKey: [‘invoice_id’]。如果工具本身就是用来重复的,比如轮询任务状态,就用 loopKey: false。

第 3 步:为每次运行设置步数和成本上限

检测只能抓住重复。如果模型每轮都尝试新东西,就永远不会重复同一个指纹;而模型在被拦截后仍继续请求,也要等到 stop 触发才会被终止。停止条件不管运行为什么变长,都能结束它。把 maxCost 和 stepCountIs 一起导入,替换 stopWhen,并添加两个生命周期钩子:

let failuresInARow = 0; // tool errors in a row, whatever the arguments

  stopWhen: [stepCountIs(15), maxCost(0.01), () => failuresInARow >= 5],
  hooks: {
    // ...the hooks from before, plus:
    PostToolUse: [{ handler: () => { failuresInARow = 0; } }],
    PostToolUseFailure: [{ handler: () => { failuresInARow++; } }],
  },

stopWhen 在每一轮之后检查,只要任一条件为真,运行就停止。maxCost 会累加我们在每个响应中返回的 usage.cost,因此统计的是你实际被计费的部分。失败计数器忽略参数,所以即使没有 loopKey,也能抓住第 2 步里那种改动参数的情况。在一次以同样方式改动参数的 Mistral Large 4 运行中,它在六次调用后以 $0.0062 结束了会话,SessionEnd: max_turns。

每个 stopWhen 条件都会以 reason: max_turns 结束会话,所以如果需要区分是哪个上限触发的,就得自己记录原因。如果上限触发时模型还有待执行的工具调用,SDK 会先把它们跑完,再在禁用工具调用的情况下做最后一轮,让这次运行以一段文字回答收尾。这就是为什么这次运行在上限为五的情况下发出了六次调用,也是为什么第一个演示在上限为四十的情况下跑了四十一次。把 allowFinalResponse 设为 false 可以跳过这一轮。

两个上限都按你平时的运行情况来设。我们的运行用了四到六轮,在 Mistral Large 4 上花费 $0.0018 到 $0.0053,所以 15 步和 $0.01 留有余地,又不至于放任一次 40 轮的运行。两者之中,成本上限更重要,因为后面的轮次比前面的更贵。

不用 Agent SDK,同样的防护也能放进任何循环里。按工具名和归一化后的参数统计调用次数,第三次重复时不要执行,而是返回一条工具结果,比如“已拦截:告诉用户出了什么问题”,并在步数或累计的 usage.cost 超过上限时停止。我们的 Python 版本对接 chat completions API,在三次运行中都于四步之后停掉了 Mistral Large 4,花费不到 $0.002。

第 4 步:给 API 密钥设置额度上限

到目前为止,所有防护都运行在智能体自己的进程里。如果那段代码有 bug、上线时没带上限,或者跑了一百个各自都不超过 maxCost 的会话,总量就没有任何限制。给密钥设额度上限就能管住这一点,因为我们会在请求到达提供商之前,先在自己这边做检查。

在控制台或用 create key API 设置 limit(以美元计)和 limit_reset(daily、weekly、monthly 或 null)。如果密钥使用 BYOK,还要把 include_byok_in_limit 设为 true,因为它默认是 false,否则 BYOK 的花费不会计入上限。每日上限在 UTC 午夜重置,每周上限在周一重置。给每个智能体或每个客户分配独立的密钥,这样一个循环最多只能花掉该密钥的那一份额度。如果多个智能体必须共用一个密钥,请参考为共享的智能体密钥设上限。

在路由请求之前,我们会先按最坏情况计价,也就是输入加上 max_tokens。如果这个数额超出了密钥剩余额度,请求就会失败,而且不会向你收费。我们用一个还剩 $9.97 的密钥,向一个昂贵的模型发送了一条 max_tokens: 900000 的 chat completions 请求,得到了这样的返回:

402
{
  "error": {
    "message": "This request requires more credits, or fewer max_tokens. You requested up to 900000 tokens, but can only afford 664657. To increase, visit https://openrouter.ai/workspaces/<workspace-slug>/keys/<key-hash> and adjust the key's total limit",
    "code": 402,
    "metadata": {
      "limit_source": "openrouter_key_limit",
      "remedy_hint": "Raise or remove this API key's usage limit at https://openrouter.ai/settings/keys, or use a key without a limit.",
      "provider_name": null
    }
  },
  "user_id": "<org-id>"
}

我们替换了工作区 slug、密钥哈希和组织 ID。

要根据 error.metadata.limit_source 分支处理,而不是根据消息文本或状态码。密钥额度耗尽时也会报告 openrouter_key_limit,limits 文档把该来源同时列在 402 额度错误和 403 预算错误之下。如果消息说该密钥能负担的 token 更少,就像上面的例子那样,就用更低的 max_tokens 重试。如果密钥已耗尽,在额度重置之前重试不可能成功,所以应当把它当作停止信号。Agent SDK 会把 402 抛出为 PaymentRequiredResponseError。唯一值得重试的 402 是 openrouter_in_flight_budget,要按它的 Retry-After 头来重试。速率限制则返回 429,我们的LLM API 速率限制指南对此有说明。

工作区预算按天、周、月或整个生命周期,把额度上限分摊到工作区内的每个密钥上。被拦截的请求会返回 403,并附带类似“Workspace monthly budget of $500.00 exceeded. Contact your org admin.”的提示。(这条提示并非我们实际触发,而是来自文档。)已经在处理中的请求仍会完成,所以实际支出可能略微超出上限,但整夜运行的循环会在接近每日上限时停下。支出控制指南介绍了工作区预算与成员级防护措施如何配合使用。

哪种防护能拦住哪种循环?

每种防护拦下的循环类型不同。密钥限额能拦住所有循环,但要等密钥额度花完才生效,所以最适合作为最后一道防线。

循环类型doomLooploopKeystopWhen 上限密钥限额或工作区预算
同样的调用、同样的参数能拦住不需要在额度上限处拦住在限额处拦住
同样的调用、参数略有不同拦不住能拦住在额度上限处拦住在限额处拦住
每一轮都换新方法拦不住拦不住在额度上限处拦住在限额处拦住
多次运行,每次都在各自上限内仅限单次运行仅限单次运行仅限单次运行拦住总量
部署时漏掉了这些防护拦不住拦不住拦不住在限额处拦住

常见问题

为什么我的智能体在报错后一直调用同一个工具?

工具返回的结果说调用失败,而提示词又要求完成任务,所以在模型看来再试一次才是正确做法。返回错误时,要告诉模型下一步该怎么做,比如“计费服务已宕机。告知用户并停止。”不要告诉模型一直重试到成功为止。

最大迭代次数限制能阻止成本失控吗?

最大迭代次数限制能阻止单次运行无限进行下去,但它并不能限制你的花费,因为越往后的轮次成本越高,而且多次运行即使都在限制之内,加起来仍然很可观。LangChain 的 AgentExecutor 中的 max_iterations 和 CrewAI 中的 max_iter 这类设置,与 OpenRouter Agent SDK 中的 stepCountIs 作用相同。要把它们与单次运行的成本上限以及 API 密钥的额度限制配合使用。

后续步骤

来源:OpenRouter:Announcements · openrouter.ai

来源:AIHOT · AI行业动 · aihot.news