Function Calling 超时策略:别让一个慢工具拖垮整个 Agent 响应

一、Agent 调用了 PDF 解析工具,用户等了 60 秒后关闭了页面

不同的工具有不同的延迟特性。
搜索 API 通常 300-800ms。
数据库查询 50-200ms。
PDF 解析可能 10-60 秒。
如果 Agent 的顺序调用模式无差别地等待每个工具返回。
一个慢工具会把整个对话卡住。

用户看到"正在思考…",15 秒后怀疑出 bug 了。
30 秒后刷新页面,60 秒后放弃。
而模型 API 实际上还在等工具返回。

超时策略不是给所有工具设一个统一值。
而是根据工具类型和时间预算做分层控制。

这是 Agent 开发中一个典型的"乐观设计"陷阱。
开发者在本地测试时工具调用通常很快(本地数据库、小文件),上线后遇到生产环境的慢工具就手足无措。
Agent 的端到端延迟是一个累加值——T = LLM推理时间 + Σ(工具调用时间)
如果 5 个工具中有一个耗时 30 秒,整个对话体验就被拖垮——哪怕其他 4 个工具都在 500ms 内返回。

对用户而言,15 秒是一个心理阈值。
15 秒内用户处于"等待模式",15 秒后进入"怀疑模式"——"是不是卡了?要不要刷新?"
如果 Agent 能在 15 秒内给出部分结果或告知"正在处理较长任务",用户的焦虑感显著降低。
这就是进度反馈的价值——不是加速任务,而是管理期望。

二、分层超时控制模型

flowchart TB
    A[工具调用开始] --> B{工具类型}
    B -->|快速工具| C[超时: 5s]
    B -->|中速工具| D[超时: 15s]
    B -->|慢速工具| E[超时: 60s]

    C --> F{超时?}
    F -->|是| G[返回部分结果 or 缓存兜底]
    F -->|否| H[正常返回]

    D --> I{超时?}
    I -->|是| G
    I -->|否| H

    E --> J{进度回调?}
    J -->|支持| K[推送中间状态给用户]
    K --> L{超时?}
    L -->|是| M[返回已完成的部分]
    L -->|否| H

三层超时设计的核心在于"差异化的失败模式"。
快速工具超时 → 大概率是下游故障,直接降级兜底,不给用户看错误详情。
中速工具超时 → 可能只是瞬时繁忙,兜底的同时记录日志以便排查。
慢速工具超时 → 预期内的可能,进度反馈已经给了用户心理预期,超时后提供"部分结果"而非空结果。

这种差异化处理避免了"一刀切"的粗暴——所有超时都返回统一的错误信息。
用户对"搜索超时"和"PDF解析超时"的容忍度完全不同,Agent 的响应策略也应该不同。

三、带超时的工具调度实现

下面的 Go 代码实现了一个按工具类型分层超时的执行器,支持兜底策略和进度回调。

package toolbox

import (
    "context"
    "fmt"
    "sync"
    "time"
)

// ToolTimeout 工具超时配置
type ToolTimeout struct {
    // 按工具名配置超时时间
    Timeouts map[string]time.Duration
    Default  time.Duration
}

// DefaultTimeouts 推荐的超时配置
var DefaultTimeouts = ToolTimeout{
    Default: 15 * time.Second,
    Timeouts: map[string]time.Duration{
        "web_search":     8 * time.Second,
        "db_query":       5 * time.Second,
        "api_call":       10 * time.Second,
        "pdf_parse":      30 * time.Second,
        "image_generate": 60 * time.Second,
        "code_execute":   20 * time.Second,
        "cache_get":      1 * time.Second,
    },
}

// TimeoutError 超时错误
type TimeoutError struct {
    ToolName string
    Timeout  time.Duration
    Elapsed  time.Duration
}

func (e *TimeoutError) Error() string {
    return fmt.Sprintf(
        "工具 %s 执行超时 (限制: %v, 实际: %v)",
        e.ToolName, e.Timeout, e.Elapsed,
    )
}

// ProgressCallback 长耗时工具的进度回调
type ProgressCallback func(percent float64, message string)

// ToolResult 工具执行结果
type ToolResult struct {
    Value    interface{}
    Error    error
    Duration time.Duration
    TimedOut bool
}

// TimeoutExecutor 带超时的工具执行器
type TimeoutExecutor struct {
    timeouts  ToolTimeout
    maxTotal  time.Duration // 单次 Agent 调用的总超时
}

// NewTimeoutExecutor 创建超时执行器
func NewTimeoutExecutor(timeouts ToolTimeout, maxTotal time.Duration) *TimeoutExecutor {
    return &TimeoutExecutor{
        timeouts: timeouts,
        maxTotal: maxTotal,
    }
}

// Execute 执行工具调用(带超时)
func (e *TimeoutExecutor) Execute(
    ctx context.Context,
    toolName string,
    fn func(ctx context.Context) (interface{}, error),
    onProgress ProgressCallback, // 可选进度回调
) *ToolResult {
    // 获取该工具的超时配置
    timeout := e.timeouts.Default
    if t, ok := e.timeouts.Timeouts[toolName]; ok {
        timeout = t
    }

    // 创建带超时的 context
    toolCtx, cancel := context.WithTimeout(ctx, timeout)
    defer cancel()

    start := time.Now()

    // 使用 channel 接收执行结果
    type result struct {
        value interface{}
        err   error
    }
    resultCh := make(chan result, 1)

    go func() {
        val, err := fn(toolCtx)
        resultCh <- result{val, err}
    }()

    // 等待结果或超时
    select {
    case res := <-resultCh:
        return &ToolResult{
            Value:    res.value,
            Error:    res.err,
            Duration: time.Since(start),
            TimedOut: false,
        }

    case <-toolCtx.Done():
        return &ToolResult{
            Value:    nil,
            Error:    &TimeoutError{
                ToolName: toolName,
                Timeout:  timeout,
                Elapsed:  time.Since(start),
            },
            Duration: time.Since(start),
            TimedOut: true,
        }
    }
}

