Agent 死循环的根因不在模型:错误分类、退避、熔断、快照四层实现

为什么你写的 Agent 一报错就死循环?我把错误自救与熔断机制拆成了 4 层

上周二凌晨一点半,我盯着终端里滚动的日志,看着同一个 search_docs 工具被调用了第 31 次。报错信息一模一样:429 Too Many Requests。Agent 还在"努力",它认为只要再试一次就能成功。四十分钟后进程被超时杀掉,任务状态是"未完成",中间产出的三份草稿全部丢失。

这不是我第一次遇到。过去两个月里,我手上跑着 6 个不同规模的 Agent 任务,其中 4 个都出现过类似的死循环。一开始我以为是模型不够聪明,后来把日志一条条翻出来看,才发现问题根本不在模型------是我的代码里压根没有"失败"这个概念。

这篇文章把我最后落地的四层机制完整写出来:错误分类、退避重试、熔断降级、状态快照。代码基于 Python 3.11 + 一个极简的 Agent 循环,不依赖 LangChain 或 CrewAI,你可以直接抄进自己的项目。

一、先看清楚:死循环到底是怎么长出来的

我把那次事故的日志导出成 CSV,用 pandas 统计了一下工具调用分布,结果很说明问题:

python 复制代码
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 的循环体大概是这样的(这是很多教程里的标准写法,也是坑的来源):

python 复制代码
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。结果是死得更快、丢得更多,因为任务还没做完就被砍了。限制步数治不了死循环,它只是缩短了死亡时间。

二、第一层:把错误分成"能救"和"不能救"

真正解决问题的第一步,是让错误带上类型,而不是一段人类可读的文本。我定义了一个异常基类,按"重试是否有意义"分成三类:

python 复制代码
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"

然后在工具适配层统一做映射。这一步是整个机制的地基,我花了大概两个小时把所有工具的返回值梳理了一遍:

python 复制代码
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 个任务并发时把下游接口打得更狠,限流反而更严重。

现在的做法是每个工具调用带一个重试预算,指数退避加随机抖动:

python 复制代码
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 的三态模型:

python 复制代码
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 会收到"该工具当前不可用",转而走别的路径(比如用本地缓存的历史文档),而不是继续撞墙。

五、第四层:状态快照,让失败可以"接着跑"

前三层解决的是"不要死循环",第四层解决的是"死了别白死"。我后来加了一个极简的检查点机制,每完成一个子任务就把状态落盘:

python 复制代码
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,下次启动解析直接抛异常,比没有检查点还糟。

配合检查点,主循环变成了这样:

python 复制代码
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 函数。

相关推荐
修炼的dance16 分钟前
同一张PNG换五种压法,体积差在颜色数上
前端
Lank_M17 分钟前
PNG 有损压缩省下的体积,大头不在 deflate
前端
用户老李40 分钟前
相机模型与标定自学教程 02:外参 \[R | t] 与完整投影链路
前端
用户老李1 小时前
无线电自学教程 03:电路基础——从欧姆定律到 LC 谐振
前端
申君健24864182772021 小时前
ESM 源码发布 + 显式子路径导出
前端
大龄秃头程序员1 小时前
实践 iOS Touch 响应链:从 sendEvent 到 nextResponder 的完整闭环
前端
用户老李1 小时前
YOLO 目标检测自学教程 05:动手——从零实现 mini-YOLOv1
前端
用户老李1 小时前
视频编码自学教程 02:颜色与色彩空间——从 RGB 到 Y'CbCr 4:2:0
前端
用户老李1 小时前
无线电自学教程 02:数学工具箱——正弦、复数、相量与分贝
前端