高级 📋 7 个步骤 第 441 / 441 篇

工具调用失败别急着重试:给 Harness 加一层上下文注入式错误恢复

多数 harness 只把裸报错丢回模型,模型因此原地重试并陷入循环。上下文注入式错误恢复把错误原文、本会话历史尝试与定向恢复建议拼成结构化错误块注入下一次推理,再配合循环检测兜底。本教程给出可复现的 Python 实现,并说明四类适得其反的场景。

2026.09.17· 20 分钟阅读· 约 2410 字· 🛠 Agent Harness / 🛡 容错重试

本地跑一个 Agent,工具返回「File not found」,模型重试、还是同一个路径、还是失败;再试一次,还是失败。十几轮过去,上下文里堆满了同一句报错,token 烧掉不少,任务没前进。问题不在模型笨,而在它每次只看到一个数据点:不知道刚才已经试过什么、也不知道还有哪些路没走。这篇教程实现一个 harness 侧的「上下文注入式错误恢复」层,把裸报错换成一个结构化错误块,并说清它在什么情况下反而有害。

🛠 本教程适合:自建 Agent 循环或自定义 harness、被重试循环拖累成本与稳定性的工程同学。需要 Python 基础,能读得懂消息数组结构。示例不依赖任何特定模型厂商。

先搞懂:裸报错回传为什么会导致重试循环

多数 harness 在处理工具调用失败时只做一件事:把原始错误字符串塞回消息列表,然后让模型再推理一次。模型看到的是孤立的一个失败点,缺少三样东西——之前试过什么、同类操作的历史、以及可选的替代路径。结果是它在信息不对称下做决策,最省力的选择就是「照原样再试一次」。

注入式错误恢复要做的事,是把「失败」从一个点扩展成一个有结构的状态描述。关键不在于提示词写得多温柔,而在于结构:模型必须同时看到「出了什么错」「已经试过什么」「还有哪些路能走」。有来源把这个改动带来的重试循环减少量估在 25% 到 40% 区间,主要贡献是省掉了前两三次注定无效的重复尝试。

Step 1:先复现一个重试循环

1 写一个最小的循环并观察它
# 最小复现:工具永远失败,看模型会重试几次
import json

def run_loop(agent_step, tools, user_task, max_steps=12):
    messages = [{"role": "user", "content": user_task}]
    for i in range(max_steps):
        plan = agent_step(messages)              # 模型决定下一步
        if not plan.get("tool"):
            return plan.get("content"), i        # 给出最终答复
        name, args = plan["tool"], plan["args"]
        try:
            result = tools[name](**args)
            messages.append({"role": "tool", "name": name, "content": str(result)})
        except Exception as e:
            # 问题就在这里:只回传一句裸报错
            messages.append({"role": "tool", "name": name, "content": f"ERROR: {e}"})
    return None, max_steps

# 观察点:同一 (工具, 参数) 组合被重复调用的次数
📉 记录「坏循环率」:同一 (工具名, 目标) 在单次会话中失败超过 2 次,就算进入坏循环。这是后面衡量效果的核心指标。

Step 2:设计结构化错误块(三段式)

2 错误原文 + 历史尝试 + 定向建议

替换掉那句 ERROR: {e},改为注入下面这个结构。三段缺一不可:

[Error Recovery Context]
Operation: edit_file("src/config.ts", ...)
Error: File not found: src/config.ts
Previous attempts (this session):
  edit_file("src/config.ts", ...) -> File not found
  read_file("src/config.ts")      -> File not found
Recovery suggestions:
  Verify the path exists (use list_directory or find_file)
  Check for typos in directory or filename
  The file may have been moved or renamed earlier in this session

写法要求:错误原文必须逐字保留(别做摘要,摘要会吃掉定位错误的关键字);历史尝试要带结果而不只是操作;建议要「通用到不指定单一解法,又具体到能排除已试过的路」。建议来自静态映射表,不要为了生成建议再调一次模型。

Step 3:实现会话级失败日志与建议目录

3 按「操作 + 目标」索引
SuggestionCatalog = {
    "FileNotFoundError": [
        "确认路径存在:先调 list_directory 或 find_file",
        "检查目录名或文件名是否拼错",
        "该文件可能在本会话中被移动或重命名过",
    ],
    "PermissionError": [
        "检查凭证是否过期或作用域不足",
        "确认沙箱是否限制了对该目录的写权限",
    ],
    "rate_limit": [
        "退避后重试,不要立刻重发",
        "考虑换用批量接口或降低并发",
    ],
}

class FailureLog:
    # 按 (操作类型, 目标) 记录本会话内的失败,供注入时引用
    def __init__(self):
        self.by_key = {}

    def key(self, name, args):
        target = args.get("path") or args.get("url") or args.get("table") or ""
        return (name, str(target))

    def record(self, name, args, error):
        k = self.key(name, args)
        self.by_key.setdefault(k, []).append({"args": args, "error": str(error)})

    def history(self, name, args, limit=5):
        return self.by_key.get(self.key(name, args), [])[-limit:]
