我写的 AI Agent 用裸 while(true) 跑成了死循环:一个搜索工具被连调 40 次烧光整把 token,从 maxSteps 上限到 finish 出口的 Agent 健壮性全面复盘

第一次写 ReAct 模式的 AI Agent,主循环图省事用了裸 while(true);接上稍复杂任务后,模型对着同一个搜索工具反复鬼打墙、连调 40 多轮,十几分钟烧光了整把 token。这篇复盘讲清:模型不会自己数循环、必须用 maxSteps 兜底,以及 finish 出口、状态感提示、容错、历史裁剪、超时——一整套 Agent 健壮性设计。

我写的 AI Agent 陷入了死循环:同一个搜索工具被连调了 40 次烧掉一整把 token,我才搞懂"给 Agent 一个停下来的理由"有多重要

那是我第一次认认真真地写一个能"自己调工具、自己干活"的 AI Agent。架子搭得很顺:一个大循环,每轮把对话历史丢给大模型,模型决定"调哪个工具、传什么参数",我执行工具、把结果(observation)塞回对话历史,再进入下一轮——经典的 ReAct(Reason + Act)模式。我测了几个简单问题,跑得有模有样:模型调一次搜索、拿到结果、给出答案,完美。我得意地把它接上了一个稍复杂的任务,然后就去倒了杯水。

回来一看,终端还在哗哗刷屏。我凑近一瞧,头皮一麻:模型在反反复复地调用同一个搜索工具,参数几乎一模一样,一轮、两轮、十轮……它就像一个鬼打墙的人,在同一个地方原地转圈,完全停不下来。等我反应过来手忙脚乱地按下 Ctrl+C,它已经循环了 40 多轮——也就是说,我向大模型 API 发了 40 多次请求,把那个任务对应的 token 预算,在十几分钟里烧得精光。看着账单页面那串数字,我又心疼又困惑:我的 Agent,怎么就停不下来了?

故障现场:一个停不下来的循环

我赶紧把那 40 多轮的完整日志拉出来,一轮一轮地看。我的 Agent 循环,简化后大概长这样:

// 我最初的 Agent 主循环(有致命缺陷的版本)
async function runAgent(task: string) {
  const messages = [
    { role: "system", content: "你是一个助手,可以调用工具来完成任务。" },
    { role: "user", content: task },
  ];

  // 致命缺陷: while(true) 没有任何退出上限!
  while (true) {
    const resp = await llm.chat({ messages, tools: myTools });
    const msg = resp.choices[0].message;
    messages.push(msg);

    if (msg.tool_calls) {
      // 模型决定调工具 → 执行 → 把结果塞回去 → 继续循环
      for (const call of msg.tool_calls) {
        const result = await execTool(call.function.name, call.function.arguments);
        messages.push({ role: "tool", tool_call_id: call.id, content: result });
      }
      // 注意: 这里直接 continue, 继续下一轮, 没有任何"是不是该停了"的判断
    } else {
      // 模型给出了最终答案(没有 tool_calls), 才退出
      return msg.content;
    }
  }
}

看出来了吗?这个循环退出的唯一条件,是"模型某一轮不再调用工具、而是直接给出最终答案"。我天真地以为,模型拿到搜索结果后,自然就会满足、就会给答案、就会退出。可现实是:模型在某种情况下,会一轮接一轮地"决定再调一次工具",而我的循环对此毫无招架之力——它只会忠实地执行模型的每一个决定,模型让它调,它就调,模型不喊停,它就永远转下去。

那模型为什么会鬼打墙、反复调同一个工具呢?我盯着日志里那几轮几乎一样的请求,慢慢看出了门道。我那个任务,搜索工具返回的结果其实并不理想——它返回了一堆不太相关的内容,里面并没有模型真正想要的那个确切答案。于是模型的"想法"大概是:"我搜了一下,但没找到我要的,那我……再换个说法搜一次吧。"可它换的那个"说法",和上一次又差不多,于是又搜回了类似的、依然不理想的结果。模型陷入了一个"搜索→结果不满意→再搜→结果还是不满意→再搜"的怪圈,而它自己,既没有意识到"我已经搜过好几次了、该停了",也没有一个机制强迫它停下来。

第一件事:模型不会自己数"我循环了几次",你必须替它兜底

定位到问题,我做的第一件、也是最重要的一件事,是给这个 while(true) 加一个最大步数(max iterations)的硬性上限。这是写 Agent 循环的一条铁律,我却在第一版里漏掉了:

