不稳定 tool 触发 Agent 重试风暴

一个偶发失败的工具调用,让 Agent 重试上百次,烧光预算、撞上 rate limit。给重试加上次数上限、带 jitter 的退避、429 处理和熔断器。

你的 Inngest、Temporal 或 LangGraph 流水线调用一个工具(代码执行、HTTP API、沙箱),它有 90% 的概率成功。某次偶发超时后,Agent 开始重试——零延迟、无退避、无上限。3 秒内这个工具被调用了 50 次。沙箱限速 10 req/s,于是每次重试都返回 429,而 Agent 把 429 也当成失败继续重试。500 次 LLM 调用之后,流水线烧掉了 30 万 token、撞上了模型方的 rate limit,而原始任务还没完成。一次 5 秒的小抖动,变成了 10 分钟的故障。

最快的修复: 给重试加上次数上限(一般 5 次比较合适)、加上带 jitter 的指数退避、对 HTTP 429 单独处理(读取并遵守 Retry-After),并在每个工具前面放一个熔断器,让 Agent 在工具明显挂掉时停止猛打。最后,只重试工具调用本身——绝不要重试整个 LLM 推理循环。

先判断你属于哪一类

日志里的症状最可能的原因跳到
某个工具被调上百次,停不下来没有重试上限(或框架默认是”无限”)Step 1
重试固定每 100ms / 500ms 一次没有指数退避Step 1
满屏 429,每个都立刻重试429 被当成普通错误Step 2
10 个并行 Agent 在同一瞬间一起飙没有 jitter / 没有共享限速Step 1 和 Step 5
成本是应有成本的 10-50 倍重试的是 LLM 调用,不是工具Step 4
连续 20 次以上失败,毫无恢复没有熔断器Step 3
400 / 403 被反复重试不可重试的错误被当成可重试Step 2

常见原因

1. 没有重试上限——Agent 一直循环直到预算耗尽

手写的 while not success: retry() 没有上限,会无限重试(直到成本预算花光)。框架默认值比看上去更危险:Temporal 的 activity 默认 RetryPolicymaximum_attempts = 0,含义是无限——它会一直重试外部工具,直到你设上限为止(其余默认值:退避系数 2.0、初始间隔 1 秒、最大间隔 100 秒,截至 2026 年 6 月)。Inngest 默认每个 step.run() 一共执行 5 次(1 次初始 + 4 次重试),且每个 step 有独立计数器,所以多 step 函数会把次数乘开。LangGraph 节点 RetryPolicy 默认 max_attempts=3

怎么判断:在 Agent/工具封装里搜没有 max_attemptsattempt < N 守卫的重试循环,并检查每个框架 RetryPolicy 是否显式设了上限。Temporal 那个没设上限的默认值,是这里最隐蔽的杀手。

2. 没有指数退避——重试比恢复来得还快

工具返回 503,Agent 100ms 后重试,还是失败,再 100ms 后重试。一个过载的服务在调用方退让时恢复得更快;固定间隔的重试会让它一直承压,把故障时间拖长。

怎么判断:记录重试时间戳。如果重试间隔是恒定的(100ms、500ms)而不是逐渐变大,就说明没有指数退避。

3. HTTP 429 没被识别——被当成普通错误

if status_code != 200: retry() 这种处理无法遵守 rate limit。429 的含义是”别再打我了”,而按 RFC 9110 §10.2.3,Retry-After 头会告诉你具体等多久——要么是秒数(Retry-After: 30),要么是 HTTP 日期(Retry-After: Wed, 10 Jun 2026 14:30:00 GMT)。立刻重试 429 只会产生更多 429,加速风暴。

怎么判断:检查你的错误处理是否把 429500 / 503 区分开。如果它们走同一条分支,你就没法正确退避。

4. 不可重试的错误被反复重试

400 Bad Request(参数错误)或 403 Forbidden(权限不足),重试多少次都不会成功。“所有错误都重试”的策略会把一个确定性失败变成风暴。确定性异常同理——比如 LangGraph 的默认策略就刻意不重试 ValueError / TypeError

