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 会一直循环,直到模型不再请求工具,所以这次运行本来不会自己停下来。最终回答里说服务超时了,可见模型每一轮都知道这一点,却还是照样重试。

我们在六个模型上各跑了同一个智能体三次,分别开启和关闭检测:
| 模型 | 每次运行的工具调用数(无防护) | 触发 40 步上限 | 每次运行的工具调用数(doomLoop: true) |
|---|---|---|---|
| anthropic/claude-haiku-5.5 | 6, 6, 6 | 0/3 | 2, 2, 2 |
| deepseek/deepseek-v4.1-flash | 6, 5, 7 | 0/3 | 2, 2, 2 |
| google/gemini-3.8-flash | 5, 6, 5 | 0/3 | 2, 2, 2 |
| mistralai/mistral-large-4-0 | 11, 41, 41 | 2/3 | 2, 12, 2 |
| openai/gpt-6-luna | 17, 37, 21 | 0/3 | 2, 2, 20 |
| x-ai/grok-4.7 | 41, 12, 41 | 2/3 | 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.死循环检测会按工具名和参数为每次工具调用生成指纹。它先对参数做规范化处理,所以即使 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.”的提示。(这条提示并非我们实际触发,而是来自文档。)已经在处理中的请求仍会完成,所以实际支出可能略微超出上限,但整夜运行的循环会在接近每日上限时停下。支出控制指南介绍了工作区预算与成员级防护措施如何配合使用。
哪种防护能拦住哪种循环?
每种防护拦下的循环类型不同。密钥限额能拦住所有循环,但要等密钥额度花完才生效,所以最适合作为最后一道防线。
| 循环类型 | doomLoop | loopKey | stopWhen 上限 | 密钥限额或工作区预算 |
|---|---|---|---|---|
| 同样的调用、同样的参数 | 能拦住 | 不需要 | 在额度上限处拦住 | 在限额处拦住 |
| 同样的调用、参数略有不同 | 拦不住 | 能拦住 | 在额度上限处拦住 | 在限额处拦住 |
| 每一轮都换新方法 | 拦不住 | 拦不住 | 在额度上限处拦住 | 在限额处拦住 |
| 多次运行,每次都在各自上限内 | 仅限单次运行 | 仅限单次运行 | 仅限单次运行 | 拦住总量 |
| 部署时漏掉了这些防护 | 拦不住 | 拦不住 | 拦不住 | 在限额处拦住 |
常见问题
为什么我的智能体在报错后一直调用同一个工具?
工具返回的结果说调用失败,而提示词又要求完成任务,所以在模型看来再试一次才是正确做法。返回错误时,要告诉模型下一步该怎么做,比如“计费服务已宕机。告知用户并停止。”不要告诉模型一直重试到成功为止。
最大迭代次数限制能阻止成本失控吗?
最大迭代次数限制能阻止单次运行无限进行下去,但它并不能限制你的花费,因为越往后的轮次成本越高,而且多次运行即使都在限制之内,加起来仍然很可观。LangChain 的 AgentExecutor 中的 max_iterations 和 CrewAI 中的 max_iter 这类设置,与 OpenRouter Agent SDK 中的 stepCountIs 作用相同。要把它们与单次运行的成本上限以及 API 密钥的额度限制配合使用。
后续步骤
-
死循环检测:分级处理、引导、升级以及按工具覆盖。
-
停止条件:stepCountIs、maxCost、maxTokensUsed 以及自定义条件。
-
设置团队 AI 支出控制:在仪表盘中配置工作区预算和防护措施。
-
Agent SDK:在 OpenRouter 上构建多轮智能体工作流:callModel 如何运行智能体循环。
来源:OpenRouter:Announcements · openrouter.ai
来源:AIHOT · AI行业动 · aihot.news