async function runAgent(task: string, maxSteps = 8) {
  const messages = [/* ...system, user... */];

  for (let step = 0; step < maxSteps; step++) {   // ← 用 for + 上限, 而非 while(true)
    const resp = await llm.chat({ messages, tools: myTools });
    const msg = resp.choices[0].message;
    messages.push(msg);

    if (!msg.tool_calls) {
      return msg.content;   // 正常: 模型给出最终答案
    }
    for (const call of msg.tool_calls) {
      const result = await execTool(call.function.name, call.function.arguments);
      messages.push({ role: "tool", tool_call_id: call.id, content: result });
    }
  }
  // 关键: 跑满 maxSteps 还没结束, 强制收尾, 而不是无限转下去
  return "抱歉,我尝试了多次仍未能完成这个任务,这是我目前掌握的信息:……";
}

这个 maxSteps 上限,是 Agent 的"安全带"。它的核心认知是:大语言模型在一个 Agent 循环里,并没有一个可靠的、内建的"我已经转了几圈、是不是该停了"的自我意识——它每一轮,都只是基于当前的上下文,独立地做一个"下一步调不调工具"的决定,它不会主动地去数"我是不是重复了太多次"。所以,这个"数圈数、到了上限就强制停"的职责,必须由你的外层代码来兜底。就像你不能指望一个埋头干活的人自己抬头看表,你得给他设个闹钟。加上这个上限后,哪怕模型还想鬼打墙,转够 8 圈我也会强制把它拽出来、给个体面的收尾,绝不会再出现烧穿 token 的惨剧。

第二件事:光有上限不够,得让模型"知道自己进展到哪了"

加了 maxSteps 上限,我的 Agent 至少不会再烧穿 token 了——但这只是"兜底",治标不治本。我不想让它每次都靠"撞上限"来结束,那等于每个难一点的任务都白白浪费 8 轮。我想让它聪明地、主动地停下来。于是我开始琢磨第二个、更深层的问题:模型为什么会"鬼打墙",一次次重复几乎相同的搜索?

我重新审视那段对话历史,发现了一个微妙的问题:我塞回去的 observation(工具结果),并没有清晰地告诉模型"这次和上次有什么不同、你的进展到哪了"。模型每一轮看到的,都是一堆相似的、不理想的搜索结果,它没有一个清晰的"状态感"——它不知道"我已经为这个子问题搜了 3 次都没结果了,该换个策略,或者干脆承认搜不到了"。它就像一个没有记性的人,每次都觉得"我再试一次说不定就成了"。我意识到:要让模型主动停下来,我得在喂给它的上下文里,给它足够的"线索",帮它意识到"再这么下去没有意义了"。我做了两件事:

// 改进1: 在 observation 里, 显式地告诉模型"这是第几次、之前试过什么"
function buildObservation(result: string, step: number, prevQueries: string[]): string {
  let obs = `[工具结果] ${result}\n`;
  obs += `[提示] 这是你第 ${step + 1} 次调用工具。`;
  if (prevQueries.length > 0) {
    obs += `你之前已经搜索过: ${prevQueries.join("; ")}。`;
    obs += `如果这些搜索都没能得到答案,请不要重复类似的搜索,`;
    obs += `而是基于已有信息直接作答,或明确告知用户你无法找到确切答案。`;
  }
  return obs;
}

// 改进2: 在 system prompt 里, 明确给出"何时该停"的指令
const systemPrompt = `你是一个会使用工具的助手。重要规则:
1. 不要重复进行相似的、已经失败过的工具调用。
2. 如果连续几次工具调用都没有得到有用信息,请停止调用工具,
   基于你已掌握的信息,诚实地给出你能给的最佳回答(哪怕是"我无法确定")。
3. 一旦你有了足够回答问题的信息,就立即给出最终答案,不要画蛇添足地再调工具。`;

这两个改进,本质上都是在帮模型建立"状态感"和"停止的判断力"。改进1把"你已经搜过这些了、别再重复了"这个关键信息,显式地、明明白白地写进了 observation 里——我不再假设模型能自己"记得"并"反思"它的历史行为,而是主动地把"反思的依据"喂到它嘴边。改进2则在 system prompt 这个"行为准则"层面,直接给了模型"何时该停"的明确指令:别重复失败的调用、几次没结果就停、有答案了就赶紧给。这背后的认知是:模型的行为,极大地受它所看到的上下文和指令的影响。它之所以"鬼打墙",一部分原因正是我没有给它"停下来"的理由和依据;当我把"你已经试过了""几次不行就该停了"这些线索清晰地给到它,它"主动停下来"的概率,就大大提高了。