怎么判断:看被重试失败的 HTTP 状态码。反复出现 400 / 401 / 403 / 404 / 422 说明在重试不可重试的错误。只有 429500502503504 以及连接/超时错误才值得重试。

5. 并行子 Agent 同时一起重试

向 10 个 Agent 扇出、共用一个不稳定工具:工具一失败,10 个 Agent 在同一瞬间一起重试。合计负载是单 Agent 的 10 倍,把工具压得更快。没有 jitter 时,即使做了退避,重试也会重新对齐成一波波的请求峰值(“惊群效应”)。

怎么判断:检查跨 Agent 的重试是否协调(共享熔断器/限速器)还是各自为政,以及退避里是否加了随机 jitter。各自独立、没有 jitter 的并行重试会叠加成风暴。

6. 重试逻辑包住了整个 LLM 调用,而不只是工具调用

工具失败时,Agent 重试整个推理循环——重新调 LLM、重新生成 tool call、重新执行。明明问题在工具而不在模型,每次”重试”却花掉一整次 LLM 调用。这把成本乘上了模型的单次价格(截至 2026 年 6 月,Opus 4.7 是每百万 token $5/$25,一次没必要的 8K token 重新推理累积起来很快)。还有一个相关的漏洞:把每次失败的完整错误追加到消息历史里,于是每次重试都发出更长、更贵的 prompt。

怎么判断:看清楚 retry() 到底重新调用了什么。如果它重新跑 LLM 而不只是工具执行器,每次重试就比必要成本贵 10-50 倍。对比第 1 次和第 5 次重试的 token 数——如果变大了,说明你在把错误文本往上下文里累积。

7. 没有熔断器——工具明显挂了仍在重试

同一个 endpoint 连续失败 10 次后,工具显然已不可用。Agent 应该停止调用并上报。没有熔断器它就会一直重试,工具没恢复,预算却在烧。注意:没有任何主流工作流引擎替你内置熔断——Temporal、Inngest、LangGraph 都做重试,但都不做熔断;这部分要你自己在工具封装里加。

怎么判断:统计每个工具的连续失败次数。连续 20 次以上失败、却没有”熔断开启” / “工具禁用”事件,就说明没有熔断器。

最短修复路径

Step 1:加上带 jitter 的指数退避和硬上限

import time, random

def retry_with_backoff(fn, max_attempts=5, base_delay=1.0, max_delay=60.0):
    for attempt in range(1, max_attempts + 1):
        try:
            return fn()
        except TransientError as e:
            if attempt == max_attempts:
                raise
            delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
            jitter = random.uniform(0, delay)   # full jitter 把请求峰打散
            wait = jitter
            logger.warning(
                "Tool call failed (attempt %d/%d): %s — retrying in %.1fs",
                attempt, max_attempts, e, wait
            )
            time.sleep(wait)

max_attempts=5 配指数退避,最后一次重试大约在首次失败后 30 秒发出——足够大多数偶发问题恢复。“full jitter”(在 0 到计算出的延迟之间取随机等待)比”固定延迟 + 小幅抖动”更能把并行 Agent 错开。

如果不想手写,tenacity 久经考验。要做带 jitter 的指数退避,用 wait_random_exponential(纯 wait_exponential 没有 jitter):

from tenacity import (
    retry, stop_after_attempt, wait_random_exponential, retry_if_exception_type,
)

@retry(
    stop=stop_after_attempt(5),
    wait=wait_random_exponential(multiplier=1, max=60),
    retry=retry_if_exception_type((ConnectionError, TimeoutError)),
    reraise=True,
)
def call_tool(payload):
    return execute_tool(payload)

Temporal 上,一定要显式设上限,避免默认的无限策略失控:

from datetime import timedelta
from temporalio.common import RetryPolicy

await workflow.execute_activity(
    call_flaky_tool, payload,
    start_to_close_timeout=timedelta(seconds=30),
    retry_policy=RetryPolicy(
        maximum_attempts=5,                       # 不要写 0——0 表示无限
        initial_interval=timedelta(seconds=1),
        backoff_coefficient=2.0,
        maximum_interval=timedelta(seconds=60),
        non_retryable_error_types=["BadRequestError", "ForbiddenError"],
    ),
)

Step 2:处理 429,跳过不可重试的错误

