package llm import ( "context" "errors" "fmt" "time" "github.com/cloudwego/eino/components/model" "github.com/cloudwego/eino/schema" "github.com/sundynix/sundynix-dispatcher/internal/harness" ) // 模型层 failover 的每模型熔断参数:比编排层更紧(更快跳过挂掉的主模型),冷却后半开探测恢复。 const ( fbBreakerThreshold = 3 // 某模型连续失败达此数 → 熔断,后续请求跳过它 fbBreakerCooldown = 20 * time.Second // 熔断后多久放行探测(回主模型) fbBreakerHalfOpen = 1 ) var errAllModelsFailed = errors.New("failover: 所有模型均不可用") // failoverModel 把多个 ToolCallingChatModel 串成主备链:按序调用,主模型遇错即切下一个, // 直到成功或全部失败。它本身就是个 model.ToolCallingChatModel,故 compose / ReAct / Chat // 全路径透明白嫖 failover —— 单 provider 抖动/挂掉时平台不整体宕。 // // 每模型带一个熔断器:主模型持续失败达阈值即熔断,后续请求**直接跳过主、走备用**(省掉每次 // 白试主模型的失败往返);冷却到点半开放行一个探测回主,成功即恢复走主(自动,无需外部通知)。 // // 局限(v1):Stream 仅在「建流(Stream() 调用)同步报错」时切备;已开始回流 token 的中途失败不切 // (输出已半出,无法干净重来)。连接级失败(拒连/立即 5xx)由 openai 客户端在 Stream() 同步返回, // 已覆盖"provider 整体挂"的主场景。 type failoverModel struct { models []model.ToolCallingChatModel // 主模型在前,其余为按序备用 breakers []*harness.CircuitBreaker // 与 models 一一对应;WithTools 重包时共享(状态不清零) onFailover func(idx int, err error) // 切换回调(日志/观测;可空) } // newFailoverModel 建主备链。models 至少 1 个;只有 1 个时调用方应直接用该模型而非本包装。 // 返回具体类型 *failoverModel(仍满足接口):同包的 buildWithFallbacks 据此读 .breakers 上报健康态。 func newFailoverModel(models []model.ToolCallingChatModel, onFailover func(int, error)) *failoverModel { breakers := make([]*harness.CircuitBreaker, len(models)) for i := range breakers { breakers[i] = harness.NewCircuitBreakerWith(fbBreakerThreshold, fbBreakerCooldown, fbBreakerHalfOpen) } return &failoverModel{models: models, breakers: breakers, onFailover: onFailover} } // runFailover 是 Generate/Stream 共用的选路循环:按序过链,熔断的模型跳过,成功即返回并上报, // 失败上报后切下一个;调用方取消(ctx)则不再切。全被熔断跳过时强制试主模型兜底。 func runFailover[T any](f *failoverModel, ctx context.Context, call func(model.ToolCallingChatModel) (T, error)) (T, error) { var zero T var lastErr error attempted := false for i, m := range f.models { if b := f.breakers[i]; b != nil && !b.Allow() { continue // 该模型熔断中(Open 冷却)→ 跳过,直连下一个可用模型 } attempted = true out, err := call(m) if err == nil { f.report(i, true) return out, nil } f.report(i, false) lastErr = err if ctx.Err() != nil { // 调用方主动取消/截止 → 不再切(切了也没用,且违背用户意图) return zero, err } if f.onFailover != nil { f.onFailover(i, err) } } // 全部模型都熔断、一个没试 → 强制试主模型兜底(编排层 o.breaker 兜"全挂")。 if !attempted && len(f.models) > 0 { out, err := call(f.models[0]) f.report(0, err == nil) return out, err } if lastErr == nil { lastErr = errAllModelsFailed } return zero, lastErr } func (f *failoverModel) report(i int, success bool) { if i >= 0 && i < len(f.breakers) && f.breakers[i] != nil { f.breakers[i].Report(success) } } func (f *failoverModel) Generate(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error) { return runFailover(f, ctx, func(m model.ToolCallingChatModel) (*schema.Message, error) { return m.Generate(ctx, input, opts...) }) } func (f *failoverModel) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) { return runFailover(f, ctx, func(m model.ToolCallingChatModel) (*schema.StreamReader[*schema.Message], error) { return m.Stream(ctx, input, opts...) }) } // WithTools 给链上每个模型绑定工具,返回新的 failover 链(不可变,并发安全;ReAct 用)。 // 关键:复用同一批 breakers(共享指针)——否则每次 rewrap 熔断状态清零,熔断形同虚设。 func (f *failoverModel) WithTools(tools []*schema.ToolInfo) (model.ToolCallingChatModel, error) { bound := make([]model.ToolCallingChatModel, len(f.models)) for i, m := range f.models { b, err := m.WithTools(tools) if err != nil { return nil, fmt.Errorf("failover WithTools[%d]: %w", i, err) } bound[i] = b } return &failoverModel{models: bound, breakers: f.breakers, onFailover: f.onFailover}, nil }