前置阅读:Agent 流式输出 | 系列目录 核心增量:
RetryFunc.py(88 行)+CallFunc.py(40 行)+ 主循环 2 处调用点替换
一、问题:Agent 太脆弱了
前几篇文章构建的 Agent 在"正常情况"下运行良好,但真实世界没有"正常情况":
场景 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(鉴权失败)重试还可能触发账户锁定。
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 指数退避
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 主循环集成
# 之前(无重试)
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.get、sqlite3.connect、文件读写),线程池方案不要求用户改工具函数签名,同步阻塞 IO 在线程中天然被操作系统调度。
4.2 实现
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 可以看到并自主决策:
🔧 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 零侵入集成
# 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 完全不受影响。
八、完整演进路径
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 辅助编程到底层原理,帮你建立不可替代的技术壁垒。