读取并遵守 Retry-After,绝不重试确定性的 4xxRetry-After 可能是秒数,也可能是 HTTP 日期,两种都要能解析:

import httpx, time
from email.utils import parsedate_to_datetime
from datetime import datetime, timezone

NON_RETRYABLE = {400, 401, 403, 404, 422}

def parse_retry_after(value: str, default: int = 60) -> int:
    if not value:
        return default
    if value.isdigit():
        return int(value)                          # delta-seconds 形式
    try:                                            # HTTP-date 形式
        when = parsedate_to_datetime(value)
        return max(0, int((when - datetime.now(timezone.utc)).total_seconds()))
    except (TypeError, ValueError):
        return default

def call_tool_with_rate_limit(url: str, payload: dict, max_attempts: int = 5) -> dict:
    for attempt in range(max_attempts):
        resp = httpx.post(url, json=payload, timeout=30)
        if resp.status_code == 429:
            wait = parse_retry_after(resp.headers.get("Retry-After"))
            logger.warning("Rate limited — waiting %ds", wait)
            time.sleep(wait + 1)
            continue
        if resp.status_code in NON_RETRYABLE:
            resp.raise_for_status()                 # 快速失败,不重试
        resp.raise_for_status()
        return resp.json()
    raise RateLimitExhaustedError("Still rate-limited after retries")

没读 Retry-After 就绝不要重试 429;而 400 / 403 则根本不该重试。

Step 3:在每个工具前放一个熔断器

import time
from enum import Enum

class CircuitState(Enum):
    CLOSED = "closed"        # 正常工作
    OPEN = "open"            # 故障中——直接拒绝调用
    HALF_OPEN = "half_open"  # 探测是否恢复

class CircuitBreaker:
    def __init__(self, failure_threshold=5, recovery_timeout=60):
        self.state = CircuitState.CLOSED
        self.failure_count = 0
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        self.opened_at: float = 0

    def call(self, fn):
        if self.state == CircuitState.OPEN:
            if time.time() - self.opened_at > self.recovery_timeout:
                self.state = CircuitState.HALF_OPEN
            else:
                raise CircuitOpenError("Circuit open — tool is down")
        try:
            result = fn()
            self._on_success()
            return result
        except Exception:
            self._on_failure()
            raise

    def _on_success(self):
        self.failure_count = 0
        self.state = CircuitState.CLOSED

    def _on_failure(self):
        self.failure_count += 1
        if self.failure_count >= self.failure_threshold:
            self.state = CircuitState.OPEN
            self.opened_at = time.time()
            logger.error("Circuit OPENED for tool after %d failures", self.failure_count)

每个工具实例化一个 CircuitBreaker,并在所有用到该工具的 Agent 间共享。如果偏好装饰器写法,PyPI 上的 circuitbreaker 包能给出同样的行为。熔断开启时要明确决定怎么办:快速失败并告诉用户”服务暂时不可用”,降级到缓存结果或备用工具,或把任务排队、等熔断恢复后再处理。

Step 4:只重试工具调用,不重试整个 LLM 调用

# 错误做法——重试整个 LLM 推理循环(成本 10-50 倍)
def agent_step_with_retry(state):
    for _ in range(5):
        try:
            return llm.invoke(state)          # LLM 调用 + 工具调用一起重试
        except ToolError:
            continue

# 正确做法——只重试工具执行
def agent_step(state):
    tool_call = llm.plan_tool_call(state)                       # LLM 调用,不重试
    result = retry_with_backoff(lambda: execute_tool(tool_call))  # 只重试工具
    return llm.process_result(state, result)                    # LLM 调用,不重试

顺带一提:不要把完整的工具错误追加到消息历史里。改成在一个旁路字段里记一个紧凑的失败标记(错误类型 + 次数),这样重试就不会发出越来越长、越来越贵的 prompt。

Step 5:用共享限速器协调并行 Agent

import threading

_tool_semaphore = threading.Semaphore(5)  # 该工具最多 5 个并发调用

def call_tool_safe(payload):
    with _tool_semaphore:
        return retry_with_backoff(lambda: call_tool(payload))

