LLM 可靠性设计:Retry + Failover 源码拆解(第58篇-E44)

系列「企业级 AI Agent 实现拆解」E44 篇,Part 10 生产工程篇第二章。上一篇 讲了多 Agent 编排。这篇聚焦单个 LLM 调用的可靠性------API 调用失败怎么重试,主力模型挂了怎么切换。

读完这篇你会知道

  • Eino ADK 的 Retry 配置:从简单错误重试到智能输出质量检测
  • ShouldRetry:怎么在重试时修改输入、压缩 context、动态调整参数
  • Failover:主模型失败后如何自动切换到备用 Provider
  • lastSuccessModel:为什么 Failover 会记住上次成功的模型
  • 流式场景下 Retry/Failover 怎么处理 mid-stream 错误

为什么 LLM 调用需要重试和 Failover

生产环境的 LLM 调用面临三类失败:

  1. 临时性故障:Rate limit(429)、网络超时、服务瞬时不可用
  2. 质量问题:模型返回了响应,但内容不符合要求(格式错误、内容截断、幻觉太严重)
  3. Provider 级故障:某个 Provider 的整个 API 不可用,需要切换到备用 Provider

Retry 解决前两类,Failover 解决第三类。


Retry:重试的配置结构

go 复制代码
type ModelRetryConfig struct {
    // 最多重试几次(不含首次调用)
    // MaxRetries=3 → 最多 4 次总调用
    MaxRetries int

    // 智能重试决策回调(推荐)
    // nil 时默认对所有错误重试
    ShouldRetry func(ctx context.Context, retryCtx *RetryContext) *RetryDecision

    // 已废弃:只能判断 err,无法检查输出内容
    // ShouldRetry 设置时此字段被忽略
    IsRetryAble func(ctx context.Context, err error) bool

    // 退避策略:默认指数退避 + 随机抖动
    // 基础 100ms,最大 10s,每次指数递增
    BackoffFunc func(ctx context.Context, attempt int) time.Duration
}

接入方式:

go 复制代码
agent, _ := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
    Model: deepseekModel,
    ModelRetryConfig: &adk.ModelRetryConfig{
        MaxRetries: 3,
        ShouldRetry: myRetryDecider,
    },
})

RetryContext + RetryDecision:比错误重试聪明得多

ShouldRetry 拿到的不只是错误,而是完整的调用结果

go 复制代码
type RetryContext struct {
    RetryAttempt  int              // 当前是第几次重试(从 1 开始)
    InputMessages []*schema.Message // 这次调用的输入
    Options       []model.Option
    OutputMessage *schema.Message  // 模型输出(可能为 nil)
    Err           error            // 调用错误(可能为 nil)
}

四种状态组合:

OutputMessage Err 含义
non-nil nil 调用成功,可以检查输出质量
nil non-nil 调用失败,没有输出
non-nil non-nil 流式中途失败,有部分输出
nil nil 流式返回了空(0 个 chunk)

RetryDecision 不只是"要不要重试",还可以修改下次调用的参数

go 复制代码
type RetryDecision struct {
    Retry   bool   // 是否重试

    // 不重试时:把原始错误换成这个错误(用于把"输出不合格"变成明确的错误)
    RewriteError error

    // 重试时:修改下次调用的输入消息(如压缩 context)
    ModifiedInputMessages []*schema.Message

    // true = 把修改后的消息也写回 Agent 的对话历史
    // false = 只影响本次重试,不影响后续对话
    PersistModifiedInputMessages bool

    // 重试时:追加额外的 model option(如增大 MaxTokens)
    AdditionalOptions []model.Option

    // 本次重试的退避时间(覆盖 BackoffFunc)
    Backoff time.Duration

    // 给外部观察用的拒绝原因(通过 WillRetryError.RejectReason() 读取)
    RejectReason any
}

实战:三种 Retry 场景

场景一:只重试错误,忽略质量

go 复制代码
// 最简单:所有错误都重试
ModelRetryConfig: &adk.ModelRetryConfig{
    MaxRetries: 2,
    // ShouldRetry=nil → 默认对所有非 nil 错误重试
}

场景二:检测输出质量,拒绝不合格回答

