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:
@@ -0,0 +1,82 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user