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:
+5
-4
@@ -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 干净。(审计原说"死代码删文件"不准:它是接了线的残骸,需拆接线)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -34,9 +44,11 @@ type ChatMessage struct {
|
|||||||
|
|
||||||
// Pool 维护当前激活的后端配置 + 据此构建的 Eino ChatModel(控制面经 NATS 下发,可热更新)。
|
// Pool 维护当前激活的后端配置 + 据此构建的 Eino ChatModel(控制面经 NATS 下发,可热更新)。
|
||||||
type Pool struct {
|
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user