🧱 键的选择决定效果:用「工具名 + 目标标识」当键,就能把「同一个文件被反复尝试」串起来;只用工具名当键,会漏掉跨目标的历史。

Step 4:把错误块注入到正确的消息位置

4 紧跟在错误结果之后
def build_error_context(name, args, error, log):
    lines = ["[Error Recovery Context]"]
    lines.append(f'Operation: {name}({json.dumps(args, ensure_ascii=False)})')
    lines.append(f'Error: {error}')                     # 原文保留
    hist = log.history(name, args)
    if hist:
        lines.append("Previous attempts (this session):")
        for h in hist:
            lines.append(f'  {name}({json.dumps(h["args"], ensure_ascii=False)}) -> {h["error"]}')
    sug = SuggestionCatalog.get(type(error).__name__, [])
    if sug:
        lines.append("Recovery suggestions:")
        for s in sug:
            lines.append(f'  {s}')
    return "
".join(lines)

# 注入:错误结果与结构化块一起进消息列表
log.record(name, args, e)
messages.append({"role": "tool", "name": name, "content": str(e)})
messages.append({"role": "tool", "name": name,
                 "content": build_error_context(name, args, e, log)})

不要把错误块塞进系统提示。系统提示是稳定前缀,注进去会破坏提示缓存命中,成本立刻上升。它应该紧跟在错误结果之后,属于「本轮的增量上下文」。

Step 5:加一层循环检测兜底

5 阈值 3,触发即中断

注入式恢复和循环检测解决的是失败生命周期的不同阶段:前者在失败瞬间起作用,靠信息阻断循环形成;后者在循环已经形成后把它掐断。两者要一起用。

MAX_REPEAT = 3

def guard_repeat(log, name, args):
    n = len(log.history(name, args))
    if n >= MAX_REPEAT:
        return {
            "status": "error",
            "code": "LOOP_DETECTED",
            "message": f'已连续 {n} 次在同一操作上失败,停止重试,请换策略或升级给人工',
        }
    return None
🔁 更实用的一步:让工具在「无数据」时也返回结构化错误而不是空字符串。返回 {"status":"error","code":"EMPTY_RESULT"} 比返回 "" 好得多——模型看不到结果时会误判成「调用没生效」,于是反复重试。

Step 6:量化对比,别凭感觉

6 同一批任务跑两遍
# 对照组:裸报错回传    实验组:注入结构化错误块
# 固定随机种子与任务集,记录三个数:
#   1) 坏循环率        = 失败超过 2 次的 (工具, 目标) 占比
#   2) 平均步数        = 完成任务所用工具调用次数
#   3) 输入 token 总量 = 含所有重试的开销

# 期望结果:坏循环率下降,步数下降;token 总量要单独看——
# 因为注入本身也增加 token,近上下文上限的会话可能反而变差

只盯着「重试次数变少」会误判。注入块本身要花 token,如果会话已经接近上下文上限,省下的重试可能抵不过注入带来的截断风险。三个指标一起看,才算证明它有效。

Step 7:四类适得其反的场景

7 该关掉这层的时候要关掉
场景为什么变差处理
会话已接近上下文上限注入历史与建议会把总长顶过上限,截断更早的会话历史超阈值时只注入精简版(仅建议,不带历史)
高频、低方差的错误系统性鉴权失败或网络中断会让每次重试都拿到同一批通用建议识别为系统性故障,直接中断并告警
建议目录陈旧工具面变了但映射没更新,会建议已经不存在的能力把建议目录纳入工具变更的检查清单
参数量很小的模型结构化块的阅读成本超出小模型的遵循能力小模型只注入建议段,或改用固定重试策略
🧭 判断口径:什么时候「不注入」比「注入」好?当错误是系统性而非操作性的时候。恢复建议对「路径写错」有用,对「整个网络断了」无用。

常见问题速查

现象常见原因处理
模型仍原地重试结构化块位置不对或被摘要过紧跟错误结果注入,原文逐字保留
成本不降反升注入块进了稳定前缀,缓存失效改为追加在消息尾部,不进系统提示
工具返回空字符串导致重试无数据时返回空而不是结构化错误统一返回带 code 的错误对象
建议里出现已下线的工具建议目录没随工具面更新把目录纳入工具变更评审
小模型越注入越乱结构化块超出其遵循能力只给建议段,或退回固定重试

结语

这一层的本质是把「工具失败」从一句话升级成一份状态报告。它不改变模型能力,只改变模型在失败瞬间能看到的信息量——而重试循环大多不是能力问题,是信息问题。实现成本也不高:一张建议映射表、一个会话级失败日志、一次位置正确的注入,加起来不到两百行。

它不该被当成万能药:在近上下文上限、系统性故障、建议目录陈旧、模型很小这四种情况下,最好有开关能把它降级或关掉。把它做成可配置的一层,而不是硬编码进主循环,是这次改造最值得保留的设计。

← 返回教程中心