信号量限制并发数;令牌桶限制单位时间内的速率。对于分布式 Agent,用 Redis 来支撑限速器(redis-py 加一段用 INCREXPIRE 的 Lua 脚本,或一个维护良好的限速库),这样无论扩展到多少进程,所有 worker 共用同一份配额。

如何确认已修复

  1. 确定性地注入失败。 让 Agent 指向一个 mock 工具:前两次调用失败、第三次成功。确认任务在第 3 次完成;当 mock 每次都失败时,确认在 max_attempts 处停下。
  2. 检查退避曲线。 日志里重试间隔应当逐渐变大(约 1s、2s、4s……),且在并行 Agent 之间各不相同,而不是固定节奏。
  3. 强制触发 429 让 mock 返回 429 并带 Retry-After: 5;确认客户端等待约 5 秒,而不是几毫秒。
  4. 触发熔断。 让 mock 连续失败 5 次以上;确认出现 “Circuit OPENED” 日志,且在 recovery_timeout 到期前,后续调用立刻抛 CircuitOpenError(不发起网络请求)。
  5. 盯住总调用次数和 token 量。 单个任务的工具总调用次数应当只是 max_attempts 的小倍数,而不是上百次;token 用量也不应随每次重试递增。

预防建议

  • 所有工具调用都包上重试逻辑:指数退避 + jitter + 硬性 max_attempts(默认 5 比较合适)。
  • 在 Temporal 上,永远显式设置 maximum_attempts——默认的 0 表示无限。
  • 单独处理 429:解析 Retry-After(秒数 HTTP 日期),然后精确等待那么久。
  • 只重试 429 / 5xx / 连接 / 超时错误。绝不重试 400 / 401 / 403 / 404 / 422
  • 每个工具加熔断器:连续失败 5 次后开启,60 秒后探测恢复。没有任何工作流引擎替你做这件事。
  • 只重试工具执行层,不重试整个 LLM 循环,并把错误文本挡在 prompt 历史之外。
  • 用共享信号量、令牌桶或 Redis 限速器协调并行 Agent。
  • 为每个任务设置重试预算(以及 token 上限),让持续失败快速失败,而不是烧光整个预算。
  • 在 CI 里用”前两次失败”的 mock 测试重试路径。熔断器一旦开启就告警——那是事故,不是会自愈的重试。

常见问答 (FAQ)

Q:该用 tenacity 还是自己手写重试逻辑? A:用库。tenacity(Python)和 p-retry / async-retry(JS/TS)在线程安全、异步、带 jitter 的延迟这些手写循环容易漏掉的地方都久经考验。在 tenacity 里做带 jitter 的指数退避,用 wait=wait_random_exponential(multiplier=1, max=60)stop=stop_after_attempt(5)——光用 wait_exponential 没有 jitter,会让并行 Agent 重新对齐到一起。

Q:Temporal 不是会自动处理这一切吗? A:Temporal 会按 RetryPolicy 重试 activity,但它默认的 maximum_attempts0,也就是无限——所以不稳定的工具会一直重试,直到你设上限。请设置 maximum_attempts、列出 non_retryable_error_types、并限定 maximum_interval。Temporal 不做熔断,这部分要在你的 activity 代码里加。

Q:熔断器的失败阈值怎么定? A:先从连续 5 次失败起步。观察一周的开启/关闭事件,针对误报(工具没事却开了)和漏报(本该早点开)来调。对低流量的工具,用连续失败次数,而不是百分比。

Q:重试风暴正在发生,怎么快速止损? A:1)在网关 / 负载均衡层强制限速,把到达后端的请求速率压回正常水平。2)暂停 workflow 实例(Temporal、Inngest,或你的 LangGraph 运行器)。3)临时把 max_attempts 改为 1 并重新部署。然后再按上面的步骤修根因。

Q:工具是慢,不是失败——重试逻辑有用吗? A:没用。慢调用不会抛异常,所以根本进不了重试路径,它只是占着 Agent 的线程。给每个工具调用加超时(httpx.post(..., timeout=30)),这样卡住的调用会快速失败、进入重试/熔断流程。一次要花 5 分钟的调用,本质上就是失败。

相关阅读

标签: #AI 编程 #Agents #排查