下面这张图,对比了我那个"停不下来"的 Agent,和改进后"知道何时停"的 Agent:

左边那条红色的路径,就是我最初的死循环:结果塞回去、无脑继续,模型没有任何"该不该停"的判断节点。右边绿色的路径,则在每一轮都给了模型一个"反思与停止"的机会,再加上外层 maxSteps 的强制兜底(黄色),双保险之下,我的 Agent 才真正既"聪明地主动停",又"兜底地不会失控"。

第三件事:给模型一个明确的"我做完了"的出口

还有一个让我顿悟的改进:我给 Agent 显式地增加了一个名为 finish 的"工具"。听起来有点反直觉——但这恰恰是很多成熟 Agent 框架的常见做法。在我最初的设计里,模型"结束任务"的方式是"不调用任何工具、直接输出答案",这是一种隐式的结束信号。而我发现,把"结束"也变成一个模型可以显式调用的动作,效果会好很多:

// 给 Agent 增加一个显式的 "finish" 工具
const finishTool = {
  type: "function",
  function: {
    name: "finish",
    description: "当你已经收集到足够信息、可以回答用户问题时,调用此工具来给出最终答案并结束任务。",
    parameters: {
      type: "object",
      properties: {
        answer: { type: "string", description: "给用户的最终答案" },
      },
      required: ["answer"],
    },
  },
};

// 主循环里, 检测到 finish 工具被调用, 就结束
for (const call of msg.tool_calls) {
  if (call.function.name === "finish") {
    const { answer } = JSON.parse(call.function.arguments);
    return answer;   // ← 模型主动、明确地宣告"我做完了"
  }
  // ... 其他工具正常执行
}

为什么显式的 finish 工具,比隐式的"不调工具就算结束"更好?因为它把"结束"这个动作,变成了一个和"调用搜索""调用计算器"地位平等的、模型需要主动去做的、明确的决策。在工具列表里摆着一个 finish,等于时时刻刻在提醒模型:"嘿,你随时可以选择'结束'这个动作哦。"这让"结束"在模型的"决策选项"里有了清晰的存在感,而不是一个需要它"忘记调工具"才能触发的、模糊的默认行为。给 Agent 设计一个明确的'出口',就像给一个房间装一扇清晰的'安全出口'指示灯——它让'离开'这件事,变得显而易见、随时可选,而不是让人在屋里瞎转、指望碰巧撞到门。

第四件事:死循环只是冰山一角,Agent 还有一串"失控"的坑

这次"停不下来"的事故,让我警觉起来,系统地排查了 Agent 循环里其它可能"失控"的地方。结果发现,除了"无限循环",还有好几个亲戚坑,都源于同一个根本认知的缺失——"模型的输出是不可完全信赖的,你的外层代码必须对它做约束和兜底":

// 坑1: 模型返回的工具参数, JSON 可能是坏的 / 带 markdown 围栏
//   不要直接 JSON.parse, 要 try/catch + 容错
function safeParseArgs(raw: string): any {
  try {
    // 有些模型会把 JSON 包在 ```json ... ``` 里, 先剥掉
    const cleaned = raw.replace(/^```json\n?|\n?```$/g, "").trim();
    return JSON.parse(cleaned);
  } catch {
    return null;   // 解析失败, 别让整个 Agent 崩溃, 回喂"参数格式错误,请重试"
  }
}

// 坑2: 模型可能调用一个根本不存在的工具
function execTool(name: string, args: string) {
  const tool = myTools.find(t => t.function.name === name);
  if (!tool) {
    // 别抛异常崩掉, 而是回喂错误信息让模型纠正
    return `错误: 不存在名为 "${name}" 的工具。可用工具有: ${toolNames.join(", ")}`;
  }
  // ... 正常执行
}

// 坑3: 对话历史无限增长, 迟早撑爆上下文窗口
//   要做"历史裁剪"——保留 system + 最近 N 轮, 旧的摘要或丢弃
function trimHistory(messages: Message[], maxMessages = 20): Message[] {
  if (messages.length <= maxMessages) return messages;
  const system = messages[0];
  const recent = messages.slice(-(maxMessages - 1));
  return [system, ...recent];
}

