Agent 容错机制 —— 重试与超时,让 Agent 更扛造

前置阅读:Agent 流式输出 | 系列目录 核心增量:RetryFunc.py(88 行)+ CallFunc.py(40 行)+ 主循环 2 处调用点替换


一、问题:Agent 太脆弱了

前几篇文章构建的 Agent 在"正常情况"下运行良好,但真实世界没有"正常情况":

yaml 复制代码
场景 A: LLM API 偶尔 429(限流)
  → Agent 直接崩溃,用户看到一堆 traceback

场景 B: LLM API 返回 503(服务暂不可用)
  → Agent 直接崩溃,已经跑了 4 轮的对话全部白费

场景 C: 工具函数(如 HTTP 请求)卡死 60 秒
  → Agent 永远等不到响应,整个进程 hang 住

生产环境的黄金法则:任何一次远程调用都可能失败,任何一次工具执行都可能超时。不处理这些"常态异常"的 Agent 只适合跑 demo。

本文要解决的就是这两个正交的容错需求:

  • 重试:LLM API 瞬时故障时自动恢复,避免一次抖动毁掉整个会话
  • 超时:工具函数卡死时强制中断,避免 Agent 无限等待

二、两层容错的职责划分

flowchart TB subgraph LLM[LLM 调用] L1[stream_llm_call] L2[with_retry - 指数退避重试] end subgraph TOOL[工具执行] T1[active_tool_map] T2[call_with_timeout - 线程池超时] end subgraph ERRORS[错误分类] E1[429 限流 - 重试] E2[500/502/503 - 重试] E3[网络超时 - 重试] E4[401/403 鉴权 - 不重试] E5[400 参数错误 - 不重试] E6[工具卡死 - 超时中断] end L1 --> L2 L2 --> E1 L2 --> E2 L2 --> E3 L2 -. 不重试 .-> E4 L2 -. 不重试 .-> E5 T1 --> T2 T2 --> E6

两条线互不干扰------LLM 的瞬时故障靠重试兜底,工具的长时间阻塞靠超时兜底。两者不共享任何状态或逻辑。


三、重试机制:with_retry

3.1 核心原则:只重试该重试的

重试最大的坑不是"没重试",而是"重试了不该重试的"。比如 400 错误(参数格式错误)重试一万次也没用,401(鉴权失败)重试还可能触发账户锁定。

python 复制代码
RETRYABLE_STATUSES = {429, 500, 502, 503, 504}
RETRYABLE_ERROR_CODES = {
    "rate_limit_exceeded", "server_error", "internal_server_error",
    "service_unavailable", "api_connection_error", "api_timeout",
}

def is_retryable(error: Exception) -> bool:
    if hasattr(error, "status_code"):
        if error.status_code in RETRYABLE_STATUSES:
            return True
        if error.status_code in (400, 401, 403):
            return False                          # ← 明确不重试
    if hasattr(error, "code") and error.code in RETRYABLE_ERROR_CODES:
        return True
    if isinstance(error, (ConnectionError, TimeoutError)):
        return True
    return False                                 # ← 未知错误保守不重试
策略 场景 理由
重试 429 / 5xx / 网络超时 瞬时故障,下次大概率成功
不重试 400 / 401 / 403 请求本身有问题,重试只会重复失败
保守不重试 未知错误 不知道是什么,不冒险

3.2 指数退避

python 复制代码
def with_retry(fn, max_retries=3, base_delay=1.0, max_delay=30.0, label=""):
    for attempt in range(max_retries + 1):
        try:
            return fn()
        except Exception as e:
            if not is_retryable(e):
                raise
            if attempt == max_retries:
                break
            delay = min(base_delay * (2 ** attempt), max_delay)
            print(f"[{label}] 第 {attempt+1}/{max_retries} 次重试,{delay:.1f}s 后重试")
            time.sleep(delay)
    raise last_error

退避序列(base_delay=1.0):0s → 1s → 2s → 4s,上限 30s。

3.3 主循环集成

python 复制代码
# 之前(无重试)
content, tool_calls = stream_llm_call(client, model, messages, active_tools)

# 之后(有重试)
content, tool_calls = with_retry(lambda: stream_llm_call(...), label="LLM")

四、工具超时:call_with_timeout

4.1 为什么用线程池而不是 asyncio

工具函数是同步的requests.getsqlite3.connect、文件读写),线程池方案不要求用户改工具函数签名,同步阻塞 IO 在线程中天然被操作系统调度。

4.2 实现

python 复制代码
DEFAULT_TOOL_TIMEOUT = 30

