From 6cb840718f4a9ea64d7946fddbbcdfa0de3b3e5e Mon Sep 17 00:00:00 2001 From: Sakurasan <26715255+Sakurasan@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:21:41 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20Pick=20=E9=99=90=E6=9C=80=E9=AB=98?= =?UTF-8?q?=E4=BC=98=E5=85=88=E7=BA=A7=E5=B1=82=E5=8A=A0=E6=9D=83=E9=9A=8F?= =?UTF-8?q?=E6=9C=BA=EF=BC=8C=E6=8E=A5=E5=85=A5=E7=BB=91=E5=AE=9A=E7=BA=A7?= =?UTF-8?q?=E6=9D=83=E9=87=8D=EF=BC=8C=E4=BF=AE=20loadBound=20=E9=A1=BA?= =?UTF-8?q?=E5=BA=8F=E4=B8=A2=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pick 只在最高 priority 层内加权随机:低优先级渠道此前可能在首选 渠道健康时被随机打中,priority 只体现在故障转移顺序上 - 均衡权重 = 绑定级 Weight × 渠道级 Weight:绑定表 weight 字段此前 存而不用;乘法合成使默认绑定权重 1 时等价纯渠道权重 - loadBound 原按绑定记录返回顺序输出候选,丢掉了 SQL 的 priority ASC 排序,绑定渠道的故障转移顺序实际是随机的 - 新增两个测试锁定优先级层与权重语义 --- server/internal/channel/channel.go | 61 +++++++++++++++++-------- server/internal/channel/channel_test.go | 49 ++++++++++++++++++++ 2 files changed, 90 insertions(+), 20 deletions(-) diff --git a/server/internal/channel/channel.go b/server/internal/channel/channel.go index a6cd9d8..0a90cee 100644 --- a/server/internal/channel/channel.go +++ b/server/internal/channel/channel.go @@ -55,6 +55,7 @@ func NewService(db *gorm.DB, enc *crypto.Encryptor) *Service { type Candidate struct { Channel *store.Channel UpstreamModel string // 全局模型在此渠道的映射名(无绑定则为空,用客户端模型名) + Weight int // 绑定级权重(默认 1;均衡权重 = 绑定级 × 渠道级) } // Candidates 返回可用渠道候选:健康 + 启用 + 不在运行期冷却,按优先级、权重降序、id 升序排列。 @@ -103,15 +104,18 @@ func (s *Service) loadBound(bindings []store.ChannelModelBinding) []Candidate { var chs []store.Channel s.db.Where("id IN ? AND enabled = ? AND health_status = ?", ids, true, store.ChannelHealthHealthy). Order("priority ASC, weight DESC, id ASC").Find(&chs) - byID := map[uint64]*store.Channel{} + out := make([]Candidate, 0, len(chs)) + // 按查询排好序的 chs 输出(而不是绑定返回顺序),保证候选保持 priority ASC 的故障转移顺序 for i := range chs { - byID[chs[i].ID] = &chs[i] - } - out := make([]Candidate, 0, len(ids)) - for _, id := range ids { - if ch, ok := byID[id]; ok && !s.InCooldown(id) { - out = append(out, Candidate{Channel: ch, UpstreamModel: byChannel[id].UpstreamModel}) + ch := &chs[i] + if s.InCooldown(ch.ID) { + continue } + out = append(out, Candidate{ + Channel: ch, + UpstreamModel: byChannel[ch.ID].UpstreamModel, + Weight: byChannel[ch.ID].Weight, + }) } return out } @@ -131,31 +135,48 @@ func (s *Service) AvailableModelIDs() []uint64 { } // Pick 按权重加权随机选一个候选渠道(负载均衡)。 +// 只在最高优先级层(priority 最小,候选已按 priority ASC 排序)内随机—— +// 低优先级候选不参与首个请求的分配,仅作为故障转移备选。 +// 权重 = 绑定级 Weight × 渠道级 Weight(见 pickWeight)。 func (s *Service) Pick(cands []Candidate) *store.Channel { if len(cands) == 0 { return nil } - total := 0 - for _, c := range cands { - w := c.Channel.Weight - if w <= 0 { - w = 1 + top := cands[0].Channel.Priority + pool := cands + for i, c := range cands { + if c.Channel.Priority != top { + pool = cands[:i] + break } - total += w + } + total := 0 + for _, c := range pool { + total += pickWeight(c) } n, _ := rand.Int(rand.Reader, big.NewInt(int64(total))) acc := 0 - for _, c := range cands { - w := c.Channel.Weight - if w <= 0 { - w = 1 - } - acc += w + for _, c := range pool { + acc += pickWeight(c) if int(n.Int64()) < acc { return c.Channel } } - return cands[len(cands)-1].Channel + return pool[len(pool)-1].Channel +} + +// pickWeight 候选的均衡权重 = 绑定级权重 × 渠道级权重(各 ≤0 按 1)。 +// 绑定级默认 1,未显式配置时等价于纯渠道权重;绑定上显式加权可对该模型单独放大。 +func pickWeight(c Candidate) int { + bw := c.Weight + if bw <= 0 { + bw = 1 + } + cw := c.Channel.Weight + if cw <= 0 { + cw = 1 + } + return bw * cw } // TryAcquire 尝试获取渠道并发槽;渠道满载返回 false(调用方可溢出到其他渠道)。 diff --git a/server/internal/channel/channel_test.go b/server/internal/channel/channel_test.go index 45d5e8b..d8bc382 100644 --- a/server/internal/channel/channel_test.go +++ b/server/internal/channel/channel_test.go @@ -277,3 +277,52 @@ func TestReportFailureCooldownOverrideAndClamp(t *testing.T) { t.Fatalf("短 Retry-After 不应缩短默认冷却,实际 %s", rem) } } + +// Pick 只在最高优先级层内加权随机;低优先级渠道即使权重再大也不该被首选打中。 +func TestPickStaysInTopPriorityTier(t *testing.T) { + db := newTestDB(t) + seedChannel(t, db, "low-pri", 100000, 5, 4) // 低优先级 + 巨大权重 + seedChannel(t, db, "top-pri", 1, 0, 4) + + s := NewService(db, crypto.NewEncryptor("test-master-key-1234567890")) + cands := s.Candidates("") + if len(cands) != 2 { + t.Fatalf("candidates = %d, want 2", len(cands)) + } + for i := 0; i < 50; i++ { + ch := s.Pick(cands) + if ch.Name != "top-pri" { + t.Fatalf("pick %d: 应始终选最高优先级渠道,实际 %s", i, ch.Name) + } + } +} + +// 绑定级权重与渠道级权重相乘:默认绑定权重 1 时等价纯渠道权重,显式加权可单独放大。 +func TestPickBindingWeightMultiplier(t *testing.T) { + db := newTestDB(t) + seedChannel(t, db, "plain", 1, 0, 4) + seedChannel(t, db, "boosted", 1, 0, 4) + + var m store.Model + m = store.Model{Name: "mm", Enabled: true} + db.Create(&m) + var chs []store.Channel + db.Order("id").Find(&chs) + db.Create(&store.ChannelModelBinding{ChannelID: chs[0].ID, ModelID: m.ID, UpstreamModel: "mm"}) // 默认 weight=1 + db.Create(&store.ChannelModelBinding{ChannelID: chs[1].ID, ModelID: m.ID, UpstreamModel: "mm", Weight: 100}) // 显式放大 + + s := NewService(db, crypto.NewEncryptor("test-master-key-1234567890")) + cands := s.Candidates("mm") + if len(cands) != 2 { + t.Fatalf("candidates = %d, want 2", len(cands)) + } + boosted := 0 + for i := 0; i < 200; i++ { + if ch := s.Pick(cands); ch.Name == "boosted" { + boosted++ + } + } + if boosted < 150 || boosted > 200 { + t.Fatalf("权重 100:1 下 boosted 命中 %d/200,应接近 200", boosted) + } +}