
29可靠性设计超时、重试、降级与幂等《Eino 实战用 Go 构建 AI 应用与智能体》· 第 29 篇关键词context、超时分层、重试退避、故障切换、幂等键前置条件已完成 28Callbacks 与可观测性 与 20人机协作。AI 应用依赖的外部服务特别多模型、向量库、业务接口、第三方工具。它们都会失败而且失败方式是多样的模型偶发返回 500重试就好了。模型网关整体故障重试多少次都没用。请求本身超时了重试只会更慢。工具执行成功了但结果没送达用户又点了一次。不同的失败要用不同的处理方式。这一篇把四件武器讲清楚超时、重试、降级、幂等。一、先分类这个错误该不该重试这是所有可靠性设计的第一步。错误类型特征处理暂时性偶发失败、限流、连接抖动可重试永久性参数错误、鉴权失败、资源不存在不重试立即报错超时/取消context.DeadlineExceeded、context.Canceled不重试业务失败业务规则不满足不重试返回明确原因判断依据是错误是否可能“再试一次就好了”。参数写错了重试一百次也还是错的用户主动取消更不该继续。在 Go 里用errors.Is判断iferrors.Is(err,context.Canceled){return,err// 调用方主动取消}iferrors.Is(err,context.DeadlineExceeded){return,fmt.Errorf(已超时不再重试: %w,err)}注意默认不要重试写操作。除非操作本身幂等见第五节。二、超时必须分层设置超时缺失的后果很直接一个卡住的调用会拖垮整个请求连接和 goroutine 不断堆积。关键是分层设置而不是只设一个总超时层级建议说明HTTP 请求用户可接受的最长时间前端体验的上限整体上下文略小于请求超时留出响应返回的时间模型调用按模型典型耗时 余量通常由适配器的Timeout配置工具/外部接口按对方 SLA 设置外部依赖不可控必须设各层之间的关系是嵌套的外层超时到点内层即使还在等也会被取消。一个常见错误是只设了外层外层 60 秒模型调用没有独立超时。结果是模型卡住时会占满全部 60 秒用户什么也看不到。内层超时应明显小于外层这样才能在内层失败后还有时间走降级路径。三、重试要有上限和退避funcwithRetry(ctx context.Context,maxAttemptsint,fnfunc(context.Context)(string,error))(string,error){varlastErrerrorforattempt:1;attemptmaxAttempts;attempt{out,err:fn(ctx)iferrnil{returnout,nil}lastErrerr// 不可重试的错误立即返回不必浪费尝试次数。iferrors.Is(err,context.Canceled){return,err}iferrors.Is(err,context.DeadlineExceeded){return,fmt.Errorf(已超时不再重试: %w,err)}ifattemptmaxAttempts{// 退避真实系统应有 jitter避免大量请求同时重试。time.Sleep(time.Duration(attempt)*5*time.Millisecond)}}return,fmt.Errorf(重试 %d 次后仍失败: %w,maxAttempts,lastErr)}三个要点1. 必须有上限。无限重试会放大故障——对方已经过载你的重试让它更慢。2. 必须退避。立即重试等于继续冲击。真实系统还应在退避时间上加随机抖动jitter避免大量客户端在同一时刻集中重试惊群。3. 必须能区分错误类型。上面那段代码里取消和超时直接返回不再浪费尝试次数。关于 Eino 的内置能力Eino ADK 在ChatModelAgentConfig上提供了两个内置配置配置作用ModelRetryConfig模型调用失败时按策略重试ModelFailoverConfig模型调用失败时切换备用模型优先用内置配置而不是自己写WrapModel。它们已经处理好了与 Agent 循环的配合第 19 篇提到过。四、降级主路径不可用时的备选方案重试解决不了“整体故障”。这时需要降级场景降级方案模型网关不可用切换备用模型或备用网关向量检索不可用退化为关键词检索或明确告知“暂无法检索”知识库不可用明确说明无法基于资料回答而不是让模型自由发挥某个工具不可用跳过该能力告知用户哪部分未执行out,err:callPrimary(ctx)iferr!nil{// 记录降级事件便于事后统计备用路径的使用频率。out,errcallBackup(ctx)}降级要有兜底终点。备用也失败时应该给用户一个明确的答复“服务暂时不可用请稍后重试”而不是抛出一个内部错误页面。降级不能降低安全要求。比如检索降级为关键词匹配可以接受但鉴权降级为“跳过校验”绝对不可以。降级方案要先过一遍安全检查。五、幂等让重复执行不产生重复副作用这是 HITL第 20 篇之后最需要关注的一点。恢复运行、用户重试、网络重发都可能导致同一个操作被执行两次。如果操作有副作用关闭工单、扣款、发通知就会出问题。解决方式是幂等键func(s*idempotentStore)CloseTicket(_context.Context,key,ticketIDstring)(string,error){s.mu.Lock()defers.mu.Unlock()ifgot,ok:s.done[key];ok{// 已经执行过返回原结果不重复产生副作用。returngot幂等命中未重复执行,nil}result:已关闭工单 ticketID s.done[key]resultreturnresult,nil}设计要点要点说明幂等键来源由业务生成如请求 ID不能由模型生成存储需要持久化且多实例共享Redis、数据库有效期设置 TTL避免无限增长结果也要存重复请求应返回相同结果而不只是“不执行”并发安全同一键的并发请求要能正确处理锁或唯一约束写操作的工具都应该考虑幂等。尤其是配合 HITL 中断恢复的场景——恢复时重新执行是常态。六、完整示例下面这段程序依次演示重试、超时短路、降级、幂等四种处理已实机运行packagemainimport(contexterrorsfmtlogsyncsync/atomictime)varerrModelerrors.New(模型调用暂时不可用)// flakyModel 前几次调用失败之后成功用于演示重试与故障切换。typeflakyModelstruct{callsint32failNint32namestring}func(m*flakyModel)Generate(ctx context.Context,_string)(string,error){n:atomic.AddInt32(m.calls,1)ifnm.failN{return,fmt.Errorf(%s 第 %d 次调用失败: %w,m.name,n,errModel)}returnfmt.Sprintf(%s 成功第 %d 次尝试,m.name,n),nil}// withRetry 对暂时性失败做有限次重试并区分是否值得重试。funcwithRetry(ctx context.Context,maxAttemptsint,fnfunc(context.Context)(string,error))(string,error){varlastErrerrorforattempt:1;attemptmaxAttempts;attempt{out,err:fn(ctx)iferrnil{returnout,nil}lastErrerr// 不可重试的错误立即返回不必浪费尝试次数。iferrors.Is(err,context.Canceled){return,err}iferrors.Is(err,context.DeadlineExceeded){return,fmt.Errorf(已超时不再重试: %w,err)}ifattemptmaxAttempts{// 退避真实系统应有 jitter避免大量请求同时重试。time.Sleep(time.Duration(attempt)*5*time.Millisecond)}}return,fmt.Errorf(重试 %d 次后仍失败: %w,maxAttempts,lastErr)}// idempotentStore 用幂等键保证同一请求重复执行只产生一次副作用。typeidempotentStorestruct{mu sync.Mutex donemap[string]string}funcnewStore()*idempotentStore{returnidempotentStore{done:map[string]string{}}}func(s*idempotentStore)CloseTicket(_context.Context,key,ticketIDstring)(string,error){s.mu.Lock()defers.mu.Unlock()ifgot,ok:s.done[key];ok{// 已经执行过返回原结果不重复产生副作用。returngot幂等命中未重复执行,nil}result:已关闭工单 ticketID s.done[key]resultreturnresult,nil}funcmain(){iferr:run();err!nil{log.Fatal(err)}}funcrun()error{ctx:context.Background()// ---- 1. 重试暂时性失败可通过重试解决 ----fmt.Println( 重试 )m:flakyModel{failN:2,name:主模型}out,err:withRetry(ctx,4,func(ctx context.Context)(string,error){returnm.Generate(ctx,)})fmt.Printf(结果: %v err%v\n,out,err)// ---- 2. 重试不适用于超时 ----fmt.Println( 超时不重试 )shortCtx,cancel:context.WithTimeout(ctx,10*time.Millisecond)defercancel()blocked:func(ctx context.Context)(string,error){select{case-time.After(time.Second):return不该到这里,nilcase-ctx.Done():return,ctx.Err()}}_,errwithRetry(shortCtx,3,blocked)fmt.Printf(结果 err%v\n,err)// ---- 3. 降级主模型不可用时切到备用 ----fmt.Println( 降级故障切换 )primary:flakyModel{failN:99,name:主模型}// 始终失败backup:flakyModel{failN:0,name:备用模型}out,errwithRetry(ctx,2,func(ctx context.Context)(string,error){returnprimary.Generate(ctx,)})iferr!nil{fmt.Println( 主模型失败切换到备用:,err)out,errbackup.Generate(ctx,)}fmt.Printf(结果: %v err%v\n,out,err)// ---- 4. 幂等同一请求重复执行只产生一次副作用 ----fmt.Println( 幂等 )store:newStore()key:req-abc-123// 由业务生成的幂等键同一请求复用fori:1;i2;i{res,err:store.CloseTicket(ctx,key,T-1001)fmt.Printf( 第 %d 次调用: %v err%v\n,i,res,err)}returnnil}实机输出 重试 结果: 主模型 成功第 3 次尝试 errnil 超时不重试 结果 err已超时不再重试: context deadline exceeded 降级故障切换 主模型失败切换到备用: 重试 2 次后仍失败: 主模型 第 2 次调用失败: 模型调用暂时不可用 结果: 备用模型 成功第 1 次尝试 errnil 幂等 第 1 次调用: 已关闭工单 T-1001 errnil 第 2 次调用: 已关闭工单 T-1001幂等命中未重复执行 errnil四段输出对应四个结论重试有效前两次失败第三次成功。终态是成功用户无感知。超时不重试已超时不再重试。这很重要——超时重试只会让用户等更久。降级兜底主模型重试后仍失败切换到备用模型成功。幂等生效第二次调用命中幂等键没有重复执行副作用并且返回了与第一次相同的结果。七、和前面的章节怎么配合章节关联点20人机协作恢复时会重新执行写操作必须幂等28可观测性记录重试次数与降级事件才能发现“备用路径变常态”11工具安全重试与降级都不能绕过鉴权27HTTP 与 SSE超时分层与取消传播特别强调第 20 篇的关联HITL 的恢复机制会让工具再次执行。如果工具有副作用且不幂等用户点一次“确认”可能产生两条业务记录。这是最容易在联调时才发现的问题。八、常见问题重试后出现了重复数据写操作缺少幂等键或幂等键由模型生成不稳定。幂等键必须由业务生成并持久化。服务被打满重试没有上限或没有退避与 jitter。也可能是降级路径也被高频调用。用户等到超时也没收到回复内层没有独立超时全部时间被某一个调用消耗。分层设置超时并让内层先失败以留出降级时间。重试了不该重试的错误参数错误、鉴权失败被当成暂时性错误。用errors.Is分类处理。降级后结果不对但没人发现降级事件没有记录。接入观测第 28 篇统计备用路径的触发频率。模型整个不可用ModelFailoverConfig可以切换到备用模型。但要注意备用模型的能力差异可能导致输出质量波动需要评测覆盖第 31 篇。九、小结先分类错误暂时性可重试永久性、超时、业务失败不该重试。超时必须分层内层应明显小于外层才有时间走降级。重试要有上限、退避与 jitterEino 提供ModelRetryConfig/ModelFailoverConfig内置能力。降级要有兜底终点且不能降低安全要求。写操作要幂等幂等键由业务生成、持久化存储、并发安全。HITL 恢复会重新执行所以第 20 篇的场景尤其需要幂等。重试与降级事件都要记录否则“临时兜底”会悄悄变成常态。下一篇《安全设计不要把业务权限交给提示词》我们系统梳理 AI 应用的安全边界。参考资料与版本说明资料核对日期为2026 年 10 月 9 日五分钟上手 Eino ADKModelRetry / ModelFailoverEino FAQ超时与取消排查Eino 主仓库本文示例按Eino v0.9.21编写。示例本身使用标准库实现重试/降级/幂等不依赖 Eino 运行时已实机运行正文输出为真实结果。