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>
This commit is contained in:
Blizzard
2026-07-02 10:49:54 +08:00
parent 65e939889e
commit 7c211719d2
4 changed files with 173 additions and 44 deletions
@@ -4,9 +4,12 @@ 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 报错。
@@ -128,6 +131,83 @@ func TestFailover_StreamFailover(t *testing.T) {
}
}
// 主模型持续失败达阈值 → 熔断 → 后续请求跳过主、直连备用(省掉每次白试主的失败往返)。
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{