// 坑4: 工具执行本身可能很慢/卡住, 要加超时
async function execWithTimeout(fn: () => Promise, ms = 10000) {
  return Promise.race([
    fn(),
    new Promise((_, rej) => setTimeout(() => rej(new Error("工具超时")), ms)),
  ]);
}

这四个坑,加上最初的"无限循环",共同指向一个 Agent 开发的核心心法:大语言模型是一个"能力强但不可靠"的决策者,你的 Agent 框架代码,本质上是它的"监护人"——你必须假设它会犯各种错(返回坏 JSON、调不存在的工具、陷入循环、让历史爆掉),并为每一种错都准备好"约束"与"兜底"。坑1:模型给的工具参数 JSON 可能是坏的、或裹着 markdown 围栏,得容错解析。坑2:模型可能凭空"幻觉"出一个不存在的工具名,得检测并友好地回喂错误。坑3:对话历史会随着循环不断膨胀,迟早撑爆上下文窗口,得做裁剪。坑4:工具本身可能卡住,得加超时。把这些"失控点"和对应的"兜底策略"整理成一张表:

失控点 会发生什么 兜底策略
无限循环 烧穿 token、停不下来 maxSteps 上限 + finish 工具 + 状态提示
坏 JSON 参数 JSON.parse 抛异常崩溃 try/catch + 剥围栏 + 回喂"格式错"
调用不存在的工具 找不到工具崩溃 校验工具名 + 回喂可用工具列表
历史无限膨胀 撑爆上下文窗口报错 裁剪历史(留 system + 最近 N 轮)
工具卡住 整个 Agent 卡死 给工具执行加超时

第五件事:把"Agent 健壮性自查清单"固化下来

把这些坑都补上后,我索性把"写一个 Agent 循环时,该自查哪些健壮性问题"整理成了一张清单,以后每写一个新 Agent,都照着过一遍。我把它分成"防失控""防崩溃""控成本"三类:

类别 自查项 是否已处理
防失控 有没有 maxSteps 循环上限? 必须有
防失控 有没有给模型明确的"何时停"指令/finish 出口? 必须有
防失控 有没有检测"重复的无效工具调用"? 建议有
防崩溃 工具参数解析有没有 try/catch 容错? 必须有
防崩溃 调用不存在的工具有没有友好兜底? 必须有
防崩溃 工具执行有没有超时/异常捕获? 必须有
控成本 对话历史有没有裁剪策略? 必须有
控成本 有没有对单任务的总 token/调用次数做监控? 建议有

这张清单,是我那次"40 次循环烧穿 token"的事故,换来的最实在的财富。它背后的核心思想可以浓缩成一句话:写 Agent,本质上不是在写一段"让模型自由发挥"的代码,而是在为一个强大但不羁的智能,精心设计一个"既给它自由、又给它边界"的笼子。"给自由",是让模型自主地决定调什么工具、怎么推理;"给边界",则是用 maxSteps、超时、历史裁剪、容错兜底这些机制,牢牢地框住它"失控"的可能。一个好的 Agent 框架,优雅之处恰恰在于这种平衡:它放手让模型去聪明地解决问题,同时又用一层严密的"防护网",接住模型每一种可能的"犯错"与"失控"。而我最初那个 while(true),正是一个只给了自由、却忘了给边界的笼子——它信任模型会自己停下来,却忘了模型,从来没有"自己停下来"的可靠保证。

一张"Agent 循环每一轮该怎么走"的决策图

把这次踩坑沉淀成一张图。写 Agent 主循环时,照着它把每一轮的判断都补全:

这张图的精髓,在于它把"放手让模型自由决策"(中间那条"调用工具"的主路径)和"层层设防的边界"(上限判断、参数校验、工具校验、超时、明确出口)编织在了一起。每一轮的开头,先问"该不该停"(maxSteps);模型决策后,再校验"它说的靠不靠谱"(参数、工具名);执行时,还兜着"会不会卡死"(超时)。把这张图变成你 Agent 主循环的骨架,那个"40 次死循环"的坑,以及它的一众亲戚坑,就都被你挡在了门外。

我立下的几条 Agent 开发规矩

