Agentpath
闯关18 / 31 节 · 预计 30 分钟

闯关:agent 卡在工具死循环里

同一个工具连调二十次,token 一路涨,任务毫无进展。在模拟终端里找出根因。

学完这节你能做到

  • 从事件流里定位重复调用的起点与触发条件
  • 区分三类根因:工具返回没信息、提示词冲突、退出条件缺失
  • 给出修复方案并说明为什么这次不会再犯

同事凌晨挂了一个无人值守任务:「把仓库里所有过期的 API 调用换成新写法」。 早上来看:任务没做完,账单 47 美元,会话里同一个工具被调了二十多次。

他把 --mode json 的输出留下来了。接手,找出根因。

接手现场

dev@build-01目标 0/5
  1. 1.看清哪个工具被反复调用
  2. 2.确认这是「烧钱但没进展」而不是正常的长任务
  3. 3.看它每次调用的参数是不是在变
  4. 4.看工具到底返回了什么
  5. 5.确认失败从来没被标记出来
现场只有两样东西:一份事件流 run.jsonl,和那位同事写的自定义扩展。目标有四个,按顺序推进。输入 goals 看目标,hint 要提示。
[dev@build-01 ~/incident]#
help 查看用法 · goals 看目标 · hint 要提示 · ↑↓ 翻历史

复盘:三类根因怎么分辨

同一个现象(工具反复调用)有三种常见根因,分辨方法各不相同:

根因识别信号这次是不是
工具返回没信息返回值为空 / 无变化,参数在小幅试错✅ 就是它
提示词冲突参数在两种模式间来回横跳,像在「服从两个矛盾指令」
退出条件缺失参数完全不变,返回值也不变,纯粹原地打转❌(参数一直在变)

这次的证据链很完整:参数每次都在变(模型在试错)→ 返回值一直是空 → isError 从来没被置上 → 模型没有任何信号能判断「是没找到,还是我写错了」。

根因不是模型笨,是工具把「执行失败」伪装成了「没有结果」。

三处该改的地方

    async execute(_id, params) {
      const { stdout, stderr, code } = await pi.exec("rg", ["-n", "--fixed-strings", params.pattern, "."])

      // ① 区分三种结果:命中 / 没命中 / 出错
      if (code === 2) {
        return {
          content: [{ type: "text", text:
            `搜索失败:${stderr.trim()}\n` +
            `本工具默认按字面量搜索。要用正则请去掉特殊字符或改用 regex=true。` }],
          isError: true,                                   // ② 明确标记失败
        }
      }
      if (code === 1 || !stdout.trim()) {
        return { content: [{ type: "text", text:
          `没有匹配「${params.pattern}」。已按字面量搜索整个仓库(排除 .git、node_modules)。` +
          `确认拼写,或换更短的关键词。` }] }              // ③ 「没找到」也要说话
      }

      const lines = stdout.trimEnd().split("\n")
      const head = lines.slice(0, 50).join("\n")
      return { content: [{ type: "text", text:
        lines.length > 50
          ? `${head}\n\n[共 ${lines.length} 处,只显示前 50 处。缩小范围或指定目录。]`
          : head }] }
    },

再补上声明层:

    description:
      "在仓库里按字面量搜索代码(底层是 ripgrep --fixed-strings,排除 .git 与 node_modules)。" +
      "没有匹配时会明确告知;超过 50 处会截断并提示。需要正则请设置 regex=true。",
    parameters: Type.Object({
      pattern: Type.String({ description: "搜索内容,默认按字面量匹配,不是正则" }),
      regex: Type.Optional(Type.Boolean({ description: "按正则解释 pattern" })),
    }),
×空字符串是最坏的返回值

它同时可能意味着:没找到、出错了、被过滤光了、超时了。模型无法分辨, 只能换个参数再试一次 —— 于是就有了这份 47 美元的账单。 任何工具在任何情况下都应该说一句话。

为什么四道防线全没拦住

防线这次为什么失效
工具集白名单生效了(只给了三个工具),但和这次事故无关
轮次上限没设 —— 只有 token 上限,所以它跑到 47 分钟才停
权限门生效了(没造成破坏),但拦不住「空转」
预算上限生效了,是它最后止的血 —— 代价是 47 美元
给无人值守加一条「无进展」检测

轮次和预算上限只能限制损失的上限,拦不住「早就该停了」。 更有效的是进展检测:连续 N 轮没有任何写操作、或者同一个工具连续失败 M 次, 就主动停下并报告。这条能把 47 美元变成 3 美元。

检查点多选

从这次现场里,哪些证据共同指向「工具返回没信息」而不是「退出条件缺失」?

这一节的结论

  1. 排障入口永远是事件流:先数调用次数,再看参数变不变,再看返回值。
  2. 参数在变 = 模型在试错(工具没给信息);参数不变 = 退出条件缺失。
  3. 工具必须区分「没结果」和「出错」,并且两种情况都要说话、都要给下一步。
  4. 预算上限只能限制损失上限;要早点停,得加「无进展检测」。

延伸资料