Agent Function Calling 错误分类:网络超时权限与业务异常

一、你的 Agent 把所有工具调用失败都当"网络超时"处理,准确率一路下滑

Agent 调用外部工具(API、数据库、文件系统)的失败模式不是"成功"和"失败"两个状态。真实的失败至少可以分解为四类:网络超时、认证权限错误、参数校验失败、业务逻辑异常。这四类的处理方式完全不同——超时需要重试,权限错误需要中断,参数错误需要反馈修正,业务异常需要降级。

如果把所有错误都当成一种来处理,Agent 的行为会不可预知:权限错误被当作超时重试了三次,每次都返回相同的结果;参数校验失败被当作业务异常,Agent 开始自行"修正"参数然后重新调用;业务异常被当作网络问题,Agent 告诉用户"请稍后重试"但实际上这是数据层面的错误,重试一百次也没用。

Function Calling 的错误分类不是可选的优化,是 Agent 行为可预测性的基础。没有分类,Agent 在错误面前的决策就是随机的。

二、底层机制与原理剖析

错误分类的核心是把工具返回的错误映射到一个标准化的"错误处理决策树"中。系统首先根据 HTTP 状态码和响应体内容判定错误类型,随后触发对应的处理策略。具体流程为:若返回 HTTP 2xx 且数据正常则视为成功;若为 408、504 或连接重置则归类为网络超时;若为 401、403 则归类为权限错误;若为 400、422 则归类为参数校验错误;若为 200 但包含错误体则归类为业务逻辑异常。判定完成后,系统将根据错误类型执行重试、中断、修正或降级等操作。

四类错误的处理原则:

网络超时:可重试,但必须有上限和退避。三次重试、指数退避(1s、2s、4s)。超过三次后视为服务不可用,走降级。

权限错误:不可重试,立即终止。权限错误不是临时性的——再试多少次也不会改成 200。继续重试只会浪费 Token 和时间。

参数校验错误:可修正重试一次。如果 API 返回的 error message 中包含参数修正提示(如"city_code 应为 3 位数字"),可以用这个信息自动修正后重试一次。但如果第二次还是参数错误,说明不是简单的修正能解决的,交给用户处理。

业务逻辑异常:不重试,走降级。比如"账户余额不足"、"库存为 0"——这些是正常的业务流程,不是系统故障。需要把业务错误码映射为用户能理解的消息。

三、生产级代码实现

"""
Agent Function Calling 错误分类与处理策略
四类错误:网络超时、权限、参数校验、业务异常
每种错误有独立的退避、重试和降级策略
"""
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Any, Callable
from enum import Enum
import time
import re
import asyncio
import aiohttp


class ErrorCategory(Enum):
    """错误大类"""
    NETWORK_TIMEOUT = "network_timeout"     # 网络超时/连接问题
    AUTH_PERMISSION = "auth_permission"     # 认证/权限
    PARAM_VALIDATION = "param_validation"   # 参数校验失败
    SERVER_ERROR = "server_error"           # 服务端内部错误
    BUSINESS_LOGIC = "business_logic"       # 业务逻辑异常
    UNKNOWN = "unknown"                     # 未分类

class RetryStrategy(Enum):
    """重试策略"""
    NO_RETRY = "no_retry"               # 不重试
    LINEAR_BACKOFF = "linear_backoff"   # 线性退避
    EXPONENTIAL_BACKOFF = "exponential" # 指数退避
    FIXED_INTERVAL = "fixed_interval"   # 固定间隔

@dataclass
class ErrorPolicy:
    """单类错误的处理策略"""
    category: ErrorCategory
    retry_strategy: RetryStrategy
    max_retries: int
    base_delay_ms: int  # 基础延迟(毫秒)
    should_interrupt: bool  # 是否中断工具链(不继续调后续工具)
    
@dataclass
class ErrorClassification:
    """错误分类结果"""
    category: ErrorCategory
    confidence: float     # 分类置信度 0-1
    error_code: str       # 标准化错误码
    original_message: str
    sanitized_message: str  # 净化后的消息(可安全展示给 LLM)
    retriable: bool
    auto_fixable: bool      # 是否可以自动修正

