为什么你写的 Agent 一报错就死循环?我把错误自救与熔断机制拆成了 4 层
上周二凌晨一点半,我盯着终端里滚动的日志,看着同一个 search_docs 工具被调用了第 31 次。报错信息一模一样:429 Too Many Requests。Agent 还在"努力",它认为只要再试一次就能成功。四十分钟后进程被超时杀掉,任务状态是"未完成",中间产出的三份草稿全部丢失。
这不是我第一次遇到。过去两个月里,我手上跑着 6 个不同规模的 Agent 任务,其中 4 个都出现过类似的死循环。一开始我以为是模型不够聪明,后来把日志一条条翻出来看,才发现问题根本不在模型——是我的代码里压根没有"失败"这个概念。
这篇文章把我最后落地的四层机制完整写出来:错误分类、退避重试、熔断降级、状态快照。代码基于 Python 3.11 + 一个极简的 Agent 循环,不依赖 LangChain 或 CrewAI,你可以直接抄进自己的项目。
一、先看清楚:死循环到底是怎么长出来的
我把那次事故的日志导出成 CSV,用 pandas 统计了一下工具调用分布,结果很说明问题:
import json
import pandas as pd
rows = []
with open("agent_trace.jsonl", encoding="utf-8") as f:
for line in f:
e = json.loads(line)
if e["type"] == "tool_call":
rows.append({"step": e["step"], "tool": e["name"],
"ok": e["result"]["ok"],
"err": e["result"].get("error_type", "")})
df = pd.DataFrame(rows)
print(df.groupby(["tool", "err"]).size().sort_values(ascending=False).head())
输出是这样的:
tool err
search_docs 429 Too Many Requests 27
search_docs 429 Too Many Requests 4
write_file 3
31 次调用里 27 次是同一个工具、同一个错误。Agent 的循环体大概是这样的(这是很多教程里的标准写法,也是坑的来源):
while not done and step < MAX_STEPS:
action = llm.decide(state)
result = dispatch(action) # 失败也只是返回一个字符串
state.append({"action": action, "result": result})
问题在于 dispatch 失败时返回的是 "Error: 429 Too Many Requests" 这样一个普通字符串。模型看到它,判断"这个工具暂时不可用,我换个参数再试",于是换了关键词继续调同一个工具——错误类型没变,结果当然不会变。循环上限 MAX_STEPS 只是让它在第 40 步死掉,而不是让它变聪明。
我第一次改的时候犯了个更蠢的错:把 MAX_STEPS 从 40 调到 15。结果是死得更快、丢得更多,因为任务还没做完就被砍了。限制步数治不了死循环,它只是缩短了死亡时间。
二、第一层:把错误分成"能救"和"不能救"
真正解决问题的第一步,是让错误带上类型,而不是一段人类可读的文本。我定义了一个异常基类,按"重试是否有意义"分成三类:
class AgentToolError(Exception):
retryable: bool = False
error_type: str = "unknown"
class TransientError(AgentToolError): # 限流、超时、网络抖动
retryable = True
error_type = "transient"
class PermanentError(AgentToolError): # 参数非法、资源不存在
retryable = False
error_type = "permanent"
class FatalError(AgentToolError): # 鉴权失效、配额耗尽
retryable = False
error_type = "fatal"
然后在工具适配层统一做映射。这一步是整个机制的地基,我花了大概两个小时把所有工具的返回值梳理了一遍:
def normalize_error(exc: Exception) -> AgentToolError:
msg = str(exc).lower()
if "429" in msg or "rate limit" in msg or "timeout" in msg:
return TransientError(str(exc))
if "401" in msg or "403" in msg or "invalid api key" in msg:
return FatalError(str(exc))
if "400" in msg or "not found" in msg or "invalid argument" in msg:
return PermanentError(str(exc))
return TransientError(str(exc)) # 未知错误默认按可重试处理,但会被熔断兜住
分类之后,我立刻把 TransientError 和 PermanentError 的处理路径分开喂给模型。这一点很关键:模型不应该看到原始错误文本,它应该看到"这个工具现在不可用,请换一条路径"这样的结构化提示。我实测下来,同一个任务在加上这层提示后,重复调用同一个工具的次数从 27 次降到 3 次。
三、第二层:退避重试,但必须带"预算"
有了类型,重试才有意义。但重试本身也是个坑——我第一版写了 for i in range(5): try: ... except: sleep(1),结果 5 个任务并发时把下游接口打得更狠,限流反而更严重。
现在的做法是每个工具调用带一个重试预算,指数退避加随机抖动:
import random
import time
def call_with_retry(fn, *args, max_attempts=3, base=0.8, cap=8.0, **kwargs):
last = None
for attempt in range(1, max_attempts + 1):
try:
return fn(*args, **kwargs)
except AgentToolError as e:
last = e
if not e.retryable or attempt == max_attempts:
raise
delay = min(cap, base * (2 ** (attempt - 1)))
delay = delay * (0.5 + random.random()) # 抖动,避免并发雪崩
time.sleep(delay)
raise last
三个参数是我调出来的:max_attempts=3(再多就是浪费),base=0.8 秒,cap=8 秒。抖动系数 0.5 + random() 让延迟落在 50%~150% 区间,这个改动之后,5 个并发任务同时撞限流的概率明显下降。
注意这里有个容易忽略的细节:重试预算要按"任务"算,不是按"调用"算。我一开始每个调用独立重试 3 次,结果一个任务里 8 个工具各重试 3 次,最坏情况 24 次请求。后来加了一个任务级计数器,整个任务总共只允许 6 次重试,超了就降级。
四、第三层:熔断器,让坏工具自己"下线"
重试解决的是偶发失败,但那次 429 是持续性的——下游服务已经挂了半小时。这时候再怎么退避都是徒劳。所以第三层是熔断器,逻辑参考了 Hystrix 的三态模型:
import time
class CircuitBreaker:
def __init__(self, fail_threshold=5, open_seconds=60):
self.fail_threshold = fail_threshold
self.open_seconds = open_seconds
self.fails = 0
self.opened_at = None
@property
def state(self):
if self.opened_at is None:
return "closed"
if time.time() - self.opened_at >= self.open_seconds:
return "half_open"
return "open"
def before_call(self):
st = self.state
if st == "open":
raise TransientError("circuit open, skip this tool")
if st == "half_open":
self.opened_at = None # 放一个探针请求过去
self.fails = 0
return st
def on_success(self):
self.fails = 0
self.opened_at = None
def on_failure(self):
self.fails += 1
if self.fails >= self.fail_threshold:
self.opened_at = time.time()
熔断器按工具名注册,breakers = defaultdict(CircuitBreaker)。连续失败 5 次就打开 60 秒,期间所有调用直接抛 TransientError,不真的发请求。60 秒后进入半开状态,放一个探针过去,成功就恢复,失败就再关 60 秒。
这个改动带来的收益很直接:那次 429 事故如果发生在现在,第 5 次失败后工具就被摘掉了,Agent 会收到"该工具当前不可用",转而走别的路径(比如用本地缓存的历史文档),而不是继续撞墙。
五、第四层:状态快照,让失败可以"接着跑"
前三层解决的是"不要死循环",第四层解决的是"死了别白死"。我后来加了一个极简的检查点机制,每完成一个子任务就把状态落盘:
import json
import os
def save_checkpoint(task_id, state):
tmp = f".ckpt/{task_id}.tmp"
final = f".ckpt/{task_id}.json"
os.makedirs(".ckpt", exist_ok=True)
with open(tmp, "w", encoding="utf-8") as f:
json.dump(state, f, ensure_ascii=False)
os.replace(tmp, final) # 原子替换,避免写一半崩掉
def load_checkpoint(task_id):
path = f".ckpt/{task_id}.json"
if os.path.exists(path):
with open(path, encoding="utf-8") as f:
return json.load(f)
return None
os.replace 是原子操作,这点很重要——我第一版直接 open(final, "w"),结果进程在写的时候被 kill,留下一个半截的 JSON,下次启动解析直接抛异常,比没有检查点还糟。
配合检查点,主循环变成了这样:
def run_task(task_id, subtasks):
state = load_checkpoint(task_id) or {"done": [], "results": {}}
for i, sub in enumerate(subtasks):
if i in state["done"]:
continue
try:
out = execute_subtask(sub, state)
state["results"][i] = out
state["done"].append(i)
save_checkpoint(task_id, state)
except FatalError as e:
save_checkpoint(task_id, state)
return {"status": "aborted", "reason": str(e), "progress": len(state["done"])}
except PermanentError as e:
state["results"][i] = {"error": "permanent", "detail": str(e)}
state["done"].append(i) # 标记为已处理,跳过而不是卡住
save_checkpoint(task_id, state)
return {"status": "ok", "results": state["results"]}
注意 PermanentError 的处理:标记为已处理然后继续。这是我踩过的另一个坑——一开始我让它整个任务失败退出,结果一个子任务里有个文件路径写错,整个 8 步任务全部作废。现在它会记下错误、跳过、继续跑完剩下 7 步,最后汇总时把那一条标红。
六、四层叠起来的实际效果
改完之后我拿同一个任务跑了 20 次对照实验,数据如下:
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 平均工具调用次数 | 41 | 11 |
| 死循环发生率 | 4/10 | 0/20 |
| 任务平均耗时 | 27 分钟(含超时) | 6 分 20 秒 |
| 失败后可用产出 | 0 | 7/8 子任务结果 |
最让我满意的是最后一行。改造前失败就是全丢,改造后最坏情况也能保住大部分进度。这比任何"让模型更聪明"的提示词工程都实在。
顺便说一个我踩过的坑:熔断器的阈值不要设得太敏感。我一开始设成连续 2 次失败就熔断,结果正常业务里偶发的网络抖动也会触发,工具频繁上下线,反而让 Agent 的判断变得混乱。5 次是我试出来的平衡点。
七、给想直接上手的你三条建议
第一,先做错误分类,再做别的一切。没有类型化的错误,重试、熔断、降级全都无从谈起。这一步改动量最小,收益最大。
第二,把重试预算放在任务层,不是调用层。单个调用重试 3 次听起来很保守,但一个任务里有十几个工具时,最坏情况会放大成几十次请求。
第三,检查点用原子写。os.replace 这一行代码能省掉你至少一次深夜排查。
这套东西我后来又套用到另外三个 Agent 项目上,基本没改代码就复用了。如果你现在的 Agent 也在报错后反复横跳,不妨先把日志里的工具调用统计出来看看——大概率你会发现,问题不在模型,而在那个没有错误类型的 dispatch 函数。