Agent 工具调用异常治理:超时、幂等与重试实战

📅 2026/7/3 16:52:45
Agent 工具调用异常治理:超时、幂等与重试实战
一、工具调用超时场景分析LLM Agent 在与外部工具交互时面临两类典型的超时问题LLM 响应延迟导致 Agent 挂起Agent 发起工具调用后需要等待 LLM 返回下一个动作Action。如果模型推理延迟如上下文过长、模型温启动或 API 网关抖动Agent 的状态机会永久阻塞。生产环境中我们曾遇到 P99 延迟从 2s 飙升至 45s导致 30% 的 Agent 实例线程池耗尽。外部服务慢调用阻塞 Agent 整体流程工具本身依赖第三方 API如天气查询、数据库查询、邮件发送。即使 LLM 返回很快下游服务 5s 未响应Agent 同样会卡住。更恶劣的是慢调用堆积会导致整个 Agent 服务 OOM。关键问题没有超时控制的 Agent本质上是一个分布式死锁源。二、超时判定实现2.1 基于 Token 超时LLM 调用层在 LLM 调用层设置max_wait_time使用asyncio.wait_for实现对协程的超时控制import asyncio import logging from typing import Any logger logging.getLogger(__name__) async def call_llm_with_timeout(prompt: str, timeout: float 30.0) - dict: 带超时的 LLM 调用超时后抛出 asyncio.TimeoutError async def _call(): # 实际 LLM API 请求此处用 sleep 模拟 await asyncio.sleep(2) # 假设正常响应 2s return {action: search, params: {query: weather}} try: result await asyncio.wait_for(_call(), timeouttimeout) return result except asyncio.TimeoutError: logger.error(fLLM call timeout after {timeout}s for prompt: {prompt[:50]}) raise2.2 调用链跟踪 超时传播使用 OpenTelemetry 在每次工具调用创建 span并设置 deadline。当超时发生时span 记录错误并中断调用链from opentelemetry import trace from opentelemetry.trace.status import Status, StatusCode tracer trace.get_tracer(__name__) async def execute_tool_with_trace(tool_name: str, request_id: str, timeout: float): with tracer.start_as_current_span(ftool_call.{tool_name}) as span: span.set_attribute(request_id, request_id) span.set_attribute(timeout, timeout) try: result await asyncio.wait_for(_tool_call(tool_name, request_id), timeouttimeout) span.set_status(Status(StatusCode.OK)) return result except asyncio.TimeoutError: span.set_status(Status(StatusCode.ERROR, tool call timeout)) span.record_exception() raise2.3 熔断器模式当工具调用连续失败超时或异常达到阈值时打开熔断器快速失败避免下游雪崩。使用pybreaker实现import pybreaker import aiohttp # 定义熔断器连续 5 次错误半开后 30s 探测 breaker pybreaker.CircuitBreaker(fail_max5, reset_timeout30) breaker async def call_external_api(url: str, payload: dict) - dict: async with aiohttp.ClientSession() as session: async with session.post(url, jsonpayload, timeoutaiohttp.ClientTimeout(total5)) as resp: return await resp.json() # 在 Agent 中调用时捕获熔断器异常 try: result await call_external_api(https://api.weather.com/current, {city: Beijing}) except pybreaker.CircuitBreakerError: logger.error(Circuit breaker open, skip call) # 返回降级结果或快速失败注意事项- 超时值应基于 P99 延迟 20% 缓冲不设死值。例如通过 Prometheus 监控历史延迟动态调整。- 熔断器的reset_timeout需结合业务容忍度设置通常为 30s~60s。三、幂等性设计3.1 唯一请求 ID 服务端去重每个工具调用携带全局唯一request_idUUID。服务端在收到请求时先检查缓存中是否已存在该 ID 的记录若存在直接返回上次结果否则执行并写入缓存。import hashlib import json from typing import Optional import aiocache # 或 Redis此处用内存字典模拟 # 内存缓存生产环境建议 Redis 带 TTL _result_cache: dict[str, dict] {} def _cache_key(request_id: str) - str: return ftool_result:{request_id} async def execute_tool_with_idempotency(tool_name: str, request_id: str, params: dict, ttl: int 3600) - dict: # 1. 查询缓存 cached _result_cache.get(_cache_key(request_id)) if cached is not None: logger.info(fIdempotency hit for request_id{request_id}, returning cached result) return cached # 2. 执行工具调用假设工具本身是无副作用的查询 result await _do_tool_call(tool_name, params) # 3. 写入缓存注意只有幂等操作才缓存结果 _result_cache[_cache_key(request_id)] result return result def _do_tool_call(tool_name: str, params: dict) - dict: # 实际工具逻辑例如查询数据库 return {status: ok, data: params}3.2 客户端重试冒烟测试客户端在重试时必须传递相同的request_id服务端通过去重避免重复执行副作用。例如发送邮件的非幂等操作服务端应使用消息队列或数据库记录已发送状态拒绝重复请求。注意事项- 幂等缓存必须有 TTL防止内存泄漏同时避免长时间锁定导致结果过时。- 对于写操作如创建订单服务端需在业务层实现幂等如数据库唯一索引。- 非幂等工具如发送短信应使用“预生成 ID 状态机”模式服务端保证只执行一次。四、重试策略4.1 指数退避 抖动重试间隔base_delay * (2^attempt) random(0, jitter)避免惊群效应。import asyncio import random from typing import Callable, Coroutine, Any async def retry_with_backoff( func: Callable[..., Coroutine], *args, max_retries: int 3, base_delay: float 1.0, max_delay: float 30.0, jitter: float 0.5, **kwargs, ) - Any: last_exception None for attempt in range(1, max_retries 1): try: return await func(*args, **kwargs) except (asyncio.TimeoutError, aiohttp.ClientError) as e: last_exception e if attempt max_retries: raise delay min(base_delay * (2 ** (attempt - 1)), max_delay) delay random.uniform(0, jitter * delay) # 加入抖动 logger.warning(fAttempt {attempt}/{max_retries} failed, retrying in {delay:.2f}s...) await asyncio.sleep(delay) raise last_exception # type: ignore4.2 结合幂等缓存避免重复调用重试时如果上次调用实际上已完成但因网络问题未收到响应服务端幂等缓存会直接返回结果重试几乎零成本。async def safe_tool_call(tool_name: str, request_id: str, params: dict) - dict: # 使用幂等封装 重试 return await retry_with_backoff( execute_tool_with_idempotency, tool_name, request_id, params, max_retries2, base_delay0.5 )注意事项- 区分可重试错误超时、网络异常、503和不可重试错误401、400、参数错误。不可重试错误应直接抛出。- 重试次数不宜过多推荐 ≤3 次否则会浪费大量 TokenLLM 开销和外部 API 配额。- 对于写操作重试前建议先查询状态如GET /order/status?idxxx确认未被处理。五、观测指标设计5.1 关键指标指标定义阈值建议agent_tool_timeout_rate超时次数 / 总调用次数1% 触发告警agent_tool_retry_count每次工具调用实际重试次数分布P99 2agent_tool_idempotency_hit_rate幂等命中次数 / 总调用次数反映客户端重试比例5.2 OpenTelemetry 埋点在工具调用前后记录结构化数据便于链路分析import time from opentelemetry import metrics meter metrics.get_meter(__name__) timeout_counter meter.create_counter(agent.tool.timeout, descriptionNumber of tool call timeouts) retry_counter meter.create_counter(agent.tool.retry, descriptionNumber of retries per call) idempotency_hit_counter meter.create_counter(agent.tool.idempotency.hit, descriptionIdempotency cache hits) async def instrumented_tool_call(tool_name: str, request_id: str, params: dict) - dict: start time.monotonic() attempt 0 while True: attempt 1 try: result await execute_tool_with_trace(tool_name, request_id, timeout10.0) duration time.monotonic() - start # 记录重试次数 if attempt 1: retry_counter.add(attempt - 1, {tool: tool_name}) # 记录幂等命中在 execute_tool_with_idempotency 内已打点 return result except asyncio.TimeoutError: timeout_counter.add(1, {tool: tool_name}) if attempt 3: raise await asyncio.sleep(1 * 2 ** attempt)5.3 指标可视化与告警使用 Prometheus Grafana 监控agent_tool_timeout_rate当 1% 触发 PagerDuty 告警。使用 OpenTelemetry Collector 将 trace 发送到 Jaeger 或 Tempo快速定位超时链路哪个工具、哪个 Agent 会话。总结Agent 工具调用的可靠性不是单一技术能解决的需要系统性的治理超时控制是底线为 LLM 和外部工具调用设置合理超时结合熔断器防止雪崩。幂等设计是基础通过唯一请求 ID 和服务端去重使重试变得无害。重试策略要谨慎指数退避 抖动区分可重试/不可重试错误并利用幂等缓存降低开销。可观测性是排查的钥匙埋点超时率、重试次数、幂等命中率通过 OpenTelemetry 跟踪完整调用链。实践建议- 先实现超时和幂等最易见效再逐步引入重试和熔断。- 不要相信 LLM 会按时返回永远假设它可能超时不要相信外部服务会成功永远做好熔断准备。- 每一层治理都需要独立的告警阈值避免“静默失败”成为线上事故的温床。