feat: 模型健康/熔断态 surface 到管理端(T4.F 可观测)
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>
This commit is contained in:
@@ -19,10 +19,20 @@ import (
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/sundynix/sundynix-dispatcher/internal/harness"
|
||||
"github.com/sundynix/sundynix-shared/contract"
|
||||
"github.com/sundynix/sundynix-shared/otelx"
|
||||
)
|
||||
|
||||
// ModelHealth 是主备链上单个模型的实时健康态(供管理端展示 failover/熔断可见)。
|
||||
type ModelHealth struct {
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
Role string `json:"role"` // primary / fallback
|
||||
State string `json:"state"` // closed(在线) / open(熔断中) / half-open(半开探测) / single(无备用链)
|
||||
Fails int `json:"fails"` // 闭合态连续失败计数
|
||||
}
|
||||
|
||||
// requestTimeout 是单次推理请求的上限。
|
||||
const requestTimeout = 120 * time.Second
|
||||
|
||||
@@ -34,9 +44,11 @@ type ChatMessage struct {
|
||||
|
||||
// Pool 维护当前激活的后端配置 + 据此构建的 Eino ChatModel(控制面经 NATS 下发,可热更新)。
|
||||
type Pool struct {
|
||||
mu sync.RWMutex
|
||||
cfg *contract.ModelConfig
|
||||
cm model.BaseChatModel // 由 SetConfig 用激活配置构建;未配置时为 nil
|
||||
mu sync.RWMutex
|
||||
cfg *contract.ModelConfig
|
||||
cm model.BaseChatModel // 由 SetConfig 用激活配置构建;未配置时为 nil
|
||||
health []ModelHealth // 主备链各模型静态信息(provider/model/role),与 breakers 同序
|
||||
breakers []*harness.CircuitBreaker // 与 health 一一对应;无 failover 链时为对应 nil
|
||||
}
|
||||
|
||||
func NewPool() *Pool { return &Pool{} }
|
||||
@@ -48,12 +60,16 @@ func forceStub() bool { return os.Getenv("LLM_FORCE_STUB") == "1" }
|
||||
// SetConfig 热更新后端配置:用激活配置(含备用模型)重建 ChatModel(控制面变更时调用)。
|
||||
func (p *Pool) SetConfig(cfg *contract.ModelConfig) {
|
||||
var cm model.BaseChatModel
|
||||
var health []ModelHealth
|
||||
var breakers []*harness.CircuitBreaker
|
||||
if cfg != nil && cfg.Ready() && !forceStub() {
|
||||
cm = buildWithFallbacks(cfg)
|
||||
cm, health, breakers = buildWithFallbacks(cfg)
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.cfg = cfg
|
||||
p.cm = cm
|
||||
p.health = health
|
||||
p.breakers = breakers
|
||||
p.mu.Unlock()
|
||||
if cfg != nil {
|
||||
// 不打印 api_key。
|
||||
@@ -64,19 +80,24 @@ func (p *Pool) SetConfig(cfg *contract.ModelConfig) {
|
||||
|
||||
// buildWithFallbacks 构建主模型,并把可用的备用模型串成 failover 链(无备用则直接返回主模型)。
|
||||
// 主模型构建失败 → 返回 nil(降级桩);备用单个失败 → 跳过该备用,不影响主链。
|
||||
func buildWithFallbacks(cfg *contract.ModelConfig) model.BaseChatModel {
|
||||
// 第二/三返回值为主备链的健康态元信息(provider/model/role)与对应熔断器(无 failover 链时为 nil),
|
||||
// 供 Pool.ModelHealth 上报管理端。
|
||||
func buildWithFallbacks(cfg *contract.ModelConfig) (model.BaseChatModel, []ModelHealth, []*harness.CircuitBreaker) {
|
||||
primary, err := buildChatModel(cfg)
|
||||
if err != nil {
|
||||
fmt.Printf("[llm] 构建主 ChatModel 失败(降级桩运行): %v\n", err)
|
||||
return nil
|
||||
return nil, nil, nil
|
||||
}
|
||||
ptcm, ok := primary.(model.ToolCallingChatModel)
|
||||
if !ok {
|
||||
return primary // 不支持 WithTools(无法包 failover/cache)→ 直接用主模型
|
||||
// 不支持 WithTools(无法包 failover/cache)→ 单模型,无独立熔断器。
|
||||
return primary,
|
||||
[]ModelHealth{{Provider: cfg.Provider, Model: cfg.Model, Role: "primary"}},
|
||||
[]*harness.CircuitBreaker{nil}
|
||||
}
|
||||
// 主链:主模型 +(可用的)备用模型串成 failover。
|
||||
chain := ptcm
|
||||
// 主链:主模型 +(可用的)备用模型串成 failover。metas 与 models 同序。
|
||||
models := []model.ToolCallingChatModel{ptcm}
|
||||
metas := []ModelHealth{{Provider: cfg.Provider, Model: cfg.Model, Role: "primary"}}
|
||||
for i := range cfg.Fallbacks {
|
||||
fb := cfg.Fallbacks[i]
|
||||
if !fb.Ready() {
|
||||
@@ -89,16 +110,39 @@ func buildWithFallbacks(cfg *contract.ModelConfig) model.BaseChatModel {
|
||||
}
|
||||
if t, ok := fbm.(model.ToolCallingChatModel); ok {
|
||||
models = append(models, t)
|
||||
metas = append(metas, ModelHealth{Provider: fb.Provider, Model: fb.Model, Role: "fallback"})
|
||||
}
|
||||
}
|
||||
var chain model.ToolCallingChatModel = ptcm
|
||||
breakers := make([]*harness.CircuitBreaker, len(models)) // 无 failover 时全 nil
|
||||
if len(models) > 1 {
|
||||
fmt.Printf("[llm] 启用模型 failover:主 %s + %d 个备用\n", cfg.Model, len(models)-1)
|
||||
chain = newFailoverModel(models, func(idx int, ferr error) {
|
||||
fm := newFailoverModel(models, func(idx int, ferr error) {
|
||||
fmt.Printf("[llm] 模型 failover:第 %d 个模型失败(%v),切下一个\n", idx, ferr)
|
||||
})
|
||||
copy(breakers, fm.breakers) // 每模型熔断器(同序),供上报态
|
||||
chain = fm
|
||||
}
|
||||
// 缓存包在最外层:命中直接跳过整条 failover 链(省成本+提速)。键含模型名 → 换模型自然失效。
|
||||
return withCache(chain, cfg.Model)
|
||||
return withCache(chain, cfg.Model), metas, breakers
|
||||
}
|
||||
|
||||
// ModelHealth 返回当前主备链各模型的实时健康态(含每模型熔断状态),供管理端展示。
|
||||
func (p *Pool) ModelHealth() []ModelHealth {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
out := make([]ModelHealth, len(p.health))
|
||||
for i, h := range p.health {
|
||||
out[i] = h
|
||||
if i < len(p.breakers) && p.breakers[i] != nil {
|
||||
snap := p.breakers[i].Snapshot()
|
||||
out[i].State = snap.State.String()
|
||||
out[i].Fails = snap.Fails
|
||||
} else {
|
||||
out[i].State = "single" // 无备用链 → 该模型无独立熔断器
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildChatModel 据 provider 归一化连接参数后构建 OpenAI 兼容 ChatModel。
|
||||
|
||||
Reference in New Issue
Block a user