闯关第 18 / 31 节 · 预计 30 分钟
闯关:agent 卡在工具死循环里
同一个工具连调二十次,token 一路涨,任务毫无进展。在模拟终端里找出根因。
学完这节你能做到
- 从事件流里定位重复调用的起点与触发条件
- 区分三类根因:工具返回没信息、提示词冲突、退出条件缺失
- 给出修复方案并说明为什么这次不会再犯
同事凌晨挂了一个无人值守任务:「把仓库里所有过期的 API 调用换成新写法」。 早上来看:任务没做完,账单 47 美元,会话里同一个工具被调了二十多次。
他把 --mode json 的输出留下来了。接手,找出根因。
接手现场
- 1.看清哪个工具被反复调用
- 2.确认这是「烧钱但没进展」而不是正常的长任务
- 3.看它每次调用的参数是不是在变
- 4.看工具到底返回了什么
- 5.确认失败从来没被标记出来
现场只有两样东西:一份事件流 run.jsonl,和那位同事写的自定义扩展。目标有四个,按顺序推进。输入 goals 看目标,hint 要提示。
[dev@build-01 ~/incident]#
复盘:三类根因怎么分辨
同一个现象(工具反复调用)有三种常见根因,分辨方法各不相同:
| 根因 | 识别信号 | 这次是不是 |
|---|---|---|
| 工具返回没信息 | 返回值为空 / 无变化,参数在小幅试错 | ✅ 就是它 |
| 提示词冲突 | 参数在两种模式间来回横跳,像在「服从两个矛盾指令」 | ❌ |
| 退出条件缺失 | 参数完全不变,返回值也不变,纯粹原地打转 | ❌(参数一直在变) |
这次的证据链很完整:参数每次都在变(模型在试错)→ 返回值一直是空 →
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 美元。
从这次现场里,哪些证据共同指向「工具返回没信息」而不是「退出条件缺失」?
这一节的结论
- 排障入口永远是事件流:先数调用次数,再看参数变不变,再看返回值。
- 参数在变 = 模型在试错(工具没给信息);参数不变 = 退出条件缺失。
- 工具必须区分「没结果」和「出错」,并且两种情况都要说话、都要给下一步。
- 预算上限只能限制损失上限;要早点停,得加「无进展检测」。