@dataclass
class ToolCallResult:
    """工具调用结果"""
    tool_name: str
    success: bool
    data: Optional[Any] = None
    error: Optional[ErrorClassification] = None
    retry_count: int = 0
    elapsed_ms: float = 0.0


# 错误分类规则表
ERROR_CLASSIFICATION_RULES = {
    # === 网络超时 ===
    "timeout": ErrorCategory.NETWORK_TIMEOUT,
    "timed out": ErrorCategory.NETWORK_TIMEOUT,
    "connection refused": ErrorCategory.NETWORK_TIMEOUT,
    "connection reset": ErrorCategory.NETWORK_TIMEOUT,
    "no route to host": ErrorCategory.NETWORK_TIMEOUT,
    "temporary failure in name resolution": ErrorCategory.NETWORK_TIMEOUT,
    "eof": ErrorCategory.NETWORK_TIMEOUT,
    "broken pipe": ErrorCategory.NETWORK_TIMEOUT,
    
    # === 权限错误 ===
    "unauthorized": ErrorCategory.AUTH_PERMISSION,
    "forbidden": ErrorCategory.AUTH_PERMISSION,
    "access denied": ErrorCategory.AUTH_PERMISSION,
    "permission denied": ErrorCategory.AUTH_PERMISSION,
    "invalid api key": ErrorCategory.AUTH_PERMISSION,
    "token expired": ErrorCategory.AUTH_PERMISSION,
    "insufficient scope": ErrorCategory.AUTH_PERMISSION,
    
    # === 参数校验 ===
    "invalid parameter": ErrorCategory.PARAM_VALIDATION,
    "missing required": ErrorCategory.PARAM_VALIDATION,
    "validation error": ErrorCategory.PARAM_VALIDATION,
    "bad request": ErrorCategory.PARAM_VALIDATION,
    "unprocessable entity": ErrorCategory.PARAM_VALIDATION,
    "invalid format": ErrorCategory.PARAM_VALIDATION,
    
    # === 服务端错误 ===
    "internal server error": ErrorCategory.SERVER_ERROR,
    "service unavailable": ErrorCategory.SERVER_ERROR,
    "bad gateway": ErrorCategory.SERVER_ERROR,
}

# 各类错误的处理策略
DEFAULT_POLICIES = {
    ErrorCategory.NETWORK_TIMEOUT: ErrorPolicy(
        category=ErrorCategory.NETWORK_TIMEOUT,
        retry_strategy=RetryStrategy.EXPONENTIAL_BACKOFF,
        max_retries=3,
        base_delay_ms=1000,
        should_interrupt=False,
    ),
    ErrorCategory.AUTH_PERMISSION: ErrorPolicy(
        category=ErrorCategory.AUTH_PERMISSION,
        retry_strategy=RetryStrategy.NO_RETRY,
        max_retries=0,
        base_delay_ms=0,
        should_interrupt=True,       # 权限错误:立即停止工具链
    ),
    ErrorCategory.PARAM_VALIDATION: ErrorPolicy(
        category=ErrorCategory.PARAM_VALIDATION,
        retry_strategy=RetryStrategy.FIXED_INTERVAL,
        max_retries=1,               # 仅重试一次(修正参数后)
        base_delay_ms=0,
        should_interrupt=False,
    ),
    ErrorCategory.SERVER_ERROR: ErrorPolicy(
        category=ErrorCategory.SERVER_ERROR,
        retry_strategy=RetryStrategy.LINEAR_BACKOFF,
        max_retries=2,
        base_delay_ms=2000,
        should_interrupt=False,
    ),
    ErrorCategory.BUSINESS_LOGIC: ErrorPolicy(
        category=ErrorCategory.BUSINESS_LOGIC,
        retry_strategy=RetryStrategy.NO_RETRY,
        max_retries=0,
        base_delay_ms=0,
        should_interrupt=False,
    ),
}


