AI Agent 已经反复调用工具时,先不要改 prompt,也不要等它“自己想明白”。立即在 runtime 执行入口 做三件事:
- 暂停 run 和自动重试,但保留 trace、最后安全状态与已完成操作;
- 在所有有副作用的工具前关闭放行,特别是写库、发消息、支付、删除和启动子 Agent;
- 给当前 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 和轮询预算 | 等待后查询状态,不重放创建动作 |
RETRYABLE | timeout、临时 5xx、受控 429 | 有界 | backoff,并改变时间这一前置条件 |
PRECONDITION | 缺少文件、凭证过期、资源版本冲突 | 不能原样重试 | 先满足条件,再用新状态重试 |
FATAL | 403、policy denied、无效 schema | 否 | FAILED 或 NEEDS_HUMAN |
“再试一次”只有在相关前置条件发生变化时才成立:等待了服务端给出的时间、刷新了 credential、修正了 schema、取得了新游标,或切换到明确不同的策略。相同输入、相同状态、相同失败的再次执行,不是恢复策略。
一个框架无关的 LoopGuard
下面的 JavaScript 把 guard 放在工具执行前后。它不依赖模型承认自己卡住,也没有宣称这些阈值适合所有生产环境;2 次相同调用和 3 步无进展是本文固定回放使用的测试参数。
jsfunction 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 success | 1 次工具调用后 completed | 正常成功不会被误杀 |
identical_retry | 同一 search、同一参数、retryable 且无进展 | 第 3 次执行前以 same_call_repeat 阻断;只有 2 次工具副作用 | 精确重复在总上限前停止 |
alternating_cycle | read/check 交替,状态不推进 | 3 次执行后以 no_progress 停止 | 短周期或无进展能有界结束 |
changed_state_then_success | cursor 与 progressVersion 持续前进 | 3 个不同调用后成功 | 合法探索不会因重复工具名被停止 |
fatal_no_retry | write 返回 permission_denied | 1 次后停止 | 不可恢复错误不会盲重试 |
上线前还要补你自己的 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 SDK | Runner 支持 max_turns;运行文档说明超过上限时的错误处理。官方运行指南 | 进展定义、重试分类、幂等、checkpoint 与 StopReport |
| LangChain | middleware 可限制模型调用、工具调用并配置超限行为。内置 middleware | 跨工具短周期、业务状态变化和副作用台账 |
| Google ADK | LoopAgent 提供 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 测试;
RETRYABLE、PRECONDITION、FATAL、PENDING、SUCCESS不共用一个重试分支;- 有副作用工具在执行前检查持久化幂等键;
- 停止时输出 partial result、checkpoint、guard reason 和 resume condition;
- 任何 resume 都先改变并验证相关前置条件;
- 日志能回答“哪条规则阻止了哪一次调用”。
LoopGuard 解决“Agent 为什么继续调用、何时应停”;它不等于供应商成本闸门。若还需要在模型请求离开系统前强制控制金额,请继续配置 LLM Agent API 花费熔断开关。两者叠加后,控制循环与付费路径才分别有确定性的停止点。



