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