def call_with_timeout(func, args=(), kwargs=None, timeout=DEFAULT_TOOL_TIMEOUT):
    kwargs = kwargs or {}
    with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
        future = executor.submit(func, *args, **kwargs)
        try:
            return str(future.result(timeout=timeout))
        except concurrent.futures.TimeoutError:
            future.cancel()
            return f"工具执行超时({timeout}秒),已取消执行"
        except Exception as e:
            return f"工具执行错误: {e}"

4.3 超时后的行为

超时不抛异常,返回字符串格式的错误信息,Agent 的 LLM 可以看到并自主决策:

erlang 复制代码
🔧 http_request({"url":"https://slow-api.com"})
  → 工具执行超时(30秒),已取消执行

🧠 工具超时了,我换个方式试试...

五、重试与超时的协作时序

sequenceDiagram participant R as with_retry participant SL as stream_llm_call participant API as LLM API participant CT as call_with_timeout participant TOOL as 工具函数 Note over R,TOOL: LLM 调用阶段(带重试) R->>SL: _call() SL->>API: POST (stream=True) API-->>SL: 429 Too Many Requests SL-->>R: raise RateLimitError Note over R: is_retryable → True, sleep 1s R->>SL: _call() (第1次重试) SL->>API: POST (stream=True) API-->>SL: 503 Service Unavailable SL-->>R: raise ServiceUnavailableError Note over R: is_retryable → True, sleep 2s R->>SL: _call() (第2次重试) SL->>API: POST (stream=True) API-->>SL: 200 OK + tool_calls SL-->>R: return (None, tool_calls) Note over R,TOOL: 工具执行阶段(带超时) R->>CT: call_with_timeout(search_web, timeout=30) CT->>TOOL: ThreadPool.submit(search_web) Note over TOOL: 正常执行 0.5s TOOL-->>CT: "北京晴,25°C" CT-->>R: "北京晴,25°C"

六、错误分类决策树

flowchart TD E[LLM 调用异常] --> STATUS{status_code?} STATUS -->|429| R1[重试 - 指数退避] STATUS -->|5xx| R1 STATUS -->|400| F1[立即失败 - 参数错误] STATUS -->|401/403| F2[立即失败 - 鉴权错误] STATUS -->|其他| TYPE{异常类型?} TYPE -->|ConnectionError| R1 TYPE -->|TimeoutError| R1 TYPE -->|code in RETRYABLE| R1 TYPE -->|未知| F3[保守失败 - 不重试] T[工具执行] --> TIMEOUT{30s 内返回?} TIMEOUT -->|是| OK[返回结果] TIMEOUT -->|否| TO[返回超时错误字符串] TO --> AGENT[Agent 看到错误后自主决策]

七、设计精要

7.1 重试和超时是正交的

LLM 重试 工具超时
解决什么 API 瞬时故障 函数卡死
失败模式 抛异常 不返回
机制 指数退避 + 重试 线程池 + future.timeout
失败后 重试或抛异常 返回错误字符串
可配置 max_retries / base_delay timeout 秒数

7.2 错误返回是字符串而非异常

工具超时不抛异常,返回字符串让 LLM 看到并智能决策。抛异常会直接中断 ReAct 循环。

7.3 保守优于激进

is_retryable 默认"不重试"------只在明确识别为瞬时故障时才重试,避免无效重试触发风控。

7.4 零侵入集成

python 复制代码
# LLM:多包一层 with_retry
content, tool_calls = with_retry(lambda: stream_llm_call(...), label="LLM")

# 工具:多包一层 call_with_timeout
result = call_with_timeout(func, kwargs=args, timeout=30)

SkillManager、AgentTracer、ContextManager、PersistenceManager、chat_loop 完全不受影响。


八、完整演进路径

yaml 复制代码
Phase 1: 基础 Agent               (~50 行)
Phase 2: 可观测 + 压缩 + 持久化     (+~200 行)
Phase 3: Skill 渐进加载            (+~150 行)
Phase 4: 多轮对话循环              (+~120 行)
Phase 5: 流式输出                  (+~80 行)
Phase 6: 重试 + 超时               (+~128 行)  ← 本文
  RetryFunc.py (88 行) + CallFunc.py (40 行)
  两个独立模块,各管各的故障域

推荐学习资源

  • 从零开始打造一个AI Agent CLI --- 手把手带你构建一个完整的 AI Agent 命令行工具,从工具调用、对话管理到发布上线的全流程实战。
  • AI Agents 开发实践 --- 涵盖 Agent 架构设计、记忆系统、多 Agent 协作等核心主题的实战教程,适合想深入 Agent 开发的工程师。
  • AI 全栈编程生存指南 --- AI 时代的全栈开发者生存法则,从 AI 辅助编程到底层原理,帮你建立不可替代的技术壁垒。