OpenRouter · 2026年11月12日

TL;DR 为什么 AI Agent 会陷入无限循环?第 1 步:开启死循环检测第 2 步:告诉检测器什么算作相同调用第 3 步:为每次运行设置步骤和成本上限第 4 步:为 API 密钥设置额度上限哪种防护能阻止哪种循环?常见问题下一步
当模型不断调用一个失败的工具、而且通常还是用相同的参数调用时,AI Agent 就会陷入无限循环,因为 Agent 循环中没有任何机制告诉它停下来。每次重试都会重新发送整段对话,所以每一轮都比上一轮更贵,而一个放任运行一整晚的循环可能烧掉数千美元。
本指南用四道防护来终止循环,我们在六个当前模型上做了测试。其中三道防护通过 OpenRouter Agent SDK(@openrouter/agent 0.11.0)在你的 Agent 内部生效:死循环检测、一条界定哪些调用算作重复的规则,以及步骤数和成本上限。第四道是你 API 密钥上的额度上限。这道上限由我们在服务端强制执行,所以即使你的 Agent 代码有 bug,它依然有效。
TL;DR
- 开启死循环检测后,我们测试的全部六个模型都在第三次相同的工具调用时被拦截。不开检测时,18 次运行中有 4 次一直重试到 40 步的上限。开启检测后,18 次运行中有 16 次在两次真实的工具调用后就结束了。
- 在 callModel 中设置 doomLoop: true 会拦截第三次相同的调用,并在第六次时终止运行。
- 有些模型只需稍微改动参数就能绕过检测。设置一个对参数做归一化处理的 loopKey,就能拦住它们。
- stopWhen: [stepCountIs(n), maxCost(x)] 会为每次运行设上限,无论循环是由什么引起的。
- 密钥上的额度上限会限制你的总支出。超出额度的请求会失败,返回 402 和 limit_source: “openrouter_key_limit”。
为什么 AI Agent 会陷入无限循环?
当模型不再请求工具、直接给出回答时,Agent 循环就结束了。而一个不断失败的工具,不会给模型任何这样做的理由。错误被送回对话,指令仍然要求完成任务,于是再试一次看起来就是正确的下一步。除非你加上会计数的代码,否则循环里没有任何东西会统计尝试次数。
循环还会越跑越贵,因为每一轮都会重新发送整段对话,包括之前每一次失败。在一次我们逐轮记录日志的运行中,输入从 131 个 token 增长到 3,262 个 token,每轮成本几乎翻了两番。如果换成更贵的模型、同时又有大量会话在运行,这种增长正是一夜之间烧掉数千美元的原因。
为了亲眼看到这个过程,我们构建了一个账单 Agent,它唯一的工具会以账单服务宕机的方式失败。要运行它,把它保存为 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 会一直循环下去,直到模型不再请求工具,所以这次运行本来不会自行停止。最终的回答说服务超时了,可见模型在每一轮都知道这一点,却还是照样重试。

我们在六个模型上各运行同一个 Agent 三次,分别开启和关闭检测:
| 模型 | 每次运行的工具调用次数,无防护 | 触及 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 次调用,所以一次干净的测试并不能证明你的 Agent 不会循环。在全部 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 次时观察(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 会让你指定的更强模型来跑下一轮。