跳到正文
原文
OpenRouter:Blog(Announcements)·· 16 小时前精选AI 评分65

OpenRouter 教程:如何阻止 AI 智能体陷入工具调用死循环

How to Stop an AI Agent Stuck in an Infinite Loop

文章要点
  • 1OpenRouter 发布教程,用四道防线阻止智能体陷入工具调用死循环,避免通宵循环累积数千美元花费。
  • 2官方自测中 doom-loop 检测把 18 次运行的工具调用从 314 次降到 64 次,花费从 $0.29 降到 $0.05。
  • 3部分模型会微调参数绕过检测,如把 INV-2291 改成 inv-2291,需用 loopKey 归一化参数才能持续拦截。
  • 4第四道防线是 API key 信用限额,由 OpenRouter 在服务端强制执行,超限请求返回 402 且不扣费。
决策要点

智能体死循环源于循环结构本身没有计数与止损机制,成本控制要同时放在应用代码内和平台侧,缺一层就留有失控口子。

  • 影响:智能体每轮重发全部对话,单轮成本逐轮上涨,无人值守任务通宵可累积数千美元花费;应用内防护依赖代码正确部署,多会话各自低于上限时总额仍会累计超支。
  • 参考:可考虑在生产智能体上开启 doom-loop 检测,并按正常运行的轮次和花费设定 stepCountIs 与 maxCost 上限;值得给每个智能体单配 API key 并设日限额;工具报错文案可改成告知模型停止,而不是让它重试。
  • 依据:数据为 OpenRouter 在 6 个模型各跑 3 次的厂商自测,尚无独立验证;同模型行为波动明显,Grok 4.7 同一输入三次运行分别调用 41、12、41 次。
正文 · AI 翻译

当模型不断调用一个总是失败的工具时——通常使用相同的参数——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 步:开启 doom-loop 检测

在 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 次时 observe(观察),3 次时 block(阻止),6 次时 stop(停止)。

当某次调用被阻止时,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 会运行这些调用,并在禁用工具调用的情况下进行最后一轮,因此运行会以一段书面回答结束。这就是为什么这次运行在五次上限下进行了六次调用,而第一个演示在 40 次上限下进行了 41 次调用。设置 allowFinalResponse: false 可跳过该回合。

根据你的正常运行情况来设置这两个上限。我们在 Mistral Large 4 上需要四到六个回合,成本在 $0.0018 到 $0.0053 之间,因此 15 步和 $0.01 既留有余地,又不会放任 40 回合的运行。两者之中,成本上限更重要,因为后面的回合比前面的回合花费更多。

没有 Agent SDK 时,同样的防护措施也可以放进任何循环里。按工具名称和归一化后的参数统计调用次数,在出现第三次重复时返回类似“已阻止:告诉用户出了什么问题”的工具结果,而不是继续执行,并在步数或累计 usage.cost 超过上限时停止。我们针对 chat completions API 的 Python 版本在三次运行中都让 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,限额文档在 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:Blog(Announcements) · openrouter.ai