go 复制代码
ShouldRetry: func(ctx context.Context, retryCtx *adk.RetryContext) *adk.RetryDecision {
    // 有错误 → 重试
    if retryCtx.Err != nil {
        return &adk.RetryDecision{Retry: true}
    }

    // 检查输出是否包含必要的 JSON 结构
    output := retryCtx.OutputMessage.Content
    if !isValidJSON(output) {
        return &adk.RetryDecision{
            Retry:        true,
            RejectReason: "invalid JSON output",
            // 加一条提示消息,告诉模型上次输出有问题
            ModifiedInputMessages: append(retryCtx.InputMessages,
                schema.UserMessage("你的输出必须是合法 JSON,请重新生成")),
        }
    }

    return nil // 接受输出
}

场景三:context 超长 → 压缩后重试

go 复制代码
ShouldRetry: func(ctx context.Context, retryCtx *adk.RetryContext) *adk.RetryDecision {
    if retryCtx.Err == nil {
        return nil // 成功,不重试
    }

    // 检测是否是 context length 超限错误
    if isContextLengthError(retryCtx.Err) {
        compressed := compressMessages(retryCtx.InputMessages)
        return &adk.RetryDecision{
            Retry:                        true,
            ModifiedInputMessages:        compressed,
            PersistModifiedInputMessages: true,  // 压缩也写回历史,避免下次还超限
            AdditionalOptions: []model.Option{
                model.WithMaxTokens(2000),  // 同时限制输出 token
            },
        }
    }

    return &adk.RetryDecision{Retry: true}
}

流式场景:Retry 怎么处理 mid-stream 错误

流式模式下,ShouldRetry 不是在 chunk 到来时调用,而是消费完整个 stream 之后才调用:

go 复制代码
// 内部实现(stream retry 核心逻辑)
copies := stream.Copy(2)
checkCopy := copies[0]   // 用来同步消费、检查质量
returnCopy := copies[1]  // 用来返回给调用方

// 先把 checkCopy 消费完,拿到完整消息
msg, streamErr := typedConsumeStream(checkCopy)

// 然后调用 ShouldRetry
decision := config.ShouldRetry(ctx, &RetryContext{
    OutputMessage: msg,      // 完整的流式输出(可能是中途截断的部分)
    Err:           streamErr,
})

if decision.Retry {
    returnCopy.Close()  // 丢掉这次输出,重新来
    // 下一次 attempt...
} else {
    return returnCopy, nil  // 返回给调用方
}

事件流的处理:如果重试发生,客户端侧会收到多轮输出流(第一轮因为重试被终止,第二轮是真正的输出)。Eino 用 WillRetryError 事件通知客户端正在重试:

go 复制代码
// 客户端通过 AgentEvent 观察到重试
for event := range iter.Next() {
    if event.Err != nil {
        var retryErr *adk.WillRetryError
        if errors.As(event.Err, &retryErr) {
            log.Printf("第 %d 次重试,原因:%v", retryErr.RetryAttempt, retryErr.RejectReason)
            continue  // 继续等待下一次输出
        }
    }
}

Failover:主 Provider 挂了自动切换

go 复制代码
type ModelFailoverConfig struct {
    // 最多 failover 几次
    MaxRetries uint

    // 是否触发 failover 的条件
    ShouldFailover func(ctx context.Context, outputMsg *schema.Message, outputErr error) bool

    // 返回下一个要试的 Provider 和可选的转换后输入
    GetFailoverModel func(ctx context.Context, failoverCtx *FailoverContext) (
        failoverModel model.BaseModel,
        failoverModelInputMessages []*schema.Message,
        failoverErr error,
    )
}

实战配置------DeepSeek 失败切 GPT-4o:

go 复制代码
providers := []model.BaseModel{deepseekModel, gpt4oModel, claudeModel}