class ErrorClassifier:
    """错误分类器
    
    基于规则匹配 + 关键词提取,将工具返回的错误归类。
    规则是声明式的可维护的表格,不是硬编码的 if-else。
    """
    
    def classify(self, error: Exception, http_status: Optional[int] = None) -> ErrorClassification:
        """分类一个错误"""
        error_str = str(error).lower()
        error_type = type(error).__name__
        
        # Step 1: HTTP 状态码分类
        if http_status:
            if http_status == 401 or http_status == 403:
                return self._build_result(
                    ErrorCategory.AUTH_PERMISSION, 0.95,
                    f"HTTP_{http_status}", str(error), "认证或权限不足"
                )
            if http_status == 400 or http_status == 422:
                return self._build_result(
                    ErrorCategory.PARAM_VALIDATION, 0.85,
                    f"HTTP_{http_status}", str(error), "请求参数有误"
                )
            if http_status >= 500:
                return self._build_result(
                    ErrorCategory.SERVER_ERROR, 0.8,
                    f"HTTP_{http_status}", str(error), "服务端内部错误"
                )
        
        # Step 2: 异常类型分类
        if isinstance(error, asyncio.TimeoutError):
            return self._build_result(
                ErrorCategory.NETWORK_TIMEOUT, 0.95,
                "TIMEOUT", str(error), "请求超时"
            )
        
        if isinstance(error, (ConnectionError, ConnectionRefusedError, ConnectionResetError)):
            return self._build_result(
                ErrorCategory.NETWORK_TIMEOUT, 0.9,
                "CONNECTION_ERROR", str(error), "网络连接异常"
            )
        
        # Step 3: 关键词匹配
        for keyword, category in ERROR_CLASSIFICATION_RULES.items():
            if keyword in error_str:
                return self._build_result(category, 0.8, keyword.upper().replace(" ", "_"), str(error), "")
        
        # Step 4: 降级为 UNKNOWN
        return self._build_result(
            ErrorCategory.UNKNOWN, 0.3, "UNKNOWN", str(error), "未分类的错误"
        )
    
    def _build_result(
        self, category: ErrorCategory, confidence: float,
        error_code: str, original: str, sanitized: str
    ) -> ErrorClassification:
        policy = DEFAULT_POLICIES.get(category)
        return ErrorClassification(
            category=category,
            confidence=confidence,
            error_code=error_code,
            original_message=original,
            sanitized_message=sanitized or f"{category.value}",
            retriable=policy.retry_strategy != RetryStrategy.NO_RETRY if policy else False,
            auto_fixable=(category == ErrorCategory.PARAM_VALIDATION),
        )


