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

📅 2026/7/11 23:13:23 👁️ 阅读次数 📝 编程学习
Function Calling 超时策略:别让一个慢工具拖垮整个 Agent 响应

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