如何阻止AI Agent陷入无限循环

发布时间 2026-11-12
来源 OpenRouter Blog
字数 4,325 字
查看原文 反馈

智能总结

OpenRouter 在六个当前模型上测试了防止 AI Agent 死循环的四道防护。开启死循环检测后,六个模型都在第三次相同工具调用时被拦截,18 次运行中有 16 次在两次真实工具调用后结束;不开检测时,有 4 次一直重试到 40 步上限。

OpenRouter · 2026年11月12日

How to Stop an AI Agent Stuck in an Infinite Loop

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

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.

我们在六个模型上各运行同一个 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 会让你指定的更强模型来跑下一轮。