给 Agent Loop 加 Max Iteration
昨天我们手写了一个 100 行 Agent Loop。
今天给它加 max iteration。
很多教程会把这一步写得很轻:
for (let i = 0; i < maxSteps; i++) {
// call model
// call tools
}
这当然要写。
但如果你的理解停在这里,max iteration 就只是一个计数器。真正上线后,它会变成用户看到的那句:
Agent stopped due to max iterations.
这句话很熟。LangChain、LangFlow、n8n、各种 agent 项目的 issue 里都能看到类似问题。Agent 一直尝试工具调用,最后被最大迭代次数拦住。用户不知道它为什么停,工程师也不一定知道。
所以今天要讲的不是'怎么加一个 for 循环'。
是 Agent Loop 到底应该怎么停。
maxSteps 是保险丝,不是诊断报告
没有 maxSteps 的 Agent Loop 是事故入口。
模型可能重复读同一个文件。可能在工具失败后换个参数继续试。可能一直调用搜索工具。也可能拿到无用 observation 后继续绕圈。
maxSteps 的作用,是防止它无限跑下去。
但保险丝跳了,不等于你知道哪里短路。
如果最终只返回:
达到最大迭代次数。
用户什么也学不到。工程师也只能翻日志。
更好的做法,是把停止原因拆开:
type StopReason =
| "final"
| "max_steps"
| "repeated_error"
| "repeated_tool_call"
| "no_new_information"
| "budget_exhausted"
| "permission_denied"
| "user_cancelled";
max_steps 仍然保留,但它不应该吃掉所有问题。
Agent 连续三次读同一个不存在的文件,不该最后只说 max_steps。它应该停在 repeated_error。Agent 连续调用同一个工具、同一组参数,也不该跑满 20 步才停。它应该更早触发 repeated_tool_call。
最小实现:先把 stop reason 写进 state
昨天的 RunState 可以扩一下:
type StopReason =
| "final"
| "max_steps"
| "repeated_error"
| "repeated_tool_call";
type RunState = {
userGoal: string;
messages: RuntimeMessage[];
steps: RunStep[];
finalAnswer?: string;
stopReason?: StopReason;
sameErrorCount: number;
toolFingerprints: Map<string, number>;
};
工具调用要能生成 fingerprint:
function stableJson(value: unknown): string {
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
return `{${Object.entries(value as Record<string, unknown>)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, val]) => `${JSON.stringify(key)}:${stableJson(val)}`)
.join(",")}}`;
}
function toolFingerprint(call: ToolCall) {
return `${call.name}:${stableJson(call.input)}`;
}
不要直接 JSON.stringify()。对象 key 顺序不稳定时,两个相同语义的输入可能生成不同字符串。
shouldStop 不要散落在 loop 里
停止判断最好集中成函数。
function shouldStopBeforeStep(state: RunState, maxSteps: number) {
if (state.steps.length >= maxSteps) {
return { stop: true as const, reason: "max_steps" as const };
}
if (state.sameErrorCount >= 3) {
return { stop: true as const, reason: "repeated_error" as const };
}
for (const count of state.toolFingerprints.values()) {
if (count >= 3) {
return { stop: true as const, reason: "repeated_tool_call" as const };
}
}
return { stop: false as const };
}
然后 loop 每一轮开始前先问它:
for (;;) {
const decision = shouldStopBeforeStep(state, maxSteps);
if (decision.stop) {
state.stopReason = decision.reason;
return state;
}
// build context
// call model
// execute tools
}
这比在 loop 里到处写 if 稳。后面加 token budget、用户取消、权限拒绝、run timeout,也都放进这个函数。
repeated_error 怎么算
最简单的算法:连续错误码相同,就累计。
function updateErrorCounter(state: RunState, results: ToolResult[]) {
const errorResults = results.filter((r) => r.status === "error");
if (errorResults.length === 0) {
state.sameErrorCount = 0;
return;
}
const firstCode = errorResults[0].errorCode ?? "UNKNOWN_ERROR";
const allSame = errorResults.every((r) => (r.errorCode ?? "UNKNOWN_ERROR") === firstCode);
state.sameErrorCount = allSame ? state.sameErrorCount + 1 : 1;
}
这只是入门版。
更好的实现会把工具名、错误码、关键参数一起算进去:
function errorFingerprint(result: ToolResult) {
return `${result.toolName}:${result.errorCode ?? "UNKNOWN_ERROR"}`;
}
比如模型连续三次调用 read_file({ path: "src/foo.ts" }),都返回 NOT_FOUND,那就该停。
不要让模型继续猜。
repeated_tool_call 怎么算
每次执行工具前,记录 fingerprint:
function recordToolCall(state: RunState, call: ToolCall) {
const fp = toolFingerprint(call);
const count = state.toolFingerprints.get(fp) ?? 0;
state.toolFingerprints.set(fp, count + 1);
}
如果同一个工具、同一组参数出现 3 次,通常说明 observation 没有给模型足够新信息,或者工具描述误导了模型。
这时候继续跑,意义不大。
停止后应该给用户一个能理解的解释:
我停止在第 6 步,因为连续 3 次调用 read_file(path="src/foo.ts") 都返回 NOT_FOUND。
建议先确认文件路径,或让我重新列出 src 目录。
这比'max iterations reached'好太多。
maxSteps 应该怎么设
不要上来就设 50。
不同任务应该有不同默认值:
问答 / 摘要:3-5 步
读代码定位入口:6-10 步
小型修复:10-15 步
跨文件重构:20+ 步,但必须有预算和 checkpoint
更重要的是,maxSteps 应该和成本、权限、checkpoint 一起看。
一个允许写文件、跑测试、联网、安装依赖的 Agent,maxSteps 不应该和只读巡检 Agent 一样。每多一步,都可能多一次工具副作用和一轮模型成本。
所以配置不要只写:
maxSteps: 20
至少写成:
type LoopBudget = {
maxSteps: number;
maxModelCalls: number;
maxToolCalls: number;
maxRunMs: number;
maxRunTokens: number;
};
下一篇会专门讲 token budget。这里先记住:step 是成本单位,也是风险单位。
stop reason 要进入 trace
最小 trace 里应该记录:
{
"runId": "run_123",
"maxSteps": 8,
"steps": [
{
"index": 1,
"toolCalls": [
{
"name": "read_file",
"fingerprint": "read_file:{\"path\":\"src/foo.ts\"}"
}
],
"toolResults": [
{
"status": "error",
"errorCode": "NOT_FOUND"
}
]
}
],
"finished": {
"reason": "repeated_tool_call"
}
}
这份 trace 不需要华丽。它要能回答三个问题:
模型为什么继续?
工具是否带来新信息?
runtime 为什么停止?
如果 trace 里只有聊天记录,max iteration 问题很难复盘。模型会解释得头头是道,但你要看的是系统事实。
eval 也要测停止条件
很多人写 eval,只测'最后回答对不对'。
Agent Runtime 还要测'有没有及时停'。
至少写几个 golden task:
case 1:fake model 一直调用 unknown_tool。
期望:runtime 以 repeated_error 停止。
case 2:fake model 三次读取同一个不存在文件。
期望:runtime 以 repeated_tool_call 或 repeated_error 停止。
case 3:fake model 直到 maxSteps 都不 final。
期望:runtime 以 max_steps 停止,step 数等于配置。
case 4:用户取消。
期望:runtime 以 user_cancelled 停止,不再调度新 tool。
本书 demo 的 eval harness 就不是靠最终回答完全一致来判定,而是看 tool ledger、required tools、forbidden tools、denied tool、最终回答包含的证据。
停止条件也应该进入这种行为断言。
UI 里不要只显示'失败'
对用户来说,停止不一定是失败。
permission_denied 可能是正常结果。budget_exhausted 可能是在保护成本。repeated_error 可能是在避免浪费时间。max_steps 可能表示任务需要拆小。
最终回答可以这样写:
我没有继续执行。
原因:连续三次读取 src/foo.ts 都返回 NOT_FOUND。
已完成:
- 列出了 src 目录。
- 读取了 src/index.ts。
建议:
- 确认目标文件名。
- 或允许我重新搜索相关函数。
这才像一个工具,而不是一个突然停摆的黑盒。
最后
给 Agent Loop 加 max iteration,不是为了让它'最多跑几步'。
是为了让它在失控前停下来,并且说清楚为什么停。
maxSteps 是保险丝。
stop reason 是诊断。
trace 是证据。
eval 是防止同样问题下次再回来。
这几个东西连起来,Agent Loop 才开始像 Runtime。
参考资料
- LangChain issue: "Agent stopped due to max iterations."
- LangChainJS issue: agent stuck in a loop
- LangFlow issue: Agent stopped due to max iterations
- LangGraph Docs: GRAPH_RECURSION_LIMIT
- n8n community: How to fix AI Agent stopped due to max iterations


