你的 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 默认 RetryPolicy 的 maximum_attempts = 0,含义是无限——它会一直重试外部工具,直到你设上限为止(其余默认值:退避系数 2.0、初始间隔 1 秒、最大间隔 100 秒,截至 2026 年 6 月)。Inngest 默认每个 step.run() 一共执行 5 次(1 次初始 + 4 次重试),且每个 step 有独立计数器,所以多 step 函数会把次数乘开。LangGraph 节点 RetryPolicy 默认 max_attempts=3。
怎么判断:在 Agent/工具封装里搜没有 max_attempts 或 attempt < 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,加速风暴。
怎么判断:检查你的错误处理是否把 429 和 500 / 503 区分开。如果它们走同一条分支,你就没法正确退避。
4. 不可重试的错误被反复重试
400 Bad Request(参数错误)或 403 Forbidden(权限不足),重试多少次都不会成功。“所有错误都重试”的策略会把一个确定性失败变成风暴。确定性异常同理——比如 LangGraph 的默认策略就刻意不重试 ValueError / TypeError。
怎么判断:看被重试失败的 HTTP 状态码。反复出现 400 / 401 / 403 / 404 / 422 说明在重试不可重试的错误。只有 429、500、502、503、504 以及连接/超时错误才值得重试。
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,绝不重试确定性的 4xx。Retry-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 加一段用 INCR 和 EXPIRE 的 Lua 脚本,或一个维护良好的限速库),这样无论扩展到多少进程,所有 worker 共用同一份配额。
如何确认已修复
- 确定性地注入失败。 让 Agent 指向一个 mock 工具:前两次调用失败、第三次成功。确认任务在第 3 次完成;当 mock 每次都失败时,确认在
max_attempts处停下。 - 检查退避曲线。 日志里重试间隔应当逐渐变大(约 1s、2s、4s……),且在并行 Agent 之间各不相同,而不是固定节奏。
- 强制触发
429。 让 mock 返回429并带Retry-After: 5;确认客户端等待约 5 秒,而不是几毫秒。 - 触发熔断。 让 mock 连续失败 5 次以上;确认出现 “Circuit OPENED” 日志,且在
recovery_timeout到期前,后续调用立刻抛CircuitOpenError(不发起网络请求)。 - 盯住总调用次数和 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_attempts 是 0,也就是无限——所以不稳定的工具会一直重试,直到你设上限。请设置 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 分钟的调用,本质上就是失败。