跳到正文
OpenRouter Announcements·· 36 分钟前精选AI 评分62

OpenRouter 教程:如何阻止 AI Agent 陷入无限循环

How to Stop an AI Agent Stuck in an Infinite Loop

AI 导读

OpenRouter 发布教程,介绍用四层防护阻止 AI Agent 反复调用失败工具导致的无限循环,并在六款模型上做了 18 次实测。开启 doomLoop 后,六款模型都在第三次相同工具调用时被拦截;未开启时 18 次运行中有 4 次一直重试到 40 步上限。

推荐理由

OpenRouter 用六款模型的 18 次实测对比四层防护,给出了可直接照搬的 Agent 循环止损配置。

正文 · AI 翻译

当模型不断调用一个失败的工具(通常参数还相同)时,AI 智能体会陷入无限循环,因为智能体循环中没有任何机制告诉它停下来。每次重试都会重新发送整个对话,因此每一轮的开销都比上一轮更高,一个运行整夜的循环可能花费数千美元。

本指南用四道防线来阻止循环,我们在六个当前模型上进行了测试。其中三道通过 OpenRouter Agent SDK(@openrouter/agent 0.11.0)在你的智能体内部运行:末日循环检测、判定哪些调用算作重复的规则,以及对步数和成本的限制。第四道是你 API 密钥上的信用额度限制。我们在我们这一侧强制执行,因此即使你的智能体代码有 bug,它也能生效。

简而言之

  • 开启末日循环检测后,我们测试的全部六个模型都在第三次相同的工具调用时被阻止。不开启时,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 个 token,每轮成本几乎翻了两番。如果使用更贵的模型并且同时运行许多会话,这种增长就是一夜之间累积到数千美元的原因。

为了观察这一过程,我们构建了一个账单智能体,它唯一的工具会像账单服务宕机那样失败。要运行它,将其保存为 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, 63 次中 0 次2, 2, 2
deepseek/deepseek-v4.1-flash6, 5, 73 次中 0 次2, 2, 2
google/gemini-3.8-flash5, 6, 53 次中 0 次2, 2, 2
mistralai/mistral-large-4-011, 41, 413 次中 2 次2, 12, 2
openai/gpt-6-luna17, 37, 213 次中 0 次2, 2, 20
x-ai/grok-4.741, 12, 413 次中 2 次2, 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.

Doom-loop 检测通过工具调用的名称和参数为其生成指纹。它会先对参数进行规范化,因此重新排序的 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 触发之前不会被停止。停止条件无论运行为何过长都会结束运行。与 stepCountIs 一起导入 maxCost,替换 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 会执行这些调用,并在禁用工具调用的情况下进行最后一轮,因此运行会以书面答案结束。这就是为什么本次运行在五个上限下进行了六次调用,以及为什么第一个演示在四十个上限下进行了 41 次调用。设置 allowFinalResponse: false 可跳过该轮。

根据你的正常运行来设置这两个上限。我们的运行需要四到六轮,在 Mistral Large 4 上花费 $0.0018 到 $0.0053,因此 15 步和 $0.01 留有余地,同时不会允许 40 轮的运行。两者中,成本上限更重要,因为后面的轮次比早期的轮次花费更多。

不使用 Agent SDK 时,同样的防护措施可以适用于任何循环。按工具名称和规范化参数统计调用次数,返回类似“Blocked: tell the user what went wrong”的工具结果,而不是执行第三次重复,并在步骤数或累计 usage.cost 超过你的上限时停止。我们的 Python 版本针对 chat completions API,在三次运行中均在四步后停止 Mistral Large 4,费用低于 $0.002。

第 4 步:为 API 密钥设置信用额度

到目前为止,每个防护措施都在你的 agent 进程内运行。如果该代码有 bug、发布时没有带上限,或者运行一百个各自保持在 maxCost 以下的会话,那么总花费就没有任何限制。密钥上的信用额度可以做到这一点,因为我们在请求到达提供商之前会在我们这边检查。

在仪表板中或使用 create key API 设置 limit(以美元计)和 limit_reset(daily、weekly、monthly 或 null)。如果密钥使用 BYOK,还要将 include_byok_in_limit 设置为 true,因为它默认为 false,否则 BYOK 支出不会计入限额。每日限额在 UTC 午夜重置,每周限额在周一重置。为每个 agent 或客户分配自己的密钥,这样单个循环只能花费该密钥的份额。如果多个 agent 必须共享一个密钥,请遵循 Capping Shared Agent Keys。

在我们路由请求之前,我们会计算其最坏情况的价格,即输入加上 max_tokens。如果这不符合密钥剩余额度,请求就会失败,并且你不会被收费。我们向一个昂贵模型发送了一个 max_tokens: 900000 的 chat completions 请求,使用的密钥还剩 $9.97,得到了以下返回:

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,并附带类似“工作区每月预算 $500.00 已超支。请联系你的组织管理员”的消息。(我们并未触发这一条,所以该消息来自文档。)已在执行中的请求仍会完成,因此支出可能略微超出,但整夜运行的循环会在接近每日上限时停止。支出控制指南展示了工作区预算与按成员设置的防护措施如何配合。

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

每种防护拦截不同类型的循环。密钥限额能拦截所有循环,但只有在密钥的上限被用尽后才生效,因此它最适合作为最后一道防线。

循环doomLooploopKeystopWhen 上限密钥限额或工作区预算
相同调用、相同参数能拦截不需要在上限处拦截在限额处拦截
相同调用、参数略有不同无法拦截能拦截在上限处拦截在限额处拦截
每一轮都采用新方法无法拦截无法拦截在上限处拦截在限额处拦截
多次运行,每次都在各自上限之内仅按单次运行仅按单次运行仅按单次运行拦截总量
缺少防护措施的部署无法拦截无法拦截无法拦截在限额处拦截

常见问题

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

工具结果说明调用失败,而提示词要求完成任务,因此在模型看来再试一次似乎是正确的做法。返回能告诉模型下一步该做什么的错误信息,例如“账单服务已宕机。告知用户并停止。”不要告诉模型重试直到成功。

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

最大迭代次数限制能阻止单次运行无限进行下去,但它不会限制你的支出,因为后续轮次成本更高,而且在上限之内的多次运行仍会累加。LangChain 的 AgentExecutor 中的 max_iterations 和 CrewAI 中的 max_iter 等设置,与 OpenRouter Agent SDK 中的 stepCountIs 作用相同。请将其与每次运行的成本上限以及 API 密钥的额度限制配合使用。

后续步骤

来源:OpenRouter Announcements · openrouter.ai