Function Calling 错误处理与重试策略:让 LLM 工具调用从脆弱走向可靠

cover

一、工具调用的脆弱性:Function Calling 为什么比普通 API 更容易失败

LLM 的 Function Calling 机制让大模型具备了调用外部工具的能力,但这个能力在生产环境中异常脆弱。与普通 API 调用不同,Function Calling 的失败点分布在三个完全不同的层面,每一层都有独特的错误模式。

第一层是模型输出层。LLM 生成的函数调用参数可能格式错误:JSON 解析失败、枚举值超出范围、必填参数缺失。GPT-4 在简单函数上的参数格式错误率约 2%,但在参数超过 5 个的复杂函数上,错误率上升到 8%。更棘手的是,模型可能调用不存在的函数,或者传入语义正确但类型不匹配的参数。

第二层是网络传输层。LLM API 本身可能超时、限流或返回 5xx 错误。国内调用 OpenAI API 的超时率在高峰期可达 5%,而函数调用的响应时间比普通 Chat 更长(因为需要等待模型生成结构化输出),进一步增加了超时概率。

第三层是工具执行层。即使模型正确生成了调用参数,工具本身的执行也可能失败:数据库连接超时、第三方 API 返回错误、文件不存在。这些错误的处理逻辑与普通 API 调用类似,但有一个关键区别——当工具执行失败后,我们需要将错误信息回传给 LLM,让它决定是重试还是换一种方式完成任务。

这三层错误叠加起来,一个包含 3 次工具调用的 Agent 任务,成功率可能低至 85%。如果不做系统性的错误处理和重试,生产环境根本无法稳定运行。

二、Function Calling 的错误分类与重试决策模型

不同类型的错误需要不同的处理策略。盲目重试不仅浪费 Token,还可能让情况更糟。

flowchart TD
    A[Function Calling 错误] --> B{错误类型判断}
    B -->|模型输出错误| C{参数可修复?}
    C -->|是| D[自动修正参数后重试]
    C -->|否| E[将错误反馈给 LLM 重新生成]
    B -->|网络传输错误| F{是否可重试?}
    F -->|限流/超时| G[指数退避重试]
    F -->|认证失败| H[立即报错,不重试]
    B -->|工具执行错误| I{是否幂等?}
    I -->|是| J[带退避重试]
    I -->|否| K[回传 LLM 决策]

    D --> L[成功?]
    E --> L
    G --> L
    J --> L
    K --> L
    L -->|否| M[达到最大重试次数]
    M --> N[降级处理或报错]

模型输出错误分为两类:可自动修正的(如日期格式从 2024-1-1 修正为 2024-01-01)和不可修正的(如调用了不存在的函数)。前者可以通过 Schema 校验后自动修正重试,后者必须将错误信息回传 LLM 让它重新选择。

网络传输错误需要区分可重试和不可重试。限流(429)和超时可以重试,但认证失败(401)重试无意义。重试时必须使用指数退避,避免雪崩。

工具执行错误最复杂。如果工具是幂等的(如查询操作),可以直接重试;如果是非幂等的(如创建订单),重试可能导致重复操作,必须将错误回传 LLM 让它决定下一步。

三、生产级错误处理与重试框架

3.1 错误分类与重试决策

// function_calling.go
// Function Calling 的错误处理与重试框架

package agent

import (
    "context"
    "encoding/json"
    "fmt"
    "math"
    "time"
)

// CallError 函数调用错误,携带错误类型和是否可重试信息
type CallError struct {
    Type       string // model_output / network / tool_execution
    Retriable  bool   // 是否可重试
    RawError   error  // 原始错误
    Suggestion string // 给 LLM 的修正建议
}

func (e *CallError) Error() string {
    return fmt.Sprintf("[%s] %v (retriable=%v)", e.Type, e.RawError, e.Retriable)
}

// RetryConfig 重试配置
type RetryConfig struct {
    MaxRetries    int           // 最大重试次数
    InitialDelay  time.Duration // 初始退避时间
    MaxDelay      time.Duration // 最大退避时间
    Multiplier    float64       // 退避倍数
}

var DefaultRetryConfig = RetryConfig{
    MaxRetries:   3,
    InitialDelay: 1 * time.Second,
    MaxDelay:     30 * time.Second,
    Multiplier:   2.0,
}

// classifyError 对错误进行分类,决定重试策略
func classifyError(err error) *CallError {
    if err == nil {
        return nil
    }

    // JSON 解析错误 → 模型输出错误,可重试(让 LLM 重新生成)
    if isJSONError(err) {
        return &CallError{
            Type:      "model_output",
            Retriable: true,
            RawError:  err,
            Suggestion: "函数调用参数格式错误,请检查 JSON 格式后重新生成",
        }
    }

    // 参数校验错误 → 模型输出错误,尝试自动修正
    if isValidationError(err) {
        return &CallError{
            Type:      "model_output",
            Retriable: true,
            RawError:  err,
            Suggestion: extractValidationSuggestion(err),
        }
    }

    // 限流错误 → 网络错误,可重试
    if isRateLimitError(err) {
        return &CallError{
            Type:      "network",
            Retriable: true,
            RawError:  err,
        }
    }

    // 认证错误 → 网络错误,不可重试
    if isAuthError(err) {
        return &CallError{
            Type:      "network",
            Retriable: false,
            RawError:  err,
        }
    }

    // 默认归为工具执行错误
    return &CallError{
        Type:      "tool_execution",
        Retriable: true,
        RawError:  err,
    }
}

