系列「企业级 AI Agent 实现拆解」E44 篇,Part 10 生产工程篇第二章。上一篇 讲了多 Agent 编排。这篇聚焦单个 LLM 调用的可靠性------API 调用失败怎么重试,主力模型挂了怎么切换。
读完这篇你会知道
- Eino ADK 的 Retry 配置:从简单错误重试到智能输出质量检测
ShouldRetry:怎么在重试时修改输入、压缩 context、动态调整参数- Failover:主模型失败后如何自动切换到备用 Provider
lastSuccessModel:为什么 Failover 会记住上次成功的模型- 流式场景下 Retry/Failover 怎么处理 mid-stream 错误
为什么 LLM 调用需要重试和 Failover
生产环境的 LLM 调用面临三类失败:
- 临时性故障:Rate limit(429)、网络超时、服务瞬时不可用
- 质量问题:模型返回了响应,但内容不符合要求(格式错误、内容截断、幻觉太严重)
- 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