文章将智能体循环按重复工具及参数等模式分类,并通过规范化参数、哈希签名和调用计数实时识别相同操作。相比单纯设置最大轮数,这种方法能更早终止异常并反馈具体原因。
本系列:AI in TypeScript——全套 5 本,从第一次调用 LLM 到将 Agent 投入生产环境——五本均可在此查看
我的项目:Hermes IDE | GitHub——一款面向使用 Claude Code 和其他 AI 编程工具交付软件的开发者 IDE
关于我:xgabriel.com | GitHub
设置最大轮次是常见建议,而且确实有必要。但这也是一种简单粗暴的手段:等它触发时,你已经为达到上限前的每一轮付出了成本,而用户最终只会得到一个不完整的答案,却不知道 Agent 为什么放弃。
循环有不同的形态。每种形态都有对应的成因。在循环刚开始形成时就检测出来,你便可以通过提供信息来打破它,而不是依靠限制强行终止。这样通常可以挽救本次运行,而不是直接结束它。
形态一:完全相同的重复
同一个工具、同一组参数,一遍又一遍地调用。
search_docs { query: "refund policy" } → 0 results
search_docs { query: "refund policy" } → 0 results
search_docs { query: "refund policy" } → 0 results
这种情况几乎总是由空结果或没有帮助的结果引起。模型会把「没有结果」理解成暂时性故障,于是再次尝试,就像人也可能这么做一样。
检测方法是对工具名称与参数的组合计算哈希:
const sig = (b: ToolUseBlock) =>
`${b.name}:${createHash("sha1")
.update(JSON.stringify(b.input, Object.keys(b.input as object).sort()))
.digest("hex").slice(0, 12)}`;
export class LoopDetector {
private seen = new Map<string, number>();
observe(b: ToolUseBlock): Signal | null {
const k = sig(b);
const n = (this.seen.get(k) ?? 0) + 1;
this.seen.set(k, n);
if (n >= 3) return { kind: "identical", tool: b.name, count: n };
return null;
}
}
对键进行排序很重要——同一次调用即使属性顺序不同,也必须生成相同的哈希,否则检测器永远不会触发。
打破循环的方法,是直接告诉模型它正在做什么:
if (signal?.kind === "identical") {
results.push(errorResult(block.id,
`You have called ${signal.tool} with these exact arguments ` +
`${signal.count} times and received the same result. It will not ` +
`change. Either try different arguments or tell the user what you ` +
`could not find.`));
continue;
}
相比轮次上限,这条消息更容易解决循环,因为它不仅告诉模型原因,还提供了两个具体选项。
每次使用略有不同的参数,却始终找不到任何东西。
search_docs { query: "refund policy" }
search_docs { query: "refunds policy" }
search_docs { query: "policy for refunds" }
search_docs { query: "refund rules" }
每个签名都是唯一的,所以用于检测完全相同重复的检测器永远不会触发。这类循环会悄无声息地消耗二十轮调用。
此时应该根据工具的调用频率检测,而不是判断调用是否完全重复:
observe(b: ToolUseBlock): Signal | null {
const byTool = (this.tools.get(b.name) ?? 0) + 1;
this.tools.set(b.name, byTool);
if (byTool >= 5 && !this.progressed) {
return { kind: "drift", tool: b.name, count: byTool };
}
// ...
}
progressed 是关键——如果同一个工具的五次调用每次都返回了新内容,那完全没有问题。你需要跟踪结果是否真的发生了变化:
markResult(name: string, out: unknown) {
const h = sha1(JSON.stringify(out));
const prev = this.lastResult.get(name);
if (prev && prev !== h) this.progressed = true;
this.lastResult.set(name, h);
}
形态三:来回拉扯
两个工具交替调用,但都无法产生另一个工具可以使用的结果。
get_order → not found
search_orders → 3 results
get_order → not found (wrong id again)
search_orders → 3 results
这种情况应根据最近的调用序列检测,而不是看调用次数:
private recent: string[] = [];
observe(b: ToolUseBlock): Signal | null {
this.recent.push(b.name);
if (this.recent.length > 6) this.recent.shift();
if (this.recent.length === 6) {
const [a, c] = this.recent;
const alternating = this.recent.every((t, i) => t === (i % 2 ? c : a));
if (alternating && a !== c) return { kind: "pingpong", tools: [a, c] };
}
return null;
}
打破这种循环,通常需要明确地把缺失的关联信息返回给模型——例如搜索结果中的 ID,并将其格式化成能让下一次 get_order 成功调用的形式。

