跳转到主要内容

AI Agent 工具调用死循环怎么停:重复检测、错误分类与熔断

12 分钟阅读AI API

先阻止下一次不安全工具调用,再用可运行的 LoopGuard、故障矩阵和五组固定回放证明死循环已经被真正截断。

AI Agent 工具循环防护流程,展示预算、错误分类、重复与进展检测、幂等闸门以及停止恢复路径。

AI Agent 已经反复调用工具时,先不要改 prompt,也不要等它“自己想明白”。立即在 runtime 执行入口 做三件事:

  1. 暂停 run 和自动重试,但保留 trace、最后安全状态与已完成操作;
  2. 在所有有副作用的工具前关闭放行,特别是写库、发消息、支付、删除和启动子 Agent;
  3. 给当前 run 设置明确终态,例如 STOPPED_LOOP_GUARD,输出停止原因、已完成工作、未满足条件和恢复步骤。

验收不是“日志不再滚动”,而是:下一次不安全调用在执行前被拒绝;重复副作用没有发生;trace 能指出是哪条 guard 触发;修正前的调用序列可以回放,修正后会有界终止。

max_steps 是必要的保险丝,但它只保证最终会停,不会判断失败能否重试、任务是否有进展,也不会撤销已经执行的副作用。真正可靠的控制必须把硬上限、进展检测、幂等、终态和恢复分成不同责任。

先从 trace 判断是哪一种循环

至少记录 run_id、step、tool、规范化参数、结果类别、错误码、状态版本、幂等键和决策原因。只保存模型文本,很难区分“正在探索”与“换着说法原地踏步”。

Trace 症状更可能的问题执行前该检查什么正确处置
同一工具、同一有效参数连续出现精确重复call fingerprint 的近期计数达到单调用预算后停止
参数只变空格、字段顺序或无意义游标参数抖动canonical JSON 与业务相关字段归一化后按同一调用计数
A → B → A → B短周期振荡最近四次 fingerprint换策略或返回部分结果
工具已经成功,Agent 又要求执行成功重放completed-operation ledger返回已完成结果,不再执行
401、403、404 或 schema invalid 不断重试错误分类缺失结果是否不可恢复进入失败或人工终态
每次输出不同,但事实、子目标、游标都不变无进展循环单调状态版本或外部 state diff达到无进展预算后停止

不要只对整段参数做字符串 hash。对象字段顺序、空白和默认值会制造假差异;反过来,也不能删除页码、游标、资源版本等真正会改变结果的参数。fingerprint 应只规范化无语义差异,保留业务前置条件。

正常探索也可能连续调用同一个搜索或读取工具。它与死循环的差别不是“文本看起来像不像”,而是任务状态是否产生可验证变化,例如新增事实、完成子目标、推进游标、缩小候选集,或解决一个未决条件。能从数据库版本、游标、文件哈希或任务状态机推导进展时,不要让模型自己给自己打分。

先规定工具结果,再讨论重试

工具结果要带机器可判定的语义。Anthropic 的 tool-use 流程也明确由应用执行客户端工具并把 tool_result 返回给模型;因此循环控制仍属于应用 runtime,而不是模型调用本身。参见 Anthropic 工具调用流程tool result 处理说明

结果类别常见例子可否原样重试下一步
SUCCESS写入已提交、查询已得到目标数据记录 completed operation,推进或完成
PENDING异步任务尚未结束仅按 retry_after 和轮询预算等待后查询状态,不重放创建动作
RETRYABLEtimeout、临时 5xx、受控 429有界backoff,并改变时间这一前置条件
PRECONDITION缺少文件、凭证过期、资源版本冲突不能原样重试先满足条件,再用新状态重试
FATAL403、policy denied、无效 schemaFAILEDNEEDS_HUMAN

“再试一次”只有在相关前置条件发生变化时才成立:等待了服务端给出的时间、刷新了 credential、修正了 schema、取得了新游标,或切换到明确不同的策略。相同输入、相同状态、相同失败的再次执行,不是恢复策略。

一个框架无关的 LoopGuard

下面的 JavaScript 把 guard 放在工具执行前后。它不依赖模型承认自己卡住,也没有宣称这些阈值适合所有生产环境;2 次相同调用和 3 步无进展是本文固定回放使用的测试参数。