3.2 带重试的函数调用执行器

// executor.go
// 函数调用执行器,封装重试逻辑和错误回传

type FunctionExecutor struct {
    llmClient  LLMClient
    retryConfig RetryConfig
    tools      map[string]ToolDefinition
}

// ExecuteWithRetry 执行函数调用,带重试和 LLM 反馈
func (e *FunctionExecutor) ExecuteWithRetry(
    ctx context.Context,
    messages []Message,
    maxRounds int,
) (*FunctionResult, error) {
    for round := 0; round < maxRounds; round++ {
        // 第一步:调用 LLM 获取函数调用决策
        response, err := e.llmClient.Chat(ctx, messages)
        if err != nil {
            callErr := classifyError(err)
            if callErr.Retriable && round < e.retryConfig.MaxRetries {
                delay := e.backoffDelay(round)
                time.Sleep(delay)
                continue
            }
            return nil, fmt.Errorf("LLM 调用失败: %w", err)
        }

        // 如果 LLM 没有发起函数调用,直接返回文本响应
        if !response.HasFunctionCall() {
            return &FunctionResult{Content: response.Content}, nil
        }

        // 第二步:校验函数调用参数
        fnCall := response.FunctionCall
        if err := e.validateFunctionCall(fnCall); err != nil {
            // 参数校验失败,将错误反馈给 LLM 重新生成
            messages = append(messages, Message{
                Role:    "assistant",
                Content: response.ToMessage(),
            }, Message{
                Role:    "user",
                Content: fmt.Sprintf("函数调用参数错误: %v。请修正后重新调用。", err),
            })
            continue
        }

        // 第三步:执行工具函数
        result, err := e.executeTool(ctx, fnCall)
        if err != nil {
            callErr := classifyError(err)
            if callErr.Retriable && round < e.retryConfig.MaxRetries {
                // 可重试错误:将错误信息回传 LLM
                messages = append(messages, Message{
                    Role:    "assistant",
                    Content: response.ToMessage(),
                }, Message{
                    Role:    "function",
                    Name:    fnCall.Name,
                    Content: fmt.Sprintf("执行失败: %v。%s", err, callErr.Suggestion),
                })
                continue
            }
            return nil, fmt.Errorf("工具执行失败: %w", err)
        }

        return result, nil
    }

    return nil, fmt.Errorf("达到最大重试轮次 %d,任务失败", maxRounds)
}

// backoffDelay 计算指数退避延迟
func (e *FunctionExecutor) backoffDelay(retryCount int) time.Duration {
    delay := float64(e.retryConfig.InitialDelay) *
        math.Pow(e.retryConfig.Multiplier, float64(retryCount))
    if delay > float64(e.retryConfig.MaxDelay) {
        return e.retryConfig.MaxDelay
    }
    return time.Duration(delay)
}

// validateFunctionCall 校验函数调用的参数格式
func (e *FunctionExecutor) validateFunctionCall(fnCall *FunctionCall) error {
    tool, exists := e.tools[fnCall.Name]
    if !exists {
        return fmt.Errorf("函数 %s 不存在,可用函数: %v", fnCall.Name, e.availableFunctions())
    }

    // 校验参数是否符合 JSON Schema
    var params map[string]interface{}
    if err := json.Unmarshal([]byte(fnCall.Arguments), &params); err != nil {
        return fmt.Errorf("参数 JSON 解析失败: %w", err)
    }

    return tool.ValidateParams(params)
}

四、架构权衡与适用边界

重试轮次与 Token 消耗的矛盾。每次将错误信息回传 LLM 重新生成,都会消耗额外的 Token。一个 3 轮重试的任务,Token 消耗可能达到单次调用的 3-4 倍。对于使用 GPT-4 等高成本模型的场景,需要设置合理的重试上限(建议不超过 3 轮),并在预算耗尽时降级到更便宜的模型。

自动修正与 LLM 重新生成的选择。对于简单的格式错误(如日期格式、枚举值大小写),自动修正比回传 LLM 更高效,因为避免了额外的模型调用。但自动修正逻辑本身也可能出错,特别是当参数语义复杂时。建议只对确定性高的格式错误做自动修正,其余情况交给 LLM 处理。

幂等性判断的困难。工具是否幂等,决定了执行失败后能否直接重试。但很多工具的幂等性取决于上下文:数据库查询是幂等的,但"创建用户"在用户名冲突时不是。最佳实践是在工具定义中显式声明幂等性标记,让执行器据此决策。

适用边界:该重试框架适用于工具调用失败率超过 5% 的生产环境。对于内部工具调用成功率接近 100% 的简单场景,简单的 try-catch 即可,引入完整的重试框架属于过度设计。

五、总结

Function Calling 的错误处理需要覆盖模型输出、网络传输、工具执行三个层面,每层有不同的错误模式和重试策略。核心实践包括:第一,对错误进行分类,区分可重试和不可重试错误,避免无意义的重试浪费 Token;第二,将工具执行错误回传 LLM,让模型自主决策下一步行动;第三,使用指数退避控制重试节奏,防止雪崩。工程落地时,需要重点权衡重试轮次与 Token 成本,建议将最大重试轮次控制在 3 轮以内,并对确定性高的格式错误优先使用自动修正而非 LLM 重新生成。

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