当模型不断调用一个失败的工具时,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 个 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';
constopenrouter=newOpenRouter({ apiKey: process.env.OPENROUTER_API_KEY});
letattempts=0;
constfetchInvoice=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}")`);
thrownewError(`billing-service timeout after 30s fetching ${invoice_id}`);
},
});
letcost=0;
constresult=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]asconst,
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}`) }],
},
});
consttext=awaitresult.getText();
constverdict=awaitresult.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
我无法获取发票 INV-2291 的总额。计费服务在全部 41 次尝试获取该发票时
均持续在 30 秒后超时,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 步:开启 doom-loop 检测
在 callModel 调用中添加一个选项:
doomLoop: true,
fetch_invoice("INV-2291")
DoomLoopDetected: observe(连续 2 次)
fetch_invoice("INV-2291")
DoomLoopDetected: block(连续 3 次)
DoomLoopDetected: block(连续 4 次)
DoomLoopDetected: block(连续 5 次)
DoomLoopDetected: stop(连续 6 次)
SessionEnd: doom_loop
attempts: 2, cost: $0.0034
stopped: 疑似 doom loop:工具 "fetch_invoice" 在连续 6 轮中被调用
且参数完全相同(指纹 64be4614c5ea3e26…)。重复该调用不会
改变结果。请换一种方法,或解释为什么必须重复。
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(连续 2 次)
fetch_invoice("INV-2291")
DoomLoopDetected: block(连续 3 次)
fetch_invoice("2291")
fetch_invoice("inv-2291")
fetch_invoice("INV-2291")
DoomLoopDetected: observe(连续 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 " 和 "INV-2291" 是同一次调用
fetch_invoice("INV-2291")
DoomLoopDetected: observe(连续 2 次)
fetch_invoice("INV-2291")
DoomLoopDetected: block(连续 3 次)
DoomLoopDetected: block(连续 4 次)
DoomLoopDetected: block(连续 5 次)
SessionEnd: complete
attempts: 2, cost: $0.0039
现在每个变体都映射到 2291,因此连续计数持续增长,重复项始终被拦截。六次运行中有五次在两次真实调用后停止。在第六次中,模型尝试了另一张发票 INV-0000,这正确地重置了连续计数。
规范化大小写、空格、前缀以及其他任何不改变工具实际获取哪条记录的内容。要完全忽略某个字段,比如模型在每次重试时都会触发的超时,只列出真正重要的字段:loopKey: ['invoice_id']。如果某个工具的职责就是重复,比如轮询任务状态的工具,则使用 loopKey: false。
步骤 3:为每次运行限制步数和成本
检测只能捕获重复。如果模型每一轮都尝试新东西,就永远不会重复同一个指纹;而如果模型在被拦截后仍不断请求,在 stop 触发之前也不会被阻止。停止条件无论运行为何超长,都要结束运行。在导入 stepCountIs 的同时导入 maxCost,替换 stopWhen 并添加两个生命周期钩子:
let failuresInARow = 0; // 连续的工具错误次数,无论参数是什么
stopWhen: [stepCountIs(15), maxCost(0.01), () => failuresInARow >= 5],
hooks: {
// ...之前的钩子,再加上:
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,同样的防护也能放进任何循环里。按工具名和归一化后的参数统计调用次数,第三次重复时不要执行,而是返回一条工具结果,比如“Blocked: tell the user what went wrong”,并在步数或累计 usage.cost 超过你的上限时停止。我们的 Python 版本对接 chat completions API,在三次运行中都在四步之后停掉了 Mistral Large 4,花费不到 $0.002。
第 4 步:给 API key 设置信用额度上限
到目前为止,所有防护都运行在你的智能体进程内部。如果那段代码有 bug、发布时没带上限,或者跑了一百个各自都不超过 maxCost 的会话,那就没有任何东西限制总量。而 key 上的信用额度上限可以,因为我们会在请求到达提供商之前,在我们这一侧进行检查。
在仪表盘或用 create key API 设置 limit(以美元计)和 limit_reset(daily、weekly、monthly 或 null)。如果该 key 使用 BYOK,还要把 include_byok_in_limit 设为 true,因为它默认是 false,否则 BYOK 的花费不会计入上限。每日上限在 UTC 午夜重置,每周上限在周一重置。给每个智能体或客户分配各自的 key,这样一个循环最多只能花掉该 key 的那一份。如果多个智能体必须共用一个 key,请遵循为共享的智能体 key 设置上限。
在我们路由请求之前,会先按最坏情况计价,也就是输入加上 max_tokens。如果这超出了该 key 剩余的额度,请求就会失败,而且不会向你收费。我们用一个还剩 $9.97 的 key,向一个昂贵的模型发送了一个 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": "在 https://openrouter.ai/settings/keys 提高或移除此 API key 的使用限额,或使用一个没有限额的 key。"
"provider_name": null
}
},
"user_id": "<org-id>"
}
我们替换了 workspace slug、key hash 和组织 ID。
根据 error.metadata.limit_source 分支处理,而不是根据消息文本或状态码。已耗尽的 key 也会报告 openrouter_key_limit,而限额文档将该来源同时列在 402 额度错误和 403 预算错误下。如果消息说该 key 能负担的 token 更少,如上面的示例,就用更低的 max_tokens 重试。如果 key 已耗尽,在限额重置之前重试不可能成功,因此将其视为停止。Agent SDK 将 402 抛出为 PaymentRequiredResponseError。唯一值得重试的 402 是 openrouter_in_flight_budget,需遵循其 Retry-After 头。速率限制则返回 429,我们的LLM API 速率限制指南对此有说明。
Workspace 预算将一个 workspace 内所有 key 的上限合并计算,按天、周、月或终身。被阻止的请求会收到 403,消息类似 Workspace monthly budget of $500.00 exceeded. Contact your org admin.(我们没有触发这一条,所以消息来自文档。)已在途的请求仍会完成,因此支出可能略微超出,但整夜运行的循环会在接近每日上限时停止。支出控制指南展示了 workspace 预算和按成员的护栏如何配合。
哪道护栏能拦住哪种循环?
每道护栏拦截不同类型的循环。key 限额能拦住所有类型,但只有在 key 的上限耗尽后才生效,因此它最适合作为最后一层。
| 循环 | doomLoop | loopKey | stopWhen 上限 | Key 限额或 workspace 预算 |
|---|---|---|---|---|
| 相同调用,相同参数 | 能拦住 | 不需要 | 在上限处拦住 | 在限额处拦住 |
| 相同调用,参数略有不同 | 漏掉 | 能拦住 | 在上限处拦住 | 在限额处拦住 |
| 每一轮都是新方法 | 漏掉 | 漏掉 | 在上限处拦住 | 在限额处拦住 |
| 多次运行,每次都在各自上限内 | 仅按每次运行 | 仅按每次运行 | 仅按每次运行 | 拦住总量 |
| 缺少护栏的部署 | 漏掉 | 漏掉 | 漏掉 | 在限额处拦住 |
常见问题
为什么我的智能体在出错后一直调用同一个工具?
工具返回的结果说调用失败,而提示词又要求完成任务,所以在模型看来,再试一次似乎是正确的做法。返回错误时要告诉模型下一步该做什么,比如“计费服务已宕机。告知用户并停止。”不要告诉模型一直重试直到成功。
最大迭代次数限制能阻止成本失控吗?
最大迭代次数限制能阻止单次运行无限持续下去,但它并不能给你的花费设上限,因为后面的轮次成本更高,而且许多未触及上限的运行累积起来仍然很可观。LangChain 的 AgentExecutor 中的 max_iterations 和 CrewAI 中的 max_iter 这类设置,与 OpenRouter Agent SDK 中的 stepCountIs 作用相同。请将其中一项与每次运行的成本上限以及 API 密钥的额度限制配合使用。
后续步骤
-
死循环检测:阶梯式升级、引导、升级以及按工具覆盖。
-
停止条件:stepCountIs、maxCost、maxTokensUsed 以及自定义条件。
-
设置团队 AI 支出控制:仪表盘中的工作区预算和护栏。
-
Agent SDK:在 OpenRouter 上构建多轮智能体工作流:callModel 如何运行智能体循环。