这次"Agent 停不下来烧穿 token"的事故后,我给自己立了几条写 Agent 的规矩:

  1. 循环必有上限:任何 Agent 主循环都用 for(step < maxSteps),绝不用裸 while(true);跑满上限要有体面的强制收尾。
  2. 给模型明确的"停":在 system prompt 里明说"何时该停",并提供一个显式的 finish 工具作为清晰出口。
  3. 喂给模型状态感:在 observation 里带上"这是第几次、之前试过什么",帮模型避免重复无效的调用。
  4. 模型输出全都要容错:工具参数解析 try/catch、剥 markdown 围栏、校验工具名,假设模型会犯各种错并兜底。
  5. 历史要裁剪:对话历史随循环膨胀,留 system + 最近 N 轮,别让它撑爆上下文窗口。
  6. 工具执行加超时:每个工具调用都包一层超时,别让单个慢工具卡死整个 Agent。
  7. 监控成本:对单任务的总调用次数、总 token 做监控和告警,早发现"烧钱"的异常。

这几条里,第一条"循环必有上限"是用真金白银(那串 token 账单)换来的、最该刻进骨子里的铁律。而贯穿所有规矩的那条主线,是对"模型不可完全信赖"这一前提的清醒认知。我最初之所以栽跟头,根子上是我太"信任"模型了——我下意识地假设它会像一个理性的人一样,搜不到就停、有答案就给、不会鬼打墙。可大语言模型,本质上是一个基于概率的、强大但并不总是"理性"的文本生成器;它在一个 Agent 循环里的行为,有它自己的"脾气"和"惯性",并不会天然地符合我们对"一个理智的 Agent 该如何行事"的期待。把它当成一个需要被精心约束、被层层兜底的"强大但不羁的合作者",而非一个"理所当然会乖乖听话的下属",是我从这次事故里学到的、关于 Agent 开发最根本的一课。

写在最后:驾驭强大的东西,靠的是给它设好边界

这次被自己写的 Agent 死循环坑到的经历,让我对"如何驾驭强大的东西"这件事,有了一层更深的体会。大语言模型,无疑是一种强大到令人惊叹的能力——它能理解、能推理、能自主决策;可这次事故狠狠地提醒我:能力越强的东西,越需要给它配上同样强大的"边界"与"约束",否则,它的强大,既可能成就你,也可能在某个不经意的地方,反过来失控、烧穿你的钱包。一个没有 maxSteps、没有兜底的 Agent,就像一辆只有油门、没有刹车和方向盘的高性能跑车——发动机越强劲,失控时撞得就越惨。我那 40 次死循环,正是模型的"强大自主性",在缺乏边界的情况下,横冲直撞的结果。

想通这一点,我对"约束"这个词,有了全新的、正面的理解。我们常常觉得"约束"是束缚、是限制、是对自由的妨碍;可这次让我明白,对于一个强大而不羁的力量而言,恰当的'约束',非但不是妨碍,反而正是'驾驭'它、让它的强大真正为你所用的前提。给 Agent 设 maxSteps、设超时、设容错兜底,看起来是在"限制"模型,实则是在为它的强大"保驾护航"——正是这些边界,接住了它可能的失控,让它得以在一个安全的范围内,放心地施展它的能力。没有刹车的车不敢开快,有了可靠的刹车,你才敢真正踩下油门;边界给的,从来不只是安全,更是放手施展的底气。

所以,如果你也在写 AI Agent、或者在驾驭任何一种强大的技术,我想把这次踩坑最想说的话送给你:请永远不要只迷恋于一种能力的'强大',而忘了给它配上与之匹配的'边界'。越是强大、越是自主、越是"聪明"的东西,你越要谦逊地、审慎地去想:它会在哪里失控?它会犯什么错?我该用什么样的机制,去接住它的每一种可能的"出格"?因为真正的"驾驭",从来不是放任一匹烈马狂奔,而是既给它驰骋的草原,又给它能勒住它的缰绳;能力决定了一个系统的上限有多高,而边界,决定了它在触及上限的路上,会不会中途失控、车毁人亡。那 40 次停不下来的循环,最终教给我的,正是这份对"边界"的敬重——它让我明白,在这个 AI 能力日新月异、愈发强大的时代,学会给强大的智能,设计好恰当的笼子与缰绳,或许,正是我们这些手握这份力量的工程师,最重要、也最不该被忽视的一项修炼。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

一个合法的 0 被 || 悄悄吃成了 3000:我在 TypeScript 里因为分不清逻辑或和空值合并而踩的取默认值大坑,以及 || 与 ?? 精确区别的全面复盘

2026-6-1 18:03:47

技术教程

第二个用户的购物车里混进了第一个用户的商品:我在 Python 里因为一个 def func(items=[]) 的可变默认参数,踩了个跨用户数据泄露的共享状态大坑

2026-6-1 18:12:26

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索