js
function stable(value) { if (Array.isArray(value)) return value.map(stable); if (value && typeof value === "object") { return Object.fromEntries( Object.keys(value).sort().map((key) => [key, stable(value[key])]) ); } return typeof value === "string" ? value.trim() : value; } function fingerprint(tool, args) { return `${tool}:${JSON.stringify(stable(args))}`; } class LoopGuard { constructor({ maxSteps = 20, maxMs = 60_000, sameCallLimit = 2, noProgressLimit = 3, completed = new Map(), initialProgressVersion = 0, } = {}) { this.maxSteps = maxSteps; this.deadline = Date.now() + maxMs; this.sameCallLimit = sameCallLimit; this.noProgressLimit = noProgressLimit; this.completed = completed; this.history = []; this.steps = 0; this.lastProgressVersion = initialProgressVersion; this.noProgress = 0; } before({ tool, args, operationKey }) { if (this.steps >= this.maxSteps) return this.stop("step_budget"); if (Date.now() >= this.deadline) return this.stop("wall_clock_budget"); if (operationKey && this.completed.has(operationKey)) { return { decision: "return_cached_success", result: this.completed.get(operationKey), }; } const fp = fingerprint(tool, args); const repeats = this.history.filter((item) => item.fp === fp).length; if (repeats >= this.sameCallLimit) return this.stop("same_call_repeat"); const recent = this.history.slice(-3).map((item) => item.fp); if (recent.length === 3 && recent[0] === recent[2] && recent[1] === fp) { return this.stop("short_cycle"); } this.steps += 1; return { decision: "execute", fp }; } after({ fp, result, progressVersion, operationKey }) { if (progressVersion > this.lastProgressVersion) { this.lastProgressVersion = progressVersion; this.noProgress = 0; this.history = []; } else { this.noProgress += 1; } this.history.push({ fp, kind: result.kind }); if (result.kind === "FATAL") { const terminalState = ["permission_denied", "policy_blocked", "needs_human"] .includes(result.code) ? "NEEDS_HUMAN" : "FAILED"; return this.stop(result.code || "fatal", terminalState); } if (result.kind === "SUCCESS") { if (operationKey) this.completed.set(operationKey, result); if (result.terminal) return { decision: "complete" }; } if (this.noProgress >= this.noProgressLimit) { return this.stop("no_progress"); } if (result.kind === "SUCCESS") return { decision: "continue" }; if (result.kind === "RETRYABLE") { return { decision: "retry_after_backoff", retryAfterMs: result.retryAfterMs }; } if (result.kind === "PRECONDITION") { return { decision: "needs_changed_precondition" }; } return { decision: "wait_bounded", retryAfterMs: result.retryAfterMs }; } stop(reason, terminalState = "STOPPED_LOOP_GUARD") { return { decision: "stop_partial", report: { terminalState, reason, completedSteps: this.steps, lastProgressVersion: this.lastProgressVersion, resumeRequires: "operator_changes_relevant_precondition", }, }; } }

调用方必须严格遵守 before() 的决定:只有 execute 才能真正触发工具。return_cached_success 直接返回台账中的成功结果;stop_partial 必须终止 planner,而不是把错误文本再次交给模型继续尝试。恢复 run 时要把持久化 checkpoint 的版本作为 initialProgressVersion,否则第一条未变化结果可能被误判为新进展。每次确认进展后会开启新的重复检测窗口,所以同 job-id 的合法 polling 不会因历史总次数被永久拦截;非终态 SUCCESS 仍需经过 no-progress 判断。

这段实现仍有刻意保留的边界:

  • fingerprint 能抓精确重复和短周期,抓不到所有语义等价参数;
  • progressVersion 的可靠性取决于谁维护它,优先由外部状态产生;
  • 内存中的 completed map 不能覆盖进程重启和多 worker,并发系统需要持久化、唯一约束和原子提交;
  • timeout 只能限制持续时间,不能自动回滚已经完成的支付、消息或写入。

幂等闸门要挡在副作用之前

步数上限最迟可能在第 20 步停止,但第二步就可能重复付款。对有副作用工具,要为“同一次业务操作”生成稳定的 operationKey,并在执行前查询 completed-operation ledger。

例如发送通知可以使用 tenant + incident + notification_type;创建订单可以使用调用方生成的 request id。台账写入与业务提交必须处在供应商支持的幂等协议、数据库事务或等价的原子边界里。仅在内存里先查再写,会被并发 worker 同时穿透。

幂等不是“忽略所有重复”。如果操作者确实要执行第二次,应生成新的业务操作标识并留下授权记录,而不是删掉旧台账。对于无法自动补偿的动作,guard 触发后应进入 NEEDS_HUMAN,不要假装系统可以自动回滚。

