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:
Blizzard
2026-07-06 12:00:13 +08:00
parent 6a258fc884
commit 55d50417a9
11 changed files with 1719 additions and 17 deletions
@@ -94,6 +94,7 @@ func main() {
"model": pool.ModelName(),
"ready": pool.Ready(),
"uptime_s": int(time.Since(startedAt).Seconds()),
"models": pool.ModelHealth(), // 主备链每模型实时健康/熔断态(供管理端展示)
})
return data
}); herr != nil {
@@ -139,3 +139,16 @@ func (c *CircuitBreaker) State() State {
defer c.mu.Unlock()
return c.state
}
// Snapshot 是熔断器的只读观测快照(供 surface 到管理端)。
type Snapshot struct {
State State // 当前状态:closed / open / half-open
Fails int // 闭合态连续失败计数
}
// Snapshot 返回当前观测快照(不改状态机;管理端展示 breaker 态用)。
func (c *CircuitBreaker) Snapshot() Snapshot {
c.mu.Lock()
defer c.mu.Unlock()
return Snapshot{State: c.state, Fails: c.fails}
}
@@ -15,6 +15,24 @@ func newTestCB(threshold int, cooldown time.Duration, clock *time.Time) *Circuit
return c
}
// Snapshot 应如实反映当前状态与连续失败计数(管理端展示用,不改状态机)。
func TestCircuitBreaker_Snapshot(t *testing.T) {
now := time.Unix(0, 0)
c := newTestCB(3, 10*time.Second, &now)
if s := c.Snapshot(); s.State != Closed || s.Fails != 0 {
t.Fatalf("初始应 closed/0, got %+v", s)
}
c.Report(false)
c.Report(false)
if s := c.Snapshot(); s.State != Closed || s.Fails != 2 {
t.Fatalf("2 次失败未到阈值应 closed/2, got %+v", s)
}
c.Report(false) // 第 3 次 → 熔断
if s := c.Snapshot(); s.State != Open || s.Fails != 3 {
t.Fatalf("阈值后应 open/3, got %+v", s)
}
}
func TestCircuitBreaker_OpensAfterThreshold(t *testing.T) {
now := time.Unix(0, 0)
c := newTestCB(3, 10*time.Second, &now)
+2 -1
View File
@@ -38,7 +38,8 @@ type failoverModel struct {
}
// newFailoverModel 建主备链。models 至少 1 个;只有 1 个时调用方应直接用该模型而非本包装。
func newFailoverModel(models []model.ToolCallingChatModel, onFailover func(int, error)) model.ToolCallingChatModel {
// 返回具体类型 *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)
+55 -11
View File
@@ -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。
@@ -0,0 +1,45 @@
package llm
import (
"testing"
"github.com/sundynix/sundynix-shared/contract"
)
// Pool.ModelHealth 应把主备链各模型的名字/角色与其熔断态正确配对上报。
func TestPool_ModelHealth(t *testing.T) {
p := NewPool()
// 主 + 1 备 → failover 链,两模型都在线(closed)。
p.SetConfig(&contract.ModelConfig{
Provider: "openai-compatible", BaseURL: "http://primary", APIKey: "k", Model: "primary-m",
Fallbacks: []contract.ModelConfig{
{Provider: "openai-compatible", BaseURL: "http://backup", APIKey: "k", Model: "backup-m"},
},
})
h := p.ModelHealth()
if len(h) != 2 {
t.Fatalf("主+备应 2 条, got %d: %+v", len(h), h)
}
if h[0].Role != "primary" || h[0].Model != "primary-m" || h[0].State != "closed" {
t.Errorf("主模型态错: %+v", h[0])
}
if h[1].Role != "fallback" || h[1].Model != "backup-m" || h[1].State != "closed" {
t.Errorf("备模型态错: %+v", h[1])
}
// 单模型 → 无 failover 链,state=single。
p.SetConfig(&contract.ModelConfig{
Provider: "openai-compatible", BaseURL: "http://only", APIKey: "k", Model: "only-m",
})
h = p.ModelHealth()
if len(h) != 1 || h[0].State != "single" || h[0].Role != "primary" {
t.Fatalf("单模型应 1 条 single/primary, got %+v", h)
}
// 未配置 → 空。
p.SetConfig(nil)
if h := p.ModelHealth(); len(h) != 0 {
t.Fatalf("未配置应空, got %+v", h)
}
}