From dd02bf4ac3d16ddc8357d531ef358b4bf62faae5 Mon Sep 17 00:00:00 2001 From: chengqian Date: Thu, 27 Aug 2026 17:50:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=B8=A0=E9=81=93=E5=88=97?= =?UTF-8?q?=E8=A1=A8/=E8=AF=A6=E6=83=85=E5=B1=95=E7=A4=BA=E6=9C=80?= =?UTF-8?q?=E5=B7=AE=E6=A8=A1=E5=9E=8B=EF=BC=8C=E6=9A=B4=E9=9C=B2=E8=A2=AB?= =?UTF-8?q?=E5=9D=87=E5=80=BC=E6=91=8A=E8=96=84=E7=9A=84=E5=8D=95=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E6=95=85=E9=9A=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 渠道健康分是各模型 succ_ewma 的均值,单个模型全挂会被其余健康模型摊薄——10 个 健康模型 + 1 个挂掉仍能算出 90 分绿灯,而这恰是模型级熔断要隔离的典型场景。 补一个最差项:ChannelRuntimeStates 遍历目录快照时顺带取每个渠道 succ_ewma 最小的 模型,按 succ^α×100 折算为与渠道分同口径的健康分。 不落库、不加列:catalog 内存快照已逐 (渠道,模型) 持有 SuccEwma,遍历它即可,零额外 I/O;数据是调度器同源的 5 秒刷新值,比 5 分钟落盘快照更新鲜。取最小值也天然免疫冷 模型问题——无上报的模型读作满分 1.0,永远不会被选为最差项。 byModel 是 map,遍历顺序随机,同分时按模型名取小者打破平局,否则全部模型满分时 每次请求返回的模型名都会跳变。 前端:列表页健康度列在最差项低于 80(健康档)时补一行「⚠ 模型名 分数」,详情页 健康度字段同理;disabled / testing 渠道不在目录快照,不展示。 --- api/admin/v1/channel.go | 4 ++ internal/dispatchadapter/catalog.go | 42 ++++++++++++-- internal/dispatchadapter/catalog_test.go | 60 ++++++++++++++++++++ internal/logic/admin/channel.go | 4 ++ web/admin/src/views/ai/ChannelDetailPage.vue | 7 +++ web/admin/src/views/ai/ChannelsPage.vue | 18 +++++- 6 files changed, 127 insertions(+), 8 deletions(-) diff --git a/api/admin/v1/channel.go b/api/admin/v1/channel.go index 5ec5ebb..b151590 100644 --- a/api/admin/v1/channel.go +++ b/api/admin/v1/channel.go @@ -47,6 +47,8 @@ type ChannelItem struct { HealthScore *float64 `json:"health_score"` BreakerState int `json:"breaker_state"` // 调度熔断状态:0=正常 1=熔断中 2=半开探活 BreakerModels int `json:"breaker_models"` // 处于熔断/半开的模型数量(渠道×模型级汇总) + WorstModel string `json:"worst_model"` // 健康度最低的模型名(空=渠道不在调度目录) + WorstModelScore float64 `json:"worst_model_score"` } // ChannelCreateReq 创建渠道请求 @@ -143,6 +145,8 @@ type ChannelDetailRes struct { HealthScore *float64 `json:"health_score"` BreakerState int `json:"breaker_state"` // 调度熔断状态:0=正常 1=熔断中 2=半开探活 BreakerModels int `json:"breaker_models"` // 处于熔断/半开的模型数量(渠道×模型级汇总) + WorstModel string `json:"worst_model"` // 健康度最低的模型名(空=渠道不在调度目录) + WorstModelScore float64 `json:"worst_model_score"` KeyType string `json:"key_type"` KeyStatus string `json:"key_status"` KeyName string `json:"key_name"` diff --git a/internal/dispatchadapter/catalog.go b/internal/dispatchadapter/catalog.go index 0e91ec6..26386d3 100644 --- a/internal/dispatchadapter/catalog.go +++ b/internal/dispatchadapter/catalog.go @@ -2,6 +2,7 @@ package dispatchadapter import ( "context" + "math" "slices" "sync/atomic" "time" @@ -157,6 +158,13 @@ func (c *Catalog) ChannelModels() map[int64][]string { type ChannelRuntimeState struct { Breaker dispatch.BreakerState // 渠道级熔断状态(同一渠道所有模型取值相同) BreakerModels int // 处于 OPEN/HALF_OPEN 的模型数量(渠道×模型级汇总) + + // WorstModel / WorstModelScore 健康度最低的模型及其健康分(succ_ewma^α×100, + // 与渠道聚合分同口径)。渠道分是均值,单个模型全挂会被其余健康模型摊薄——10 个 + // 健康模型 + 1 个挂掉仍能算出 90 分绿灯,最差项让这种情况在列表页直接可见。 + // 取最小值天然免疫冷模型问题:无上报的模型读作满分 1.0,永远不会成为最差项。 + WorstModel string + WorstModelScore float64 } // ChannelRuntimeStates 返回目录快照中各渠道的熔断聚合视图。 @@ -170,19 +178,41 @@ func (c *Catalog) ChannelRuntimeStates() map[int64]ChannelRuntimeState { if idx == nil { return nil } - out := make(map[int64]ChannelRuntimeState) - for _, chans := range idx.byModel { + type accEntry struct { + st ChannelRuntimeState + worstSucc float64 + found bool + } + acc := make(map[int64]*accEntry) + for model, chans := range idx.byModel { for _, ch := range chans { - agg, ok := out[ch.ID] + a, ok := acc[ch.ID] if !ok { - agg = ChannelRuntimeState{Breaker: ch.Breaker} + a = &accEntry{st: ChannelRuntimeState{Breaker: ch.Breaker}} + acc[ch.ID] = a } if ch.ModelBreaker != dispatch.BreakerClosed { - agg.BreakerModels++ + a.st.BreakerModels++ + } + // 同分时按模型名取小者:byModel 是 map,遍历顺序随机,不做确定性 + // 打破平局的话,全部模型同分(例如都满分)时每次请求返回的模型名都会跳变。 + if !a.found || ch.SuccEwma < a.worstSucc || + (ch.SuccEwma == a.worstSucc && model < a.st.WorstModel) { + a.found = true + a.worstSucc = ch.SuccEwma + a.st.WorstModel = model } - out[ch.ID] = agg } } + + alpha := HealthAlpha() + out := make(map[int64]ChannelRuntimeState, len(acc)) + for id, a := range acc { + if a.found { + a.st.WorstModelScore = math.Pow(clampFloat(a.worstSucc, 0, 1), alpha) * 100 + } + out[id] = a.st + } return out } diff --git a/internal/dispatchadapter/catalog_test.go b/internal/dispatchadapter/catalog_test.go index 79a0149..966efd8 100644 --- a/internal/dispatchadapter/catalog_test.go +++ b/internal/dispatchadapter/catalog_test.go @@ -233,3 +233,63 @@ func TestCatalog_运行状态_未Rebuild返回空(t *testing.T) { c := NewCatalog(func() *dispatch.RoutingPolicy { return dispatch.DefaultRoutingPolicy() }, nil, nil) assert.Empty(t, c.ChannelRuntimeStates()) } + +// TestCatalog_最差模型_单模型故障不被均值摊薄 渠道健康分是均值,10 个健康模型 + 1 个 +// 全挂仍能算出 90 分绿灯;最差项要如实报出那个挂掉的模型。 +func TestCatalog_最差模型_单模型故障不被均值摊薄(t *testing.T) { + rows := []catalogRow{ + {ChannelID: 1, ChannelName: "A", ModelName: "healthy-a", Weight: 10, Tier: "primary"}, + {ChannelID: 1, ChannelName: "A", ModelName: "healthy-b", Weight: 10, Tier: "primary"}, + {ChannelID: 1, ChannelName: "A", ModelName: "broken", Weight: 10, Tier: "primary"}, + } + c := testCatalog(rows, nil, func(_ context.Context, _ int64, model string) RuntimeReadout { + if model == "broken" { + return RuntimeReadout{SuccEwma: 0.1, HasHealth: true} + } + return RuntimeReadout{SuccEwma: 1.0, HasHealth: true} + }) + + got := c.ChannelRuntimeStates() + require.Len(t, got, 1) + assert.Equal(t, "broken", got[1].WorstModel) + // α 默认 2:0.1² × 100 = 1 + assert.InDelta(t, 1.0, got[1].WorstModelScore, 1e-6) +} + +// TestCatalog_最差模型_冷模型不被误报 无真实上报的模型读作满分 1.0,取最小值天然 +// 不会把它选成最差项——这正是拖垮渠道均值的缺陷在 min 聚合下不存在的原因。 +func TestCatalog_最差模型_冷模型不被误报(t *testing.T) { + rows := []catalogRow{ + {ChannelID: 1, ChannelName: "A", ModelName: "cold", Weight: 10, Tier: "primary"}, + {ChannelID: 1, ChannelName: "A", ModelName: "warm", Weight: 10, Tier: "primary"}, + } + c := testCatalog(rows, nil, func(_ context.Context, _ int64, model string) RuntimeReadout { + if model == "cold" { + return RuntimeReadout{SuccEwma: 1} // HasHealth=false,从未上报 + } + return RuntimeReadout{SuccEwma: 0.8, HasHealth: true} + }) + + got := c.ChannelRuntimeStates() + assert.Equal(t, "warm", got[1].WorstModel, "冷模型读作满分,不应被选为最差") + assert.InDelta(t, 64.0, got[1].WorstModelScore, 1e-6) // 0.8² × 100 +} + +// TestCatalog_最差模型_同分时结果稳定 byModel 是 map,遍历顺序随机;同分不做确定性 +// 打破平局的话,全部模型满分时每次请求返回的模型名都会跳变。 +func TestCatalog_最差模型_同分时结果稳定(t *testing.T) { + rows := []catalogRow{ + {ChannelID: 1, ChannelName: "A", ModelName: "zeta", Weight: 10, Tier: "primary"}, + {ChannelID: 1, ChannelName: "A", ModelName: "alpha", Weight: 10, Tier: "primary"}, + {ChannelID: 1, ChannelName: "A", ModelName: "mid", Weight: 10, Tier: "primary"}, + } + c := testCatalog(rows, nil, func(_ context.Context, _ int64, _ string) RuntimeReadout { + return RuntimeReadout{SuccEwma: 1, HasHealth: true} + }) + + for range 20 { + got := c.ChannelRuntimeStates() + require.Equal(t, "alpha", got[1].WorstModel, "同分必须稳定取模型名最小者") + assert.InDelta(t, 100.0, got[1].WorstModelScore, 1e-6) + } +} diff --git a/internal/logic/admin/channel.go b/internal/logic/admin/channel.go index 6ca3820..69ab27d 100644 --- a/internal/logic/admin/channel.go +++ b/internal/logic/admin/channel.go @@ -149,6 +149,8 @@ func (s *sAdmin) ListChannels(ctx context.Context, req *v1.ChannelListReq) (*v1. if rs, ok := runtimeStates[ch.ID]; ok { item.BreakerState = int(rs.Breaker) item.BreakerModels = rs.BreakerModels + item.WorstModel = rs.WorstModel + item.WorstModelScore = rs.WorstModelScore } list = append(list, item) } @@ -594,6 +596,8 @@ func (s *sAdmin) GetChannelDetail(ctx context.Context, req *v1.ChannelDetailReq) if rs, ok := channelRuntimeStates()[req.ID]; ok { res.BreakerState = int(rs.Breaker) res.BreakerModels = rs.BreakerModels + res.WorstModel = rs.WorstModel + res.WorstModelScore = rs.WorstModelScore } if res.KeyType == "" { res.KeyType = "apikey" diff --git a/web/admin/src/views/ai/ChannelDetailPage.vue b/web/admin/src/views/ai/ChannelDetailPage.vue index ae1ade5..352ad99 100644 --- a/web/admin/src/views/ai/ChannelDetailPage.vue +++ b/web/admin/src/views/ai/ChannelDetailPage.vue @@ -1069,6 +1069,13 @@ function formatHeaders(headers: Record): string { {{ detail.health_score.toFixed(0) }} N/A + + + ⚠ 最差模型 {{ detail.worst_model }} {{ detail.worst_model_score.toFixed(0) }} 分 + diff --git a/web/admin/src/views/ai/ChannelsPage.vue b/web/admin/src/views/ai/ChannelsPage.vue index 814ecef..46c7c06 100644 --- a/web/admin/src/views/ai/ChannelsPage.vue +++ b/web/admin/src/views/ai/ChannelsPage.vue @@ -121,10 +121,24 @@ const columns: TableColumnData[] = [ }, }, { - title: '健康度', dataIndex: 'health_score', width: 90, + title: '健康度', dataIndex: 'health_score', width: 140, render({ record }) { if (record.health_score === null || record.health_score === undefined) return h('span', { style: 'color:#94a3b8' }, 'N/A') - return h('span', { style: { color: healthColor(record.health_score), fontWeight: 600 } }, record.health_score.toFixed(0)) + const score = h('span', { style: { color: healthColor(record.health_score), fontWeight: 600 } }, record.health_score.toFixed(0)) + // 渠道分是各模型均值,单个模型全挂会被其余健康模型摊薄(10 健康 + 1 全挂 ≈ 90 分绿灯)。 + // 最差模型低于健康档时补一行提示,让"是哪个模型拖后腿"在列表页直接可见。 + // disabled / testing 渠道不在目录快照,无最差项数据。 + const worst = record.worst_model_score + if (record.status !== 'active' || !record.worst_model || worst === null || worst === undefined || worst >= 80) { + return score + } + return h('div', { style: 'display:flex; flex-direction:column; gap:2px; line-height:1.3' }, [ + score, + h('span', { + style: `color:${healthColor(worst)}; font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap`, + title: `最差模型 ${record.worst_model}:${worst.toFixed(0)} 分`, + }, `⚠ ${record.worst_model} ${worst.toFixed(0)}`), + ]) }, }, {