// ExecuteWithFallback 执行工具调用(超时后使用兜底策略)
func (e *TimeoutExecutor) ExecuteWithFallback(
    ctx context.Context,
    toolName string,
    fn func(ctx context.Context) (interface{}, error),
    fallback func() (interface{}, error),
) *ToolResult {
    result := e.Execute(ctx, toolName, fn, nil)

    if result.TimedOut && fallback != nil {
        // 超时了,使用兜底
        fallbackVal, fallbackErr := fallback()
        if fallbackErr != nil {
            result.Error = fmt.Errorf(
                "工具超时且兜底策略失败: %w", fallbackErr,
            )
        } else {
            result.Value = fallbackVal
            result.Error = nil // 兜底成功
        }
    }

    return result
}

// ---- 使用示例 ----

func demo() {
    executor := NewTimeoutExecutor(DefaultTimeouts, 60*time.Second)
    ctx := context.Background()

    // 场景1:正常超时
    slowSearch := func(ctx context.Context) (interface{}, error) {
        select {
        case <-ctx.Done():
            return nil, ctx.Err()
        case <-time.After(10 * time.Second):
            return "search results", nil
        }
    }

    result := executor.Execute(ctx, "web_search", slowSearch, nil)
    if result.TimedOut {
        fmt.Printf("搜索超时: %v\n", result.Error)
    }

    // 场景2:超时后兜底(返回缓存结果)
    cacheFallback := func() (interface{}, error) {
        return "cached_results_for_search", nil
    }

    result2 := executor.ExecuteWithFallback(
        ctx, "web_search", slowSearch, cacheFallback,
    )
    fmt.Printf("结果: %v\n", result2.Value)

    // 场景3:长耗时工具的进度反馈
    pdfParse := func(ctx context.Context) (interface{}, error) {
        // 这里应该通过 onProgress 实时推送进度
        for i := 0; i < 10; i++ {
            select {
            case <-ctx.Done():
                return nil, ctx.Err()
            case <-time.After(2 * time.Second):
                // onProgress(float64(i+1)/10, fmt.Sprintf("解析中 %d/10 页", i+1))
            }
        }
        return "pdf_parsed", nil
    }

    progressFn := func(pct float64, msg string) {
        fmt.Printf("进度: %.0f%% - %s\n", pct*100, msg)
    }

    result3 := executor.Execute(ctx, "pdf_parse", pdfParse, progressFn)
    fmt.Printf("PDF 解析: %v (耗时 %v)\n", result3.Value, result3.Duration)
}

ExecuteWithFallback 的兜底策略设计是这段代码的核心价值。
超时不等于失败——很多场景下,过时的数据比没有数据好。
缓存兜底让搜索超时时返回上次缓存的结果(标记"数据可能不是最新的"),这样用户至少能看到东西,而不是一个空白页面。
这是一种"优雅降级"的思维——不是追求 100% 完美,而是在任何情况下都给用户最小可用的结果。

四、超时与重试的协调

超时不应该立即触发重试。
超时意味着工具还在执行中(只是慢),重试意味着同时有两个慢请求。
推荐策略:超时 → 降级兜底 → 不重试。
如果是网络瞬时故障的超时,重试才有意义。

兜底策略的选择:
搜索超时 → 返回空结果并告知用户。
数据库超时 → 返回缓存数据(标记陈旧)。
生成超时 → 返回部分结果或让用户稍后查看。

超时和重试的区分点在于"根因"。
网络超时(连接被拒绝、TLS 握手失败)→ 瞬时故障,可以重试。
执行超时(服务端处理太慢)→ 不是瞬时故障,不应该重试——否则等于用 2 个慢请求叠加。

一个实用的区分方法是检查 context.DeadlineExceeded 和具体的 net.Error
如果是前者 + 底层错误是 connection refused → 快速重试。
如果是前者 + 没有底层网络错误 → 说明服务端在缓慢处理,直接兜底。

另外,超时配置不要硬编码。
超时值应该从配置中心读取,支持热更新。
一个工具的 P99 延迟可能在业务高峰时翻倍,你需要在不发版的情况下调整超时阈值。
建议把超时配置放在 Redis 或配置中心中,Agent 启动时加载,运行时通过 Admin API 动态调整。

五、总结

工具调用超时策略应按工具类型分层设置。
搜索引擎 8s,数据库 5s,AI 生成 60s。
超时后优先使用兜底策略(缓存/降级),而非重试。
长耗时工具需要进度回调机制。
超时策略的目标是保障整体响应时间,单工具可牺牲。

最后强调一点:Agent 的超时策略不是在"防止慢工具",而是在"管理用户的等待预期"。
即使用了最好用的进度条和最完善的兜底策略,用户仍然会有不满——但他们不会责怪系统"卡死了",而是理解"这个任务确实需要时间"。
这就是超时策略的最终目标:把"不可接受的等待"变成"可接受的延迟"。

Logo

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

更多推荐