feat(llm): T2.1 模型路由 + Fallback —— 单 provider 抖动不再整体宕

现状:单 provider,一家 API 抖动/挂掉全平台不可用。加主备 failover:主模型调用失败
自动按序切备用,compose/ReAct/Chat 全路径透明白嫖。

- llm/failover.go: failoverModel 把多个 ToolCallingChatModel 串成主备链,按序调用、
  遇错切下一个;它本身是 model.ToolCallingChatModel 故全路径透明。调用方主动取消
  (ctx.Err()!=nil) 不切;模型自身请求超时走内部 ctx、不污染父 ctx 故仍 failover。
  局限(v1):Stream 仅建流同步报错时切(已回流 token 的中途失败不切)。
- llm/pool.go: SetConfig 用激活配置(含 Fallbacks)重建——主+可用备用串成 failover 链,
  无备用则直接用主;备用单个构建失败跳过不影响主链。
- contract.ModelConfig: 加 Fallbacks 字段(骑在主配置里下发,不改任何 bus/ServeConfig 签名)。
- gateway store.ActiveConfig: chat 把"其它已登记 chat 模型"按序填进 Fallbacks;
  provide(main) + broadcast(admin) 共用 → 注册多个 chat 模型即自动成主备。
- bus.decryptConfig: 备用模型的 api_key(密文)一并解密。

测试:failover 单测(主可用不调备/主挂切备/全挂报错/取消不切/Stream 切备/WithTools 链)。
live 验证:active=死 ollama 主 + deepseek 备 → 任务连主拒连→自动切 deepseek→4s 完成。
DEPTH_ROADMAP T2.1(admin 注册多模型即主备,无需新 UI)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-30 09:26:18 +08:00
parent ef6f525a74
commit ecf4a80466
8 changed files with 312 additions and 22 deletions
+1 -5
View File
@@ -53,11 +53,7 @@ func main() {
for _, kind := range []string{contract.ConfigKindChat, contract.ConfigKindEmbedding} {
k := kind
if _, err := bus.ServeConfig(k, func() *contract.ModelConfig {
row, _ := db.GetActiveModel(context.Background(), k)
if row == nil {
return nil
}
return &contract.ModelConfig{Provider: row.Provider, BaseURL: row.BaseURL, APIKey: row.APIKey, Model: row.Model}
return db.ActiveConfig(context.Background(), k) // chat 含 Fallbacks(其它模型作备用)
}); err != nil {
log.Printf("[gateway] serve %s config: %v", k, err)
}
+3 -6
View File
@@ -202,15 +202,12 @@ func (h *Handler) TestModel(c *gin.Context) {
}
// broadcastActive 重新广播各 kind 当前激活配置,触发对应消费方热更新。
// chat 配置带 Fallbacks(其它已登记 chat 模型作备用),dispatcher 据此重建 failover 链。
func (h *Handler) broadcastActive(ctx context.Context) {
for _, kind := range []string{contract.ConfigKindChat, contract.ConfigKindEmbedding} {
row, _ := h.db.GetActiveModel(ctx, kind)
if row == nil {
continue
if cfg := h.db.ActiveConfig(ctx, kind); cfg != nil {
_ = h.bus.PublishConfigUpdated(kind, cfg)
}
_ = h.bus.PublishConfigUpdated(kind, &contract.ModelConfig{
Provider: row.Provider, BaseURL: row.BaseURL, APIKey: row.APIKey, Model: row.Model,
})
}
}
+24
View File
@@ -7,6 +7,8 @@ import (
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/sundynix/sundynix-shared/contract"
)
// KB 是一个知识库(按 owner 隔离 + 按 kind 组织:文件夹/项目/案件/通用)。
@@ -321,6 +323,28 @@ func (p *Postgres) GetActiveModel(ctx context.Context, kind string) (*LLMModel,
return &m, nil
}
// ActiveConfig 取某 kind 的激活模型配置;chat 还把"其它已登记 chat 模型"按序填进 Fallbacks
// 供 dispatcher 串成 failover 链(主 provider 抖动/挂掉时自动切备,平台不整体宕)。
func (p *Postgres) ActiveConfig(ctx context.Context, kind string) *contract.ModelConfig {
row, _ := p.GetActiveModel(ctx, kind)
if row == nil {
return nil
}
cfg := &contract.ModelConfig{Provider: row.Provider, BaseURL: row.BaseURL, APIKey: row.APIKey, Model: row.Model}
if kind == contract.ConfigKindChat {
all, _ := p.ListModels(ctx, kind)
for _, m := range all {
if m.ID == row.ID {
continue // 跳过激活模型(它已是主)
}
cfg.Fallbacks = append(cfg.Fallbacks, contract.ModelConfig{
Provider: m.Provider, BaseURL: m.BaseURL, APIKey: m.APIKey, Model: m.Model,
})
}
}
return cfg
}
// DeleteModel 删除一条模型配置。
func (p *Postgres) DeleteModel(ctx context.Context, id string) error {
if p.db == nil {