五组固定回放的实际结果

我们用 Node.js、固定 fake tool 和确定性 trace 运行了五个 fixture。fingerprint 对对象键排序,测试参数为总 step budget、同调用上限 2、无进展上限 3、wall-clock、终态与单调 progressVersion。这些结果证明的是实现行为,不是生产误报率或模型表现。

Fixture输入行为实际执行结果证明了什么
success_after_one第一次调用即 terminal success1 次工具调用后 completed正常成功不会被误杀
identical_retry同一 search、同一参数、retryable 且无进展第 3 次执行前以 same_call_repeat 阻断;只有 2 次工具副作用精确重复在总上限前停止
alternating_cycleread/check 交替,状态不推进3 次执行后以 no_progress 停止短周期或无进展能有界结束
changed_state_then_successcursor 与 progressVersion 持续前进3 个不同调用后成功合法探索不会因重复工具名被停止
fatal_no_retrywrite 返回 permission_denied1 次后停止不可恢复错误不会盲重试

上线前还要补你自己的 trace:参数 jitter、PENDING 轮询、并发相同幂等键、进程重启、成功响应丢包,以及 guard 到达前已经发生的副作用。回归测试的通过条件应写成“供应商或工具计数器没有新增调用”,而不是只断言抛出了异常。

停止后怎样恢复,而不是重新开一个循环

安全停止应该返回结构化 StopReport

json
{ "terminal_state": "NEEDS_HUMAN", "guard_reason": "permission_denied", "completed": ["read_config", "validate_schema"], "last_safe_checkpoint": "checkpoint-17", "unmet_precondition": "write permission for repository A", "resume_condition": "credential scope changed and approved", "next_allowed_action": "resume_from_checkpoint" }

恢复时从 checkpoint 读取 completed ledger,重新验证外部状态,只重做未完成操作。不要把完整原始对话重新喂给模型后从第一个工具开始;那会重放成功副作用,也会丢掉 guard 已经确认的失败原因。

终态至少应区分:

  • COMPLETED:目标已满足;
  • STOPPED_LOOP_GUARD:重复、短周期、无进展或预算触发;
  • NEEDS_HUMAN:需要权限、业务判断或高风险授权;
  • FAILED:不可恢复且没有安全替代;
  • PARTIAL:可以交付已有结果,但明确列出缺口。

框架上限负责兜底,业务语义仍归应用

官方框架普遍提供循环上限或停止钩子,但不能替你判断业务进展和副作用。

框架可用的官方控制应用仍要负责
OpenAI Agents SDKRunner 支持 max_turns;运行文档说明超过上限时的错误处理。官方运行指南进展定义、重试分类、幂等、checkpoint 与 StopReport
LangChainmiddleware 可限制模型调用、工具调用并配置超限行为。内置 middleware跨工具短周期、业务状态变化和副作用台账
Google ADKLoopAgent 提供 max_iterations,并支持由子 Agent 发出退出信号。Loop agents工具结果契约、恢复条件、持久化幂等
Anthropic tool use应用接收工具请求、执行工具并返回 tool_result处理工具调用完整 controller loop 与是否再次执行

OpenAI Agents SDK 的 reset_tool_choice 针对强制 tool choice 可能导致继续选工具的特定问题;它不是总预算、进展检测或幂等方案。max_turns 触发也只说明硬上限工作了,不证明根因已修复或副作用已回滚。

上线前的最小验收

  • guard 位于真正的工具执行入口,而不是只写进 system prompt;
  • step、wall-clock、token/cost 至少有明确预算与终态;
  • exact repeat、ABAB 和 no-progress 都有固定 trace 测试;
  • RETRYABLEPRECONDITIONFATALPENDINGSUCCESS 不共用一个重试分支;
  • 有副作用工具在执行前检查持久化幂等键;
  • 停止时输出 partial result、checkpoint、guard reason 和 resume condition;
  • 任何 resume 都先改变并验证相关前置条件;
  • 日志能回答“哪条规则阻止了哪一次调用”。

LoopGuard 解决“Agent 为什么继续调用、何时应停”;它不等于供应商成本闸门。若还需要在模型请求离开系统前强制控制金额,请继续配置 LLM Agent API 花费熔断开关。两者叠加后,控制循环与付费路径才分别有确定性的停止点。

#AI Agent#工具调用#死循环#熔断
分享文章: