本地跑一个 Agent,工具返回「File not found」,模型重试、还是同一个路径、还是失败;再试一次,还是失败。十几轮过去,上下文里堆满了同一句报错,token 烧掉不少,任务没前进。问题不在模型笨,而在它每次只看到一个数据点:不知道刚才已经试过什么、也不知道还有哪些路没走。这篇教程实现一个 harness 侧的「上下文注入式错误恢复」层,把裸报错换成一个结构化错误块,并说清它在什么情况下反而有害。
先搞懂:裸报错回传为什么会导致重试循环
多数 harness 在处理工具调用失败时只做一件事:把原始错误字符串塞回消息列表,然后让模型再推理一次。模型看到的是孤立的一个失败点,缺少三样东西——之前试过什么、同类操作的历史、以及可选的替代路径。结果是它在信息不对称下做决策,最省力的选择就是「照原样再试一次」。
注入式错误恢复要做的事,是把「失败」从一个点扩展成一个有结构的状态描述。关键不在于提示词写得多温柔,而在于结构:模型必须同时看到「出了什么错」「已经试过什么」「还有哪些路能走」。有来源把这个改动带来的重试循环减少量估在 25% 到 40% 区间,主要贡献是省掉了前两三次注定无效的重复尝试。
Step 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:设计结构化错误块(三段式)
替换掉那句 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:实现会话级失败日志与建议目录
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:把错误块注入到正确的消息位置
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:加一层循环检测兜底
注入式恢复和循环检测解决的是失败生命周期的不同阶段:前者在失败瞬间起作用,靠信息阻断循环形成;后者在循环已经形成后把它掐断。两者要一起用。
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:量化对比,别凭感觉
# 对照组:裸报错回传 实验组:注入结构化错误块
# 固定随机种子与任务集,记录三个数:
# 1) 坏循环率 = 失败超过 2 次的 (工具, 目标) 占比
# 2) 平均步数 = 完成任务所用工具调用次数
# 3) 输入 token 总量 = 含所有重试的开销
# 期望结果:坏循环率下降,步数下降;token 总量要单独看——
# 因为注入本身也增加 token,近上下文上限的会话可能反而变差
只盯着「重试次数变少」会误判。注入块本身要花 token,如果会话已经接近上下文上限,省下的重试可能抵不过注入带来的截断风险。三个指标一起看,才算证明它有效。
Step 7:四类适得其反的场景
| 场景 | 为什么变差 | 处理 |
|---|---|---|
| 会话已接近上下文上限 | 注入历史与建议会把总长顶过上限,截断更早的会话历史 | 超阈值时只注入精简版(仅建议,不带历史) |
| 高频、低方差的错误 | 系统性鉴权失败或网络中断会让每次重试都拿到同一批通用建议 | 识别为系统性故障,直接中断并告警 |
| 建议目录陈旧 | 工具面变了但映射没更新,会建议已经不存在的能力 | 把建议目录纳入工具变更的检查清单 |
| 参数量很小的模型 | 结构化块的阅读成本超出小模型的遵循能力 | 小模型只注入建议段,或改用固定重试策略 |
常见问题速查
| 现象 | 常见原因 | 处理 |
|---|---|---|
| 模型仍原地重试 | 结构化块位置不对或被摘要过 | 紧跟错误结果注入,原文逐字保留 |
| 成本不降反升 | 注入块进了稳定前缀,缓存失效 | 改为追加在消息尾部,不进系统提示 |
| 工具返回空字符串导致重试 | 无数据时返回空而不是结构化错误 | 统一返回带 code 的错误对象 |
| 建议里出现已下线的工具 | 建议目录没随工具面更新 | 把目录纳入工具变更评审 |
| 小模型越注入越乱 | 结构化块超出其遵循能力 | 只给建议段,或退回固定重试 |
结语
这一层的本质是把「工具失败」从一句话升级成一份状态报告。它不改变模型能力,只改变模型在失败瞬间能看到的信息量——而重试循环大多不是能力问题,是信息问题。实现成本也不高:一张建议映射表、一个会话级失败日志、一次位置正确的注入,加起来不到两百行。
它不该被当成万能药:在近上下文上限、系统性故障、建议目录陈旧、模型很小这四种情况下,最好有开关能把它降级或关掉。把它做成可配置的一层,而不是硬编码进主循环,是这次改造最值得保留的设计。