Files
Blizzard 7c211719d2 fix(dispatcher): 熔断器接回 failover —— 挂掉的主模型跳过而非每次白试(T4.E 收官)
- 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>
2026-07-02 10:49:54 +08:00

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)
}
}