class RetryExecutor:
    """重试执行器
    
    根据 ErrorPolicy 执行不同策略的重试。
    """
    
    def __init__(self):
        self.classifier = ErrorClassifier()
    
    async def execute_with_retry(
        self,
        tool_name: str,
        call_fn: Callable,
        *args, **kwargs
    ) -> ToolCallResult:
        """带错误分类的重试执行"""
        start_time = time.time()
        retry_count = 0
        last_error = None
        
        while True:
            try:
                result = await call_fn(*args, **kwargs)
                elapsed = (time.time() - start_time) * 1000
                return ToolCallResult(
                    tool_name=tool_name,
                    success=True,
                    data=result,
                    retry_count=retry_count,
                    elapsed_ms=elapsed,
                )
            except Exception as e:
                retry_count += 1
                last_error = e
                
                # 提取 HTTP 状态码(如果有)
                http_status = getattr(e, 'status', None) or getattr(e, 'status_code', None)
                
                # 分类错误
                classification = self.classifier.classify(e, http_status)
                policy = DEFAULT_POLICIES.get(classification.category)
                
                if not policy:
                    break  # 未知错误不重试
                
                # 权限错误:立即中断
                if policy.should_interrupt:
                    break
                
                # 达到最大重试次数
                if retry_count > policy.max_retries:
                    break
                
                # 参数校验:尝试自动修正
                if classification.auto_fixable and retry_count == 1:
                    kwargs = self._attempt_auto_fix(e, kwargs)
                    if kwargs is None:
                        break  # 无法自动修正
                
                # 计算退避延迟
                delay = self._calculate_delay(policy, retry_count)
                await asyncio.sleep(delay / 1000.0)
        
        # 所有重试已耗尽
        elapsed = (time.time() - start_time) * 1000
        classification = self.classifier.classify(
            last_error or Exception("unknown"),
            getattr(last_error, 'status', None) if last_error else None
        )
        
        return ToolCallResult(
            tool_name=tool_name,
            success=False,
            error=classification,
            retry_count=retry_count,
            elapsed_ms=elapsed,
        )
    
    def _calculate_delay(self, policy: ErrorPolicy, retry_count: int) -> int:
        """计算退避延迟"""
        base = policy.base_delay_ms
        
        if policy.retry_strategy == RetryStrategy.EXPONENTIAL_BACKOFF:
            return base * (2 ** (retry_count - 1))
        elif policy.retry_strategy == RetryStrategy.LINEAR_BACKOFF:
            return base * retry_count
        elif policy.retry_strategy == RetryStrategy.FIXED_INTERVAL:
            return base
        return 0
    
    def _attempt_auto_fix(self, error: Exception, kwargs: dict) -> Optional[dict]:
        """尝试从错误消息中自动修正参数"""
        # 简化实现:实际应使用 LLM 来解析错误消息中的修正建议
        # 例如:API 返回 {"error": "city_code must be 3 digits, got '12'"}
        # LLM 可以解析出正确的 city_code 应该是 "012"
        return None  # 暂时不支持自动修正

    def build_response_for_llm(self, result: ToolCallResult) -> str:
        """为 LLM 构建工具调用的响应消息"""
        if result.success:
            return f"工具 [{result.tool_name}] 调用成功。"
        
        if result.error:
            cat = result.error.category
            if cat == ErrorCategory.NETWORK_TIMEOUT:
                return f"工具 [{result.tool_name}] 网络超时,已重试 {result.retry_count} 次。请稍后重试或跳过此步骤。"
            elif cat == ErrorCategory.AUTH_PERMISSION:
                return f"工具 [{result.tool_name}] 权限不足,无法继续。请检查 API Key 或权限配置。"
            elif cat == ErrorCategory.PARAM_VALIDATION:
                return f"工具 [{result.tool_name}] 参数校验失败:{result.error.sanitized_message}。请检查调用参数。"
            elif cat == ErrorCategory.SERVER_ERROR:
                return f"工具 [{result.tool_name}] 服务端异常,已重试 {result.retry_count} 次。暂时不可用。"
            else:
                return f"工具 [{result.tool_name}] 调用失败。"
        
        return f"工具 [{result.tool_name}] 调用失败。"

四、边界分析与架构权衡

错误分类的准确性边界

基于关键词匹配的分类精度在 80-85% 左右。有些 API 返回的错误消息比较模糊(如"Something went wrong"),分类器无法区分是超时还是服务端错误。另外,有些 API 的 4xx 错误信息中包含了可修正的提示——比如 OpenAI 的 429(限流)需要用不同的退避策略处理。分类器需要持续根据 API 特性做规则调整。

参数自动修正的风险

自动修正参数是一个有风险的操作。修正后的参数可能引入新的错误。比如 API 返回"city_code 应为 3 位数字,当前为 '12'",自动修正为 "012" 看起来正确——但如果 API 期望的 city_code 根本不是 3 位只是恰好名字相似,自动修正反而带来了更大的偏差。建议自动修正在内部环境或开发阶段使用,生产环境由 Agent 提示用户确认。

适用边界

最适合调用多个不同外部 API 的 Agent。API 种类越多,标准化的错误分类越有价值——避免了为每个 API 单独实现错误处理逻辑。

禁用场景

不适合只有一两个确定性工具的简单 Agent。也不适合通过中间件(如 API Gateway)已经在入口层统一处理了错误的场景。

五、总结

Function Calling 的错误处理不能一刀切。四类错误对应四种策略:网络超时用指数退避重试(3 次上限)、权限错误立即中断(不能把 403 当 504 处理)、参数校验可尝试自动修正后重试一次、服务端异常可选降级。分类不是用于展示给用户的,而是用于指导 Agent 的下一个动作——是重试、是中断、还是降级。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