模型能连着调两三个工具,走到第三步开始解析报错或参数拼错,通常不是模型能力不够,而是每一步看到的工具返回形状不一致:有的工具回纯文本,有的回带多层包裹的 JSON,有的出错时直接抛一段异常字符串。这种场景下先改提示词收益有限,先把工具返回结构钉成固定字段,再放开自主循环,出问题时才有日志可对。
判断:多步 Agent 在第三步崩,多半是工具返回结构漂移,不是提示词不够强。动作:先在工具层统一返回字段,再在模型输出侧做格式校验和有限重试,最后把步数上限、同名工具连调、单步超时写进循环控制。验证:用同一条任务单步复跑,看每步 raw 返回是否同形。边界:这套做法挡的是格式类失败,任务逻辑错误和外部接口不可用仍需单独处理,通常要保留原样日志再退到上一步。
把一次真实的多步任务拆成单步工具序列
不要整体重跑整条任务,那样只能看到最终失败。先用一条固定输入,把任务的每一步写下来:step 序号、期望工具名、入参、期望返回内容、实际返回能否解析。通常一处解析失败之前的步骤都被顺带浪费了。
例如一条“查文本—读文件—写评论”的任务可以拆成:
- step 1,工具 search_text,入参 {"q":"release note"},期望返回 status=ok 与 data.hits,实际可解析。
- step 2,工具 read_file,入参 {"path":"..."},期望返回 status=ok 与 data.content,实际可解析。
- step 3,工具 add_comment,入参 {"id":"...","body":"..."},期望返回 status=ok 与 data.comment_id。这里第一次出现异常:read_file 在文件不存在时回了一整段带堆栈的裸文本,模型据此拼出了错误的 add_comment 入参。
把异常步骤标出来之后,改哪一层就清楚了:异常表现出现在 step 3 的入参,根因却在 step 2 的返回结构。验证方式是把 step 2 单独跑一遍,看它在成功和失败两条路径下返回的东西是否同形。
在工具层把返回结构统一成固定字段
工具层要做的事很简单:不管成功、业务错误还是异常,都返回同一个外壳。字段名可以自己定,但定下之后就不要在某个工具里随手回纯文本。
{
"status": "ok",
"tool": "read_file",
"data": { "content": "..." },
"error": null,
"meta": { "step": 2, "trace_id": "t-1024" }
}
失败时保持同一形状:
{
"status": "error",
"tool": "read_file",
"data": null,
"error": { "code": "NOT_FOUND", "message": "path not found", "retryable": false },
"meta": { "step": 2, "trace_id": "t-1024" }
}
字段分工:status 是模型和循环控制唯一用来分支的字段,通常只取 ok / error 两个值;data 在 status=ok 时是对象,在 status=error 时统一置 null,避免模型从半截数据里猜;error.code 用短枚举,message 给人看,retryable 告诉上层这一步该不该重试。meta 里的 step 和 trace_id 是给日志对齐用的,不影响模型判断。这段骨架适用于自建工具包装层,不绑定具体 Agent 框架,需要替换的是字段名和错误码枚举。
在模型输出侧加一层格式校验与重试
模型输出不要直接执行,先解析;解析失败就把错误原文回灌,限次重试。下面是一段与语言无关的骨架:
def run_step(model_out, max_retry=2):
fail = 0
while fail <= max_retry:
parsed = parse_tool_call(model_out)
if parsed.ok:
return parsed # 拿到 tool 名和 args
fail += 1
log.warn("parse_fail attempt=%d err=%s raw=%s", fail, parsed.err, model_out)
if fail > max_retry:
break
model_out = ask_model(
"上一步输出无法解析为工具调用,错误:%s。"
"请只输出 JSON,字段 tool 和 args,不要解释。" % parsed.err
)
return None # 交给循环控制决定跳过还是终止
max_retry 通常设 1 到 2 次就够,再多只是在同一个错误上打转。回灌时把校验器给出的错误原文带上,不要把错误改写成一大段解释,否则模型容易顺着解释写散文。超过次数后不要在重试函数里自行吞掉,返回 None 让上层循环控制统一处理;日志里保留 raw 输出和错误类型,方便回看是模型输出漂移,还是工具 schema 本身有歧义。
把终止条件写进循环控制而不是提示词
终止条件要写进循环控制器,不要指望提示词里的“最多调用几次”。三处控制点建议放在同一个循环函数里:
- 最大步数:循环开始处写死 MAX_STEPS,触发时输出 log.warn("reason=max_steps step=%d", step) 并结束。
- 同名工具连续调用:每一步记录工具名和参数哈希,连续两次相同就输出 log.warn("reason=same_tool tool=%s step=%d", tool, step),通常直接中断,而不是再让模型重试。
- 单步超时:用超时包裹单次工具调用,触发时输出 log.warn("reason=timeout tool=%s step=%d", tool, step)。
MAX_STEPS = 12
last = None
for step in range(MAX_STEPS):
out = ask_model(...)
call = run_step(out, max_retry=2)
if call is None:
log.warn("reason=parse_exhausted step=%d", step); break
if same_as_last(last, call):
log.warn("reason=same_tool tool=%s step=%d", call.tool, step); break
last = call
result = call_with_timeout(call, seconds=10)
log.info("step=%d tool=%s status=%s err=%s", step, call.tool, result.status, result.error_code)
这样每条日志都带 step 和 reason,出问题时能直接看出是模型不收尾、在原地打转,还是外部调用卡住。上面的步数和超时只是示例值,需要结合任务实际长度确认,不要照搬。
按单工具到多步的顺序逐步放开自主执行
放开节奏按“单工具—单工具加解析—两步串联—多步加控制”推进,每一步都能退回上一步。每一步放开后需要复测的项目:
- 单工具固定入参:只验证工具返回在成功和失败两条路径下字段是否同形,看 raw 日志。
- 加上模型输出解析:验证模型能否稳定输出 tool 和 args,重点看 parse_fail 日志有没有出现。
- 两步串联:验证第一步的 data 是否被第二步正确读到,step 号是否连续。
- 放开多步并开启重试与终止条件:验证 reason 日志在不该触发时没有出现。
出现异常时按相反顺序退回:先把 MAX_STEPS 降到上一步的值,再关掉 retry,最后退回单工具模式,用同一条输入复跑,对比两次日志里第一个出现差异的 step。回退是为了把变量减少到一个,而不是绕过问题;如果退回单工具后异常仍在,就要回到工具层的返回结构去查,不要再继续调提示词。