ecf4a80466
现状:单 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>
146 lines
4.6 KiB
Go
146 lines
4.6 KiB
Go
package llm
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"testing"
|
|
|
|
"github.com/cloudwego/eino/components/model"
|
|
"github.com/cloudwego/eino/schema"
|
|
)
|
|
|
|
// fakeModel 是 model.ToolCallingChatModel 测试替身:fail=true 则 Generate/Stream 报错。
|
|
type fakeModel struct {
|
|
name string
|
|
reply string
|
|
fail bool
|
|
calls *int // 共享计数器,记录被调次数(验证 failover 没多调/早停)
|
|
}
|
|
|
|
func (f *fakeModel) Generate(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.Message, error) {
|
|
if f.calls != nil {
|
|
*f.calls++
|
|
}
|
|
if f.fail {
|
|
return nil, fmt.Errorf("%s down", f.name)
|
|
}
|
|
return schema.AssistantMessage(f.reply, nil), nil
|
|
}
|
|
|
|
func (f *fakeModel) Stream(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.StreamReader[*schema.Message], error) {
|
|
if f.calls != nil {
|
|
*f.calls++
|
|
}
|
|
if f.fail {
|
|
return nil, fmt.Errorf("%s down", f.name)
|
|
}
|
|
sr, sw := schema.Pipe[*schema.Message](1)
|
|
go func() { sw.Send(schema.AssistantMessage(f.reply, nil), nil); sw.Close() }()
|
|
return sr, nil
|
|
}
|
|
|
|
func (f *fakeModel) WithTools(_ []*schema.ToolInfo) (model.ToolCallingChatModel, error) { return f, nil }
|
|
|
|
func genText(t *testing.T, ctx context.Context, m model.ToolCallingChatModel) (string, error) {
|
|
t.Helper()
|
|
out, err := m.Generate(ctx, []*schema.Message{schema.UserMessage("hi")})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return out.Content, nil
|
|
}
|
|
|
|
// TestFailover_PrimaryWorks 主可用 → 直接用主,备用不被调用。
|
|
func TestFailover_PrimaryWorks(t *testing.T) {
|
|
var pc, fc int
|
|
m := newFailoverModel([]model.ToolCallingChatModel{
|
|
&fakeModel{name: "primary", reply: "主回答", calls: &pc},
|
|
&fakeModel{name: "fb", reply: "备回答", calls: &fc},
|
|
}, nil)
|
|
ans, err := genText(t, context.Background(), m)
|
|
if err != nil || ans != "主回答" {
|
|
t.Fatalf("应返回主回答, got %q err=%v", ans, err)
|
|
}
|
|
if pc != 1 || fc != 0 {
|
|
t.Fatalf("主可用时不应调备用: primary=%d fb=%d", pc, fc)
|
|
}
|
|
}
|
|
|
|
// TestFailover_PrimaryDownUseFallback 主挂 → 切备用并成功。
|
|
func TestFailover_PrimaryDownUseFallback(t *testing.T) {
|
|
var pc, fc int
|
|
switched := false
|
|
m := newFailoverModel([]model.ToolCallingChatModel{
|
|
&fakeModel{name: "primary", fail: true, calls: &pc},
|
|
&fakeModel{name: "fb", reply: "备回答", calls: &fc},
|
|
}, func(int, error) { switched = true })
|
|
ans, err := genText(t, context.Background(), m)
|
|
if err != nil || ans != "备回答" {
|
|
t.Fatalf("主挂应切备用, got %q err=%v", ans, err)
|
|
}
|
|
if pc != 1 || fc != 1 || !switched {
|
|
t.Fatalf("主备各调一次且触发回调: primary=%d fb=%d switched=%v", pc, fc, switched)
|
|
}
|
|
}
|
|
|
|
// TestFailover_AllDown 全挂 → 返回最后一个错误。
|
|
func TestFailover_AllDown(t *testing.T) {
|
|
m := newFailoverModel([]model.ToolCallingChatModel{
|
|
&fakeModel{name: "p", fail: true},
|
|
&fakeModel{name: "fb", fail: true},
|
|
}, nil)
|
|
if _, err := genText(t, context.Background(), m); err == nil {
|
|
t.Fatal("全挂应返回错误")
|
|
}
|
|
}
|
|
|
|
// TestFailover_CtxCanceledNoSwitch 调用方已取消 → 不切备用(切了也没用,且违背意图)。
|
|
func TestFailover_CtxCanceledNoSwitch(t *testing.T) {
|
|
var pc, fc int
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // 立即取消
|
|
m := newFailoverModel([]model.ToolCallingChatModel{
|
|
&fakeModel{name: "p", fail: true, calls: &pc},
|
|
&fakeModel{name: "fb", reply: "备", calls: &fc},
|
|
}, nil)
|
|
if _, err := genText(t, ctx, m); err == nil {
|
|
t.Fatal("已取消仍应返回主错误")
|
|
}
|
|
if fc != 0 {
|
|
t.Fatalf("取消时不应切备用: fb 被调 %d 次", fc)
|
|
}
|
|
}
|
|
|
|
// TestFailover_StreamFailover Stream 建流失败也切备用。
|
|
func TestFailover_StreamFailover(t *testing.T) {
|
|
m := newFailoverModel([]model.ToolCallingChatModel{
|
|
&fakeModel{name: "p", fail: true},
|
|
&fakeModel{name: "fb", reply: "流备"},
|
|
}, nil)
|
|
sr, err := m.Stream(context.Background(), []*schema.Message{schema.UserMessage("hi")})
|
|
if err != nil {
|
|
t.Fatalf("Stream 应切到备用: %v", err)
|
|
}
|
|
defer sr.Close()
|
|
chunk, _ := sr.Recv()
|
|
if chunk == nil || chunk.Content != "流备" {
|
|
t.Fatalf("应收到备用的流, got %+v", chunk)
|
|
}
|
|
}
|
|
|
|
// TestFailover_WithToolsBindsAll WithTools 给链上每个模型绑定,返回仍是 failover 链。
|
|
func TestFailover_WithToolsBindsAll(t *testing.T) {
|
|
m := newFailoverModel([]model.ToolCallingChatModel{
|
|
&fakeModel{name: "p", fail: true},
|
|
&fakeModel{name: "fb", reply: "带工具的备"},
|
|
}, nil)
|
|
bound, err := m.WithTools(nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ans, err := genText(t, context.Background(), bound)
|
|
if err != nil || ans != "带工具的备" {
|
|
t.Fatalf("绑定工具后 failover 仍生效, got %q err=%v", ans, err)
|
|
}
|
|
}
|