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
+5 -4
View File
@@ -202,10 +202,11 @@ RBAC 未做,暂以单管理员账号代理;概览口径必须是**系统级*
- [ ] `X-Session-ID` 防伪 / 开发 JWT 密钥外置 / 输入校验补强 | S(待) - [ ] `X-Session-ID` 防伪 / 开发 JWT 密钥外置 / 输入校验补强 | S(待)
- [ ] 性能:ListModels 去 O(N²)admin.go:48)、KB/Agent 列表分页、报告分章检索缓存、Milvus 批量 ensure | S~M - [ ] 性能:ListModels 去 O(N²)admin.go:48)、KB/Agent 列表分页、报告分章检索缓存、Milvus 批量 ensure | S~M
- [ ] 可观测:panic 进 trace span + 回写通知、TTFT/token-s/各路检索耗时指标 | M - [ ] 可观测:panic 进 trace span + 回写通知、TTFT/token-s/各路检索耗时指标 | M
- [ ] 🔴 模型健康/熔断态 surface 到管理端(新,2026-07-02 failover demo 暴露的真实缺口)—— - [x] 🔴 模型健康/熔断态 surface 到管理端2026-07-06)—— harness `CircuitBreaker.Snapshot()` +
failover/熔断现只在 dispatcher 日志,admin UI 看不到「哪个模型正被熔断、切了几次」。 `llm.Pool.ModelHealth()`(主备链每模型 provider/model/role/state/fails,同包读 failoverModel.breakers) →
补:dispatcher 经 NATS 心跳上报每模型 breaker 态(在线/熔断Open/半开 + 连续失败数 + failover 切换计数) dispatcher 心跳带 `models[]` → gateway `/admin/overview` 独立超时 Ping dispatcher 合并 → admin 概览
→ gateway 聚合 → 展示到概览「模型路由」面板或状态页。| M(后期一起补) 「模型路由」新增「运行时链路态(实时)」逐模型状态点(🟢在线/🔴熔断中+失败数/🟡半开/单点)。
单测(Snapshot/ModelHealth 名字↔态配对) + **live:配坏主→提交任务打熔断→概览 broken-demo 显示「熔断中·失败3」**
- [ ] 配置化:切块大小 / history 轮数 / 各并发度 收口为统一可配 | S - [ ] 配置化:切块大小 / history 轮数 / 各并发度 收口为统一可配 | S
- [x] 拆除 `search.Hybrid` 残骸 ✅ —— 该包空转(NewHybrid 返空、Query 返 nil)、构造后存进 Gateway 却从不调用(真 RAG 走 rag.Engine)。删整个 internal/search 包 + gateway/main.go 接线。build/vet 干净。(审计原说"死代码删文件"不准:它是接了线的残骸,需拆接线) - [x] 拆除 `search.Hybrid` 残骸 ✅ —— 该包空转(NewHybrid 返空、Query 返 nil)、构造后存进 Gateway 却从不调用(真 RAG 走 rag.Engine)。删整个 internal/search 包 + gateway/main.go 接线。build/vet 干净。(审计原说"死代码删文件"不准:它是接了线的残骸,需拆接线)
+17 -1
View File
@@ -219,6 +219,15 @@ export async function statsOverview(): Promise<Overview> {
// —— 管理端系统级聚合(控制塔口径,RequireAdmin)—— // —— 管理端系统级聚合(控制塔口径,RequireAdmin)——
// 区别于 statsOverview(桌面端个人工作台):这里一律全平台口径——全部用户/任务/评测/模型态/提示词态/健康。 // 区别于 statsOverview(桌面端个人工作台):这里一律全平台口径——全部用户/任务/评测/模型态/提示词态/健康。
// 单模型运行时健康态(failover 链上主/备各一条)。
export interface ModelHealthItem {
provider: string;
model: string;
role: string; // primary / fallback
state: string; // closed(在线) / open(熔断中) / half-open(半开探测) / single(无备用链)
fails: number;
}
export interface AdminOverview { export interface AdminOverview {
users: number; users: number;
kb_count: number; // 全平台知识库数 kb_count: number; // 全平台知识库数
@@ -230,7 +239,14 @@ export interface AdminOverview {
eval_avg: number; eval_avg: number;
faithful_avg: number; faithful_avg: number;
eval_count: number; eval_count: number;
models: { chat_count: number; embedding_count: number; active_chat: string; active_embedding: string; fallbacks: number }; models: {
chat_count: number;
embedding_count: number;
active_chat: string;
active_embedding: string;
fallbacks: number;
health: ModelHealthItem[]; // 运行时每模型 failover/熔断态(来自 dispatcher
};
prompts: { managed: number; overrides: number }; prompts: { managed: number; overrides: number };
services: Record<string, boolean>; services: Record<string, boolean>;
checked_at: string; checked_at: string;
@@ -14,6 +14,15 @@ const STATUS_STYLE: Record<string, { label: string; color: string }> = {
}; };
const statusStyle = (s: string) => STATUS_STYLE[s] ?? { label: s, color: "#94a3b8" }; const statusStyle = (s: string) => STATUS_STYLE[s] ?? { label: s, color: "#94a3b8" };
// 模型熔断态 → 展示样式。
const BREAKER_STYLE: Record<string, { label: string; dot: string; text: string }> = {
closed: { label: "在线", dot: "bg-emerald-500", text: "text-emerald-600" },
open: { label: "熔断中", dot: "bg-rose-500", text: "text-rose-500" },
"half-open": { label: "半开探测", dot: "bg-amber-500", text: "text-amber-600" },
single: { label: "单点", dot: "bg-gray-300", text: "text-gray-400" },
};
const breakerStyle = (s: string) => BREAKER_STYLE[s] ?? { label: s, dot: "bg-gray-300", text: "text-gray-400" };
function last7DaysTrend(trend: { key: string; count: number }[]): { key: string; count: number }[] { function last7DaysTrend(trend: { key: string; count: number }[]): { key: string; count: number }[] {
const byKey = new Map(trend.map((d) => [d.key, d.count])); const byKey = new Map(trend.map((d) => [d.key, d.count]));
const out: { key: string; count: number }[] = []; const out: { key: string; count: number }[] = [];
@@ -169,6 +178,27 @@ export function DashboardPage() {
<span className="text-gray-500">chat {m.chat_count} · embedding {m.embedding_count}</span> <span className="text-gray-500">chat {m.chat_count} · embedding {m.embedding_count}</span>
</Row> </Row>
</div> </div>
{/* 运行时 failover/熔断态(来自 dispatcher */}
{m.health && m.health.length > 0 && (
<div className="mt-3 border-t border-gray-50 pt-3">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-gray-300"></div>
<div className="space-y-1.5">
{m.health.map((h, i) => {
const st = breakerStyle(h.state);
return (
<div key={`${h.model}-${i}`} className="flex items-center gap-2 text-xs">
<span className={`h-2 w-2 shrink-0 rounded-full ${st.dot}`} title={st.label} />
<span className="rounded bg-gray-50 px-1.5 text-[10px] text-gray-400">{h.role === "primary" ? "主" : "备"}</span>
<code className="min-w-0 flex-1 truncate text-[11px] text-gray-600">{h.model}</code>
<span className={`shrink-0 text-[10px] ${st.text}`}>{st.label}</span>
{h.fails > 0 && h.state !== "closed" && <span className="shrink-0 text-[10px] text-gray-300"> {h.fails}</span>}
</div>
);
})}
</div>
</div>
)}
</div> </div>
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm"> <div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
File diff suppressed because it is too large Load Diff
@@ -94,6 +94,7 @@ func main() {
"model": pool.ModelName(), "model": pool.ModelName(),
"ready": pool.Ready(), "ready": pool.Ready(),
"uptime_s": int(time.Since(startedAt).Seconds()), "uptime_s": int(time.Since(startedAt).Seconds()),
"models": pool.ModelHealth(), // 主备链每模型实时健康/熔断态(供管理端展示)
}) })
return data return data
}); herr != nil { }); herr != nil {
@@ -139,3 +139,16 @@ func (c *CircuitBreaker) State() State {
defer c.mu.Unlock() defer c.mu.Unlock()
return c.state 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 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) { func TestCircuitBreaker_OpensAfterThreshold(t *testing.T) {
now := time.Unix(0, 0) now := time.Unix(0, 0)
c := newTestCB(3, 10*time.Second, &now) c := newTestCB(3, 10*time.Second, &now)
+2 -1
View File
@@ -38,7 +38,8 @@ type failoverModel struct {
} }
// newFailoverModel 建主备链。models 至少 1 个;只有 1 个时调用方应直接用该模型而非本包装。 // 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)) breakers := make([]*harness.CircuitBreaker, len(models))
for i := range breakers { for i := range breakers {
breakers[i] = harness.NewCircuitBreakerWith(fbBreakerThreshold, fbBreakerCooldown, fbBreakerHalfOpen) breakers[i] = harness.NewCircuitBreakerWith(fbBreakerThreshold, fbBreakerCooldown, fbBreakerHalfOpen)
+52 -8
View File
@@ -19,10 +19,20 @@ import (
"go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace" "go.opentelemetry.io/otel/trace"
"github.com/sundynix/sundynix-dispatcher/internal/harness"
"github.com/sundynix/sundynix-shared/contract" "github.com/sundynix/sundynix-shared/contract"
"github.com/sundynix/sundynix-shared/otelx" "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 是单次推理请求的上限。 // requestTimeout 是单次推理请求的上限。
const requestTimeout = 120 * time.Second const requestTimeout = 120 * time.Second
@@ -37,6 +47,8 @@ type Pool struct {
mu sync.RWMutex mu sync.RWMutex
cfg *contract.ModelConfig cfg *contract.ModelConfig
cm model.BaseChatModel // 由 SetConfig 用激活配置构建;未配置时为 nil cm model.BaseChatModel // 由 SetConfig 用激活配置构建;未配置时为 nil
health []ModelHealth // 主备链各模型静态信息(provider/model/role),与 breakers 同序
breakers []*harness.CircuitBreaker // 与 health 一一对应;无 failover 链时为对应 nil
} }
func NewPool() *Pool { return &Pool{} } func NewPool() *Pool { return &Pool{} }
@@ -48,12 +60,16 @@ func forceStub() bool { return os.Getenv("LLM_FORCE_STUB") == "1" }
// SetConfig 热更新后端配置:用激活配置(含备用模型)重建 ChatModel(控制面变更时调用)。 // SetConfig 热更新后端配置:用激活配置(含备用模型)重建 ChatModel(控制面变更时调用)。
func (p *Pool) SetConfig(cfg *contract.ModelConfig) { func (p *Pool) SetConfig(cfg *contract.ModelConfig) {
var cm model.BaseChatModel var cm model.BaseChatModel
var health []ModelHealth
var breakers []*harness.CircuitBreaker
if cfg != nil && cfg.Ready() && !forceStub() { if cfg != nil && cfg.Ready() && !forceStub() {
cm = buildWithFallbacks(cfg) cm, health, breakers = buildWithFallbacks(cfg)
} }
p.mu.Lock() p.mu.Lock()
p.cfg = cfg p.cfg = cfg
p.cm = cm p.cm = cm
p.health = health
p.breakers = breakers
p.mu.Unlock() p.mu.Unlock()
if cfg != nil { if cfg != nil {
// 不打印 api_key。 // 不打印 api_key。
@@ -64,19 +80,24 @@ func (p *Pool) SetConfig(cfg *contract.ModelConfig) {
// buildWithFallbacks 构建主模型,并把可用的备用模型串成 failover 链(无备用则直接返回主模型)。 // buildWithFallbacks 构建主模型,并把可用的备用模型串成 failover 链(无备用则直接返回主模型)。
// 主模型构建失败 → 返回 nil(降级桩);备用单个失败 → 跳过该备用,不影响主链。 // 主模型构建失败 → 返回 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) primary, err := buildChatModel(cfg)
if err != nil { if err != nil {
fmt.Printf("[llm] 构建主 ChatModel 失败(降级桩运行): %v\n", err) fmt.Printf("[llm] 构建主 ChatModel 失败(降级桩运行): %v\n", err)
return nil return nil, nil, nil
} }
ptcm, ok := primary.(model.ToolCallingChatModel) ptcm, ok := primary.(model.ToolCallingChatModel)
if !ok { 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。 // 主链:主模型 +(可用的)备用模型串成 failover。metas 与 models 同序。
chain := ptcm
models := []model.ToolCallingChatModel{ptcm} models := []model.ToolCallingChatModel{ptcm}
metas := []ModelHealth{{Provider: cfg.Provider, Model: cfg.Model, Role: "primary"}}
for i := range cfg.Fallbacks { for i := range cfg.Fallbacks {
fb := cfg.Fallbacks[i] fb := cfg.Fallbacks[i]
if !fb.Ready() { if !fb.Ready() {
@@ -89,16 +110,39 @@ func buildWithFallbacks(cfg *contract.ModelConfig) model.BaseChatModel {
} }
if t, ok := fbm.(model.ToolCallingChatModel); ok { if t, ok := fbm.(model.ToolCallingChatModel); ok {
models = append(models, t) 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 { if len(models) > 1 {
fmt.Printf("[llm] 启用模型 failover:主 %s + %d 个备用\n", cfg.Model, 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) fmt.Printf("[llm] 模型 failover:第 %d 个模型失败(%v),切下一个\n", idx, ferr)
}) })
copy(breakers, fm.breakers) // 每模型熔断器(同序),供上报态
chain = fm
} }
// 缓存包在最外层:命中直接跳过整条 failover 链(省成本+提速)。键含模型名 → 换模型自然失效。 // 缓存包在最外层:命中直接跳过整条 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。 // 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)
}
}
@@ -119,6 +119,30 @@ func (h *Handler) AdminOverview(c *gin.Context) {
} }
} }
// 模型运行时健康态:Ping dispatcher 心跳取每模型 failover/熔断态(在线/熔断中/半开)。
// 权威配置来自 DB(上面 chat/emb),运行时态只有 dispatcher 知道 → 二者互补。
modelHealth := []gin.H{}
dctx, dcancel := context.WithTimeout(ctx, 2*time.Second) // 独立超时,不与 mcp-go 探活共用 cctx(避免被挤掉)
defer dcancel()
if data, err := h.bus.Ping(dctx, contract.SubjectHealthDispatcher); err == nil && len(data) > 0 {
var dh struct {
Models []struct {
Provider string `json:"provider"`
Model string `json:"model"`
Role string `json:"role"`
State string `json:"state"`
Fails int `json:"fails"`
} `json:"models"`
}
if json.Unmarshal(data, &dh) == nil {
for _, m := range dh.Models {
modelHealth = append(modelHealth, gin.H{
"provider": m.Provider, "model": m.Model, "role": m.Role, "state": m.State, "fails": m.Fails,
})
}
}
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"users": users, "kb_count": kbs, "kb_docs": docs, "users": users, "kb_count": kbs, "kb_docs": docs,
"tasks_today": ov.TasksToday, "tasks_total": ov.TasksTotal, "tasks_today": ov.TasksToday, "tasks_total": ov.TasksTotal,
@@ -127,6 +151,7 @@ func (h *Handler) AdminOverview(c *gin.Context) {
"models": gin.H{ "models": gin.H{
"chat_count": len(chat), "embedding_count": len(emb), "chat_count": len(chat), "embedding_count": len(emb),
"active_chat": activeChat, "active_embedding": activeEmb, "fallbacks": fallbacks, "active_chat": activeChat, "active_embedding": activeEmb, "fallbacks": fallbacks,
"health": modelHealth, // 运行时每模型 failover/熔断态
}, },
"prompts": gin.H{"managed": len(prompts.Known), "overrides": len(overrides)}, "prompts": gin.H{"managed": len(prompts.Known), "overrides": len(overrides)},
"services": services, "services": services,