55d50417a9
failover/熔断的运行时态原来只在 dispatcher 日志、admin 看不到 —— 本次接到概览可见:
- harness: CircuitBreaker.Snapshot() 只读观测访问器(state + fails,不动状态机)
- llm: Pool.ModelHealth() 上报主备链每模型 {provider,model,role,state,fails};
buildWithFallbacks 把模型名↔breaker 配对(同包直接读 failoverModel.breakers);
newFailoverModel 改返回具体类型以便读 breakers
- dispatcher 心跳 payload 加 models[]
- gateway /admin/overview 独立超时 Ping dispatcher,合并进 models.health
- admin 概览「模型路由」新增「运行时链路态(实时)」:逐模型状态点
(🟢在线/🔴熔断中+失败数/🟡半开探测/单点)
- 单测:Snapshot、Pool.ModelHealth(名字↔态配对/单点/空)
- live:配坏主→提交任务打熔断→概览显示 broken-demo「熔断中·失败3」,备用在线
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
118 lines
5.0 KiB
Go
118 lines
5.0 KiB
Go
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
|
||
}
|