OpenRouter 教程:如何阻止 AI Agent 陷入无限循环
How to Stop an AI Agent Stuck in an Infinite Loop
OpenRouter 发布教程,介绍用四层防护阻止 AI Agent 反复调用失败工具导致的无限循环,并在六款模型上做了 18 次实测。开启 doomLoop 后,六款模型都在第三次相同工具调用时被拦截;未开启时 18 次运行中有 4 次一直重试到 40 步上限。
OpenRouter 用六款模型的 18 次实测对比四层防护,给出了可直接照搬的 Agent 循环止损配置。
当模型不断调用一个失败的工具(通常参数还相同)时,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 会一直循环,直到模型不再请求工具,因此这次运行不会自行停止。最终答案说服务超时了,所以模型在每一轮都知道这一点,却仍然重试。

我们在六个模型上各运行同一个智能体三次,分别开启和不开启检测:
| 模型 | 每次运行的工具调用次数,无防护 | 触及 40 步上限 | 每次运行的工具调用次数,doomLoop: true |
|---|---|---|---|
| anthropic/claude-haiku-5.5 | 6, 6, 6 | 3 次中 0 次 | 2, 2, 2 |
| deepseek/deepseek-v4.1-flash | 6, 5, 7 | 3 次中 0 次 | 2, 2, 2 |
| google/gemini-3.8-flash | 5, 6, 5 | 3 次中 0 次 | 2, 2, 2 |
| mistralai/mistral-large-4-0 | 11, 41, 41 | 3 次中 2 次 | 2, 12, 2 |
| openai/gpt-6-luna | 17, 37, 21 | 3 次中 0 次 | 2, 2, 20 |
| x-ai/grok-4.7 | 41, 12, 41 | 3 次中 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 已超支。请联系你的组织管理员”的消息。(我们并未触发这一条,所以该消息来自文档。)已在执行中的请求仍会完成,因此支出可能略微超出,但整夜运行的循环会在接近每日上限时停止。支出控制指南展示了工作区预算与按成员设置的防护措施如何配合。
哪种防护能拦住哪种循环?
每种防护拦截不同类型的循环。密钥限额能拦截所有循环,但只有在密钥的上限被用尽后才生效,因此它最适合作为最后一道防线。
| 循环 | doomLoop | loopKey | stopWhen 上限 | 密钥限额或工作区预算 |
|---|---|---|---|---|
| 相同调用、相同参数 | 能拦截 | 不需要 | 在上限处拦截 | 在限额处拦截 |
| 相同调用、参数略有不同 | 无法拦截 | 能拦截 | 在上限处拦截 | 在限额处拦截 |
| 每一轮都采用新方法 | 无法拦截 | 无法拦截 | 在上限处拦截 | 在限额处拦截 |
| 多次运行,每次都在各自上限之内 | 仅按单次运行 | 仅按单次运行 | 仅按单次运行 | 拦截总量 |
| 缺少防护措施的部署 | 无法拦截 | 无法拦截 | 无法拦截 | 在限额处拦截 |
常见问题
为什么我的智能体在出错后一直调用同一个工具?
工具结果说明调用失败,而提示词要求完成任务,因此在模型看来再试一次似乎是正确的做法。返回能告诉模型下一步该做什么的错误信息,例如“账单服务已宕机。告知用户并停止。”不要告诉模型重试直到成功。
最大迭代次数限制能阻止失控的成本吗?
最大迭代次数限制能阻止单次运行无限进行下去,但它不会限制你的支出,因为后续轮次成本更高,而且在上限之内的多次运行仍会累加。LangChain 的 AgentExecutor 中的 max_iterations 和 CrewAI 中的 max_iter 等设置,与 OpenRouter Agent SDK 中的 stepCountIs 作用相同。请将其与每次运行的成本上限以及 API 密钥的额度限制配合使用。
后续步骤
-
Doom-loop 检测:阶梯、引导、升级和按工具覆盖。
-
停止条件:stepCountIs、maxCost、maxTokensUsed 和自定义条件。
-
设置团队 AI 支出控制:仪表盘中的工作区预算和防护措施。
-
Agent SDK:在 OpenRouter 上构建多轮智能体工作流:callModel 如何运行智能体循环。
来源:OpenRouter Announcements · openrouter.ai