形态四:完全没有进展
这是最隐蔽的一种。工具不同、参数也不同,但 Agent 的状态没有发生变化——没有找到任何信息、没有做出任何决定,也没有写入任何内容。
export function stateFingerprint(s: RunState): string {
return sha1(JSON.stringify({
found: s.found.length,
decided: Object.keys(s.decisions).sort(),
written: s.written.length,
}));
}
在每轮结束后计算指纹。连续三次出现相同的指纹,意味着 Agent 看起来很忙,却没有向前推进。这正是最大轮次最终会捕获的问题,只不过那时已经额外浪费了好几个成本高昂的轮次。
如果你只打算实现一种检测器,就应该选择这个,因为它还能覆盖那些你尚未想到的循环形态。
将检测器接入循环
const detector = new LoopDetector();
const prints: string[] = [];
while (turns < maxTurns && cost < budget) {
const res = await client.messages.create({ /* ... */ });
turns++;
for (const block of res.content) {
if (block.type !== "tool_use") continue;
const signal = detector.observe(block);
if (signal) {
metrics.increment(`agent.loop.${signal.kind}`);
results.push(errorResult(block.id, adviceFor(signal)));
continue;
}
const out = await execute(block, ctx);
detector.markResult(block.name, out);
results.push(out);
}
prints.push(stateFingerprint(state));
if (prints.slice(-3).every((p, _, a) => p === a[0]) && prints.length >= 3) {
return stop("no_progress", state);
}
}
注意,检测器返回的是错误结果,而不是抛出异常。当前轮次会继续执行,模型会收到明确提示,而本次运行通常能够恢复。
为模型提供退出方式
循环之所以持续,往往是因为 Agent 没有一种可接受的失败方式。可以把退出方式作为工具提供给它:
const giveUp = tool({
name: "report_blocked",
description:
"Call this when you cannot make progress. Say what you tried and what " +
"information you would need. This is a correct outcome, not a failure.",
schema: z.object({
tried: z.array(z.string()).min(1),
needed: z.string(),
}),
async run(a) { return { acknowledged: true, ...a }; },
});
描述中的 "This is a correct outcome, not a failure" 确实发挥着重要作用。如果没有明确的退出方式,一个被要求提供帮助的模型就会不断尝试,因为停止在它看来就意味着失败。
这些参数对你同样有用:把不同运行中的 needed 汇总起来,就能得到一份 Agent 当前缺少的工具或数据清单。
logger.info("agent turn", {
runId, turn: turns,
tools: toolNames,
repeatMax: detector.maxRepeat(),
progressed: detector.progressed,
costUsd: cost,
});
repeatMax 和 progressed 是两个关键字段,只需搜索日志,就能非常直观地发现卡住的运行。如果一次运行的状态是 repeatMax: 9, progressed: false,那么无需进一步调查便可完成分类。
还应该按工具统计循环信号的触发率。如果某个工具频繁触发循环检测,问题通常出在工具设计上——例如返回的空结果没有帮助、没有区分「无匹配项」与其他情况,或者工具描述承诺的能力超出了实际表现。

这些机制都不能取代轮次上限和预算上限。检测器负责处理你能够识别的循环,而上限负责兜住那些你无法识别的情况。
不同之处在于,有了检测机制,上限就能回归其应有的角色:成为极少触发的最后一道保障,而不是主要控制手段。大多数卡住的运行最终也能给出有用的回答,而不是戛然而止的不完整内容。
《AI That Acts》介绍了 Agent 循环及其失败模式——包括如何设计不易引发循环的工具、如何检测循环、如何把错误结果作为纠正通道,以及如何通过防护措施控制第一个 Agent 的运行成本。

完整系列可在 xgabriel.com/ai-in-typescript 查看。
如需采取进一步行动,你可以考虑屏蔽此人和/或举报滥用行为。