Files
sundynix-agentix/sundynix-dispatcher/internal/llm/failover.go
T
Blizzard ecf4a80466 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>
2026-06-30 09:26:18 +08:00

83 lines
3.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package llm
import (
"context"
"fmt"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
)
// failoverModel 把多个 ToolCallingChatModel 串成主备链:按序调用,主模型遇错即切下一个,
// 直到成功或全部失败。它本身就是个 model.ToolCallingChatModel,故 compose / ReAct / Chat
// 全路径透明白嫖 failover —— 单 provider 抖动/挂掉时平台不整体宕。
//
// 局限(v1)Stream 仅在「建流(Stream() 调用)同步报错」时切备;已开始回流 token 的中途失败不切
// (输出已半出,无法干净重来)。连接级失败(拒连/立即 5xx)由 openai 客户端在 Stream() 同步返回,
// 已覆盖"provider 整体挂"的主场景。
type failoverModel struct {
models []model.ToolCallingChatModel // 主模型在前,其余为按序备用
onFailover func(idx int, err error) // 切换回调(日志/观测;可空)
}
// newFailoverModel 建主备链。models 至少 1 个;只有 1 个时调用方应直接用该模型而非本包装。
func newFailoverModel(models []model.ToolCallingChatModel, onFailover func(int, error)) model.ToolCallingChatModel {
return &failoverModel{models: models, onFailover: onFailover}
}
func (f *failoverModel) Generate(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error) {
var lastErr error
for i, m := range f.models {
out, err := m.Generate(ctx, input, opts...)
if err == nil {
return out, nil
}
lastErr = err
if !f.shouldFailover(ctx, i) {
return nil, err
}
if f.onFailover != nil {
f.onFailover(i, err)
}
}
return nil, lastErr
}
func (f *failoverModel) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) {
var lastErr error
for i, m := range f.models {
sr, err := m.Stream(ctx, input, opts...)
if err == nil {
return sr, nil
}
lastErr = err
if !f.shouldFailover(ctx, i) {
return nil, err
}
if f.onFailover != nil {
f.onFailover(i, err)
}
}
return nil, lastErr
}
// WithTools 给链上每个模型绑定工具,返回新的 failover 链(不可变,并发安全;ReAct 用)。
func (f *failoverModel) WithTools(tools []*schema.ToolInfo) (model.ToolCallingChatModel, error) {
bound := make([]model.ToolCallingChatModel, len(f.models))
for i, m := range f.models {
b, err := m.WithTools(tools)
if err != nil {
return nil, fmt.Errorf("failover WithTools[%d]: %w", i, err)
}
bound[i] = b
}
return &failoverModel{models: bound, onFailover: f.onFailover}, nil
}
// shouldFailover:还有备用模型 且 调用方未主动取消/截止 → 切。
// 调用方取消(ctx.Err()!=nil) 不切——切了也没用,且违背用户意图(注:模型自身的请求超时走的是
// 内部派生 ctx,不会污染父 ctx,故仍会正常 failover)。
func (f *failoverModel) shouldFailover(ctx context.Context, idx int) bool {
return idx < len(f.models)-1 && ctx.Err() == nil
}