agent, _ := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
    Model: providers[0],  // 默认用 DeepSeek
    ModelFailoverConfig: &adk.ModelFailoverConfig{
        MaxRetries: 2,
        ShouldFailover: func(ctx context.Context, _ *schema.Message, err error) bool {
            if err == nil { return false }
            // Rate limit 或 Service unavailable → failover
            return isRateLimitError(err) || isServiceUnavailableError(err)
        },
        GetFailoverModel: func(ctx context.Context, fc *adk.FailoverContext) (model.BaseModel, []*schema.Message, error) {
            if int(fc.FailoverAttempt) >= len(providers) {
                return nil, nil, fmt.Errorf("所有 Provider 都失败了")
            }
            // 按顺序切换:attempt 1 → GPT-4o, attempt 2 → Claude
            return providers[fc.FailoverAttempt], nil, nil
        },
    },
})

lastSuccessModel:记住上次成功的 Provider

Failover 有一个微妙但重要的设计:记住上次成功的 Provider

复制代码
// 场景:DeepSeek 稳定跑了 100 次后突然限速
// 第 101 次调用:failover 发现 lastSuccessModel = DeepSeek

// 执行顺序(每次调用):
1. 先试 lastSuccessModel(上次成功的那个)
   → 如果成功,直接返回,不调用 GetFailoverModel
2. lastSuccessModel 失败 → 调用 GetFailoverModel(attempt=1)
   → 切换到 GPT-4o
3. GPT-4o 成功 → 更新 lastSuccessModel = GPT-4o
4. 下次调用先试 GPT-4o(不是 DeepSeek)

这个设计在"Primary 恢复正常"时需要手动重置 (或者用 ShouldFailover 判断"Primary 已恢复"时不触发 failover),否则 lastSuccessModel 会一直指向 Failover Provider,造成不必要的成本浪费。


Retry + Failover 的组合

两者可以同时配置,在执行链中是嵌套关系:

复制代码
调用链(外到内):
  Failover → Retry → eventSender → callbacks → 实际 Model

每次 Failover 尝试都会触发完整的 Retry 循环。比如 MaxRetries=3(failover)+ MaxRetries=2(retry),最坏情况是 3 × 3 = 9 次 Model 调用。

注意:Failover 不会发生在以下情况:

  • ctx.Err() != nil(context 已取消)
  • ErrStreamCanceled(客户端主动放弃 stream)
  • 错误是 InterruptSignal(HITL 中断,不该被重试)

小结

Eino ADK 的 Retry/Failover 设计核心是可组合、可观测、不误伤

机制 解决什么 关键参数
Retry 临时错误 + 输出质量 ShouldRetry(可改输入/参数)
Failover Provider 级故障 GetFailoverModel(切 Provider)
lastSuccessModel 减少无效 Provider 调用 自动,需注意 Primary 恢复后的重置
WillRetryError 客户端可见重试事件 通过 AgentEvent.Err 监听
不误伤 中断信号不触发重试 InterruptSignal / ErrStreamCanceled 豁免

在 DeepFlux 里,这套机制是"多 Provider 成本路由"的基础:白天用 DeepSeek(成本低),超配额切 GPT-4o(能力强),两者都挂了走 Claude(备用)。


代码来源:eino/adk/retry_chatmodel.go · eino/adk/failover_chatmodel.go

相关推荐
乐橙开放平台9 小时前
养殖 SaaS 笔记:乐橙 IoT 物模型管环境,视频 OpenAPI 管回看
数据库·笔记·物联网·mysql·音视频
草莓熊Lotso9 小时前
【LangChain】输出解析器全解:让大模型输出从 “聊天” 变 “机器可读”
服务器·数据库·python·langchain·pip
GuWenyue9 小时前
放弃云端API!5套技术栈实战WebGPU端侧AI,前端独立跑本地大模型,省成本还保隐私
前端·数据库·人工智能
鸽芷咕11 小时前
MySQL/PostgreSQL 迁移金仓 KES:LEFT JOIN 丢数据排查与避坑指南
数据库·mysql·postgresql
2401_8414956421 小时前
【数据结构】B+树
数据结构·数据库·c++·b+树·概念·结构·操作原理
Hardworking6661 天前
第7章 软硬件系统集成
数据库·软硬件系统集成
名字还没想好☜1 天前
Go 的 time.Ticker 陷阱:定时任务里被忽略的内存泄漏与正确关闭
java·数据库·golang·go·定时器
熊猫钓鱼>_>1 天前
Redis 突发缓存穿透:一次完整的定位复盘
数据库·人工智能·redis·缓存·ai·agent·智能