7c211719d2
- failoverModel 加每模型熔断器(阈值3/冷却20s,比编排层更紧): 主模型持续失败达阈值 → 熔断 → 后续请求直接跳过主、直连备用(省掉每次白试主的失败往返); 冷却到点半开放行探测打回主,成功即自动恢复走主(靠熔断器半开机制,无需外部通知) - 全部模型都熔断时强制试主兜底(编排层 o.breaker 兜"全挂") - WithTools 重包共享同一批 breakers(状态不清零)——否则每次 rewrap 熔断失效,关键坑 - harness 加 NewCircuitBreakerWith(threshold,cooldown,halfOpenMax) 参数化构造 - Generate/Stream 用泛型 runFailover 共用选路循环(去重) - 3 新单测:熔断跳过主/WithTools 共享熔断状态/冷却后半开恢复(全三态) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
226 lines
7.6 KiB
Go
226 lines
7.6 KiB
Go
package llm
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/cloudwego/eino/components/model"
|
|
"github.com/cloudwego/eino/schema"
|
|
|
|
"github.com/sundynix/sundynix-dispatcher/internal/harness"
|
|
)
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// 主模型持续失败达阈值 → 熔断 → 后续请求跳过主、直连备用(省掉每次白试主的失败往返)。
|
|
func TestFailover_TrippedPrimarySkipped(t *testing.T) {
|
|
pc, fc := 0, 0
|
|
p := &fakeModel{name: "p", fail: true, calls: &pc}
|
|
fb := &fakeModel{name: "fb", reply: "备", calls: &fc}
|
|
m := newFailoverModel([]model.ToolCallingChatModel{p, fb}, nil)
|
|
// 连打 threshold+2 次:前 threshold 次主被调并失败→切备;熔断后主被跳过。
|
|
for i := 0; i < fbBreakerThreshold+2; i++ {
|
|
if ans, err := genText(t, context.Background(), m); err != nil || ans != "备" {
|
|
t.Fatalf("每次都应最终拿到备用回答, got %q err=%v", ans, err)
|
|
}
|
|
}
|
|
if pc != fbBreakerThreshold {
|
|
t.Fatalf("主应在失败 %d 次后被熔断跳过,实际被调 %d 次", fbBreakerThreshold, pc)
|
|
}
|
|
if fc != fbBreakerThreshold+2 {
|
|
t.Fatalf("备用应每次都被调: got %d", fc)
|
|
}
|
|
}
|
|
|
|
// 关键:WithTools 重包必须共享熔断状态。主已熔断 → 新链仍跳过主(否则 rewrap 清零 = 熔断失效)。
|
|
func TestFailover_WithToolsSharesBreakerState(t *testing.T) {
|
|
pc, fc := 0, 0
|
|
p := &fakeModel{name: "p", fail: true, calls: &pc}
|
|
fb := &fakeModel{name: "fb", reply: "备", calls: &fc}
|
|
m := newFailoverModel([]model.ToolCallingChatModel{p, fb}, nil)
|
|
for i := 0; i < fbBreakerThreshold; i++ { // 打到主熔断
|
|
_, _ = genText(t, context.Background(), m)
|
|
}
|
|
if pc != fbBreakerThreshold {
|
|
t.Fatalf("主应被调 %d 次, got %d", fbBreakerThreshold, pc)
|
|
}
|
|
bound, err := m.WithTools(nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before := pc
|
|
if _, err := genText(t, context.Background(), bound); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pc != before {
|
|
t.Fatalf("重包后主不应再被调(熔断状态须共享),却多调了 %d 次", pc-before)
|
|
}
|
|
}
|
|
|
|
// 熔断后冷却到点 → 半开探测打回主 → 主已恢复则成功切回主。
|
|
func TestFailover_RecoversAfterCooldown(t *testing.T) {
|
|
pc, fc := 0, 0
|
|
p := &fakeModel{name: "p", reply: "主回答", fail: true, calls: &pc}
|
|
fb := &fakeModel{name: "fb", reply: "备", calls: &fc}
|
|
m := &failoverModel{
|
|
models: []model.ToolCallingChatModel{p, fb},
|
|
breakers: []*harness.CircuitBreaker{
|
|
harness.NewCircuitBreakerWith(fbBreakerThreshold, 30*time.Millisecond, 1),
|
|
harness.NewCircuitBreakerWith(fbBreakerThreshold, 30*time.Millisecond, 1),
|
|
},
|
|
}
|
|
for i := 0; i < fbBreakerThreshold; i++ { // 打到主熔断
|
|
_, _ = genText(t, context.Background(), m)
|
|
}
|
|
tripped := pc
|
|
p.fail = false // 主恢复健康
|
|
// 冷却前:主仍被跳过。
|
|
if _, _ = genText(t, context.Background(), m); pc != tripped {
|
|
t.Fatalf("冷却前主应仍被跳过,却被调用了")
|
|
}
|
|
// 冷却到点:半开探测打回主 → 成功恢复。
|
|
time.Sleep(40 * time.Millisecond)
|
|
ans, err := genText(t, context.Background(), m)
|
|
if err != nil || ans != "主回答" {
|
|
t.Fatalf("冷却后应探测回主并成功, got %q err=%v", ans, err)
|
|
}
|
|
if pc <= tripped {
|
|
t.Fatalf("冷却后主应被再次探测调用")
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|