chore:移除过程文件
This commit is contained in:
1 parent
2ef5a40c26
commit
56b4bcdc32
9 files changed
-4108
No files matched your search
@@ -1,805 +0,0 @@
|
||||
# 渠道调度模块重新设计方案(claude 设计)
|
||||
|
||||
> 状态:待评审。撰写时间:2026-07-30。
|
||||
> 本方案为 clean-slate 重新设计,仅以问题清单([`当前渠道调度存在的问题.md`](./当前渠道调度存在的问题.md))与业务需求为输入,不受现有实现结构约束。
|
||||
> 目标读者:实现者。本文档包含可直接落地的模块结构、Go 接口、Redis key 与 Lua 脚本设计、数据库迁移、配置 Schema 与分阶段实施计划。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [设计目标](#1-设计目标)
|
||||
2. [核心思想:统一评分模型 + 加权一致性哈希](#2-核心思想统一评分模型--加权一致性哈希)
|
||||
3. [会话键解析(会话级亲和)](#3-会话键解析会话级亲和)
|
||||
4. [权重合成函数](#4-权重合成函数)
|
||||
5. [选择算法与绑定守卫](#5-选择算法与绑定守卫)
|
||||
6. [错误分类与双预算重试](#6-错误分类与双预算重试)
|
||||
7. [熔断器](#7-熔断器)
|
||||
8. [容量与溢出](#8-容量与溢出)
|
||||
9. [模块结构与 Go 接口](#9-模块结构与-go-接口)
|
||||
10. [Redis 状态设计(key 布局 + Lua 脚本)](#10-redis-状态设计key-布局--lua-脚本)
|
||||
11. [数据库变更](#11-数据库变更)
|
||||
12. [路由策略配置(routing policy)](#12-路由策略配置routing-policy)
|
||||
13. [多副本与降级](#13-多副本与降级)
|
||||
14. [可观测性](#14-可观测性)
|
||||
15. [请求流全景](#15-请求流全景)
|
||||
16. [分阶段实施计划](#16-分阶段实施计划)
|
||||
17. [测试策略](#17-测试策略)
|
||||
18. [问题覆盖对照表](#18-问题覆盖对照表)
|
||||
19. [风险与待验证项](#19-风险与待验证项)
|
||||
|
||||
---
|
||||
|
||||
## 1. 设计目标
|
||||
|
||||
从问题清单与需求反推出的五条硬目标:
|
||||
|
||||
1. **粘性(亲和)、健康度、层级、负载四者不互相打架。** 现状的核心病灶是四套独立机制串行叠加、互相短路(亲和绕过健康 A1、层级杀死亲和 A2、失败清亲和 A3)。
|
||||
2. **高层级渠道饱和时流量自动溢出到备用渠道**(L2),备用渠道不再是永远闲置的冷备。
|
||||
3. **失败处理分类化**:瞬时错误(502/504/连接抖动)原地重试,致命错误(401/404/欠费)立即切渠道并熔断,不再一刀切(R1/R2)。
|
||||
4. **核心逻辑纯函数化**:调度决策可脱离 DB/Redis 单测;单一代码路径,无 legacy/v2 双轨(O1)。
|
||||
5. **多副本部署安全**:无分布式锁、无选主、无读-改-写竞态(H1);调度热路径不查数据库(P1、H2)。
|
||||
|
||||
### 非目标
|
||||
|
||||
- 不做对话内容指纹(对 body 哈希推断会话):热路径成本高、误聚合风险大,只认显式会话信号。
|
||||
- 不做跨请求的全局最优分配(如最少连接、全局队列):有状态算法在多副本下需要协调,收益不抵复杂度。
|
||||
- 不引入合成探测请求作为常规探活手段(管理后台手动"测试渠道"保留,与调度无关)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 核心思想:统一评分模型 + 加权一致性哈希
|
||||
|
||||
不再让「优先级分组 → 亲和覆盖 → 健康过滤 → 权重随机」串行执行(每层都可能推翻上一层语义),而是把所有信号收敛为**单一的有效权重函数** `W(c)`,再用**加权一致性哈希(Weighted Rendezvous Hashing,下称 HRW)**做选择:
|
||||
|
||||
```
|
||||
W(c) = baseWeight(c) // 运营配置的组内权重(沿用 chn_channels.weight)
|
||||
× tierFactor(c) // 层级偏置:primary=1.0,secondary=0.15,reserve=0.02(可配)
|
||||
× healthFactor(c) // 健康因子 ∈ (0,1],连续函数,非硬阈值
|
||||
× headroom(c)^γ // 负载余量 (1 - inflight/softLimit)^γ,饱和时趋近 0
|
||||
× costFactor(c) // 成本因子:同模型更便宜的上游微幅占优
|
||||
× rampFactor(c) // 爬坡因子:新渠道 / 熔断刚恢复的渠道从小流量爬坡
|
||||
|
||||
选择:pick = argmax_c [ -W(c) / ln(u_c) ],u_c = hash(sessionKey, channelID) → (0,1)
|
||||
```
|
||||
|
||||
这个结构一次性解决三组矛盾:
|
||||
|
||||
| 矛盾 | 如何消解 |
|
||||
|---|---|
|
||||
| 亲和 vs 健康(A1) | 粘性是 HRW 对相同 sessionKey 的**涌现属性**,不是硬覆盖。渠道健康下降 → healthFactor 缩小 → W 缩小 → HRW 自然绕开。不存在"命中亲和就跳过一切检查"的短路路径。 |
|
||||
| 层级 vs 溢出(L2) | 溢出是**连续过程**:primary 饱和 → headroom 下降 → W 坍缩 → 新会话自然落到 secondary。无阈值切换、无抖动边界。 |
|
||||
| 多副本一致性 | HRW 是纯确定性计算:任何副本对相同 sessionKey + 相同候选快照得出相同结果,无需锁、无需选主。 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 会话键解析(会话级亲和)
|
||||
|
||||
### 3.1 解析链(按序取第一个命中)
|
||||
|
||||
```
|
||||
1. 显式头:X-Session-Id
|
||||
—— 写进平台接入文档,鼓励客户端主动携带;平台自有 SDK 默认携带。
|
||||
|
||||
2. 协议内信号(按入站格式):
|
||||
a) Anthropic 格式(/v1/messages):
|
||||
metadata.user_id —— Claude Code 每次请求都携带,格式形如
|
||||
user_<hash>_account_<uuid>_session_<uuid>
|
||||
用正则提取 session 段:`session_([0-9a-fA-F-]{36})`
|
||||
提取失败(格式不符)则整个 user_id 作为会话键(仍优于身份级)。
|
||||
⚠️ 实现前需抓包验证格式稳定性(见 §19 风险)。
|
||||
b) OpenAI Responses(/v1/responses):previous_response_id、conversation_id。
|
||||
c) OpenAI Chat Completions:无会话信号(`user` 字段是身份不是会话),落到第 3 层。
|
||||
d) Gemini:无会话信号,落到第 3 层。
|
||||
|
||||
3. 身份级回退:tenant:user:apiKey:model 四元组。
|
||||
```
|
||||
|
||||
会话键最终形态:`sk:<来源标记>:<原始值哈希>`,来源标记 ∈ {hdr, anthropic, openai, ident},用于观测各来源占比。
|
||||
|
||||
### 3.2 为什么"一个 key 多会话集中到一个渠道"不需要在身份级修
|
||||
|
||||
亲和的目的是命中上游 KV / prompt 缓存,缓存以会话为单位,所以**会话级键天然优于身份级键**:同一用户的会话 A / 会话 B 各自独立做 HRW,散布到不同渠道,同时各自的多轮对话仍稳定粘在各自渠道——缓存收益与负载分散兼得。
|
||||
|
||||
回退到身份级时,一个用户的所有会话确实集中到一个渠道,但这不是 bug:单用户负载相对渠道容量很小,负载均衡靠"大量用户被 HRW 散开"实现。极端情况(单身份流量压垮渠道)由 headroom 因子兜底:渠道趋饱和 → 权重坍缩 → 新绑定自动迁走。
|
||||
|
||||
---
|
||||
|
||||
## 4. 权重合成函数
|
||||
|
||||
全部为纯函数,输入是候选快照中的渠道状态字段,输出 float64。
|
||||
|
||||
### 4.1 tierFactor
|
||||
|
||||
```
|
||||
tier ∈ {primary, secondary, reserve}(chn_channels.tier,见 §11)
|
||||
tierFactor = policy.tierFactors[tier] // 默认 {1.0, 0.15, 0.02}
|
||||
```
|
||||
|
||||
- secondary 在正常态接到约 1/8 以下的零星流量,这点流量同时完成**天然保温探活**——无需合成探测即可持续验证备用渠道可用性。
|
||||
- 运营想要纯冷备时,把 secondary/reserve 的 tierFactor 配为 0;此时仅在上层全熔断时由硬性扩组规则启用(§8.3)。
|
||||
|
||||
### 4.2 healthFactor
|
||||
|
||||
```
|
||||
healthFactor = clamp(succEwma, 0.01, 1.0)^α × latencyPenalty
|
||||
α 默认 2(succEwma=0.9 → 0.81;succEwma=0.5 → 0.25)
|
||||
latencyPenalty = clamp(latRef / max(latEwma, latRef), 0.5, 1.0)
|
||||
latRef 默认 3000ms(延迟不超过 3s 不惩罚,超过按比例最多减半)
|
||||
```
|
||||
|
||||
succEwma / latEwma 来自 Redis 原子 EWMA(§10.2),读取的是候选快照里的最近值(快照刷新间隔内的滞后可接受)。
|
||||
|
||||
### 4.3 headroom
|
||||
|
||||
```
|
||||
headroom = max(0, 1 - inflight / softLimit)
|
||||
headroomFactor = headroom^γ // γ 默认 2,饱和时权重加速坍缩
|
||||
softLimit:手动 max_concurrency 优先;未配置用 429 起始点自动估计(§8.2)
|
||||
inflight 为 0 或无容量信息时 headroomFactor = 1
|
||||
```
|
||||
|
||||
### 4.4 costFactor
|
||||
|
||||
```
|
||||
costRatio = chn_abilities.cost_ratio // 上游实际价 / 平台基准价,默认 1.0
|
||||
costFactor = clamp((1 / costRatio)^β, 0.5, 2.0) // β 默认 0.5
|
||||
```
|
||||
|
||||
同模型等价渠道中更便宜的上游获得温和优势(β=0.5 使 8 折渠道获得约 1.12 倍权重),不会因价格差碾压健康度信号。cost_ratio 挂在 chn_abilities(渠道×模型粒度),支持批量导入。
|
||||
|
||||
> 币种说明:cost_ratio 是**无量纲比例**,不是金额,Go 层用 float64,不适用 decimal 强约束。
|
||||
|
||||
### 4.5 rampFactor
|
||||
|
||||
```
|
||||
新启用渠道 / 熔断恢复(OPEN→CLOSED)后 rampWindow(默认 120s)内:
|
||||
rampFactor = clamp(elapsed / rampWindow, 0.05, 1.0)
|
||||
其余时间恒为 1.0
|
||||
```
|
||||
|
||||
避免恢复瞬间被 HRW 一次性灌满流量、立刻再次打挂。
|
||||
|
||||
---
|
||||
|
||||
## 5. 选择算法与绑定守卫
|
||||
|
||||
### 5.1 HRW 选择(纯函数)
|
||||
|
||||
```go
|
||||
// relay/dispatch/hrw.go
|
||||
func PickHRW(candidates []ScoredChannel, sessionKey string) *ScoredChannel {
|
||||
best, bestScore := (*ScoredChannel)(nil), math.Inf(1)
|
||||
for i := range candidates {
|
||||
c := &candidates[i]
|
||||
if c.Weight <= 0 { continue }
|
||||
h := sha256.Sum256([]byte(sessionKey + "\x00" + strconv.FormatInt(c.ID, 10)))
|
||||
raw := binary.BigEndian.Uint64(h[:8])
|
||||
u := (float64(raw>>11) + 1) / (float64(uint64(1)<<53) + 1) // (0,1),53 位精度
|
||||
score := -math.Log(u) / c.Weight
|
||||
if score < bestScore || (score == bestScore && c.ID < best.ID) {
|
||||
best, bestScore = c, score
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 绑定守卫(防抖层)
|
||||
|
||||
纯 HRW 的弱点:W(c) 随负载连续变化,临界状态下同一会话可能在两个渠道间反复横跳,每跳一次损失一次上游缓存。因此在 HRW 之上加**绑定守卫**:
|
||||
|
||||
```
|
||||
首次选择 → 写入绑定 sessionKey → channelID(Redis,TTL = policy.bindTTL 默认 30min,成功请求滑动续期)
|
||||
后续请求 → 读绑定 → 检查被绑渠道当前是否「合格」:
|
||||
合格 = 熔断器非 OPEN
|
||||
且 healthFactor ≥ policy.keepHealthMin (默认 0.5)
|
||||
且 headroom ≥ policy.keepHeadroomMin (默认 0.1)
|
||||
且 渠道仍在本请求的候选集内(未被本请求排除、未被禁用)
|
||||
合格 → 直接复用(不重跑 HRW,绝对稳定)
|
||||
不合格 → 重跑 HRW,CAS 重绑
|
||||
```
|
||||
|
||||
与现状的本质区别(修复 A2 / A3):
|
||||
|
||||
- 绑定失效判据是**被绑渠道自身状态**(病了 / 满了 / 熔断了),不是"是否在最高优先级组"(A2 消除——tier 只是权重因子,不存在组边界)。
|
||||
- **单次请求失败不删除绑定**:failover 只把渠道加入本请求的排除列表,绑定留存,下一个请求重新做合格性检查(A3 消除)。绑定的自然消亡途径只有两条:TTL 过期、合格性检查失败触发重绑。
|
||||
- 渠道被禁用(手动或熔断长期 OPEN)时通过反向索引批量清理绑定(§10.3),与现状 `InvalidateChannelAffinities` 语义一致。
|
||||
|
||||
---
|
||||
|
||||
## 6. 错误分类与双预算重试
|
||||
|
||||
### 6.1 错误分类器(纯函数)
|
||||
|
||||
```go
|
||||
// relay/dispatch/classify.go
|
||||
type ErrorClass int
|
||||
const (
|
||||
ErrClassClient ErrorClass = iota // 客户端错误,换渠道无意义
|
||||
ErrClassTransient // 瞬时错误,原地重试
|
||||
ErrClassRateLimit // 限流
|
||||
ErrClassChannelFatal // 渠道致命错误
|
||||
ErrClassTimeout // 超时
|
||||
ErrClassPartialStream // 流已写出后中断,不可重试
|
||||
)
|
||||
|
||||
func Classify(statusCode int, err error, streamStarted bool, retryAfter time.Duration) ErrorClass
|
||||
```
|
||||
|
||||
| 类别 | 判定 | 处理 |
|
||||
|---|---|---|
|
||||
| `CLIENT` | 400 参数错、413 过大、422、内容策略拒绝 | **不重试**,原样返回(换渠道结果相同) |
|
||||
| `TRANSIENT` | **502、504**、520–527、请求未发出时连接被重置 | **原地重试**:同渠道,退避 100ms→300ms + jitter,预算 k |
|
||||
| `RATE_LIMIT` | 429 | Retry-After ≤ policy.rateLimitWaitMax(默认 2s)→ 原地等待重试一次;否则立即 failover。429 事件喂给 softLimit 估计器(§8.2) |
|
||||
| `CHANNEL_FATAL` | 401/403(上游 key 失效)、404(模型不存在)、上游余额耗尽 | **零原地重试**,立即 failover + 熔断计数直接达阈值 + 运营告警 |
|
||||
| `TIMEOUT` | 连接超时 / 首 token 超时 | 立即 failover(等下去大概率仍超时) |
|
||||
| `PARTIAL_STREAM` | SSE 已开始写出后上游中断 | **不可重试**(客户端响应已污染),记失败,结束 |
|
||||
|
||||
> 502/504 是最应该原地重试的错误:通常是上游网关/LB 瞬时抖动,短暂退避后大概率恢复;换渠道反而付出丢上游缓存的代价。而 401/404/欠费换一百次也不会好,必须直接熔断。
|
||||
|
||||
### 6.2 双预算结构
|
||||
|
||||
```
|
||||
原地预算 k = policy.inPlaceBudget 默认 2(每渠道独立计)
|
||||
failover 预算 m = policy.failoverBudget 默认 2(最多再尝试 2 个不同渠道)
|
||||
总时限 = policy.totalDeadline 默认 30s(流式请求首 token 前生效)
|
||||
```
|
||||
|
||||
三个预算独立扣减:
|
||||
|
||||
- TRANSIENT 原地重试成功只消耗原地预算,不动 failover 预算。
|
||||
- CHANNEL_FATAL 跳过原地预算,直接扣 failover 预算。
|
||||
- 容量租约获取失败(渠道满)**不扣任何预算**,只把渠道加入本请求排除列表重新选择。
|
||||
- 退避曲线:`min(100ms × 2^attempt, 1s) + jitter(0~50%)`;有 Retry-After 时优先遵从。
|
||||
|
||||
### 6.3 重试决策 FSM(纯函数)
|
||||
|
||||
```go
|
||||
// relay/dispatch/policy.go
|
||||
type RetryDecision int
|
||||
const (
|
||||
DecisionInPlaceRetry RetryDecision = iota // 同渠道退避重试
|
||||
DecisionFailover // 排除当前渠道重新选择
|
||||
DecisionAbort // 终止,返回错误
|
||||
)
|
||||
|
||||
type AttemptState struct {
|
||||
InPlaceUsed int // 当前渠道已用原地次数
|
||||
FailoverUsed int // 已切换渠道次数
|
||||
Elapsed time.Duration // 已耗时
|
||||
StreamStarted bool
|
||||
}
|
||||
|
||||
func Decide(cls ErrorClass, s AttemptState, p RetryPolicy) (RetryDecision, time.Duration /*backoff*/)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 熔断器
|
||||
|
||||
粒度:**渠道级** + **渠道×模型级**两层。渠道级管连接性/认证类故障(影响整个渠道),渠道×模型级管单模型故障(如某模型上游已下线而其它模型正常)。
|
||||
|
||||
```
|
||||
状态机:CLOSED → OPEN → HALF_OPEN → CLOSED
|
||||
↑________________|(探测失败回 OPEN)
|
||||
|
||||
CLOSED → OPEN:滑动窗口(默认 60s)内失败 ≥ failThreshold(默认 8)
|
||||
或 CHANNEL_FATAL 一次直达。
|
||||
OPEN 持续 cooldown(默认 30s,指数递增至 5min 上限)后进入 HALF_OPEN。
|
||||
HALF_OPEN:Redis 原子令牌,每探测窗口(默认 10s)只放行 1 个真实请求:
|
||||
成功 → CLOSED(rampFactor 开始爬坡)
|
||||
失败 → 回 OPEN,cooldown 翻倍。
|
||||
```
|
||||
|
||||
- **自然探活**:HALF_OPEN 放行的是真实业务请求,不发合成请求。多副本下由 Redis 原子令牌保证每窗口全局只放行一个(§10.4)。
|
||||
- OPEN 渠道从候选集硬排除(这是权重体系之外唯一的硬规则之一)。
|
||||
- 状态转移逻辑(纯函数)在 `relay/dispatch/breaker.go`,计数与令牌存取通过 StatePort。
|
||||
- 渠道级 OPEN 持续超过 policy.autoDisableAfter(默认 10min)→ 落库置 `chn_channels.status='disabled', auto_disabled=1` + 清理该渠道全部绑定 + 通知,替代现状的 consecutive_failures 自动禁用。
|
||||
|
||||
---
|
||||
|
||||
## 8. 容量与溢出
|
||||
|
||||
### 8.1 inflight 租约
|
||||
|
||||
沿用 ZSET 租约模式(多副本安全、实例崩溃自愈):
|
||||
|
||||
```
|
||||
member = requestID,score = 过期时间戳(毫秒)
|
||||
获取:Lua 原子【清过期 → ZCARD < softLimit ? ZADD : 拒绝】
|
||||
长流式请求每 30s 续租;结束 ZREM 释放;实例崩溃后租约到期自动消失。
|
||||
Redis 故障 → fail-open(放行)。
|
||||
```
|
||||
|
||||
### 8.2 softLimit 双来源
|
||||
|
||||
```
|
||||
1. 手动:chn_channels.max_concurrency > 0 时直接使用。
|
||||
2. 自动估计(未配置时):记录每次收到 429 时的 inflight 水位,
|
||||
onset_ewma = onset_ewma×0.8 + 当前水位×0.2(Lua 原子),
|
||||
softLimit = max(4, floor(onset_ewma × 0.9))。
|
||||
无 429 历史时视为无限容量(headroomFactor = 1)。
|
||||
```
|
||||
|
||||
### 8.3 溢出行为(三层递进)
|
||||
|
||||
1. **常态软溢出**:primary headroom 下降 → headroom^γ 坍缩 → W_primary 低于 W_secondary → 新会话自然落到 secondary。primary 上的既有绑定只要仍"合格"(headroom ≥ 0.1)继续留存——**老会话不迁移,保上游缓存;新会话去分流**。
|
||||
2. **租约硬拒绝**:单渠道 inflight 触顶时租约获取失败 → 该渠道本请求内排除、不扣预算、立刻重选。
|
||||
3. **硬性扩组兜底**:若某 tier 的全部渠道均被熔断排除或 tierFactor 配置为 0,候选集自动扩到下一 tier(primary → secondary → reserve)。此规则独立于权重计算,保证参数误配时可用性不丢。
|
||||
|
||||
---
|
||||
|
||||
## 9. 模块结构与 Go 接口
|
||||
|
||||
### 9.1 目录结构
|
||||
|
||||
```
|
||||
relay/dispatch/ ← 新模块。纯库:不 import gf/gdb/dao/redis
|
||||
types.go Channel、Tier、RequestProfile、Decision、Outcome、ScoredChannel
|
||||
session.go 会话键解析链(输入 headers + 已解析的 body 字段,纯函数)
|
||||
classify.go 错误分类器(纯函数)
|
||||
score.go W(c) 权重合成(纯函数)
|
||||
hrw.go 加权 rendezvous 选择(纯函数)
|
||||
policy.go 重试决策 FSM + RetryPolicy/RoutingPolicy 结构体定义
|
||||
breaker.go 熔断状态机(转移逻辑纯函数,存取走端口)
|
||||
coordinator.go 编排:解析会话键 → 快照 → 绑定守卫 → HRW → 租约 → 结果回写
|
||||
ports.go 端口接口(见 9.2)
|
||||
|
||||
internal/logic/dispatchadapter/ ← 适配层,全部 I/O 在这
|
||||
catalog.go DAO 三表 JOIN → 内存快照,定时刷新 + pub/sub 失效
|
||||
state_redis.go StatePort 的 Redis 实现(全部 Lua 原子脚本)
|
||||
state_local.go Redis 故障时的本地降级镜像
|
||||
config.go routing policy 加载 + 热更新
|
||||
wire.go 组装 coordinator 供 handler 使用
|
||||
```
|
||||
|
||||
依赖方向:`relay/handler → relay/dispatch ← internal/logic/dispatchadapter`。核心不知道适配层存在。
|
||||
|
||||
### 9.2 端口接口
|
||||
|
||||
```go
|
||||
// relay/dispatch/ports.go
|
||||
package dispatch
|
||||
|
||||
// CatalogPort 渠道目录:返回某模型的候选渠道快照(含最近的健康/负载读值)。
|
||||
// 实现方保证 O(1) 内存读取,禁止在调用路径中查库。
|
||||
type CatalogPort interface {
|
||||
Snapshot(ctx context.Context, tenantID int64, model string, scope []int64) []Channel
|
||||
}
|
||||
|
||||
// StatePort 运行时状态:绑定 / 健康 / 熔断 / 容量。实现方保证多副本原子性。
|
||||
type StatePort interface {
|
||||
// 绑定
|
||||
GetBinding(ctx context.Context, sessionKey string) (channelID int64, ok bool)
|
||||
SetBinding(ctx context.Context, sessionKey string, channelID int64, ttl time.Duration)
|
||||
TouchBinding(ctx context.Context, sessionKey string, ttl time.Duration)
|
||||
InvalidateChannelBindings(ctx context.Context, channelID int64)
|
||||
|
||||
// 健康(fire-and-forget,实现方内部异步化)
|
||||
ReportOutcome(o Outcome) // {ChannelID, Model, Success, LatencyMs, ErrClass}
|
||||
|
||||
// 熔断
|
||||
BreakerState(ctx context.Context, channelID int64, model string) BreakerState
|
||||
TryProbeToken(ctx context.Context, channelID int64) bool // HALF_OPEN 探测令牌
|
||||
|
||||
// 容量
|
||||
AcquireLease(ctx context.Context, channelID int64, softLimit int, requestID string) bool
|
||||
RefreshLease(ctx context.Context, channelID int64, requestID string)
|
||||
ReleaseLease(ctx context.Context, channelID int64, requestID string)
|
||||
}
|
||||
|
||||
// Clock / Entropy 便于测试注入
|
||||
type Clock interface{ Now() time.Time }
|
||||
```
|
||||
|
||||
### 9.3 协调器对外 API(handler 唯一入口)
|
||||
|
||||
```go
|
||||
type Coordinator struct { /* catalog CatalogPort; state StatePort; policy atomic.Pointer[RoutingPolicy]; clock Clock */ }
|
||||
|
||||
// Route 开启一次请求的调度会话。
|
||||
func (co *Coordinator) Route(ctx context.Context, req RequestProfile) *RouteSession
|
||||
|
||||
type RouteSession struct { /* 内部持有排除列表、预算状态、当前选择 */ }
|
||||
|
||||
// Next 返回下一个应尝试的渠道(首次调用 = 初始选择)。返回 nil 表示无渠道可用。
|
||||
func (s *RouteSession) Next(ctx context.Context) *Decision
|
||||
|
||||
// Report 上报本次尝试结果,返回重试决策(原地/换渠道/终止)与退避时长。
|
||||
func (s *RouteSession) Report(ctx context.Context, statusCode int, err error, streamStarted bool,
|
||||
latencyMs float64, retryAfter time.Duration) (RetryDecision, time.Duration)
|
||||
|
||||
// Finish 请求结束(成功或最终失败):释放租约、续期或落定绑定、上报健康。
|
||||
func (s *RouteSession) Finish(ctx context.Context, success bool)
|
||||
```
|
||||
|
||||
handler 侧的重试循环收敛为:
|
||||
|
||||
```go
|
||||
sess := coordinator.Route(ctx, profile)
|
||||
for {
|
||||
d := sess.Next(ctx)
|
||||
if d == nil { return errAllChannelsFailed }
|
||||
err := forward(d) // 转发(含渠道 Key 获取)
|
||||
if err == nil { sess.Finish(ctx, true); return nil }
|
||||
decision, backoff := sess.Report(ctx, code(err), err, streamStarted(err), lat, retryAfter(err))
|
||||
switch decision {
|
||||
case dispatch.DecisionInPlaceRetry: sleep(backoff) // 同一个 d 再 forward
|
||||
case dispatch.DecisionFailover: continue // 下轮 Next 换渠道
|
||||
case dispatch.DecisionAbort: sess.Finish(ctx, false); return err
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Redis 状态设计(key 布局 + Lua 脚本)
|
||||
|
||||
统一前缀 `dispatch:v1:`。所有多步操作一律单条 Lua 原子执行。
|
||||
|
||||
### 10.1 key 布局
|
||||
|
||||
| key | 类型 | 内容 | TTL |
|
||||
|---|---|---|---|
|
||||
| `dispatch:v1:bind:<sha256(sessionKey)>` | STRING | channelID | bindTTL(默认 30min,成功滑动续期) |
|
||||
| `dispatch:v1:bind:rev:<channelID>` | SET | 指向该渠道的绑定 key 集合 | bindTTL + 60s |
|
||||
| `dispatch:v1:health:<channelID>:<model>` | HASH | succ_ewma, lat_ewma, fail_window_count, window_start_ms | 24h 滑动 |
|
||||
| `dispatch:v1:breaker:<channelID>` | HASH | state(0/1/2), opened_at_ms, cooldown_ms, probe_window_start_ms | 24h |
|
||||
| `dispatch:v1:breaker:<channelID>:<model>` | HASH | 同上(模型级) | 24h |
|
||||
| `dispatch:v1:inflight:<channelID>` | ZSET | member=requestID, score=过期时间戳 ms | 租约期 + 60s |
|
||||
| `dispatch:v1:limit429:<channelID>` | HASH | onset_ewma | 24h |
|
||||
| `dispatch:v1:catalog:ver` | STRING | 目录版本号(配置变更时 INCR) | 无 |
|
||||
|
||||
pub/sub 频道:`dispatch:v1:catalog:invalidate`(负载为变更的 channelID 或 `*`)。
|
||||
|
||||
### 10.2 Lua:健康 EWMA 原子更新(消除 H1 竞态)
|
||||
|
||||
```lua
|
||||
-- KEYS[1] = health key; ARGV = success(0/1), latencyMs, nowMs, windowMs
|
||||
local succ = tonumber(redis.call('HGET', KEYS[1], 'succ_ewma') or '1.0')
|
||||
local lat = tonumber(redis.call('HGET', KEYS[1], 'lat_ewma') or '0')
|
||||
if ARGV[1] == '1' then
|
||||
succ = succ * 0.9 + 0.1
|
||||
lat = (lat == 0) and tonumber(ARGV[2]) or (lat * 0.7 + tonumber(ARGV[2]) * 0.3)
|
||||
else
|
||||
succ = succ * 0.9
|
||||
-- 失败窗口计数(供熔断判定)
|
||||
local ws = tonumber(redis.call('HGET', KEYS[1], 'window_start_ms') or '0')
|
||||
if tonumber(ARGV[3]) - ws > tonumber(ARGV[4]) then
|
||||
redis.call('HSET', KEYS[1], 'window_start_ms', ARGV[3], 'fail_window_count', 1)
|
||||
else
|
||||
redis.call('HINCRBY', KEYS[1], 'fail_window_count', 1)
|
||||
end
|
||||
end
|
||||
redis.call('HSET', KEYS[1], 'succ_ewma', succ, 'lat_ewma', lat)
|
||||
redis.call('PEXPIRE', KEYS[1], 86400000)
|
||||
return redis.call('HGET', KEYS[1], 'fail_window_count') or '0'
|
||||
```
|
||||
|
||||
调用方(state_redis.go 的 `ReportOutcome`)**fire-and-forget**:投入有界 channel 由后台 worker 批量执行,失败路径零 DB/Redis 往返阻塞(消除 H2)。返回的窗口失败数就地用于熔断转移判定,一次往返完成两件事。
|
||||
|
||||
### 10.3 Lua:绑定 CAS 写入 + 反向索引
|
||||
|
||||
```lua
|
||||
-- KEYS[1] = bind key; ARGV = channelID, ttlSec, revPrefix
|
||||
local old = redis.call('GET', KEYS[1])
|
||||
if old and old ~= ARGV[1] then
|
||||
redis.call('SREM', ARGV[3] .. old, KEYS[1])
|
||||
end
|
||||
redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2])
|
||||
redis.call('SADD', ARGV[3] .. ARGV[1], KEYS[1])
|
||||
redis.call('EXPIRE', ARGV[3] .. ARGV[1], tonumber(ARGV[2]) + 60)
|
||||
return 1
|
||||
```
|
||||
|
||||
渠道禁用/熔断长期 OPEN 时:`SMEMBERS rev → DEL 各绑定 → DEL rev`(单 Lua)。
|
||||
|
||||
### 10.4 Lua:HALF_OPEN 探测令牌(多副本每窗口全局放行 1 个)
|
||||
|
||||
```lua
|
||||
-- KEYS[1] = breaker key; ARGV = nowMs, probeWindowMs
|
||||
local ws = tonumber(redis.call('HGET', KEYS[1], 'probe_window_start_ms') or '0')
|
||||
if tonumber(ARGV[1]) - ws >= tonumber(ARGV[2]) then
|
||||
redis.call('HSET', KEYS[1], 'probe_window_start_ms', ARGV[1])
|
||||
return 1 -- 放行本请求作为探测
|
||||
end
|
||||
return 0
|
||||
```
|
||||
|
||||
### 10.5 Lua:inflight 租约获取
|
||||
|
||||
```lua
|
||||
-- KEYS[1] = inflight key; ARGV = nowMs, expireAtMs, softLimit, requestID, keyTtlMs
|
||||
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
|
||||
if redis.call('ZCARD', KEYS[1]) >= tonumber(ARGV[3]) then return 0 end
|
||||
redis.call('ZADD', KEYS[1], ARGV[2], ARGV[4])
|
||||
redis.call('PEXPIRE', KEYS[1], ARGV[5])
|
||||
return 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 数据库变更
|
||||
|
||||
迁移脚本:`migrations/000014_v0_2_1_version.sql`
|
||||
|
||||
```sql
|
||||
-- +goose Up
|
||||
|
||||
-- 1. 渠道层级:三档固定枚举,替代自由整数优先级的调度语义
|
||||
-- (priority 列保留不删,仅停止参与调度,供回滚与历史查询)
|
||||
ALTER TABLE chn_channels ADD COLUMN IF NOT EXISTS tier VARCHAR(16) NOT NULL DEFAULT 'primary';
|
||||
COMMENT ON COLUMN chn_channels.tier IS '调度层级:primary=首选 secondary=备用 reserve=保底';
|
||||
|
||||
-- 按现有 priority 自动映射:全局最高优先级值 → primary,次高 → secondary,其余 → reserve
|
||||
WITH ranked AS (
|
||||
SELECT id, DENSE_RANK() OVER (ORDER BY priority DESC) AS rk FROM chn_channels
|
||||
)
|
||||
UPDATE chn_channels c SET tier = CASE ranked.rk
|
||||
WHEN 1 THEN 'primary'
|
||||
WHEN 2 THEN 'secondary'
|
||||
ELSE 'reserve' END
|
||||
FROM ranked WHERE c.id = ranked.id;
|
||||
|
||||
-- 2. 渠道×模型成本比例(无量纲,上游实际价/平台基准价)
|
||||
ALTER TABLE chn_abilities ADD COLUMN IF NOT EXISTS cost_ratio NUMERIC(10,4) NOT NULL DEFAULT 1.0;
|
||||
COMMENT ON COLUMN chn_abilities.cost_ratio IS '成本比例:该渠道该模型上游实际价/平台基准价,1.0=等价,0.8=八折,参与调度 costFactor 计算';
|
||||
|
||||
-- 3. 健康快照表:Redis 为真相源,DB 仅存周期快照供仪表盘
|
||||
-- (chn_health_scores 保留为快照落点,不再被调度读取;
|
||||
-- 调度切换完成后由 cron 每 5min 把 Redis EWMA 值写入此表)
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE chn_abilities DROP COLUMN IF EXISTS cost_ratio;
|
||||
ALTER TABLE chn_channels DROP COLUMN IF EXISTS tier;
|
||||
```
|
||||
|
||||
执行后运行 `gf gen dao` 重新生成 entity/do/dao。
|
||||
|
||||
角色变化汇总:
|
||||
|
||||
| 表 | 现状角色 | 新角色 |
|
||||
|---|---|---|
|
||||
| `chn_channels.priority` | 调度分组依据 | 冻结(保留列,调度改读 tier) |
|
||||
| `chn_channels.weight` | 组内权重 | baseWeight(沿用) |
|
||||
| `chn_channels.max_concurrency` | 容量上限 | softLimit 手动来源(沿用) |
|
||||
| `chn_health_scores` | 调度实时读取 | 仪表盘快照(cron 从 Redis 落盘) |
|
||||
| `chn_channel_affinities`(若存在遗留表) | — | 废弃,绑定只在 Redis |
|
||||
|
||||
---
|
||||
|
||||
## 12. 路由策略配置(routing policy)
|
||||
|
||||
单一版本化 JSON 对象,存 `sys_options` key = `channel_routing_policy`,替代现有散落的布尔开关(`channel_scheduler_v2_enabled` / `channel_affinity_enabled` / `channel_capacity_enabled` / `channel_auto_disable_enabled` 全部退役)。支持全局默认 + 租户覆盖(`channel_routing_policy:tenant:<id>`,浅合并),配置中心热加载(沿用现有 Config 监听机制,加载后 `atomic.Pointer` 替换)。
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"version": 1,
|
||||
"tierFactors": { "primary": 1.0, "secondary": 0.15, "reserve": 0.02 },
|
||||
"health": { "alpha": 2.0, "latRefMs": 3000 },
|
||||
"load": { "gamma": 2.0, "leaseSeconds": 90 },
|
||||
"cost": { "beta": 0.5, "min": 0.5, "max": 2.0 },
|
||||
"ramp": { "windowSeconds": 120, "floor": 0.05 },
|
||||
"binding": {
|
||||
"ttlSeconds": 1800,
|
||||
"keepHealthMin": 0.5,
|
||||
"keepHeadroomMin": 0.1
|
||||
},
|
||||
"retry": {
|
||||
"inPlaceBudget": 2,
|
||||
"failoverBudget": 2,
|
||||
"totalDeadlineSeconds": 30,
|
||||
"backoffBaseMs": 100,
|
||||
"backoffMaxMs": 1000,
|
||||
"rateLimitWaitMaxMs": 2000
|
||||
},
|
||||
"breaker": {
|
||||
"windowSeconds": 60,
|
||||
"failThreshold": 8,
|
||||
"cooldownSeconds": 30,
|
||||
"cooldownMaxSeconds": 300,
|
||||
"probeWindowSeconds": 10,
|
||||
"autoDisableAfterSeconds": 600
|
||||
},
|
||||
"session": {
|
||||
"headerName": "X-Session-Id",
|
||||
"parseAnthropicMetadata": true,
|
||||
"parseOpenAIResponses": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
校验:加载时做 Schema 校验(范围 + 类型),非法配置拒绝生效并告警,继续用上一份有效配置——杜绝 O1 式"误配置静默降级"。
|
||||
|
||||
---
|
||||
|
||||
## 13. 多副本与降级
|
||||
|
||||
| 状态 | 存放 | 多副本策略 |
|
||||
|---|---|---|
|
||||
| 渠道目录快照 | 各实例内存 | 各自定时刷(5s)+ pub/sub 失效;秒级不一致无害(HRW 输入短暂不同只影响极少数新绑定) |
|
||||
| 健康 EWMA / 窗口失败数 | Redis | 单条 Lua 原子读-算-写,N 副本并发失败精确 +N(H1 消除) |
|
||||
| inflight 租约 | Redis ZSET | 实例崩溃后租约自然过期,无需清理协调 |
|
||||
| 会话绑定 | Redis | Lua CAS,两副本同时冷启动同一会话时先写者赢、后者跟随,收敛一致 |
|
||||
| 熔断状态 / 探测令牌 | Redis | Lua 原子转移;HALF_OPEN 每窗口全局只放行 1 个 |
|
||||
| 选择计算 | 无状态 | HRW 纯确定性,任何副本同输入同输出 |
|
||||
|
||||
**Redis 故障降级**(state_local.go):
|
||||
|
||||
- 健康:用最后一次成功读取的快照(内存镜像,随目录快照一起持有)。
|
||||
- 绑定:不可用 → 退化为纯 HRW。HRW 本身仍提供软粘性(相同 sessionKey 同选择),只是失去防抖层。
|
||||
- 熔断:退化为实例本地熔断(进程内计数),保护仍在、只是不再全局协同。
|
||||
- 容量:fail-open 放行。
|
||||
- 结论:**Redis 完全不可用时调度链路仍可服务**,只损失精度不损失可用性。
|
||||
|
||||
---
|
||||
|
||||
## 14. 可观测性
|
||||
|
||||
### 14.1 决策日志(每次选择一条,DEBUG 级可关)
|
||||
|
||||
```
|
||||
request_id, session_key_source(hdr/anthropic/openai/ident), binding(hit/rebind/new),
|
||||
chosen_channel, tier, weight_breakdown{base,tier,health,headroom,cost,ramp},
|
||||
candidates_count, excluded{breaker:n, lease:n, request:n}
|
||||
```
|
||||
|
||||
### 14.2 重试/失败日志(WARN)
|
||||
|
||||
```
|
||||
request_id, attempt_seq, channel, err_class, decision(inplace/failover/abort),
|
||||
backoff_ms, budgets{inplace_left, failover_left}
|
||||
```
|
||||
|
||||
### 14.3 指标(Prometheus 风格,供 ops 仪表盘)
|
||||
|
||||
- `dispatch_selection_total{channel,tier,reason=bind|hrw}`
|
||||
- `dispatch_retry_total{channel,err_class,decision}`
|
||||
- `dispatch_breaker_state{channel}`(gauge 0/1/2)
|
||||
- `dispatch_binding_ratio`(绑定命中率)
|
||||
- `dispatch_overflow_total{from_tier,to_tier}`(溢出计数——验证 §8.3 是否按预期工作)
|
||||
- `dispatch_session_source_total{source}`(会话键来源占比——验证 Claude Code 解析有效性)
|
||||
|
||||
### 14.4 现有字段衔接
|
||||
|
||||
`bil_usage_logs.retry_index` 语义不变(最终成功尝试的序号);`SelectionReason` 取值改为 `bind` / `hrw` / `overflow`,前端渠道日志展示同步调整。
|
||||
|
||||
---
|
||||
|
||||
## 15. 请求流全景
|
||||
|
||||
```
|
||||
请求进入
|
||||
│
|
||||
├─ 1. 解析会话键(显式头 → 协议信号 → 身份四元组) [session.go 纯函数]
|
||||
├─ 2. 取候选快照(内存;OPEN 熔断渠道硬排除;tier 硬扩组兜底)[catalog.go]
|
||||
├─ 3. 读绑定 → 被绑渠道合格? ──是──→ 选它
|
||||
│ └─否/无绑定──→ 算 W(c) → HRW → 选中 → CAS 写绑定
|
||||
├─ 4. 取 inflight 租约(满 → 本请求排除该渠道 → 回 3,不扣预算)
|
||||
├─ 5. 获取渠道 Key、转发
|
||||
│ ├─ 成功 → Finish:释放租约 / 绑定续期 / 异步上报成功 EWMA
|
||||
│ └─ 失败 → Report → 错误分类
|
||||
│ ├─ CLIENT / PARTIAL_STREAM → Abort
|
||||
│ ├─ TRANSIENT 且原地预算>0 → 退避 → 回 5(同渠道)
|
||||
│ ├─ 其余且 failover 预算>0 → 本请求排除该渠道 → 回 3
|
||||
│ └─ 预算/时限耗尽 → Abort
|
||||
│ (失败路径全程:异步上报失败 EWMA + 熔断计数;绑定不删除)
|
||||
└─ 结束
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 16. 分阶段实施计划
|
||||
|
||||
> 原则:每阶段独立可合入、可回滚;**不引入长期存活的新旧切换开关**——灰度用影子模式,切换是一次性的(阶段 4),之后立即删旧码(阶段 5)。
|
||||
|
||||
### 阶段 0:纯核心库(无任何接线,零风险)
|
||||
|
||||
**交付物**
|
||||
- `relay/dispatch/` 全部纯逻辑文件:types / session / classify / score / hrw / policy / breaker / ports。
|
||||
- 完整单元测试:
|
||||
- HRW 分布测试(10 万次采样,权重比例误差 < 2%);
|
||||
- 会话键解析链全 case(含 Claude Code metadata.user_id 真实样本);
|
||||
- 错误分类表驱动测试(覆盖 §6.1 全部行);
|
||||
- 重试 FSM 全路径(预算耗尽、流中断、Retry-After 遵从);
|
||||
- 熔断转移全路径(含 cooldown 指数递增);
|
||||
- 权重合成边界(headroom=0、health=0.01、tierFactor=0)。
|
||||
|
||||
**验收**:`go test ./relay/dispatch/...` 全绿,覆盖率 ≥ 90%(纯函数无理由达不到);`relay/dispatch` 的 import 列表中无 gf / dao / redis。
|
||||
|
||||
### 阶段 1:适配层 + 数据迁移
|
||||
|
||||
**交付物**
|
||||
- `migrations/000014_v0_2_1_version.sql`(§11)+ `gf gen dao`。
|
||||
- `internal/logic/dispatchadapter/`:
|
||||
- catalog.go(内存快照 + 5s 刷新 + pub/sub 失效;快照内容 = 现三表 JOIN 等价数据 + tier + cost_ratio + Redis 健康/负载读值);
|
||||
- state_redis.go(§10 全部 Lua 脚本 + ReportOutcome 后台批量 worker);
|
||||
- state_local.go(降级镜像);
|
||||
- config.go(routing policy 加载/校验/热更新)。
|
||||
- miniredis 集成测试:Lua 脚本正确性、并发 EWMA 精确计数(100 并发失败 → fail_window_count 精确 =100)、CAS 绑定竞争收敛。
|
||||
|
||||
**验收**:迁移 up/down 幂等;tier 自动映射结果人工抽查确认;集成测试全绿。
|
||||
|
||||
### 阶段 2:影子模式(只算不用)
|
||||
|
||||
**交付物**
|
||||
- handler 在现有调度结果返回后,**并行调用新 Coordinator 计算一次决策**(仅初始选择,不含重试),将新旧决策写入对比日志(channel 是否一致、不一致时双方 weight_breakdown)。
|
||||
- 对比统计脚本/看板:一致率、不一致的分布归因。
|
||||
|
||||
**验收**:影子运行 ≥ 3 天或 ≥ 10 万请求;对新旧不一致的每一类原因给出解释(预期差异:新方案的健康降权/溢出行为本就应不同);影子路径 p99 额外耗时 < 1ms(纯内存计算)。
|
||||
|
||||
**回滚**:删掉影子调用即可,无状态残留。
|
||||
|
||||
### 阶段 3:切换写路径(一次性)
|
||||
|
||||
**交付物**
|
||||
- handler 重试循环替换为 §9.3 的 `Route/Next/Report/Finish` 模式;
|
||||
- 健康上报、绑定写入、租约全部走新 StatePort;
|
||||
- 移除现有 `GetChannelForModel` 内的调度逻辑(保留渠道 Key 获取、租户/成员/Key 模型权限校验——它们不属于调度模块,位置不动);
|
||||
- cron 新增:Redis 健康 EWMA → `chn_health_scores` 每 5min 快照落盘(替代原实时写);熔断长期 OPEN → 自动禁用落库。
|
||||
- 渠道管理后台联动:禁用/删除渠道时调用 `InvalidateChannelBindings` + pub/sub 目录失效。
|
||||
|
||||
**验收**:DoD 全项(手动端到端 + 单测 ≥80% + 错误码文案 + 日志含 request_id + 权限不变);压测对比切换前后 p50/p99 与 DB QPS(预期 DB 调度查询归零);故障注入演练:单渠道 502 风暴(应原地重试消化)、单渠道 401(应 <1s 熔断)、primary 全灭(应扩组到 secondary)。
|
||||
|
||||
**回滚**:git revert 本阶段合并提交(阶段 0-2 的代码无行为影响可留);Redis 新 key 前缀独立,回滚后旧逻辑不受污染。
|
||||
|
||||
### 阶段 4:溢出与成本完整化
|
||||
|
||||
**交付物**
|
||||
- softLimit 429 自动估计器上线(此前只用手动 max_concurrency);
|
||||
- 管理后台:渠道表单加 tier 下拉、abilities 列表加 cost_ratio 编辑 + CSV 批量导入;
|
||||
- rampFactor 接通熔断恢复与新渠道创建事件;
|
||||
- `dispatch_overflow_total` 等指标接入 ops 仪表盘。
|
||||
|
||||
**验收**:人工压测单 primary 渠道至饱和,观测溢出指标与 secondary 流量占比曲线符合 §8.3 预期。
|
||||
|
||||
### 阶段 5:清理与收尾
|
||||
|
||||
**交付物**
|
||||
- 删除:legacy 排序调度、v2 旧调度(`relay/scheduler` 旧实现)、旧亲和(`affinity.go`)、旧健康实时写(`health.go` 的调度耦合部分)、`channel_scheduler_v2_enabled` 等四个布尔开关及其读取点;
|
||||
- `chn_channel_affinities` 遗留表(若存在)标记废弃;
|
||||
- 文档:运维手册(policy 参数调优指引、熔断/溢出排障流程)、接入文档补 `X-Session-Id` 说明;
|
||||
- 影子对比代码移除。
|
||||
|
||||
**验收**:全仓 grep 无旧开关引用;`relay/scheduler` 目录仅剩新实现或已并入 `relay/dispatch`。
|
||||
|
||||
---
|
||||
|
||||
## 17. 测试策略
|
||||
|
||||
| 层 | 手段 | 关键用例 |
|
||||
|---|---|---|
|
||||
| 纯核心 | 表驱动单测 + 分布采样 | HRW 权重比例;粘性稳定性(同 seed 万次同结果);权重连续变化下绑定守卫的防抖有效性(模拟 headroom 抖动,断言重绑次数 ≤ 阈值) |
|
||||
| 熔断/重试 | 假时钟状态机测试 | 全转移路径;cooldown 递增;探测窗口 |
|
||||
| 适配层 | miniredis | Lua 原子性(并发 100 goroutine);CAS 收敛;租约过期自愈 |
|
||||
| 协调器 | 假端口注入 | 端到端场景剧本:「primary 渐进饱和 → 新会话溢出比例曲线」「亲和渠道健康跌破 keepHealthMin → 下一请求重绑」「502×2 原地恢复不换渠道」 |
|
||||
| 系统级 | 影子模式(阶段 2)+ 故障注入(阶段 3) | 见分阶段验收 |
|
||||
|
||||
---
|
||||
|
||||
## 18. 问题覆盖对照表
|
||||
|
||||
| 问题编号 | 问题 | 本方案对应机制 |
|
||||
|---|---|---|
|
||||
| A1 🔴 | 亲和命中绕过健康度降权 | 粘性由权重涌现(§2);绑定合格性含 healthFactor ≥ 0.5(§5.2) |
|
||||
| A2 🟡 | 亲和仅在最高优先级组内生效 | 无优先级分组,tier 只是权重因子(§4.1) |
|
||||
| A3 🔴 | 任何可重试错误清除亲和 | 失败只排除不删绑定(§5.2、§6) |
|
||||
| R1 🟡 | 重试无退避 | 指数退避 + jitter + Retry-After(§6.2) |
|
||||
| R2 🟡 | 瞬时错误无原地重试 | 双预算,TRANSIENT 原地重试(§6.1/6.2) |
|
||||
| H1 🔴 | 健康度读-改-写竞态 | Lua 单脚本原子 EWMA(§10.2) |
|
||||
| H2 🟡 | 失败路径同步写库阻塞重试 | ReportOutcome fire-and-forget + 后台批量(§10.2) |
|
||||
| P1 🟡 | 每次重试重查 DB | 内存目录快照,热路径零 DB(§9.1 catalog) |
|
||||
| O1 🔴 | legacy/v2 双路径 | 单路径 + 影子灰度 + 阶段 5 删旧码(§16);policy 校验拒绝非法配置(§12) |
|
||||
| L2 ⚪ | 无优先级溢出分流 | headroom 连续溢出 + 租约硬拒绝 + 扩组兜底(§8.3) |
|
||||
| L3 ⚪ | 亲和无会话维度 | 会话键解析链(§3.1) |
|
||||
|
||||
---
|
||||
|
||||
## 19. 风险与待验证项
|
||||
|
||||
1. **Claude Code metadata.user_id 格式**:方案假设其内嵌 `session_<uuid>` 段。阶段 0 开始前抓真实流量样本验证;若格式变化或缺失,解析链自动落到身份级,功能不受损、仅粒度退化(已有 `dispatch_session_source_total` 指标持续监控占比)。
|
||||
2. **tierFactor/γ 等参数的初值合理性**:默认值来自推导而非实测。阶段 2 影子模式的 weight_breakdown 日志用于校准;参数全部热更新,无需发版调整。
|
||||
3. **429 自动估计的冷启动**:无 429 历史的渠道视为无限容量,首次过载会真实吃到一轮 429 才收敛。可接受(429 本身会触发 failover,用户无感);不可接受时运营手动配 max_concurrency。
|
||||
4. **绑定守卫与 HRW 的一致性漂移**:绑定长期存活期间权重结构可能大变(如新增渠道),绑定会"钉"在旧选择上直到 TTL 过期或不合格。这是有意为之(保缓存优先于再均衡);如需主动再均衡,未来可加"绑定年龄上限"策略参数,本期不做。
|
||||
5. **Redis 单点**:本方案把更多状态放进 Redis,Redis 可用性要求提高。降级路径(§13)保证不可用时仍可服务;生产部署建议 Redis 主从/哨兵(部署层事项,不在本方案范围)。
|
||||
@@ -1,591 +0,0 @@
|
||||
# 渠道调度器重构设计(team-api)
|
||||
|
||||
> **状态**:设计定稿(2026-07-30),尚未动工。本文是 clean-slate 重写的实现规格基线。
|
||||
> **背景**:当前调度实现的问题清单见 [`channel-scheduling-issues.md`](./channel-scheduling-issues.md)。本文不受现状约束,重新设计一套"理想"方案,而非在现有代码上打补丁。
|
||||
> **范围**:`relay/scheduler`(纯调度逻辑)+ `internal/logic/relay`(DB/Redis adapter)+ `relay/handler`(执行器)的调度相关部分。不涉及计费、协议转换、流式转发等调度以外的能力。
|
||||
|
||||
---
|
||||
|
||||
## 0. 已敲定的关键决策
|
||||
|
||||
| # | 决策点 | 定论 |
|
||||
|---|---|---|
|
||||
| 1 | 路由状态真相源 | **Redis 为真相源**:健康分用 Lua 原子 EWMA,并发占用沿用 ZSET;`chn_health_scores` 降级为周期快照。Redis 故障用本地 last-known 快照降级 |
|
||||
| 2 | 成本感知 | **直接开启**:`costFactor` 进权重,同模型等价渠道优先更便宜的上游;运营维护每渠道实际上游单价 |
|
||||
| 3 | 对话指纹 | **不做**:会话令牌仅认显式信号(`X-Session-Id` / `previous_response_id` / `conversation_id`),无则回退身份级 |
|
||||
| 4 | 熔断探活 | **自然探活**:HALF_OPEN 时 Redis 原子计数器每窗口放行 1 个真实请求探测 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 设计目标与原则
|
||||
|
||||
调度器需同时优化多个**互相矛盾**的目标:
|
||||
|
||||
| 目标 | 含义 | 主要矛盾对象 |
|
||||
|---|---|---|
|
||||
| 正确性 | 只路由到有能力/有权限服务该模型的渠道 | — |
|
||||
| 可用性 | 渠道故障用户无感(重试/failover) | 粘性 |
|
||||
| 成本 | 同模型优先更便宜的上游 | 负载均衡 |
|
||||
| 粘性 | 同一会话命中同一渠道 → 命中上游 KV/prompt 缓存 | 负载均衡 |
|
||||
| 负载均衡 | 按容量摊开流量,用满备用渠道 | 粘性、优先级独占 |
|
||||
| 多租户隔离 | 租户级路由范围、配额、不互相挤占 | — |
|
||||
|
||||
**六条设计原则**:
|
||||
|
||||
1. **策略与机制分离**:调度器只决定"用哪个渠道";执行器只做 HTTP。重试/退避是数据驱动的策略,不是散落在 handler 里的分支。
|
||||
2. **选择 = 对候选集打分采样**:优先级、健康、负载、成本、亲和统一成同一个评分公式的因子。
|
||||
3. **状态事件驱动 + 原子更新**:每个请求结果是一个事件;状态用 Redis Lua/原子操作更新,**从结构上消灭读写竞态**,多实例安全。
|
||||
4. **粘性是软信号,不是硬覆盖**:亲和由 HRW 种子承担,是涌现属性,永远要和健康/负载竞争,绝不绝对命中。
|
||||
5. **失败处理是状态机**:请求生命周期 = 显式 FSM,由错误分类器驱动转移。
|
||||
6. **调度模块是纯函数、零 I/O**:所有 DB/Redis 在接口背后;调度逻辑 100% 可单测、可复现并发 bug。
|
||||
|
||||
---
|
||||
|
||||
## 2. 顶层架构
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ Executor(relay/handler) │ 机制层:HTTP/SSE、协议适配器
|
||||
│ 只认识 HTTP 和流式,只调 Scheduler 接口 │ 负责 buildRelayInfo / 转发 / 计费钩子
|
||||
└──────────────┬───────────────────────────────────┘
|
||||
│ 依赖接口,不依赖实现
|
||||
┌──────────────▼───────────────────────────────────┐
|
||||
│ Scheduler(relay/scheduler,纯逻辑) │ ★ 核心模块,零 dao/redis import
|
||||
│ ├─ Selector 加权 HRW 打分选择 │
|
||||
│ ├─ RetryFSM 错误分类 + 状态机 │
|
||||
│ ├─ AffinityResolver 会话令牌解析 + 绑定守卫 │
|
||||
│ └─ Policy 版本化路由策略(热加载) │
|
||||
└──────────────┬───────────────────────────────────┘
|
||||
│ 依赖端口(接口)
|
||||
┌──────────────▼───────────────────────────────────┐
|
||||
│ Ports: ChannelCatalog · HealthStore · LoadGauge │
|
||||
│ AffinityStore · CostCatalog │
|
||||
└──────────────┬───────────────────────────────────┘
|
||||
│ 由 adapter 实现(internal/logic/relay)
|
||||
┌──────────────▼───────────────────────────────────┐
|
||||
│ PostgresChannelCatalog(内存缓存) · RedisHealthStore│
|
||||
│ (Lua EWMA + 熔断) · RedisLoadGauge(ZSET) │
|
||||
│ RedisAffinityStore · PostgresCostCatalog │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**关键性质**:`relay/scheduler` 包 `import` 列表里**不得出现** `dao` / `g.DB()` / `g.Redis()`。所有 I/O 通过端口接口注入。这使得选号、重试、溢出、熔断、亲和衰减全部可用 fake store 单测,包括用并发测试复现历史上的竞态 bug。
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心选择模型:加权 HRW
|
||||
|
||||
### 3.1 单一原语
|
||||
|
||||
对每个候选渠道 `c`,给定请求种子 `seed`:
|
||||
|
||||
```
|
||||
pick = argmax_c weight(c, r) / -ln( μ( hash(seed, c) ) )
|
||||
```
|
||||
|
||||
其中 `μ ∈ (0,1)` 由 `hash(seed, c)` 派生的均匀分布(参考现有 `weightedRendezvousSelect` 的 53 位精度构造,避免 `log(0)`)。
|
||||
|
||||
`seed = 会话令牌`(见 §4)。代入后,**这一个公式同时给出**:
|
||||
|
||||
- **粘性**:同一 session → seed 相同 → 选出同一渠道(命中上游缓存)。
|
||||
- **负载均衡**:不同 session → seed 不同 → 大量会话按 `weight` 比例自动摊到各渠道。
|
||||
- **跨实例一致**:纯函数 (seed, 权重),多实例无需协调即得出同一结果。
|
||||
- **自适应**:`weight` 含健康/负载因子,病渠道权重↓ → 新会话自然不再落到它。
|
||||
|
||||
> 粘性由此成为 HRW 的**涌现属性**,而非一个要和健康度打架的硬覆盖。**"亲和绕过健康"(现状 A1)在新模型里结构性不存在**:病渠道权重低,HRW 自动绕开。
|
||||
|
||||
### 3.2 复合权重
|
||||
|
||||
```
|
||||
weight(c, r) = baseWeight(c)
|
||||
× health(c) ∈ [0,1] §5 健康分(含熔断器 gating)
|
||||
× capacityHeadroom(c) ∈ (0,1] §6 = 1 − inFlight/softLimit
|
||||
× costFactor(c, r) ∈ (0,1] §8 同模型优先便宜上游
|
||||
× tierBias(c) ∈ (0,1] 首选 1.0 / 备用 β₂ / 保底 β₃
|
||||
× rampFactor(c) ∈ (0,1] 新渠道/恢复渠道灰度爬坡
|
||||
```
|
||||
|
||||
任一因子为 0(如熔断 OPEN → `health=0`)即该渠道本轮不参与。
|
||||
|
||||
### 3.3 绑定守卫(防抖)
|
||||
|
||||
权重会随健康/负载漂移,纯 argmax 可能在两个相近渠道间反复横跳。引入 hysteresis:
|
||||
|
||||
1. 会话**首次**请求:加权 HRW 选号 → 把绑定写入 `AffinityStore`(带 TTL)。
|
||||
2. **后续**请求:若绑定存在且被绑渠道 `weight ≥ keepThreshold` → 直接复用(粘性)。
|
||||
3. 若被绑渠道 `weight < keepThreshold`(病了/满了)→ 重跑 HRW,重新绑定。
|
||||
|
||||
结果:**健康时绝对粘,退化时优雅迁移并重新粘住**,不抖。`keepThreshold` 默认 `0.3 × baseWeight`,可在 routing_policy 配置。
|
||||
|
||||
---
|
||||
|
||||
## 4. 亲和子系统(决策 3)
|
||||
|
||||
### 4.1 会话令牌解析
|
||||
|
||||
亲和单位 = **会话**;身份 (tenant:user:key:model) 是**命名空间**。令牌按优先级取第一个非空:
|
||||
|
||||
```
|
||||
sessionToken =
|
||||
1) request.Header["X-Session-Id"] // 自研 SDK 显式指定,完全可控
|
||||
2) body.previous_response_id // OpenAI Responses API 链式对话
|
||||
3) body.conversation_id // Coze / Dify / Assistants thread
|
||||
4) nil → 身份级回退(不做对话指纹,决策 3)
|
||||
```
|
||||
|
||||
> Claude Code(Anthropic Messages API)、OpenAI Chat Completions 协议无 session id,且不做指纹 → **走身份级粘性**。其"单用户多会话可能先挤一个渠道"的担忧由 §6 负载溢出兜住:渠道打满 → `capacityHeadroom→0` → weight 跌破 `keepThreshold` → HRW 自动溢到下一渠道并重新粘住。
|
||||
|
||||
### 4.2 存储(Redis)
|
||||
|
||||
沿用现有 `relay:affinity:v2` 的双向结构,重命名到新前缀:
|
||||
|
||||
| 用途 | Key | 值 |
|
||||
|---|---|---|
|
||||
| 会话 → 渠道绑定 | `sched:aff:{tenantID}:{model}:{sessionTokenHash}` | `channelID`(string,TTL = affinity_ttl,默认 1800s) |
|
||||
| 渠道 → 绑定集合(失效用) | `sched:affindex:{channelID}` | SET of 绑定 key(TTL = affinity_ttl + 60s) |
|
||||
|
||||
- 绑定写入用现有 Lua 脚本模式(先清旧反向索引、再 SETEX + SADD)。
|
||||
- `InvalidateChannel(channelID)`:读反向集合,逐个 DEL 绑定 key(渠道禁用/下线时调用,已有 `InvalidateChannelAffinities`)。
|
||||
- 令牌 hash 用 SHA-256 截断,避免长 header/previous_response_id 直接当 key。
|
||||
|
||||
### 4.3 接口
|
||||
|
||||
```go
|
||||
type AffinityStore interface {
|
||||
Get(ctx context.Context, ns AffinityNamespace, token string) (channelID int64, ok bool)
|
||||
Bind(ctx context.Context, ns AffinityNamespace, token string, channelID int64, ttl time.Duration)
|
||||
InvalidateChannel(ctx context.Context, channelID int64) int // 返回清除的绑定数
|
||||
}
|
||||
type AffinityNamespace struct{ TenantID, UserID, ApiKeyID int64; Model string }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 健康子系统(决策 1、4)
|
||||
|
||||
### 5.1 模型:熔断器 × 连续评分
|
||||
|
||||
每个渠道一个**熔断器** + 一个**连续健康分**,全部 Redis 原子维护。
|
||||
|
||||
| 状态 | 含义 | 权重影响 |
|
||||
|---|---|---|
|
||||
| `CLOSED` | 健康 | `health(c)` ∈ [0,1] 正常调节权重 |
|
||||
| `OPEN` | 故障 | `health(c) = 0`,跳过 |
|
||||
| `HALF_OPEN` | 探活中 | 仅放行探测请求(见 5.4),其余 `health=0` |
|
||||
|
||||
转移:
|
||||
- `CLOSED → OPEN`:滑动窗口内错误率 > `cb_error_rate`(默认 0.5),或连续失败 ≥ `cb_consecutive`(默认 5)。
|
||||
- `OPEN → HALF_OPEN`:OPEN 持续超过 `cb_cooldown`(默认 30s),惰性判定(下次访问时按 `opened_at` 计算)。
|
||||
- `HALF_OPEN → CLOSED`:探测请求成功。
|
||||
- `HALF_OPEN → OPEN`:探测请求失败,重置 `opened_at`。
|
||||
|
||||
### 5.2 连续健康分
|
||||
|
||||
```
|
||||
health(c) = 0.40·successScore + 0.25·latencyScore + 0.20·stabilityScore + 0.15·errorDistScore
|
||||
```
|
||||
|
||||
- `successScore`:EWMA 成功率(0–100 → 归一到 0–1)。
|
||||
- `latencyScore`:<1s=100, 1–3s=80→50, 3–10s=50→20, >10s=20(沿用现有 `calcLatencyScore` 分段)。
|
||||
- `stabilityScore`:延迟波动 EWMA(沿用现有 `calcStability`)。
|
||||
- `errorDistScore`:按错误类别加权(auth 错误扣分 > 5xx > 429),近因衰减。
|
||||
|
||||
> 该公式在现有 `health.go` 基础上保留权重配比,但**计算移入 Lua 脚本内原子完成**,所有输入因子都在同一 hash 里,score 永远与输入一致——彻底消除现状 `UpdateHealthScore` 的读改写竞态(H1)。
|
||||
|
||||
### 5.3 Redis 数据结构
|
||||
|
||||
Key:`sched:health:{channelID}`(hash)
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `success_rate` | EWMA 成功率 |
|
||||
| `latency` | EWMA 延迟(ms) |
|
||||
| `stability` | 稳定性分 |
|
||||
| `err_4xx_auth` / `err_5xx` / `err_429` | 错误类别计数(衰减) |
|
||||
| `consecutive_fail` | 连续失败数 |
|
||||
| `health_score` | 综合健康分(Lua 内重算) |
|
||||
| `state` | `CLOSED` / `OPEN` / `HALF_OPEN` |
|
||||
| `opened_at` | 进入 OPEN 的时间戳 |
|
||||
| `probe_owner` | 当前 HALF_OPEN 探测请求的 token(自然探活用) |
|
||||
| `updated_at` | 最近更新时间 |
|
||||
|
||||
### 5.4 Lua 脚本(参考实现)
|
||||
|
||||
**OBSERVE**(请求结束时调用,原子更新 EWMA + 重算 health + 熔断转移):
|
||||
|
||||
```lua
|
||||
-- KEYS[1] = sched:health:{channelID}
|
||||
-- ARGV = [alpha, success(0/1), latencyMs, errClass, now,
|
||||
-- cb_consecutive, cb_cooldown_ms, affinity_ttl]
|
||||
local h = KEYS[1]
|
||||
local alpha = tonumber(ARGV[1])
|
||||
local success = ARGV[2] == '1'
|
||||
local lat = tonumber(ARGV[3])
|
||||
local errClass = ARGV[4]
|
||||
local now = tonumber(ARGV[5])
|
||||
local cbCons = tonumber(ARGV[6])
|
||||
local cbCool = tonumber(ARGV[7])
|
||||
|
||||
local state = redis.call('HGET', h, 'state') or 'CLOSED'
|
||||
local opened = tonumber(redis.call('HGET', h, 'opened_at') or '0')
|
||||
|
||||
-- OPEN -> HALF_OPEN 惰性转移
|
||||
if state == 'OPEN' and (now - opened) >= cbCool then
|
||||
state = 'HALF_OPEN'
|
||||
end
|
||||
|
||||
local sr = tonumber(redis.call('HGET', h, 'success_rate')) or 100
|
||||
local cf = tonumber(redis.call('HGET', h, 'consecutive_fail')) or 0
|
||||
local latE = tonumber(redis.call('HGET', h, 'latency')) or lat
|
||||
|
||||
if success then
|
||||
sr = sr * alpha + 100 * (1 - alpha)
|
||||
latE = latE * alpha + lat * (1 - alpha)
|
||||
cf = 0
|
||||
if state == 'HALF_OPEN' then state = 'CLOSED' end -- 探活成功
|
||||
else
|
||||
sr = sr * alpha -- 失败降成功率
|
||||
cf = cf + 1
|
||||
if state == 'HALF_OPEN' then
|
||||
state = 'OPEN'; opened = now -- 探活失败,重新熔断
|
||||
elseif cf >= cbCons then
|
||||
state = 'OPEN'; opened = now -- 连续失败熔断
|
||||
end
|
||||
end
|
||||
|
||||
-- 重算 health_score(所有因子已在 hash 中,可一并算出)
|
||||
local score = ComputeHealth(sr, latE, ...) -- 伪码:同 §5.2 公式
|
||||
|
||||
redis.call('HMSET', h,
|
||||
'success_rate', sr, 'latency', latE, 'consecutive_fail', cf,
|
||||
'health_score', score, 'state', state, 'opened_at', opened,
|
||||
'updated_at', now)
|
||||
return redis.call('HMGET', h, 'state', 'health_score')
|
||||
```
|
||||
|
||||
**PROBE_ACQUIRE**(HALF_OPEN 自然探活闸门,每窗口放行 1 个真实请求):
|
||||
|
||||
```lua
|
||||
-- KEYS[1] = sched:health:{channelID}
|
||||
-- ARGV = [now, probe_window_ms, probe_token]
|
||||
local h = KEYS[1]
|
||||
local state = redis.call('HGET', h, 'state') or 'CLOSED'
|
||||
if state ~= 'HALF_OPEN' then return 0 end -- 非 HALF_OPEN 不放行
|
||||
local now = tonumber(ARGV[1])
|
||||
local win = tonumber(ARGV[2])
|
||||
local owner = redis.call('HGET', h, 'probe_owner_at') or '0'
|
||||
if (now - tonumber(owner)) < win then return 0 end -- 窗口内已放过
|
||||
redis.call('HMSET', h, 'probe_owner', ARGV[3], 'probe_owner_at', now)
|
||||
return 1 -- 放行该探测请求
|
||||
```
|
||||
|
||||
> 被放行的探测请求走真实业务路径;其结果仍通过 OBSERVE 上报,OBSERVE 内的 HALF_OPEN 分支负责转 CLOSED 或重回 OPEN。
|
||||
|
||||
### 5.5 DB 快照
|
||||
|
||||
`chn_health_scores` 表保留,但**降级为周期快照**:一个后台任务每 N 秒把 Redis hash 落盘,仅供管理后台仪表盘/审计。路由决策不读此表。
|
||||
|
||||
---
|
||||
|
||||
## 6. 负载子系统:优雅溢出
|
||||
|
||||
### 6.1 实时并发占用
|
||||
|
||||
沿用 `internal/logic/relay/capacity.go` 的 Redis ZSET(requestID → 时间戳,30s TTL 续租)。`inFlight(c) = ZCARD sched:load:{channelID}`,跨实例实时。
|
||||
|
||||
### 6.2 软上限 softLimit
|
||||
|
||||
```
|
||||
softLimit(c) = manual_max_concurrency 若已显式配置
|
||||
否则 = 自动推断值
|
||||
```
|
||||
|
||||
**自动推断**:观察到渠道在 `inFlight ≈ X` 时开始返回 429 → `softLimit` EWMA 收敛到 `X × safety`(safety 默认 0.9)。存于 `sched:loadmeta:{channelID}`(hash: `manual_limit`, `auto_limit`, `updated_at`)。运营不填 `max_concurrency` 时溢出仍可工作。
|
||||
|
||||
### 6.3 头余量与溢出
|
||||
|
||||
```
|
||||
capacityHeadroom(c) = clamp(1 − inFlight(c)/softLimit(c), 0, 1)
|
||||
```
|
||||
|
||||
- 正常(首选空闲、`tierBias=1.0`):首选权重碾压备用(`tierBias=0.1`),流量基本只打首选。
|
||||
- 首选被打满:`capacityHeadroom→0` → 有效权重下降 → 备用相对权重上升 → 新会话**自动溢到备用**。
|
||||
- 首选全挂(`health=0`):全量 failover 到备用。
|
||||
|
||||
> **failover(健康驱动)与 spillover(负载驱动)是同一权重机制的两种表现**,替代现状"最高优先级组独占 + 失败才被动触达备用"(L2)。
|
||||
|
||||
### 6.4 接口
|
||||
|
||||
```go
|
||||
type LoadGauge interface {
|
||||
InFlight(ctx context.Context, channelID int64) (int, error)
|
||||
SoftLimit(ctx context.Context, channelID int64) (int, error)
|
||||
// Acquire/Release 复用现有 capacity.go 的 slot 机制(ZADD/ZREM + 续租)
|
||||
Acquire(ctx context.Context, channelID, max int, requestID string) bool
|
||||
Release(ctx context.Context, channelID int64, requestID string)
|
||||
// 429 观测,驱动 softLimit 自动推断
|
||||
ObserveRejection(ctx context.Context, channelID int64, inFlightAtReject int)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 失败处理:FSM + 错误分类器
|
||||
|
||||
### 7.1 错误分类
|
||||
|
||||
| 类别 | 典型 | 语义 |
|
||||
|---|---|---|
|
||||
| `TRANSIENT` | 429, 502, 503 | 上游瞬时,大概率 ms 级恢复 |
|
||||
| `NETWORK` | timeout, EOF, RST, DNS | 网络抖动,可能未送达 |
|
||||
| `CHANNEL_FATAL` | 401, 403, 404, SSL | 渠道对该请求已不可用 |
|
||||
| `CLIENT` | 400 | 客户端错误,重试无意义 |
|
||||
| `PARTIAL_STREAM` | 流已部分写入 | 不可重试(会污染响应) |
|
||||
|
||||
分类器输入:HTTP 状态码 + 错误类型 + `IsResponseWritten`/`IsPartialStreamEnd` + relayMode(非幂等生成特判)。复用现有 `constant.IsRetryable` / `IsRetryableForRequest(expensiveGen)` 逻辑,提升为显式分类表。
|
||||
|
||||
### 7.2 状态机
|
||||
|
||||
```
|
||||
┌────────────────────────────┐
|
||||
▼ │
|
||||
SELECT ──► ATTEMPT ──► outcome │
|
||||
│ │
|
||||
├ SUCCESS ──► Observe(✓) ──► DONE │
|
||||
│ │
|
||||
├ TRANSIENT / NETWORK (可重试) │
|
||||
│ └ 非幂等生成的"可能已送达"网络错误 → DONE(✗)
|
||||
│ └ 幂等 → Backoff ──► ATTEMPT(同渠道) 原地预算 k
|
||||
│ │
|
||||
├ TRANSIENT(原地耗尽) / CHANNEL_FATAL │
|
||||
│ └ Failover ──► SELECT(exclude) 换渠道预算 m
|
||||
│ │
|
||||
└ CLIENT / PARTIAL_STREAM / 全耗尽 ──► DONE(✗)
|
||||
```
|
||||
|
||||
- **原地重试**(同渠道,修 R2):`TRANSIENT`/`NETWORK` 先在原渠道退避重试,预算 `k`(默认 2)。429 强制 honor `Retry-After`。非幂等生成(图片/视频)的"可能已送达"网络错误**不重试**,避免重复计费。
|
||||
- **退避**(修 R1):`backoff(n) = min(cap, base·2ⁿ)·(1 + jitter)`,`base=120ms`、`cap=2s`。
|
||||
- **failover**(换渠道):`CHANNEL_FATAL` 立即换;原地预算耗尽才换。换渠道时**不清亲和**,只让被绑渠道健康降权——亲和绑定靠 §3.3 `keepThreshold` 守卫自然失效(修 A3)。
|
||||
- **预算分离**:`k`(原地)与 `m`(failover)独立计数,原地震荡不会耗光换渠道的机会。
|
||||
|
||||
### 7.3 Scheduler 接口(失败决策)
|
||||
|
||||
```go
|
||||
type Scheduler interface {
|
||||
// 初次选号
|
||||
Select(ctx context.Context, req *SelectRequest) (*Decision, error)
|
||||
// 失败后决策:原地重试 / 换渠道 / 终止。同渠道则返回 sameChannel=true
|
||||
Next(ctx context.Context, req *SelectRequest, prev *Outcome) (*Decision, error)
|
||||
// 异步回报结果,更新健康/亲和/负载
|
||||
Observe(ctx context.Context, channelID int64, o Outcome)
|
||||
}
|
||||
|
||||
type Outcome struct {
|
||||
Success bool
|
||||
ErrClass ErrClass
|
||||
Err error
|
||||
LatencyMs float64
|
||||
RetryAfterMs int64 // 429 的 Retry-After
|
||||
}
|
||||
```
|
||||
|
||||
Executor(handler)的职责收敛为:`Select → HTTP → Observe`;失败时 `Next` 决定下一步,handler 只执行返回的决策(同渠道重试或 exclude 后重新 Select)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 成本感知(决策 2)
|
||||
|
||||
```go
|
||||
type CostCatalog interface {
|
||||
// 返回渠道对该模型的上游单价(USD per 1M token 或统一折算),decimal 精确
|
||||
UnitCost(ctx context.Context, channelID int64, model string) (decimal.Decimal, error)
|
||||
}
|
||||
```
|
||||
|
||||
`costFactor(c, r)` 在 Selector 内归一化:同模型所有合格渠道按单价升序,最便宜的 `costFactor=1.0`,其余按 `cheapest/this_cost` 递减(夹到 `(0,1]`)。
|
||||
|
||||
- 运营在 `chn_channels` 维护每渠道实际上游单价(可批量导入)。
|
||||
- 未填单价的渠道 `costFactor=1.0`(不参与成本优化,向后兼容)。
|
||||
- 单价属于计费层(USD),与 [`CLAUDE.md`](../../CLAUDE.md) 三层币种规则一致。
|
||||
|
||||
---
|
||||
|
||||
## 9. 端口与模块边界
|
||||
|
||||
### 9.1 端口汇总
|
||||
|
||||
| 端口 | 职责 | adapter | 存储 |
|
||||
|---|---|---|---|
|
||||
| `ChannelCatalog` | 合格候选渠道集(带内存缓存 + 失效) | `PostgresChannelCatalog` | PG + 进程内缓存 |
|
||||
| `HealthStore` | 健康分 + 熔断器状态 | `RedisHealthStore` | Redis hash + Lua |
|
||||
| `LoadGauge` | 并发占用 + softLimit | `RedisLoadGauge` | Redis ZSET |
|
||||
| `AffinityStore` | 会话绑定 + 失效 | `RedisAffinityStore` | Redis |
|
||||
| `CostCatalog` | 渠道单价 | `PostgresCostCatalog` | PG + 缓存 |
|
||||
|
||||
### 9.2 ChannelCatalog 缓存(修 P1)
|
||||
|
||||
`PostgresChannelCatalog` 维护进程内候选集缓存:
|
||||
- Key:`(model, tenantChannelScope)`;TTL 默认 10s。
|
||||
- 失效:渠道/能力/状态变更时主动 invalidate(接入现有 admin 写操作后的失效钩子)。
|
||||
- 消除现状"单请求最多 4 次 DB 查询"——重试循环复用缓存,不再每次重查。
|
||||
|
||||
### 9.3 Selector 主流程
|
||||
|
||||
```go
|
||||
func (s *Scheduler) Select(ctx, req) (*Decision, error) {
|
||||
cands, _ := s.catalog.Eligible(ctx, req.Model, req.Scope) // 缓存命中零 DB
|
||||
cands = filterExcluded(cands, req.Exclude)
|
||||
|
||||
// 亲和绑定守卫
|
||||
if ch, ok := s.affinity.Get(ns, req.SessionToken); ok {
|
||||
if w := s.effectiveWeight(ctx, ch, req); w >= s.policy.KeepThreshold {
|
||||
return decide(ch, "affinity-hit"), nil
|
||||
}
|
||||
}
|
||||
|
||||
// 加权 HRW
|
||||
pick := weightedRendezvous(cands, req.SessionToken, func(c) float64 {
|
||||
return s.effectiveWeight(ctx, c, req)
|
||||
})
|
||||
s.affinity.Bind(ns, req.SessionToken, pick.ID, s.policy.AffinityTTL)
|
||||
return decide(pick, reason(pick)), nil
|
||||
}
|
||||
```
|
||||
|
||||
`effectiveWeight` 即 §3.2 公式,读 HealthStore + LoadGauge + CostCatalog 计算。
|
||||
|
||||
---
|
||||
|
||||
## 10. 数据模型与迁移
|
||||
|
||||
### 10.1 `chn_channels` 新增字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `cost_tier` | `NUMERIC(20,10)` NULL | 渠道成本档位/单价(USD),供 costFactor;NULL = 不参与成本优化 |
|
||||
| `gray_release` | `SMALLINT` DEFAULT 100 | 灰度比例 0–100,驱动 `rampFactor`(0=禁用,100=全量) |
|
||||
| `soft_limit_override` | `INT` NULL | 手动 softLimit 覆盖;NULL = 自动推断 |
|
||||
|
||||
> 现有 `priority`、`weight`、`max_concurrency`、`status` 保留。`max_concurrency` 语义改为"硬上限"(slot 租约仍用它做硬熔断),`soft_limit_override` 作为溢出用的软上限。
|
||||
|
||||
### 10.2 `chn_health_scores` 降级
|
||||
|
||||
- 字段不变,但**写入方改为后台快照任务**(从 Redis 落盘),路由不再读写。
|
||||
- 新增 `snapshot_at TIMESTAMPTZ` 标记快照时间。
|
||||
|
||||
### 10.3 `routing_policy` 配置
|
||||
|
||||
存于 `sys_options`(key = `routing_policy`,JSON)。全局默认 + 租户覆盖(`sys_options` 的 tenant 维度,或 `mdl_tenant_models` 扩展字段,二选一在实现期定)。热加载(监听变更或短 TTL 缓存)。
|
||||
|
||||
```yaml
|
||||
routing_policy:
|
||||
affinity_scope: explicit # 仅显式信号(决策 3)
|
||||
affinity_ttl_seconds: 1800
|
||||
keep_threshold_ratio: 0.3 # keepThreshold = ratio × baseWeight
|
||||
in_place_retry_budget: 2 # k
|
||||
failover_budget: 3 # m
|
||||
backoff: { base_ms: 120, cap_ms: 2000 }
|
||||
spillover: { safety: 0.9 }
|
||||
circuit_breaker:
|
||||
error_rate: 0.5
|
||||
consecutive: 5
|
||||
cooldown_ms: 30000
|
||||
probe_window_ms: 10000
|
||||
tier_bias: { primary: 1.0, secondary: 0.1, fallback: 0.01 }
|
||||
cost_aware: true # 决策 2
|
||||
```
|
||||
|
||||
> 替代现状散落的 `channel_affinity_enabled` / `channel_affinity_ttl_seconds` / `channel_scheduler_v2_enabled` / `channel_auto_disable_*` 等布尔开关。**单一路径、版本化、可回滚、热加载**(修 O1)。
|
||||
|
||||
### 10.4 迁移脚本
|
||||
|
||||
按现有 `migrations/` 六位序号递增(不在此固定编号)。预计 2 个迁移:
|
||||
1. `chn_channels` 加字段(`cost_tier` / `gray_release` / `soft_limit_override`)。
|
||||
2. `chn_health_scores` 加 `snapshot_at`;`sys_options` 写入默认 `routing_policy`。
|
||||
|
||||
迁移须可重复执行(`up/down` 幂等,不破坏数据)。
|
||||
|
||||
---
|
||||
|
||||
## 11. 可观测性
|
||||
|
||||
每个 `Decision` 输出得分分解,写入转发 trace(扩展现有 `common.ForwardingTrace`):
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_id": 12,
|
||||
"reason": "affinity-hit | weighted | spillover | failover | probe",
|
||||
"scores": {
|
||||
"12": {"health": 0.92, "headroom": 0.71, "cost": 1.0, "tier": 1.0, "ramp": 1.0, "effective": 0.65},
|
||||
"13": {"health": 0.88, "headroom": 0.30, "cost": 0.7, "tier": 0.1, "ramp": 1.0, "effective": 0.018}
|
||||
},
|
||||
"circuit": {"12": "CLOSED", "13": "CLOSED"},
|
||||
"affinity": {"token_source": "header", "bound": 12, "honored": true},
|
||||
"hops": [{"attempt": 0, "channel": 12, "outcome": "TRANSIENT", "backoff_ms": 240}, ...]
|
||||
}
|
||||
```
|
||||
|
||||
熔断状态变化(CLOSED↔OPEN↔HALF_OPEN)发结构化日志 + 可选告警(接入现有通知引擎)。没有这个,权重/阈值全是黑盒,无法调参。
|
||||
|
||||
---
|
||||
|
||||
## 12. 降级与容错
|
||||
|
||||
| 故障 | 降级行为 |
|
||||
|---|---|
|
||||
| Redis 不可用(健康/负载读) | 用进程内 last-known 快照(最近一次成功的 HealthStore/LoadGauge 结果,短 TTL);快照缺失则 `health=1, headroom=1`(退化为基础权重) |
|
||||
| Redis 不可用(亲和) | 跳过亲和守卫,直接走加权 HRW(seed 仍由会话令牌决定,粘性大体保留) |
|
||||
| Redis 不可用(熔断) | 退化为"无熔断",靠连续失败由 failover 预算兜底;不允许把已熔断渠道当健康放行 |
|
||||
| `ChannelCatalog` 缓存 miss 且 DB 慢 | 复用 last-known 候选集;全空则返回 `ErrChannelUnavailable` |
|
||||
| softLimit 自动推断缺失 | `capacityHeadroom=1`(不溢出),等同现状的优先级行为,安全降级 |
|
||||
|
||||
---
|
||||
|
||||
## 13. 分阶段实施计划
|
||||
|
||||
每阶段独立可上线、可回滚。建议在 feature flag(routing_policy 内的 `scheduler_engine: v2`)后灰度,验证完毕移除。
|
||||
|
||||
| 阶段 | 内容 | 解决的问题 | 行为变化 |
|
||||
|---|---|---|---|
|
||||
| **P1 解耦 + 端口地基** | 定义 §9 端口接口;把现有 health/affinity/capacity 逻辑搬到 adapter 后;Scheduler 纯逻辑骨架 + fake store 单测;`ChannelCatalog` 内存缓存 | P1、(为后续铺路) | **无**(纯重构) |
|
||||
| **P2 Redis 化健康 + 熔断器** | `RedisHealthStore` + Lua OBSERVE/PROBE_ACQUIRE;熔断 FSM;DB 降级快照 | H1、H2 | 有(需灰度观察健康分) |
|
||||
| **P3 重试 FSM + 退避 + 亲和守卫** | 错误分类器 + RetryFSM;原地/failover 预算;退避;`keepThreshold` 守卫替代无条件清亲和 | A3、R1、R2 | 有 |
|
||||
| **P4 加权 HRW + 溢出 + 成本 + 灰度** | Selector 上复合权重 + tierBias + capacityHeadroom + costFactor + rampFactor;softLimit 自动推断 | A1、A2、L2 | 有(路由分布变化,需观察) |
|
||||
| **P5 收尾** | 会话令牌解析(header/previous_response_id/conversation_id);routing_policy 热加载;删除 legacy 路径与 `channel_scheduler_v2_enabled` | L3、O1 | 无 |
|
||||
|
||||
> P1 是纯重构、零行为变化、风险最低,建议作为第一个可评审/可合入的交付。完成 P1 后,P2–P5 都有可测地基。
|
||||
|
||||
---
|
||||
|
||||
## 14. 与现状问题的对应关系
|
||||
|
||||
| 现状问题 | 严重度 | 新设计如何解决 |
|
||||
|---|---|---|
|
||||
| **A1** 亲和命中绕过健康度 | 🔴 | 粘性是 HRW 涌现属性,病渠道权重低自动绕开(§3.1) |
|
||||
| **A2** 亲和仅在最高优先级组内 | 🟡 | 选择在全候选池做,亲和跨优先级生效(§3、§9.3) |
|
||||
| **A3** 任何可重试错误清亲和 | 🔴 | 失败不清亲和,靠 `keepThreshold` 守卫自然失效(§3.3、§7.2) |
|
||||
| **R1** 重试无退避 | 🟡 | 指数退避 + jitter + honor Retry-After(§7.2) |
|
||||
| **R2** 瞬时错误直接换渠道 | 🟡 | 两级重试:TRANSIENT/NETWORK 先原地(§7.2) |
|
||||
| **H1** 健康度读改写竞态 | 🔴 | Lua 原子 EWMA,结构上无竞态(§5.4) |
|
||||
| **H2** 失败路径同步写健康 | 🟡 | `Observe` 异步,EWMA 在 Redis 原子完成(§5、§7.3) |
|
||||
| **P1** 选号无缓存每次查 DB | 🟡 | `ChannelCatalog` 内存缓存 + 失效(§9.2) |
|
||||
| **O1** 双路径/flag 脚枪 | 🔴 | 单一路径 + 版本化 routing_policy(§10.3、P5) |
|
||||
| **L2** 无优先级溢出 | ⚪ | capacityHeadroom 软降权实现优雅溢出(§6.3) |
|
||||
| **L3** 亲和无会话维度 | ⚪ | 会话令牌(显式信号)+ 身份级回退(§4) |
|
||||
|
||||
---
|
||||
|
||||
## 15. 开放问题(实现期再定)
|
||||
|
||||
1. **routing_policy 租户覆盖存储位置**:`sys_options` tenant 维度 vs `mdl_tenant_models` 扩展字段——实现 P5 时定。
|
||||
2. **对话指纹的二次评估**:决策 3 暂不做。若上线后发现 Claude Code 用户因身份级粘性出现渠道倾斜,可在此文档增补指纹方案(hash(system + 首条 user))作为可配置开关。
|
||||
3. **softLimit 自动推断的收敛速度与安全系数**:需用真实 429 数据标定 `safety` 默认值。
|
||||
4. **灰度 flag 的移除时机**:P2–P4 各自灰度验证通过后再推进下一阶段,全量稳定后 P5 移除 flag。
|
||||
|
||||
---
|
||||
|
||||
## 参考
|
||||
|
||||
- 现状问题清单:[`channel-scheduling-issues.md`](./channel-scheduling-issues.md)
|
||||
- 现状对比分析:[`channel-scheduling-comparison.md`](./channel-scheduling-comparison.md)
|
||||
- 决策记忆:`channel-scheduling-redesign-decisions`(项目 memory)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,254 +0,0 @@
|
||||
# new-api 模型调度策略分析
|
||||
|
||||
> **用途**:参考项目 `new-api`(QuantumNous/new-api,Gin + GORM 实现)的渠道调度、亲和性、优先级、失败重试机制的实现级分析,供 team-api 的 `relay/scheduler/`、`internal/logic/relay/`、`chn_channel_affinities` 等模块设计与对照参考。
|
||||
>
|
||||
> **路径约定**:本文所有 `file:line` 引用均相对于 **new-api 参考项目根目录**。
|
||||
> **分析时间**:2026-07-30。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [核心数据结构:三层映射](#一核心数据结构三层映射)
|
||||
2. [模型命中多个渠道时怎么分配](#二模型命中多个渠道时怎么分配先按优先级分层层内加权随机)
|
||||
3. [优先级与权重怎么设置](#三优先级与权重怎么设置)
|
||||
4. [渠道亲和性(Channel Affinity)](#四渠道亲和性channel-affinity规则驱动默认关闭为-prompt-cache-服务)
|
||||
5. [失败重试机制](#五优先渠道失败了会一直重试剩下的渠道吗不会有界且多重门控)
|
||||
6. [调度全流程图](#六调度全流程图)
|
||||
7. [对 team-api 的借鉴提示](#七对-team-api-的借鉴提示)
|
||||
|
||||
---
|
||||
|
||||
## 一、核心数据结构:三层映射
|
||||
|
||||
new-api 把「哪个分组(group)的哪个模型(model)能由哪些渠道(channel)服务」预计算成一张内存映射,定时从 DB 同步(`model/channel_cache.go:19`):
|
||||
|
||||
```go
|
||||
var group2model2channels map[string]map[string][]int // enabled channel
|
||||
```
|
||||
|
||||
启动时 `InitChannelCache`(`channel_cache.go:26`)把所有 `enabled` 渠道按 `group → model → []channelId` 建好索引,并且**在每个 model 的渠道列表里按 `Priority` 降序排好**(`channel_cache.go:70-77`)。这是后面「优先级分层」的基础。
|
||||
|
||||
每个渠道有两个关键字段(`model/channel.go:483`):
|
||||
|
||||
- **`Priority`(int64)** —— 决定**优先级层(先试哪一层)**
|
||||
- **`Weight`(int)** —— 决定**同一层内被选中的概率(权重)**
|
||||
|
||||
---
|
||||
|
||||
## 二、模型命中多个渠道时怎么分配:先按优先级分层,层内加权随机
|
||||
|
||||
核心函数 `GetRandomSatisfiedChannel(group, model, retry, requestPath)`(`model/channel_cache.go:114`)。算法分两步。
|
||||
|
||||
### 第 1 步:确定本次落在哪个优先级层(`retry` 当层索引用)
|
||||
|
||||
```go
|
||||
// channel_cache.go:143-160
|
||||
uniquePriorities := ... // 去重该 model 的所有优先级
|
||||
sort.Sort(sort.Reverse(...)) // 降序
|
||||
if retry >= len(uniquePriorities) {
|
||||
retry = len(uniquePriorities) - 1 // 越界则钳制到最低层
|
||||
}
|
||||
targetPriority := sortedUniquePriorities[retry] // retry=0→最高层, retry=1→次高层 ...
|
||||
```
|
||||
|
||||
> **关键点:`retry` 参数直接当作优先级层的下标。** 第 0 次尝试取最高优先级层,失败重试到 `retry=1` 取下一层…… 这意味着「重试次数」和「优先级层数」是同一个计数器,不是两个独立维度。
|
||||
|
||||
### 第 2 步:在该层内做加权随机
|
||||
|
||||
```go
|
||||
// channel_cache.go:162-208
|
||||
for 渠道 in targetPriority层 {
|
||||
sumWeight += channel.GetWeight()
|
||||
}
|
||||
// 平滑处理
|
||||
if sumWeight == 0 { // 全是 weight 0 → 每个等权(有效权重 100)
|
||||
sumWeight = len*100; smoothingAdjustment = 100
|
||||
} else if sumWeight/len < 10 { // 平均权重 < 10 → 放大 100 倍,减少精度偏差
|
||||
smoothingFactor = 100
|
||||
}
|
||||
randomWeight := rand.Intn(totalWeight)
|
||||
for 渠道 in 层 {
|
||||
randomWeight -= 权重; if randomWeight < 0 { return 该渠道 }
|
||||
}
|
||||
```
|
||||
|
||||
**结论:分配策略 = 严格的优先级分层 + 层内加权随机。** 不会跨层混合;高优先级层只要有渠道,就一定先用完它才会降级。
|
||||
|
||||
DB 路径(关闭内存缓存时)走 `GetChannel`(`model/ability.go:108`),`getPriority`(`ability.go`)和 `getChannelQuery`(`ability.go:93`)用 SQL 做完全等价的事:`MAX(priority)` 子查询定位层,`weight DESC` 排序后 `weight+10` 加权随机——逻辑一致。
|
||||
|
||||
### 路径过滤(Advanced Custom 渠道)
|
||||
|
||||
`filterChannelsByRequestPathAndModel`(`channel_cache.go:216`):只有 type 58(Advanced Custom)渠道会按 `requestPath + model` 过滤,其它类型一律放行。这允许同一渠道的不同路由服务于不同路径。
|
||||
|
||||
---
|
||||
|
||||
## 三、优先级与权重怎么设置
|
||||
|
||||
在管理后台编辑渠道时直接填 `优先级` 和 `权重` 两个字段,写入 `abilities` 表(`group, model, channel_id, priority, weight, enabled`)。
|
||||
|
||||
**设计语义**:
|
||||
|
||||
| 场景 | 配置方式 |
|
||||
|------|---------|
|
||||
| 主备渠道(A 挂了才用 B) | A 设高优先级、B 设低优先级 |
|
||||
| 负载均衡(多渠道分流) | 相同优先级,用 **weight** 控制比例(weight 2:1 ≈ 2/3 vs 1/3 流量) |
|
||||
|
||||
weight 的平滑(`+10` 或 `×100`)保证 weight 配得很小(甚至 0)时仍能正常随机,不会因权重过小导致选不出来。
|
||||
|
||||
---
|
||||
|
||||
## 四、渠道亲和性(Channel Affinity):规则驱动、默认关闭、为 prompt cache 服务
|
||||
|
||||
这是 new-api 相对 one-api 的**重要增强**,不是默认开的。代码在 `service/channel_affinity.go`,配置在 `operation_setting.GetChannelAffinitySetting()`。
|
||||
|
||||
**它解决的问题不是「会话粘性」,而是「上游 prompt cache 命中率」。** 把同一个用户/key 固定路由到同一渠道,让上游供应商(OpenAI/Anthropic)的 KV/prompt 缓存能命中,省钱。所以代码里专门统计 `cached_tokens`、`prompt_cache_hit_tokens`(`channel_affinity.go:742-877`)。
|
||||
|
||||
### 它是规则匹配,不是无脑粘
|
||||
|
||||
管理员配多条规则,每条规则含:
|
||||
|
||||
- `ModelRegex` / `PathRegex` / `UserAgentInclude` —— 命中条件
|
||||
- `KeySources` —— 亲和性的「主键」取值来源,可从 `context_int`、`context_string`、`request_header`、`gjson`(请求体 JSON path)四种地方取(`channel_affinity.go:289-335`)
|
||||
- `TTLSeconds`、`SkipRetryOnFailure`、`ParamOverrideTemplate`
|
||||
- `IncludeRuleName` / `IncludeModelName` / `IncludeUsingGroup`(决定是否参与缓存 key)
|
||||
|
||||
缓存 key = `namespace:ruleName(:model)(:group):affinityValue`(`channel_affinity.go:337-350`),`affinityValue` 还会算个 SHA1 指纹存日志,不泄露原值。
|
||||
|
||||
### 分发流程(`middleware/distributor.go:105-133`)
|
||||
|
||||
在随机选渠道**之前**先查亲和缓存:
|
||||
|
||||
```go
|
||||
if preferredChannelID, found := service.GetPreferredChannelByAffinity(...); found {
|
||||
// 命中亲和缓存 → 仍要校验:渠道存在 + 启用 + 支持该 requestPath
|
||||
if 校验通过 { 直接用 preferred; MarkChannelAffinityUsed(...) }
|
||||
if !affinityUsable && !ShouldKeepChannelAffinityOnChannelDisabled() {
|
||||
ClearCurrentChannelAffinityCache(c) // 渠道挂了就清缓存,别死粘
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 写入
|
||||
|
||||
`c.Next()` 返回且 HTTP 状态 `< 400`(成功)时,`RecordChannelAffinity`(`channel_affinity.go:713`)把「用到的渠道 ID」按 TTL 写回缓存。注意有个 `SwitchOnSuccess` 开关——可记录真正成功的渠道而非初选渠道。
|
||||
|
||||
### 存储
|
||||
|
||||
两层 `HybridCache`——Redis(多实例共享)+ 内存 LRU(`hot.HotCache`,容量默认 10 万),可单条 / 按规则 / 全量清空(管理接口 `controller/channel_affinity_cache.go`)。
|
||||
|
||||
---
|
||||
|
||||
## 五、优先渠道失败了,会一直重试剩下的渠道吗?——不会,有界且多重门控
|
||||
|
||||
不会无限重试。重试循环在 `controller/relay.go:194`:
|
||||
|
||||
```go
|
||||
for ; retryParam.GetRetry() <= common.RetryTimes; retryParam.IncreaseRetry() {
|
||||
channel, _ := getChannel(c, relayInfo, retryParam) // 内部调 CacheGetRandomSatisfiedChannel(retry)
|
||||
...
|
||||
newAPIError = relayHandler(c, relayInfo) // 转发
|
||||
if newAPIError == nil { return } // 成功
|
||||
processChannelError(...) // 记日志 + 可能自动禁用
|
||||
if !shouldRetry(c, newAPIError, common.RetryTimes-retryParam.GetRetry()) { break }
|
||||
}
|
||||
```
|
||||
|
||||
### 1. 重试上限 = `common.RetryTimes`,**默认是 0(完全不重试)**
|
||||
|
||||
`common/constants.go:133: var RetryTimes = 0`。`RetryTimes=0` 时 for 循环只跑一次,失败即返回。**必须管理员把 `RetryTimes` 调成 >0 才会跨渠道重试**(可在系统设置改,`model/option.go:542`)。
|
||||
|
||||
### 2. 重试次数 = 优先级层数,不是渠道个数
|
||||
|
||||
因为 `retry` 同时是优先级层下标(见第二节)。所以「能重试几次」实际是 `min(RetryTimes, 优先级层数)`:
|
||||
|
||||
| 配置 | 行为 |
|
||||
|------|------|
|
||||
| 3 渠道同层 + RetryTimes=3 | 同一层加权随机 3 次(可能命中不同渠道,也可能重复命中同一个) |
|
||||
| 3 渠道分 3 层 + RetryTimes=3 | retry0→层1、retry1→层2、retry2→层3,逐层降级 |
|
||||
| `retry >= 优先级层数` | **钳制到最低层**,继续在最低层加权随机,直到耗尽 RetryTimes |
|
||||
|
||||
### 3. 哪些错误才重试 —— `shouldRetry`(`relay.go:328`)多重判定
|
||||
|
||||
| 判定 | 行为 |
|
||||
|------|------|
|
||||
| 渠道连接级错误(`IsChannelError`) | **重试** |
|
||||
| `IsSkipRetryError`(请求体错误等不可重试错误) | **停止** |
|
||||
| 剩余次数 `<= 0` | 停止 |
|
||||
| 命中 `specific_channel_id`(token 锁定指定渠道) | **停止**(不跨渠道) |
|
||||
| 亲和性规则设了 `SkipRetryOnFailure` | **停止**(`ShouldSkipRetryAfterChannelAffinityFailure`,`relay.go:332`)——为保 prompt cache,宁可在亲和渠道上失败也不换渠道 |
|
||||
| 按 HTTP 状态码 | 见下 |
|
||||
|
||||
**状态码门控**(`setting/operation_setting/status_code_ranges.go`):
|
||||
|
||||
- 默认**会重试**:`1xx`、`3xx`、`4xx`(除 400/408)、`5xx`(除 504/524)
|
||||
- **永不重试**:`504`、`524`(网关超时,重试也没用)、2xx(成功)
|
||||
- 管理员可改 `AutomaticRetryStatusCodeRanges`
|
||||
|
||||
### 4. 失败渠道会被自动禁用(auto-ban)
|
||||
|
||||
`processChannelError`(`relay.go:360`)里:若 `ShouldDisableChannel(err) && AutoBan`,异步调 `service.DisableChannel`。默认 `AutomaticDisableStatusCodeRanges = {401}`——**401(密钥失效)会自动禁用该渠道**,并从 `group2model2channels` 缓存里移除(`CacheUpdateChannelStatus`,`channel_cache.go:271`),后续请求就不会再选到它。
|
||||
|
||||
### 5. "auto" 分组(多分组):每组用完所有优先级才切下一组
|
||||
|
||||
`service/channel_select.go:84` 的 `CacheGetRandomSatisfiedChannel` 处理 `TokenGroup == "auto"`:遍历多个分组,**每个分组穷尽自己的优先级层后才换下一个分组**,同样受全局 `RetryTimes` 总量约束(详细示例见 `channel_select.go:70-83` 注释)。
|
||||
|
||||
---
|
||||
|
||||
## 六、调度全流程图
|
||||
|
||||
```
|
||||
请求进来
|
||||
│
|
||||
▼
|
||||
Distribute 中间件 (middleware/distributor.go)
|
||||
│
|
||||
├─ 指定 channel_id? ──是──► 固定渠道,失败不跨渠道重试
|
||||
│
|
||||
├─ ① 先查亲和缓存 GetPreferredChannelByAffinity
|
||||
│ 命中且渠道可用 → 直接用该渠道(为 prompt cache 命中)
|
||||
│
|
||||
└─ ② 未命中 → CacheGetRandomSatisfiedChannel
|
||||
│
|
||||
├─ GetRandomSatisfiedChannel(group, model, retry=0, path)
|
||||
│ retry=0 → 最高优先级层 → 层内 Weight 加权随机
|
||||
│
|
||||
▼
|
||||
转发 relayHandler
|
||||
│
|
||||
成功(<400) → RecordChannelAffinity 写回亲和缓存 → 返回
|
||||
│
|
||||
失败 → processChannelError(可能 auto-ban,如 401)
|
||||
│
|
||||
shouldRetry?
|
||||
├─ 否(状态码不重试 / 越界 / 亲和 skip-retry)→ break,返回错误
|
||||
└─ 是 → retry++,回到 ②
|
||||
retry=1 → 次优先级层 → 加权随机 …… 直到 retry > RetryTimes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、对 team-api 的借鉴提示
|
||||
|
||||
team-api 的 `relay/scheduler/`(调度、亲和性、重试)和 `internal/logic/relay/`、`chn_channel_affinities` 表正好对应这套东西。new-api 的几个设计值得参考,也有坑要避开:
|
||||
|
||||
1. **「retry 当优先级层下标」是个很巧的复用**,但语义不直观——很多人以为 `RetryTimes` 是「重试几个渠道」,其实是「降几层优先级」。team-api 可以考虑**显式分离**这两个维度更清晰。
|
||||
2. **亲和性绑定 prompt cache 目的**而非会话粘性,这个定位很准,值得直接借鉴——`chn_channel_affinities` 表可以参考它的规则引擎(model/path/UA 匹配 + 多 KeySource + TTL)。
|
||||
3. **默认 `RetryTimes=0`** 这个默认值偏保守,部署时要注意——team-api 若想要高可用,默认值应该给个合理的非零值。
|
||||
4. **401 自动 auto-ban + 状态码可配置重试范围**,这套失败处理很实用,建议移植。
|
||||
|
||||
### 关键源码索引
|
||||
|
||||
| 关注点 | 文件:函数 |
|
||||
|--------|----------|
|
||||
| 内存映射 + 定时同步 | `model/channel_cache.go` — `InitChannelCache`, `SyncChannelCache` |
|
||||
| 渠道选择(内存路径) | `model/channel_cache.go:114` — `GetRandomSatisfiedChannel` |
|
||||
| 渠道选择(DB 路径) | `model/ability.go:108` — `GetChannel` |
|
||||
| 优先级层定位(DB 路径) | `model/ability.go` — `getChannelQuery`, `getPriority` |
|
||||
| 优先级 / 权重访问器 | `model/channel.go:483` — `GetPriority`, `GetWeight` |
|
||||
| auto 分组 + 跨组重试 | `service/channel_select.go` — `CacheGetRandomSatisfiedChannel` |
|
||||
| 亲和性全套 | `service/channel_affinity.go` — `GetPreferredChannelByAffinity`, `RecordChannelAffinity`, `MarkChannelAffinityUsed` |
|
||||
| 分发中间件 | `middleware/distributor.go` — `Distribute` |
|
||||
| 重试循环 + 判定 | `controller/relay.go:194` — relay loop;`relay.go:328` — `shouldRetry` |
|
||||
| 状态码重试 / 禁用范围 | `setting/operation_setting/status_code_ranges.go` |
|
||||
| 渠道状态缓存更新 / auto-ban | `model/channel_cache.go:271` — `CacheUpdateChannelStatus` |
|
||||
@@ -1,202 +0,0 @@
|
||||
# sub2api 渠道调度策略分析
|
||||
|
||||
> **用途**:参考项目 `sub2api`(Wei-Shaw/sub2api,Gin + ent ORM + Redis 实现)的渠道调度、亲和性、优先级、失败重试 / failover 机制的实现级分析,供 team-api 的 `relay/scheduler/`、`internal/logic/relay/`、`chn_channel_affinities`、`chn_health_scores` 等模块设计与对照参考。
|
||||
>
|
||||
> **重要前提**:sub2api 是「订阅转 API」(subscription-to-API),它调度的基本单位是**账户(account)**——即一个个上游订阅账户(Claude Pro OAuth、ChatGPT Plus、Gemini OAuth 等),而不是 new-api 意义上的「渠道」。本文按提问习惯把 account 对应为「渠道」。
|
||||
>
|
||||
> **路径约定**:本文所有 `file:line` 引用均相对于 **`sub2api/backend/`** 目录。
|
||||
> **分析时间**:2026-07-30。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [两套调度路径](#一两套调度路径)
|
||||
2. [多个渠道怎么判断优先级](#二多个渠道怎么判断优先级)
|
||||
3. [怎么做渠道亲和性](#三怎么做渠道亲和性)
|
||||
4. [失败重试与 failover(四重有界)](#四优先渠道失败会一直往下面的渠道重试吗不会四重有界)
|
||||
5. [与 new-api 的对比](#五与-new-api-的对比)
|
||||
6. [对 team-api 的借鉴提示](#六对-team-api-的借鉴提示)
|
||||
|
||||
---
|
||||
|
||||
## 一、两套调度路径
|
||||
|
||||
sub2api 有**两套并行的调度路径**,优先级机制完全不同,必须分开讲:
|
||||
|
||||
| 路径 | 文件 | 适用平台 | 优先级机制 |
|
||||
|------|------|---------|-----------|
|
||||
| **通用 gateway 调度** | `internal/service/gateway_scheduling.go` | Claude / Gemini / Bedrock / Antigravity 等 | **严格优先级**(数值小优先)+ LRU 兜底 |
|
||||
| **OpenAI 高级调度器** | `internal/service/openai_account_scheduler.go` | OpenAI 兼容账户 | **多维加权评分**(优先级只是其中一个软因子) |
|
||||
|
||||
入口分别由 `internal/handler/gateway_handler.go` 的主转发循环,和 `selectAccountWithScheduler`(`openai_account_scheduler.go:2038`)调用。
|
||||
|
||||
---
|
||||
|
||||
## 二、多个渠道怎么判断优先级
|
||||
|
||||
### 路径 A:通用调度 —— 严格优先级 + LRU
|
||||
|
||||
`selectAccountForModelWithPlatform`(`gateway_scheduling.go:1757`)遍历候选账户,选最优的那个(`gateway_scheduling.go:1863`):
|
||||
|
||||
```go
|
||||
if acc.Priority < selected.Priority {
|
||||
selected = acc // 数值越小,优先级越高
|
||||
} else if acc.Priority == selected.Priority {
|
||||
switch {
|
||||
case acc.LastUsedAt == nil && selected.LastUsedAt != nil:
|
||||
selected = acc // 从未用过的优先
|
||||
...
|
||||
default:
|
||||
if acc.LastUsedAt.Before(*selected.LastUsedAt) {
|
||||
selected = acc // 同优先级 → 最久未使用(LRU)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**结论**:严格分层(低数值优先),同层用 **LRU(最久未使用)** 轮转,OAuth 类型还有额外偏好。**没有加权随机**——这是和 new-api 最大的差异之一。
|
||||
|
||||
> ⚠️ 优先级数值语义和 new-api **相反**:sub2api 是**越小越优先**,new-api 是越大越优先。
|
||||
|
||||
### 路径 B:OpenAI 高级调度器 —— 多维加权评分
|
||||
|
||||
这是 sub2api 最精华的部分。`buildOpenAIAccountLoadPlan`(`openai_account_scheduler.go:791`)给每个候选算一个 `score`(`openai_account_scheduler.go:955`):
|
||||
|
||||
```go
|
||||
item.score = weights.Priority * priorityFactor + // 优先级(归一化)
|
||||
weights.Load * loadFactor + // 负载率(越空闲越高分)
|
||||
weights.Queue * queueFactor + // 等待队列长度
|
||||
weights.ErrorRate * errorFactor + // EWMA 错误率(越健康越高分)
|
||||
weights.TTFT * ttftFactor + // 首 token 延迟(越快越高分)
|
||||
weights.Reset * resetFactor + // 订阅窗口"不用就作废"(use-it-or-lose-it)
|
||||
weights.QuotaHeadroom * quotaHeadroomFactor + // 配额余量
|
||||
weights.UpstreamCost * (upstreamCostFactor) // 上游成本
|
||||
// + 粘性加分(weights.Previous / weights.SessionSticky)
|
||||
```
|
||||
|
||||
其中 `priorityFactor = 1 - (priority - minPriority)/(maxPriority - minPriority)`(`scheduler.go:924`)——**优先级数值越小,因子越大,得分越高**,但它是**和负载 / 错误率 / 延迟平等竞争的一个软因子**,不是绝对分层。`weights.*` 是可配置的权重,运营可以调整哪个维度更重要。
|
||||
|
||||
然后取 **Top-K**(配置项)个候选,用 `buildOpenAIWeightedSelectionOrder`(`scheduler.go:736`)做**加权随机排序**——把得分平移到正区间当权重(`(score - minScore) + 1.0`,避免单一高分账户垄断),用**从请求派生的确定性种子**(`deriveOpenAISelectionSeed`)做随机,保证同会话稳定。
|
||||
|
||||
**健康度怎么来**:EWMA(`updateEWMAAtomic`,`scheduler.go:214`)——每次请求结果经 `ReportResult`(`scheduler.go:1717`)反馈,平滑更新错误率和 TTFT(首 token 时间),`openAIAccountRuntimeStats` 在内存里维护。
|
||||
|
||||
### 候选排序的 tiebreaker
|
||||
|
||||
即便在 OpenAI 调度器里,`isOpenAIAccountCandidateBetter`(`scheduler.go:630`)的字典序也是:`score` → `Priority`(小优先)→ `LoadRate`(低优先)→ `WaitingCount`(少优先)→ `ID`。优先级只在 score 打平时才起决定作用。
|
||||
|
||||
---
|
||||
|
||||
## 三、怎么做渠道亲和性
|
||||
|
||||
sub2api 的亲和性是**会话级粘性**,目的是把同一会话钉在同一账户(上游订阅账户的对话状态 / KV 缓存是账户私有的)。主要两套机制,在 `Select`(`scheduler.go:368`)里按优先级依次尝试:
|
||||
|
||||
### 1. PreviousResponseID 粘性(最强)
|
||||
|
||||
OpenAI Responses API 的 `previous_response_id` 指向的响应状态只存在处理它的那个账户上。`Select` 一进来就先查(`scheduler.go:380`):有 `previous_response_id` → 直接路由到当初处理它的账户。命中后还会 `BindStickySession` 把会话也绑过去(`scheduler.go:408`)。
|
||||
|
||||
### 2. SessionHash 粘性会话
|
||||
|
||||
`selectBySessionHash`(`scheduler.go:454`):
|
||||
|
||||
- 从请求信号派生一个 `sessionHash`,到 Redis 查 `getStickySessionAccountID`(`internal/service/openai_sticky_compat.go:122`)拿到绑定的账户。
|
||||
- 命中后仍要层层校验:未被排除、可调度、平台匹配、请求兼容、传输兼容,再从 DB 复核(`recheckSelectedOpenAIAccountFromDB`)。
|
||||
- **粘性逃逸(sticky escape)**:`shouldEscapeStickyAccount`(`scheduler.go:574`)——如果该账户的 EWMA 错误率或 TTFT 超阈值,**主动放弃粘性**,降级到负载均衡选号(`scheduler.go:502`)。这是 sub2api 区别于简单粘性的关键:粘性不是死绑,健康度恶化会自动跳船。
|
||||
- 粘性会话有 TTL(`refreshStickySessionTTL`),账户变不可调度 / 换模型 / 换平台时自动清除(`deleteStickySessionAccountID`)。
|
||||
|
||||
> ⚠️ `auth_identity_channels` 表(`ent/schema/auth_identity_channel.go`)**不是路由亲和**——它是 OAuth 身份到 provider channel 的标识绑定(管理后台 `BindUserAuthIdentityChannel`),别和调度混淆。
|
||||
|
||||
---
|
||||
|
||||
## 四、优先渠道失败,会一直往下面的渠道重试吗?——不会,四重有界
|
||||
|
||||
这是 sub2api 设计得最细致的地方。重试分**三个层次**,每层都有独立上限,通过 `FailoverState`(`internal/handler/failover_loop.go`)状态机协调:
|
||||
|
||||
### 第 1 层:同账户 HTTP 重试(转发层)
|
||||
|
||||
`internal/service/gateway_forward.go:366` 的 `for attempt := 1; attempt <= maxRetryAttempts` 循环:
|
||||
|
||||
- **`maxRetryAttempts = 5`**(`gateway_forward.go:24`)
|
||||
- **`maxRetryElapsed = 10s`** 总耗时上限(`gateway_forward.go:32`)
|
||||
- 指数退避:`retryBaseDelay = 300ms`,`retryMaxDelay = 3s`(`gateway_forward.go:55`)
|
||||
- 这层主要处理可修复错误(如 thinking block 签名错误 → 过滤后原账户重试)
|
||||
|
||||
### 第 2 层:同账户业务重试(失败可重试错误)
|
||||
|
||||
`FailoverState.HandleFailoverError`(`failover_loop.go:68`)里,对 `RetryableOnSameAccount` 的临时错误(如 pool mode 的可重试状态码):
|
||||
|
||||
- **`maxSameAccountRetries = 3`**(`failover_loop.go:36`),间隔 `500ms`
|
||||
- 用尽后 `TempUnscheduleRetryableError` **临时封禁**该账户(`failover_loop.go:110`)
|
||||
|
||||
### 第 3 层:跨账户 failover(换渠道)
|
||||
|
||||
当错误满足 `shouldFailoverUpstreamError`(`gateway_forward.go:46`)——即 **401 / 403 / 429 / 529 或 ≥500**——触发换账户:
|
||||
|
||||
- 受 **`MaxAccountSwitches`** 限制,默认 **10 次**(Gemini 默认 **3 次**,`internal/handler/gateway_handler.go:81-82`)
|
||||
- 每次把失败账户加入 `FailedAccountIDs` 排除集,`SwitchCount++`,达到上限返回 `FailoverExhausted` → 给客户端报错(`failover_loop.go:117`)
|
||||
- Antigravity 平台换号有线性递增延时(`failover_loop.go:131`)
|
||||
|
||||
### 关键安全阀
|
||||
|
||||
- **流式已写入则禁止 failover**:`gateway_handler.go:888` 检查 `c.Writer.Size()` 变化,SSE 已经吐给客户端就不能再换账户(否则流拼接腐化)。
|
||||
- **客户端断开立即终止**:`ctx.Err() != nil` → `FailoverCanceled`(`failover_loop.go:78`),不会对着已取消的 context 空转误报 502。
|
||||
- **单账户分组 503 特例**:`HandleSelectionExhausted`(`failover_loop.go:148`)——所有候选都被排除时,若是 503 容量耗尽,会**清空排除列表 + 退避 2s 后重选**,给恢复中的账户第二次机会。
|
||||
|
||||
**总结**:不会无限重试。换账户最多 `MaxAccountSwitches` 次(默认 10),叠加同账户 3 次业务重试 + 5 次 / 10s HTTP 重试,且流式响应一旦开始就锁死不再换。
|
||||
|
||||
### failover 判定函数速查
|
||||
|
||||
| 函数 | 位置 | 判定 |
|
||||
|------|------|------|
|
||||
| `shouldRetryUpstreamError` | `gateway_forward.go:35` | OAuth 账号仅 403 重试;API Key 账号看 `ShouldHandleErrorCode` |
|
||||
| `shouldFailoverUpstreamError` | `gateway_forward.go:46` | 401 / 403 / 429 / 529 或 ≥500 → 换账户 |
|
||||
| `retryBackoffDelay` | `gateway_forward.go:55` | 指数退避 300ms → 3s 上限 |
|
||||
| `HandleFailoverError` | `failover_loop.go:68` | 同账户重试 → 临时封禁 → 换账户计数 |
|
||||
| `HandleSelectionExhausted` | `failover_loop.go:148` | 候选耗尽时 503 退避重试 |
|
||||
|
||||
---
|
||||
|
||||
## 五、与 new-api 的对比
|
||||
|
||||
> 完整的 new-api 分析见同目录 [`new-api-channel-scheduling-analysis.md`](./new-api-channel-scheduling-analysis.md)。
|
||||
|
||||
| 维度 | new-api | sub2api |
|
||||
|------|---------|---------|
|
||||
| 调度单位 | 渠道(channel) | 账户(account / 订阅) |
|
||||
| 优先级语义 | 数值**大**优先,严格分层 | 数值**小**优先;通用路径严格分层,OpenAI 路径是软因子 |
|
||||
| 层内分配 | 加权随机(weight) | 通用路径 LRU;OpenAI 路径 Top-K 加权随机(评分当权重) |
|
||||
| 健康感知 | 仅 auto-ban(401 禁用) | **EWMA 错误率 + TTFT 实时健康度**,纳入评分 + 粘性逃逸 |
|
||||
| 亲和性 | 规则引擎(model / path / UA + KeySource),**为 prompt cache** | 会话哈希 + previous_response_id,**为对话状态 / KV**;带健康度逃逸 |
|
||||
| 重试上限 | `RetryTimes`(默认 **0**,不重试!) | 换账户默认 **10**(Gemini 3)+ 同账户 3 + HTTP 5 次 / 10s |
|
||||
| 重试计数 vs 优先级 | retry 当优先级层下标(巧妙但隐晦) | 完全解耦,独立计数 |
|
||||
| 流式安全 | 未特殊处理 | 流式已写则禁止 failover |
|
||||
|
||||
---
|
||||
|
||||
## 六、对 team-api 的借鉴提示
|
||||
|
||||
1. **EWMA 健康度评分**(`openai_account_scheduler.go`)是 sub2api 最值得借鉴的——把错误率、首 token 延迟、负载、队列做成归一化因子加权求和,比 new-api 的纯优先级 + 权重精细得多。team-api 的 `chn_health_scores` 表可以做成实时 EWMA 而非离散分数。
|
||||
2. **粘性逃逸机制**(`shouldEscapeStickyAccount`)——粘性不等于死绑,健康度恶化自动跳船。team-api 的亲和性若做,务必加这层,否则一个坏渠道会粘死一批会话。
|
||||
3. **重试层次解耦**——sub2api 把「同账户 HTTP 重试 / 同账户业务重试 / 跨账户 failover」分成三个独立有界层次,而 new-api 是混在一起的 `retry` 计数。team-api 设计 `relay/scheduler/` 时建议学 sub2api 的分层。
|
||||
4. **流式安全阀**——SSE 已写入禁止 failover,这个细节 new-api 没有,team-api 做流式代理必须考虑。
|
||||
5. **注意优先级数值语义不统一**——new-api 大优先,sub2api 小优先。team-api 自己实现时要统一,别两套参考代码看混了。
|
||||
|
||||
### 关键源码索引
|
||||
|
||||
| 关注点 | 文件:函数 |
|
||||
|--------|----------|
|
||||
| OpenAI 调度主入口 | `internal/service/openai_account_scheduler.go:368` — `Select` |
|
||||
| 多维评分计算 | `internal/service/openai_account_scheduler.go:791` — `buildOpenAIAccountLoadPlan` |
|
||||
| 候选打分公式 | `internal/service/openai_account_scheduler.go:955` — `item.score = ...` |
|
||||
| Top-K 加权随机序 | `internal/service/openai_account_scheduler.go:736` — `buildOpenAIWeightedSelectionOrder` |
|
||||
| EWMA 健康度 | `internal/service/openai_account_scheduler.go:214` — `updateEWMAAtomic`;`:225` — `report` |
|
||||
| 通用调度(严格优先级 + LRU) | `internal/service/gateway_scheduling.go:1757` — `selectAccountForModelWithPlatform` |
|
||||
| 优先级比较 | `internal/service/gateway_scheduling.go:1863` — `acc.Priority < selected.Priority` |
|
||||
| PreviousResponse 粘性 | `internal/service/openai_account_scheduler.go:380` |
|
||||
| SessionHash 粘性 | `internal/service/openai_account_scheduler.go:454` — `selectBySessionHash` |
|
||||
| 粘性逃逸 | `internal/service/openai_account_scheduler.go:574` — `shouldEscapeStickyAccount` |
|
||||
| 粘性会话缓存 | `internal/service/openai_sticky_compat.go:122` — `getStickySessionAccountID` |
|
||||
| 同账户 HTTP 重试 | `internal/service/gateway_forward.go:366` — retry loop |
|
||||
| failover 判定 | `internal/service/gateway_forward.go:35/46` — `shouldRetryUpstreamError` / `shouldFailoverUpstreamError` |
|
||||
| failover 状态机 | `internal/handler/failover_loop.go` — `FailoverState` / `HandleFailoverError` / `HandleSelectionExhausted` |
|
||||
| 跨账户循环 + 换号上限 | `internal/handler/gateway_handler.go:586` — 主循环;`:81` — `maxAccountSwitches` |
|
||||
@@ -1,85 +0,0 @@
|
||||
# 渠道调度问题记录(team-api)
|
||||
|
||||
> 本文件**只记录当前渠道调度实现中存在的问题与局限**,不含解决方案、不做对比、不做表扬。配套的分析文档见 [`channel-scheduling-comparison.md`](./channel-scheduling-comparison.md)。
|
||||
>
|
||||
> 所有位置引用相对于项目根目录。记录时间:2026-07-30。
|
||||
> 严重度:🔴 高 / 🟡 中 / ⚪ 低(局限)
|
||||
|
||||
---
|
||||
|
||||
## 亲和性(Affinity)
|
||||
|
||||
### A1. 亲和命中绕过健康度降权 🔴
|
||||
- **位置**:`relay/scheduler/scheduler.go:71-77`(`SelectStable` 直接返回亲和渠道)、`:185`(`effectiveWeight` 健康度降权)、`:83`(`highestPriorityCandidates` 仅剔除 `<20`)
|
||||
- **现象**:`SelectStable` 一旦亲和渠道在最高优先级组内就**直接返回**,完全跳过 `effectiveWeight`。而候选过滤只剔除 `HealthScore < 20`。
|
||||
- **后果**:一个亲和渠道健康度跌到 21 分(接近不健康),只要 ≥20,对该租户/用户/key/model 仍是 **100% 命中**,精心计算的健康度降权在亲和命中时**完全失效**。健康度体系与亲和性互相冲突。
|
||||
|
||||
### A2. 亲和仅在最高优先级组内生效 🟡
|
||||
- **位置**:`relay/scheduler/scheduler.go:66-77`、`internal/logic/relay/provider.go:239-246`
|
||||
- **现象**:`SelectStable` 先取最高优先级组,再在组内找亲和渠道;亲和渠道不在该组则被忽略,甚至被清除(`deleteAffinity`)。
|
||||
- **后果**:用优先级做主备(如主=100、备=90)时,若亲和渠道是备,亲和直接失效。形成设计悖论——**优先级分层(failover)与亲和(粘性)无法同时生效**,亲和只在「不分层」时才工作。
|
||||
|
||||
### A3. 任何可重试错误都会清除亲和 🔴
|
||||
- **位置**:`relay/handler/relay_handler.go:500`、`:565`
|
||||
- **现象**:DoRequest / DoResponse 出错时**无条件**调用 `DeleteChannelAffinity`,哪怕只是瞬时 429/503 紧接着重试。
|
||||
- **后果**:一次上游打嗝就毁掉积累多请求才建立的亲和绑定,下一个请求可能落到别的渠道重建亲和。**亲和反而成为路由中最不稳定的部分**,与其「路由稳定」目标相悖。
|
||||
|
||||
---
|
||||
|
||||
## 重试(Retry)
|
||||
|
||||
### R1. 重试无退避 🟡
|
||||
- **位置**:`relay/handler/relay_handler.go:417`(`for attempt := 0; attempt <= maxRetries`)、`:502`、`:578+`(`continue`)
|
||||
- **现象**:`maxRetries=3` 的重试循环失败后**立即** `continue` 换渠道,无 sleep / 指数退避。
|
||||
- **后果**:上游 429 风暴时,瞬时把压力传导给剩余渠道,**放大对其他渠道和上游的冲击**,易引发连环失败。
|
||||
|
||||
### R2. 瞬时错误无原地重试,直接换渠道 🟡
|
||||
- **位置**:`relay/handler/relay_handler.go:482-507`、`:525-577`
|
||||
- **现象**:单层重试,不区分「同渠道瞬时重试」与「跨渠道 failover」。一个很可能 200ms 后就恢复的 503,会触发完整的「排除当前渠道 → 清亲和 → 重新选号 → 落到另一渠道」。
|
||||
- **后果**:瞬时错误代价过高,**不必要的换渠道 + 亲和抖动**,且与 A3 叠加放大亲和失效。
|
||||
|
||||
---
|
||||
|
||||
## 健康度(Health)
|
||||
|
||||
### H1. 健康度更新为读-改-写,存在并发竞态 🔴
|
||||
- **位置**:`internal/logic/relay/health.go:30-33`(读)、`:40-43` / `:70-76`(基于读值计算)、`:54-63` / `:81-88`(写)
|
||||
- **现象**:`UpdateHealthScore` 先 `Scan` 读出 `success_rate/consecutive_failures` 等字段,在 Go 内基于读值算出新值,再 `Update` 写回。无行锁、无原子表达式(如 `success_rate = success_rate * 0.9`)、无版本号。
|
||||
- **后果**:同一渠道并发失败时**丢失更新(last-write-wins)**——EWMA 平滑失真、`consecutive_failures` 被少计(N 个并发失败各读到同一旧值、各写 +1,最终只 +1 而非 +N)。健康度数值在并发下不准确,影响降权与自动禁用判定。
|
||||
|
||||
### H2. 失败路径同步写健康度,阻塞重试 🟡
|
||||
- **位置**:`relay/handler/relay_handler.go:497`、`:559`(失败分支直接调用 `UpdateChannelHealth`);`internal/logic/relay/health.go:22`(同步 Scan + Update)
|
||||
- **现象**:失败时在重试热路径里同步执行一次「读 + 写」DB 操作(带 30s 超时 settleCtx),写完才能进入下一次重试。
|
||||
- **后果**:每次失败的重试都要**等一次 DB 往返**,拖慢错误处理与 failover;高并发 + 多渠道连续失败时延迟累积。(对比:成功路径用 `postCtx` 异步化,失败路径没有。)
|
||||
|
||||
---
|
||||
|
||||
## 性能(Performance)
|
||||
|
||||
### P1. 选号无渠道池内存缓存,每次重试重查 DB 🟡
|
||||
- **位置**:`internal/logic/relay/provider.go:135-156`(`selectChannelFromDB` 每次执行 3 表 JOIN `Scan`)、`relay/handler/relay_handler.go:418`(每次 attempt 都调用)
|
||||
- **现象**:`selectChannelFromDB` 每次都跑 `chn_abilities JOIN chn_channels JOIN chn_health_scores` 的 SQL,且每次重试(最多 4 次/请求)都重选一次。未发现上游有渠道池的内存缓存。
|
||||
- **后果**:单请求最多 4 次 DB 查询,高 QPS 下成为**数据库热点**。
|
||||
|
||||
---
|
||||
|
||||
## 运维(Operations)
|
||||
|
||||
### O1. legacy / v2 双路径并存,关闭时退化为无健康度 🔴
|
||||
- **位置**:`internal/logic/relay/provider.go:162`(`channel_scheduler_v2_enabled` 开关)、`:163-195`(legacy 分支)、`:191`(`HealthScore: 100` 硬编码)
|
||||
- **现象**:开关关闭时走 legacy 路径——仅按 `priority DESC, weight DESC` 排序取首个有 key 的渠道,且 `HealthScore` 写死为 `100`。
|
||||
- **后果**:一旦误关闭开关,**健康度感知调度被静默关闭**,所有渠道被视为满分健康,退化为主备轮询。存在配置误操作风险,且双路径增加维护成本。
|
||||
|
||||
---
|
||||
|
||||
## 能力局限(Limitations)
|
||||
|
||||
### L2. 无「优先级溢出分流」能力 ⚪
|
||||
- **位置**:`relay/scheduler/scheduler.go:83-106`(`highestPriorityCandidates` 只取最高优先级组)、`:109-137`(`groupByPriority`)
|
||||
- **现象**:调度只有两档语义——最高优先级组**独占**全部流量,或同优先级组内按权重分流。不存在「高优先级渠道负载满了/并发占满后,溢出到低优先级渠道」的能力。
|
||||
- **后果**:无法表达「优先用 A,A 扛不住时自动分一部分给 B」这类**优先级 + 溢出**的混合策略。多优先级渠道只能做冷备(见 [`channel-scheduling-comparison.md`](./channel-scheduling-comparison.md)),低优先级渠道容量在主渠道健康时完全闲置。
|
||||
|
||||
### L3. 亲和无会话 / 对话维度 ⚪
|
||||
- **位置**:`internal/logic/relay/affinity.go:17-21`(`affinityIdentity` 仅含 `tenantID:userID:apiKeyID:modelName`)
|
||||
- **现象**:亲和键是「租户 + 用户 + API Key + 模型」四元组,不包含会话 ID / 对话 ID / previous_response_id。
|
||||
- **后果**:只能做**身份级粘性**,无法做**会话级粘性**——同一用户+key+模型的多段独立对话都强制路由到同一渠道;反过来也无法保证「同一段多轮对话」落在同一渠道(从而无法稳定命中上游 KV / prompt 缓存)。
|
||||
@@ -1,129 +0,0 @@
|
||||
# 渠道调度三方案对比评审
|
||||
|
||||
> 评审对象:[`claude设计的渠道调度方案.md`](./claude设计的渠道调度方案.md)、[`glm设计的渠道调度方案.md`](./glm设计的渠道调度方案.md)、[`gpt设计的渠道调度方案.md`](./gpt设计的渠道调度方案.md)
|
||||
> 评判基准:[`当前渠道调度存在的问题.md`](./当前渠道调度存在的问题.md) 的 11 个问题(A1-A3 / R1-R2 / H1-H2 / P1 / O1 / L2-L3)+ 项目 memory 中已锁定的 4 个决策(Redis 真相源、成本感知开启、不做对话指纹、自然探活)。
|
||||
> 评审时间:2026-07-30。状态:待讨论。
|
||||
|
||||
---
|
||||
|
||||
## 一、结论(TL;DR)
|
||||
|
||||
**claude 方案整体最优,建议以它为实施主干,合入 gpt 方案的 2~3 个关键补强;glm 方案关闭归档。**
|
||||
|
||||
三份方案核心骨架高度趋同(都遵守已锁定决策:加权 Rendezvous Hash 选择、粘性作为涌现属性、Redis Lua 原子状态、错误分类 + 双预算重试、自然探活熔断、纯函数核心 + 端口适配层)。差距在完成度、对本项目的贴合度、边界处理:
|
||||
|
||||
- **claude**:最自洽、最贴合本项目、可直接开工,但缺凭证级归因和非幂等重放保护;
|
||||
- **gpt**:工程严谨性和运维可控性最强,但范围严重膨胀、偏通用方案,且**漏掉了成本感知因子(违反已锁定决策 2)**;
|
||||
- **glm**:三份中最薄,且绑定守卫(keepThreshold)存在真实设计缺陷,有效内容被 claude 方案完全覆盖。
|
||||
|
||||
---
|
||||
|
||||
## 二、逐项对比
|
||||
|
||||
| 维度 | claude(805 行) | glm(591 行) | gpt(1661 行) |
|
||||
|---|---|---|---|
|
||||
| 核心模型自洽性 | ★★★ 统一权重函数 W(c),溢出是连续涌现行为,无阈值边界 | ★★★ 同一思路但更薄 | ★★ 阈值分层(0.70/0.90/0.60 + 迟滞)+ 层内 HRW 的混合模型,机制更多但更可预测 |
|
||||
| 对本项目的贴合度 | ★★★ 引用真实文件路径、表名、`gf gen dao`、goose 六位编号、现有代码(`InvalidateChannelAffinities`、`bil_usage_logs.retry_index`、`SelectionReason`) | ★★ 有贴合但多处「实现期再定」 | ★ 通用方案:表名 `sched_` 前缀违反项目前缀规范(自己也注明「可按项目规范调整」)、无迁移编号、不引用现有代码,适配成本高 |
|
||||
| 可落地程度 | ★★★ 迁移 SQL(含 tier 自动映射)、4 段完整 Lua、配置 Schema、接口签名全部可直接抄 | ★★ Lua 里有 `ComputeHealth(...)` 伪码未展开,迁移无 SQL | ★★ 规格完整但工作量最大(阶段 0-7 共 8 个阶段、6 个 feature flag) |
|
||||
| 失败处理严谨性 | ★★ 六类错误 + 双预算 + 重试 FSM,但**缺凭证级归因** | ★★ 有一句「非幂等生成不重试」,未展开 | ★★★ Replayability(Safe/Costly/Unsafe)+ DeliveryState(NotSent/MaybeSent/…)模型最严谨;401 先归因凭证、轮换、耗尽才升级渠道级 |
|
||||
| 运维可控性 | ★★ policy 版本化热更新 + Schema 校验拒绝非法配置,无临时流量控制 | ★ 最少 | ★★★ target_share / canary / drain / force_drain / pause / ramp + explain / simulate API + RBAC + 审计,独一档 |
|
||||
| 可观测性 | ★★★ 决策日志含 weight_breakdown、指标覆盖溢出/会话来源验证 | ★★ 得分分解 JSON 进 ForwardingTrace,够用 | ★★★ 五种比例口径(新会话/首次/全部尝试/成功/Token)、低基数标签纪律、看板与告警清单最全 |
|
||||
| 已锁定决策符合度 | ✅ 全部符合 | ✅ 全部符合(§0 逐条列出,基本是决策记录的展开版) | ⚠️ **有效权重公式(§9.6)无 costFactor**,成本只出现在观测口径,违反「成本感知开启」决策 |
|
||||
| 灰度与旧码清理 | ★★★ 影子模式 → 一次性切换 → 立即删旧码,明确拒绝长期开关(针对 O1 的正确姿势) | ★★ feature flag 灰度,P5 移除 | ★ 6 个 flag、7+1 阶段的长期双路径——形态上恰是 O1 问题本身 |
|
||||
| 会话识别(Claude Code 流量) | ★★★ 提出解析 `metadata.user_id` 中的 `session_<uuid>` 段,标注为待验证风险(§19.1),失败自动降级身份级,配 `dispatch_session_source_total` 指标监控 | ★ 不解析,Claude Code 走身份级 | ★ 明确拒绝解析(§7.1「不能把 metadata.user_id 默认解释为会话 ID」),放弃该流量大户的会话级亲和 |
|
||||
|
||||
### 11 个问题的覆盖情况
|
||||
|
||||
三份方案对 A1-A3、R1-R2、H1-H2、P1、O1、L2、L3 全部给出了机制(claude §18、glm §14 均有逐条对照表,gpt 分散在各章)。覆盖面上没有淘汰项,差异在机制质量,见下节。
|
||||
|
||||
---
|
||||
|
||||
## 三、各方案关键硬伤
|
||||
|
||||
### glm:绑定守卫存在真实设计缺陷
|
||||
|
||||
守卫判据是 `有效权重 ≥ keepThreshold = 0.3 × baseWeight`(§3.3),而有效权重包含 `tierBias`。备用渠道 tierBias 默认 0.1,意味着:
|
||||
|
||||
> **溢出到备用渠道建立的绑定,下一个请求必然不满足守卫条件**(0.1 × base < 0.3 × base)。持续高压期间,备用渠道上的会话每次请求都重绑、永远粘不住,上游 KV/prompt 缓存收益归零——而「保缓存」正是亲和的存在意义。
|
||||
|
||||
对照组:claude 的守卫判据是被绑渠道自身状态(熔断非 OPEN、healthFactor ≥ 0.5、headroom ≥ 0.1,§5.2),与 tier 和成本解耦,不存在此问题;gpt 用显式逃逸条件(压力 < 0.90 等,§7.5),也没有此问题。
|
||||
|
||||
除此之外 glm 整体最薄:无请求全景图、无迁移 SQL、Lua 有伪码、开放问题最多。它更像已锁定决策的「基线展开版」,全部有效内容被 claude 方案严格覆盖。
|
||||
|
||||
### gpt:漏掉成本感知 + 范围膨胀
|
||||
|
||||
1. **违反已锁定决策 2**:§9.6 `effectiveWeight = baseWeight × healthFactor × capacityHeadroom × recoveryRamp × temporaryMultiplier`,没有 costFactor。成本只作为观测口径(cost_share),不参与调度。
|
||||
2. **范围膨胀**:临时流量策略、模拟器、RBAC、双人审批、审计表等控制面能力占全文近一半,把「修复 11 个调度问题」的重构变成 8 阶段平台工程;首个产生业务价值的阶段(阶段 4 接管选择)来得很晚。
|
||||
3. **通用性代价**:`sched_` 表前缀不符合项目前缀规范,无 goose 编号,不引用现有代码,落地前需要一轮完整的「翻译」。
|
||||
4. 对 Claude Code 会话识别一刀切放弃,相比 claude「解析 + 验证 + 监控 + 优雅降级」的处理方式,工程上保守过头。
|
||||
|
||||
### claude:两个真实缺口(恰是 gpt 的强项)
|
||||
|
||||
1. **无凭证级错误归因**。本项目有 `chn_channel_keys` 表(一渠道多 Key),claude 把 401/403 直接定为 CHANNEL_FATAL 并熔断整条渠道(§6.1)——**一个 Key 失效会误杀整条渠道**。gpt 的做法正确:401 先归因凭证、同渠道轮换一次、全部凭证失败才升级渠道级(§11.3/11.4)。
|
||||
2. **非幂等请求重放保护不完整**。本项目有 taskchannel(kling/midjourney/sora/suno),图片/视频/异步任务重试可能重复扣费、重复出任务。claude 只有 PARTIAL_STREAM 一档,缺 gpt 的 `ReplayUnsafe + DeliveryMaybeSent → 必须停止` 语义(§6.1/§11.2)。
|
||||
|
||||
---
|
||||
|
||||
## 四、建议的合并方案
|
||||
|
||||
以 **claude 方案为实施主干**(模块结构、Lua 脚本、迁移 SQL、分阶段计划可直接开工,且与 CLAUDE.md 各项规范一致),合入三处修订:
|
||||
|
||||
| # | 修订 | 来源 | 落点 |
|
||||
|---|---|---|---|
|
||||
| 1 | 凭证级归因与轮换:错误分类器增加 `ErrClassCredential`,401/403 先轮换同渠道 Key(预算 1 次),耗尽后才升级 CHANNEL_FATAL | gpt §11.3/11.4 | claude §6 错误分类 + §6.2 预算结构(增加凭证轮换预算) |
|
||||
| 2 | Replayability / DeliveryState 模型:至少落地「ReplayUnsafe + MaybeSent 禁止自动重放」,覆盖图片/视频/异步任务提交 | gpt §6.1/§11.2 | claude §6.1 分类表 + §6.3 FSM 输入 |
|
||||
| 3 | 临时流量控制面(target_share/drain/canary/explain)**独立立项**为后续迭代,不混进本次重构 | gpt §10/§17 | 单独的设计文档与排期 |
|
||||
|
||||
可选参考项(不作为本次要求):gpt 的溢出迟滞(overflow_exit + 30s hold,Redis 共享溢出状态)——claude 的连续溢出模型理论上不需要,但若影子模式观察到新会话在临界压力下分配抖动,可引入该机制;gpt 的五种比例观测口径和低基数标签纪律值得在实现可观测性时对照。
|
||||
|
||||
---
|
||||
|
||||
## 五、五个原始需求问题的覆盖核对
|
||||
|
||||
这 5 个问题是三份方案共同的设计输入,三份方案均有解,差异在解的质量:
|
||||
|
||||
| # | 原始问题 | claude | glm | gpt | 该题最优 |
|
||||
|---|---|---|---|---|---|
|
||||
| Q1 | 单 Key 多会话集中一个渠道;Claude Code 有无会话 ID;无 ID 按【用户_key_模型】回退 | ✅ §3.1 唯一回答"有":解析 `metadata.user_id` 中 `session_<uuid>` 段(待验证 + 降级 + 监控) | ✅ §4.1 仅显式信号,Claude Code 走身份级 | ✅ §7.1 仅显式信号,明确禁止解析 metadata.user_id | claude |
|
||||
| Q2 | 优先渠道失败:原地重试 / 换渠道 / 有条件重试;502、504 是否原渠道重试 | ✅ §6 但 504 与 502 同归 TRANSIENT 原地重试,可商榷 | ⚠️ §7.1 分类表遗漏 504 | ✅ §11.4 最严谨:504 默认不原地重试(可能已送达 + 再超时概率高),且有凭证轮换层 | gpt |
|
||||
| Q3 | 优先级不合理、备用永远闲置;高负载自动溢出;固定三档 | ✅ §4.1/§8 连续涌现溢出 + tierFactor 0.15 使备用平时承接零星流量(天然保温探活,直接回应"备用永不被请求"质疑) | ✅ §6.3 同 claude 机制但更薄 | ✅ §9.4 显式阈值溢出(0.70~0.90 线性 + 0.60/30s 迟滞),可预测可解释,但低压时备用零业务流量 | claude / gpt 各有取舍 |
|
||||
| Q4 | 调度/亲和/自动调度独立成模块,可测低耦合 | ✅ §9 `relay/dispatch` 纯库,覆盖率 ≥90% 门槛 | ✅ §2 `relay/scheduler` 零 I/O | ✅ §16 同构 + 属性测试(跨进程确定性、预算不变量) | 三家持平 |
|
||||
| Q5 | 多副本部署 | ✅ §13 逐类状态降级语义,Redis 全挂仍可服务;但容量一律 fail-open | ✅ §12 合格但最简 | ✅ §15 最深:严格/昂贵渠道 fail-closed、实例级保守限额公式、策略版本原子切换、时钟同步 | gpt |
|
||||
|
||||
核对结论与第四节一致:claude 主干 + gpt 在失败处理(504 特判、凭证轮换、重放保护)与多副本降级(昂贵渠道 fail-closed)上的补强,恰好互补。
|
||||
|
||||
补充两个由此新增的合并候选项(并入第四节修订清单讨论):
|
||||
|
||||
- **504 处理改从 gpt**:将 HTTP 504 从 claude 的 TRANSIENT 类中拆出,默认不原地重试,仅对可重放且未输出响应的请求允许受限 failover;
|
||||
- **昂贵渠道 fail-closed 选项**:claude §13 的容量降级从"一律 fail-open"改为按渠道可配(参考 gpt §15.3),Redis 故障时严格容量/高成本渠道使用实例级保守限额。
|
||||
|
||||
---
|
||||
|
||||
## 六、遗留讨论点
|
||||
|
||||
### 6.-1 已核查:feat/relaykit-migration 分支适配性(2026-07-30)
|
||||
|
||||
评审与三份方案均基于 main 分支撰写;已核查 `feat/relaykit-migration` 分支(relay 协议转换层 → relaykit 独立模块迁移,阶段 0-7 完成)与方案的兼容性。**结论:方案完全可行,无结构性冲突**——该分支未触碰 `relay/scheduler/`、`internal/logic/relay/` 调度逻辑与 `migrations/`;`relay_handler.go` 唯一改动在 `convertRequestBody`(644 行起),问题清单引用的调度触点行号(:417/:482-577/:500/:565)均在其前,未漂移。
|
||||
|
||||
三个适配点(优化/协调项,非障碍):
|
||||
|
||||
1. **核心库落点新增更优选项**:relaykit 是独立 Go module(无 gf 依赖,主模块 replace 引用)。调度核心可放 `relaykit/dispatch/` 替代方案原定的 `relay/dispatch/`,纯度由模块边界 + 编译器强制(阶段 0「import 无 gf/dao/redis」验收自动成立)。适配层 `internal/logic/dispatchadapter/` 不变。落点二选一在动工时定。
|
||||
2. **错误分类器对接 relaykit 错误体系**:`Classify` 需解包 `relaykit/types.NewAPIError`,尊重 `IsSkipRetryError`/`IsChannelError` 语义,将 ErrorCode 映射进 ErrClass。
|
||||
3. **合并协调**:方案阶段 3 与 relaykit 分支同改 `relay_handler.go`,调度重构应基于该分支(或其合入 main 后)开工。forward 内的 relaykit/legacy 双路径转换对 Route/Next/Report/Finish 循环无影响(转换失败在请求发出前,归 CLIENT 类)。
|
||||
4. 附带提醒:方案中迁移编号 `000014` 在动工时按 `migrations/` 现状重新核定。
|
||||
|
||||
### 6.0 已议定:固定三档 vs 数值优先级(2026-07-30 讨论)
|
||||
|
||||
质疑:三档(首选/备用/保底)最初只是需求方随口提议,是否数值优先级更好?
|
||||
|
||||
**结论:维持固定三档。** 论证要点:
|
||||
|
||||
1. 老 `priority` 数值实际身兼四职(先后序、分流比、成本偏好、灾备),其隐含的「同值=同组独占」语义正是 A2/L2 的根源。新权重模型把这些职责拆给正交因子(weight / costFactor / healthFactor / headroom),tier 剩下的唯一语义「策略偏好分层」天然只有约三档粒度(new-api 的数值优先级实践也自发收敛为少数档位)。
|
||||
2. 新模型无硬分组,数值优先级必须映射为连续权重因子 `f(priority)`,任何映射曲线都让「差 1 分 vs 差 20 分」的行为不可预测——等于换个形式带回 A2 的模糊性。固定三档的因子刻意拉开数量级(1.0/0.15/0.02),档间行为可预测、可穷举测试。gpt 的角色分层模型下数值优先级更不可行(阈值状态机随档位数相乘)。
|
||||
3. 数值灵活性未丢失:档位因子在 routing policy 中可配可热更;档内精细分配靠保留的 `weight`;将来加第四档 = 一个枚举值 + 一条 tierFactors 配置(tier 为 VARCHAR 列,priority 列冻结可回滚)。
|
||||
4. 数值唯一独有的表达力——多渠道严格串行偏好序(A>B>C>D>E)——在网关场景是反模式:多数是成本序/可靠性序的误表达,且意味着多渠道长期全闲置(即最初抱怨的「备用永不被请求」的加强版)。
|
||||
5. 警惕的折中方案:「数值优先级 → 连续因子映射」看似兼得,实为最差选择(见第 2 点)。
|
||||
|
||||
1. claude §19.1 的 `metadata.user_id` 格式验证需在阶段 0 前完成抓包确认(gpt 的质疑提醒了该风险确实存在,但正确响应是验证而非放弃)。
|
||||
2. 成本因子的挂载粒度:claude 挂 `chn_abilities.cost_ratio`(渠道×模型,无量纲比例),glm 挂 `chn_channels`(渠道级单价 decimal)。**倾向 claude 的做法**——粒度更细,且无量纲比例天然回避币种/汇率问题,也不落入资金字段的 decimal 强约束范围。
|
||||
3. 凭证级归因(修订 1)会触碰 claude 方案的熔断粒度设计(渠道级 + 渠道×模型级),可能需要补充「凭证级冷却」状态,实现前需细化。
|
||||
@@ -1,120 +0,0 @@
|
||||
# 渠道调度运维手册
|
||||
|
||||
> 适用版本:调度重构阶段 5 完成后(relaykit/dispatch 调度引擎)。
|
||||
> 设计文档:[`claude设计的渠道调度方案.md`](./claude设计的渠道调度方案.md)(基线)、[`渠道调度重构开发计划.md`](./渠道调度重构开发计划.md)(修订 R1-R7 与实施记录)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 体系速览
|
||||
|
||||
一次请求的调度路径:**会话键解析 → 目录快照(内存)→ 绑定守卫 → 加权 HRW 选择 → 凭证选取 → 容量租约 → 转发 → 错误分类 + FSM 重试 → 结果回写(健康 EWMA / 熔断 / 绑定续期)**。
|
||||
|
||||
| 组件 | 位置 | 说明 |
|
||||
|---|---|---|
|
||||
| 调度核心(纯函数) | `relaykit/dispatch/` | 权重合成、HRW、重试 FSM、熔断状态机;无 I/O,可单测 |
|
||||
| 适配层 | `internal/logic/dispatchadapter/` | 目录快照、Redis Lua 状态、策略加载、降级、维护 cron |
|
||||
| 运行时状态 | Redis `dispatch:v1:*` | 绑定/健康/熔断/租约/凭证冷却/429 水位,全部 Lua 原子 |
|
||||
| 快照落盘 | `chn_health_scores` | 每 5min 由维护 cron 从 Redis 落盘,**仅供仪表盘**,调度不读 |
|
||||
| 指标 | `ops_system_metrics`(metric_type=channel_dispatch) | 每分钟累计计数快照,相邻行做差得速率 |
|
||||
|
||||
### 权重公式(每渠道)
|
||||
|
||||
```
|
||||
W = weight × tierFactor × healthFactor × headroom^γ × costFactor × rampFactor
|
||||
```
|
||||
|
||||
- **tier**(渠道表单「调度层级」):primary=1.0 / secondary=0.15 / reserve=0.02(policy 可调)。备用层平时承接零星流量=天然保温探活。
|
||||
- **healthFactor** = succEwma^α × 延迟惩罚。succEwma 按错误类别分档衰减:瞬时/超时 ×0.93、429 ×0.97、致命 ×0.70;客户端错误与凭证错误**不计**渠道健康。
|
||||
- **headroom**:softLimit 来自手动 `max_concurrency`,未配置时按 429 起始水位自动估计 `max(4, floor(onset×0.9))`。
|
||||
- **costFactor**:`chn_abilities.cost_ratio`(上游价/基准价),越便宜权重越高(温和优势,不碾压健康信号)。
|
||||
- **rampFactor**:熔断恢复 / 手动启用 / 新建渠道后 2 分钟内从 5% 爬坡到 100%。
|
||||
|
||||
---
|
||||
|
||||
## 2. 路由策略(channel_routing_policy)
|
||||
|
||||
存于 `sys_options`,key = `channel_routing_policy`,JSON 浅覆盖默认值,**30s 内热生效,无需重启**。可在**管理后台 → 系统设置 → 渠道 → 路由策略覆盖(JSON)**中编辑,保存时校验 JSON 合法性与取值范围,非法配置拒绝入库;调度侧加载仍会二次校验(失败沿用上一份)。完整字段与默认值见基线方案 §12 + 开发计划 §4.3。
|
||||
|
||||
**租户级覆盖**:key = `channel_routing_policy:tenant:<租户ID>`(同样存 `sys_options`),三层浅合并:内置默认 → 全局覆盖 → 租户覆盖。租户覆盖非法时告警并回落全局策略,不影响该租户请求;解析结果本地缓存 30s。暂无管理界面,由 SQL 管理:
|
||||
|
||||
```sql
|
||||
INSERT INTO sys_options ("key", value, category, description, is_public)
|
||||
VALUES ('channel_routing_policy:tenant:123', '{"retry":{"failoverBudget":3}}', 'channel', '租户123路由策略覆盖', false)
|
||||
ON CONFLICT ("key") DO UPDATE SET value = EXCLUDED.value;
|
||||
```
|
||||
|
||||
常用调参场景:
|
||||
|
||||
| 想要的效果 | 改什么 |
|
||||
|---|---|
|
||||
| 备用渠道多接/少接平时流量 | `tierFactors.secondary`(默认 0.15;0=纯冷备,靠扩组兜底) |
|
||||
| 溢出更早/更晚发生 | `load.gamma`(默认 2;调大→接近饱和才坍缩更快) |
|
||||
| 会话粘性更持久 | `binding.ttlSeconds`(默认 1800,成功滑动续期) |
|
||||
| 病渠道上的会话更快迁走 | `binding.keepHealthMin`(默认 0.5,healthFactor 低于即重绑) |
|
||||
| 更激进/保守的原地重试 | `retry.inPlaceBudget`(默认 2)、`retry.backoffBaseMs` |
|
||||
| 熔断更灵敏/迟钝 | `breaker.failThreshold`(60s 窗口默认 8 次) |
|
||||
| 图片/视频允许有限重试 | `retry.unsafeFailoverBudget`(默认 0;仅"确定未送达"时生效) |
|
||||
| 调整哪些端点算非幂等 | `replay.unsafeModes` / `replay.safeModes`(relay mode 字符串,默认 unsafe=images_generations/images_edits/video_generations) |
|
||||
|
||||
示例(把备用层调成纯冷备 + 熔断更灵敏):
|
||||
|
||||
```json
|
||||
{"tierFactors": {"primary": 1.0, "secondary": 0, "reserve": 0}, "breaker": {"failThreshold": 5}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 排障手册
|
||||
|
||||
### 3.1 某渠道突然没有流量
|
||||
|
||||
按顺序检查:
|
||||
|
||||
1. **熔断了?** Redis:`HGETALL dispatch:v1:breaker:<渠道ID>`。`state=1` 为 OPEN;`opened_ms`+`cooldown_ms` 决定何时进入探测。指标 `breaker_opens` 有计数。
|
||||
2. **被自动禁用?** 渠道状态为 disabled 且 `auto_disabled=1` → 熔断持续超 10 分钟被自动禁用(开关 `channel_auto_disable_enabled`)。有全租户通知与 `[Dispatch] 渠道 X 熔断持续...已自动禁用` 日志。开启「自动恢复探测」后测试通过会自动回归(回归后自动进入爬坡)。
|
||||
3. **权重被压到 0?** 看渠道日志 `ForwardingTrace.scheduler[].weights`:base=0(权重配 0)?headroom=0(inflight 打满 softLimit,查 `ZCARD dispatch:v1:inflight:<ID>`)?tier=0(层级因子配 0)?
|
||||
4. **Key 全部冷却?** 401/403 会冷却对应 Key 5 分钟:`EXISTS dispatch:v1:credcd:<keyID>`。全部 Key 冷却期间该渠道整体不可用(trace 中 excluded_request 计数)。
|
||||
|
||||
### 3.2 会话为什么换渠道了(粘性丢失)
|
||||
|
||||
绑定失效只有三种途径:TTL 过期、守卫不合格(被绑渠道 healthFactor < 0.5 或原始余量 < 0.1 或熔断)、渠道被禁用/删除时批量清理。**单次请求失败不清绑定**。排查:渠道日志 `scheduler[].reason`——`bind`=命中,`hrw`=新绑定/重绑,`overflow`=高层级在场但饱和。会话键来源看 `session_source`(hdr/anthropic/openai/ident)。
|
||||
|
||||
### 3.3 溢出没按预期发生 / 发生得太多
|
||||
|
||||
- 没溢出:primary 未配 `max_concurrency` 且从未吃过 429 → 无容量信息,headroom 恒为 1。配手动上限或等 429 估计器收敛(`HGET dispatch:v1:limit429:<ID> onset_ewma`)。
|
||||
- 溢出过多:检查 primary 健康(healthFactor 低同样压权重);`overflow_by_tier` 指标看流向。
|
||||
|
||||
### 3.4 Redis 故障时会发生什么
|
||||
|
||||
按基线方案 §13 + 修订 R4 降级,**服务不中断**:绑定退化为纯 HRW(软粘性仍在)、熔断退化为实例本地、健康用最近快照;容量默认 fail-open,**勾选「严格容量」的渠道 fail-closed**(实例限额 = `max_concurrency / degrade.maxReplicas`,未配上限的严格渠道降级期间直接不可用——这是刻意行为)。多副本部署务必把 `degrade.maxReplicas` 配成实际副本数。
|
||||
|
||||
### 3.5 502 风暴 / 401 / 504 的预期行为
|
||||
|
||||
| 现象 | 预期行为 |
|
||||
|---|---|
|
||||
| 上游 502/503 抖动 | 同渠道退避重试(100ms→200ms+jitter,预算 2),多数请求无感消化,不换渠道不丢缓存 |
|
||||
| 单 Key 401 | Key 冷却 5min;渠道有备用 Key → 轮换后继续;唯一 Key → 该渠道致命,熔断直达 + failover |
|
||||
| 504 | 不原地重试;文本请求 failover,图片/视频终止(防重复生成) |
|
||||
| 流已开始后中断 | 必终止(防响应污染),按流中断结算 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 客户端接入:X-Session-Id
|
||||
|
||||
会话级粘性的最优信号是显式头:
|
||||
|
||||
```
|
||||
X-Session-Id: <你的会话唯一标识,≤256 字节,无控制字符>
|
||||
```
|
||||
|
||||
同一值的请求会稳定路由到同一渠道(命中上游 prompt 缓存)。未携带时按协议信号回退:Anthropic `metadata.user_id`(Claude Code 自动携带,含 session 段)→ OpenAI `previous_response_id`/`conversation_id` → 身份四元组(租户+用户+Key+模型)。平台自有 SDK 应默认携带此头。
|
||||
|
||||
---
|
||||
|
||||
## 5. 遗留与冻结项
|
||||
|
||||
- `chn_channels.priority`:列保留但**不再参与调度**(迁移 000014 起由 tier 三档替代),仅供历史查询与回滚。
|
||||
- `chn_channel_affinities` 表:已废弃(迁移 000015 标记),绑定只存 Redis。
|
||||
- 旧开关 `channel_scheduler_v2_enabled` / `channel_affinity_enabled(_ttl_seconds)` / `channel_capacity_enabled` / `channel_dispatch_shadow_enabled` / `channel_auto_disable_threshold`:已从设置注册表退役(sys_options 中的历史行无害,管理界面不再展示)。
|
||||
- `channel_auto_disable_enabled` 保留,语义改为「熔断持续超过 `breaker.autoDisableAfterSeconds` 自动禁用」。
|
||||
- **实现偏差记录**:基线方案 §10.1 的目录版本号 key(`dispatch:v1:catalog:ver`,配置变更时 INCR)未实现,以 pub/sub 失效通知 + 5s 定时全量刷新替代——效果等价(管理操作即时失效 + 秒级兜底收敛),且省去版本比对逻辑;如未来目录规模大到全量刷新成为负担,再引入版本号做增量跳过。
|
||||
@@ -1,261 +0,0 @@
|
||||
# 渠道调度重构开发计划
|
||||
|
||||
> 状态:定稿待执行。撰写时间:2026-07-30。
|
||||
> 设计基线:[`claude设计的渠道调度方案.md`](./claude设计的渠道调度方案.md)(下称「基线方案」),叠加 [`渠道调度三方案对比评审.md`](./渠道调度三方案对比评审.md) 议定的修订项。
|
||||
> 开发分支:`feat/relaykit-migration`(已核查与调度代码无冲突,见评审文档 §6.-1)。
|
||||
> 本文只写「基线方案之外的修订 + 可执行的任务分解」,权重公式、Lua 脚本、Redis key 布局等设计细节以基线方案为准,不重复。
|
||||
|
||||
---
|
||||
|
||||
## 1. 输入与决策依据
|
||||
|
||||
| 输入 | 内容 |
|
||||
|---|---|
|
||||
| 问题清单 | [`当前渠道调度存在的问题.md`](./当前渠道调度存在的问题.md):A1-A3 / R1-R2 / H1-H2 / P1 / O1 / L2-L3 |
|
||||
| 锁定决策 | ① Redis 为状态真相源;② 成本感知开启;③ 不做对话指纹;④ 自然探活熔断 |
|
||||
| 评审结论 | claude 方案为主干;吸收 gpt 的凭证归因、重放保护、504 特判、fail-closed;吸收 glm 的 ForwardingTrace 观测落点、错误类别加权健康;glm 的 keepThreshold 复合权重守卫判据**禁止采用** |
|
||||
| 已议定 | tier 用固定三档枚举(primary/secondary/reserve),不用数值优先级 |
|
||||
| 分支核查 | relaykit 分支未触碰调度代码;relaykit 为独立无 gf 依赖的 Go module |
|
||||
|
||||
---
|
||||
|
||||
## 2. 对基线方案的修订清单(Delta)
|
||||
|
||||
实现时以基线方案为蓝本,叠加以下 7 项修订。修订编号在后续任务分解中引用。
|
||||
|
||||
### R1. 凭证级归因与轮换(来自 gpt §11.3/11.4)
|
||||
|
||||
现状:`getChannelKey(ctx, channelID)` 每渠道只取第一条 active Key(`internal/logic/relay/provider.go:1071`,注释明确"每渠道仅一个 Key"),**当前不存在轮换能力**,属新增:
|
||||
|
||||
- 错误分类新增 `ErrClassCredential`:401/403 以及可识别的"Key 额度耗尽"错误**先归因到凭证**,不再直接定为 CHANNEL_FATAL。
|
||||
- 处理流程:凭证错误 → 该 KeyID 写入 Redis 冷却(`dispatch:v1:credcd:<keyID>`,TTL 默认 300s)→ 同渠道选下一个未冷却的 active Key 重试一次(**凭证轮换预算 = 1/渠道/请求**,独立于原地/failover 预算)→ 该渠道全部 Key 冷却或耗尽 → 升级为 CHANNEL_FATAL(走基线方案的熔断 + 告警)。
|
||||
- 链路改造:`common.ChannelSelection` 增加 `KeyID int64` 字段;新增 `getChannelKeyExcluding(ctx, channelID, excludeKeyIDs)`(按 id 稳定排序取首个未排除的 active Key,复用现有解密与 OAuth 刷新逻辑);`Outcome` 上报带 KeyID,凭证失败**不计入渠道健康 EWMA**(只计凭证冷却),避免一个坏 Key 拉低整条渠道健康分。
|
||||
- 只有一个 Key 的渠道(当前全部渠道的实际形态):轮换预算天然用不上,行为退化为「凭证错误 → 冷却唯一 Key → 升级 CHANNEL_FATAL」,与基线方案一致,无兼容性风险。
|
||||
|
||||
### R2. 可重放性与送达状态(来自 gpt §6.1/§11.2)
|
||||
|
||||
- 新增两个纯类型(放核心库 `types.go`):
|
||||
- `Replayability`:`ReplaySafe`(查询/embedding/rerank)、`ReplayCostly`(chat/completion 文本生成)、`ReplayUnsafe`(图片/视频生成、异步任务提交)。由 relay mode 静态映射,做成可配置表。
|
||||
- `DeliveryState`:`NotSent`(连接失败/请求体未发出)、`MaybeSent`(写出后连接重置/读超时/EOF)、`ResponseReceived`、`ResponseStarted`(已向客户端写出字节)。由执行器(handler)在错误发生点标注。
|
||||
- 硬规则(进重试 FSM,优先级高于错误分类):
|
||||
- `ReplayUnsafe + MaybeSent` → **无条件 Abort**,无论状态码;
|
||||
- `ResponseStarted` → Abort(等价基线方案 PARTIAL_STREAM,统一到 DeliveryState 表达);
|
||||
- `ReplayUnsafe` 的原地/failover 预算默认收紧为 0/0(可配),只有 `NotSent` 允许 failover。
|
||||
|
||||
### R3. 504 从 TRANSIENT 拆出(来自 gpt §11.4)
|
||||
|
||||
基线方案将 502/504 同归 TRANSIENT。修订为:
|
||||
|
||||
- **502、520-527、请求未发出的连接重置**:维持 TRANSIENT,原地退避重试;
|
||||
- **504**:默认**不原地重试**(网关超时意味着请求大概率已送达且上游处理慢,原地重试大概率再超时),按 `Replayability` 决定:`ReplaySafe/Costly + 未写出响应` → 直接 failover;`ReplayUnsafe` → Abort。归类并入 TIMEOUT 处理分支。
|
||||
|
||||
### R4. 昂贵渠道 Redis 故障 fail-closed(来自 gpt §15.3)
|
||||
|
||||
基线方案 Redis 故障时容量一律 fail-open。修订为按渠道可配:
|
||||
|
||||
- `chn_channels` 新增 `strict_capacity BOOLEAN DEFAULT FALSE`(并入迁移 000014)。
|
||||
- `strict_capacity=true` 的渠道在 Redis 不可用时使用实例级保守限额:`localLimit = max(1, floor(max_concurrency / 部署副本数))`,副本数从配置读(`manifest/config`,默认 1);未配 `max_concurrency` 的 strict 渠道降级期间直接排除。
|
||||
- 默认 false(fail-open),仅对高成本/严格配额渠道由运营手动开启。
|
||||
|
||||
### R5. 决策观测并入 ForwardingTrace(来自 glm §11)
|
||||
|
||||
基线方案 §14.1 的决策日志(weight_breakdown、binding 命中、排除原因)除写 DEBUG 日志外,**同步挂到现有 `relay/common.ForwardingTrace`**,随现有渠道日志链路展示,不新建日志通道。字段命名对齐基线方案 §14。
|
||||
|
||||
### R6. 健康 EWMA 按错误类别加权(来自 glm §5.2 errorDistScore 思想,简化版)
|
||||
|
||||
基线方案失败一律 `succ = succ × 0.9`。修订为衰减系数按错误类别分档(Lua 脚本 ARGV 传入):
|
||||
|
||||
- TRANSIENT / TIMEOUT:×0.93(轻罚,瞬时抖动不重伤)
|
||||
- RATE_LIMIT:×0.97(429 主要喂 softLimit 估计器,基本不伤健康)
|
||||
- CHANNEL_FATAL:×0.70(重罚,加速权重坍缩)
|
||||
- CLIENT / Credential / 客户端取消:**不上报健康**(gpt §12.1 原则:请求级错误与凭证错误不得计入渠道失败率)
|
||||
|
||||
### R7. relaykit 分支适配
|
||||
|
||||
- **核心库落点改为 `relaykit/dispatch/`**(替代基线方案的 `relay/dispatch/`):relaykit 是独立无 gf 依赖的 module,纯度由模块边界 + 编译器强制,阶段 0「import 无 gf/dao/redis」验收自动成立;且错误分类器可自然 import 同 module 的 `relaykit/types`。
|
||||
- 错误分类器签名扩展:`Classify(statusCode int, err error, delivery DeliveryState, retryAfter time.Duration) ErrorClass`,内部解包 `relaykit/types.NewAPIError`——尊重 `IsSkipRetryError`(→ 直接 Abort 或 Failover,不原地重试)、`IsChannelError`(→ 渠道级归因),按 ErrorCode 细分凭证类错误。
|
||||
- 提交前缀约定:调度重构的提交统一用 `feat(dispatch): ...` / `test(dispatch): ...`,与本分支 relaykit 提交(`feat(relaykit):`)区分,便于将来按需 cherry-pick 或回滚。
|
||||
|
||||
---
|
||||
|
||||
## 3. 最终模块结构
|
||||
|
||||
```
|
||||
relaykit/dispatch/ ← 新增。纯库(module 边界强制无 gf/dao/redis)
|
||||
types.go Channel、Tier、RequestProfile、Decision、Outcome、ScoredChannel、
|
||||
Replayability、DeliveryState(R2)
|
||||
session.go 会话键解析链(纯函数)
|
||||
classify.go 错误分类器(含 NewAPIError 解包 R7、ErrClassCredential R1、504 特判 R3)
|
||||
score.go W(c) 权重合成(纯函数)
|
||||
hrw.go 加权 rendezvous 选择(纯函数)
|
||||
policy.go 重试决策 FSM(含凭证轮换预算 R1、重放硬规则 R2)+ 策略结构体
|
||||
breaker.go 熔断状态机(纯函数 + 端口)
|
||||
coordinator.go 编排(Route/Next/Report/Finish)
|
||||
ports.go CatalogPort / StatePort / Clock
|
||||
|
||||
internal/logic/dispatchadapter/ ← 新增。适配层,全部 I/O
|
||||
catalog.go 三表 JOIN → 内存快照(5s 刷新 + pub/sub 失效),含 Key 列表快照(R1)
|
||||
state_redis.go StatePort Redis 实现(基线方案 §10 Lua + credcd 冷却 R1 + 分档衰减 R6)
|
||||
state_local.go Redis 降级镜像(含 strict_capacity 保守限额 R4)
|
||||
config.go routing policy 加载/校验/热更新
|
||||
wire.go 组装 coordinator
|
||||
|
||||
relay/handler/relay_handler.go ← 阶段 3 重写重试循环(Route/Next/Report/Finish)
|
||||
internal/logic/relay/ ← 阶段 3 起逐步退役调度部分;渠道 Key 解密/OAuth 刷新逻辑保留复用
|
||||
relay/scheduler/ ← 阶段 5 删除
|
||||
```
|
||||
|
||||
依赖方向:`relay/handler → relaykit/dispatch ← internal/logic/dispatchadapter`。
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据与状态变更
|
||||
|
||||
### 4.1 迁移 `migrations/000014_v0_2_1_version.sql`
|
||||
|
||||
(编号已核实:当前最新 000013。合入前如被占用则顺延并同步改本文。)
|
||||
|
||||
在基线方案 §11 基础上增加 R4 字段,汇总:
|
||||
|
||||
| 变更 | 说明 |
|
||||
|---|---|
|
||||
| `chn_channels.tier VARCHAR(16) DEFAULT 'primary'` | 三档枚举,DENSE_RANK 按现有 priority 自动映射(基线方案 §11) |
|
||||
| `chn_channels.strict_capacity BOOLEAN DEFAULT FALSE` | R4,Redis 故障 fail-closed 开关 |
|
||||
| `chn_abilities.cost_ratio NUMERIC(10,4) DEFAULT 1.0` | 成本比例(渠道×模型,无量纲,Go 层 float64) |
|
||||
| `chn_health_scores` | 保留,降级为 cron 快照落点(阶段 3) |
|
||||
|
||||
所有新字段带 `COMMENT`;up/down 幂等;执行后 `gf gen dao`。
|
||||
|
||||
### 4.2 Redis key(基线方案 §10.1 之外新增)
|
||||
|
||||
| key | 类型 | 内容 | TTL |
|
||||
|---|---|---|---|
|
||||
| `dispatch:v1:credcd:<keyID>` | STRING | 冷却原因码 | credCooldownSeconds(默认 300s) |
|
||||
|
||||
### 4.3 routing policy 扩展字段(基线方案 §12 之外新增)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"retry": { "credRotateBudget": 1, "credCooldownSeconds": 300 },
|
||||
"replay": {
|
||||
"unsafeModes": ["image", "video", "task_submit"], // relay mode → ReplayUnsafe 映射
|
||||
"unsafeInPlaceBudget": 0, "unsafeFailoverBudget": 0
|
||||
},
|
||||
"degrade": { "maxReplicas": 1 } // R4 实例级保守限额分母
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 分阶段开发计划
|
||||
|
||||
> 原则沿用基线方案 §16:每阶段独立可合入、可回滚;不引入长期存活的新旧切换开关;灰度用影子模式,切换一次性完成后立即删旧码。全部在 `feat/relaykit-migration` 分支进行,提交前缀 `feat(dispatch)`。
|
||||
> 测试命令注意:跑覆盖率需 `GOTOOLCHAIN=go1.25.8 go test -cover ...`(工具链已知问题);relaykit 子 module 内需 `cd relaykit` 后执行。
|
||||
|
||||
### 阶段 0:纯核心库(零接线,零风险)|预估 4~6 人日
|
||||
|
||||
**前置任务(可并行)**
|
||||
- [ ] 抓包验证 Claude Code `metadata.user_id` 格式(`session_<uuid>` 段是否稳定),结论回写基线方案 §19.1。验证失败不阻塞——解析链自动降级身份级。
|
||||
|
||||
**开发任务**
|
||||
- [ ] `relaykit/dispatch/types.go`:全部领域类型,含 `Replayability`/`DeliveryState`(R2)、`Decision.KeyID`(R1)
|
||||
- [ ] `session.go`:解析链(X-Session-Id → anthropic metadata → openai responses → 身份四元组),按基线方案 §3
|
||||
- [ ] `classify.go`:错误分类表(R1 ErrClassCredential、R3 504 拆出、R7 NewAPIError 解包)
|
||||
- [ ] `score.go` + `hrw.go`:权重合成 + HRW,按基线方案 §4/§5.1
|
||||
- [ ] `policy.go`:重试 FSM——三预算(原地/凭证轮换/failover)+ R2 硬规则 + 退避曲线;策略结构体含 §4.3 新字段
|
||||
- [ ] `breaker.go`:熔断状态机,按基线方案 §7
|
||||
- [ ] `coordinator.go` + `ports.go`:Route/Next/Report/Finish 编排(Next 返回的 Decision 含渠道+KeyID;凭证轮换时 Next 返回同渠道新 Key)
|
||||
|
||||
**单元测试清单**(表驱动为主,补 gpt 式属性测试)
|
||||
- [ ] HRW 分布(10 万采样,权重比例误差 < 2%);同 seed 万次同结果;分数并列按 ChannelID 稳定
|
||||
- [ ] 会话键解析全 case(含抓包真实样本、超长/非法值拒绝)
|
||||
- [ ] 错误分类全表覆盖(含 NewAPIError SkipRetry/ChannelError 路径、504、凭证错误)
|
||||
- [ ] FSM 全路径:三预算独立扣减;`ReplayUnsafe+MaybeSent` 任意状态码必 Abort;`ResponseStarted` 必 Abort;凭证轮换耗尽升级 CHANNEL_FATAL;Retry-After 遵从
|
||||
- [ ] 属性测试:任意错误序列不突破预算;候选顺序打乱不改变选择结果;禁用渠道永不返回
|
||||
- [ ] 熔断全转移路径(cooldown 指数递增、探测窗口)
|
||||
- [ ] 权重边界(headroom=0、health=0.01、tierFactor=0、costFactor clamp)
|
||||
- [ ] 绑定守卫防抖(模拟 headroom 抖动,重绑次数 ≤ 阈值;**溢出到 secondary 的绑定在守卫下保持稳定**——glm 缺陷的回归用例)
|
||||
|
||||
**验收**:`cd relaykit && GOTOOLCHAIN=go1.25.8 go test -cover ./dispatch/...` 全绿、覆盖率 ≥ 90%;`relaykit/go.mod` 无新增 gf/redis/dao 依赖(编译器保证)。
|
||||
**回滚**:纯新增代码,无接线,直接 revert。
|
||||
|
||||
### 阶段 1:适配层 + 数据迁移|预估 5~7 人日
|
||||
|
||||
- [ ] `migrations/000014_v0_2_1_version.sql`(§4.1)+ `gf gen dao`
|
||||
- [ ] `catalog.go`:内存快照(含 tier/cost_ratio/strict_capacity/Key 列表)+ 5s 刷新 + pub/sub 失效
|
||||
- [ ] `state_redis.go`:基线方案 §10 全部 Lua(EWMA 改造为 R6 分档衰减系数)+ 绑定 CAS + 探测令牌 + 租约 + credcd 冷却(R1)+ ReportOutcome 有界 channel 后台批量 worker
|
||||
- [ ] `state_local.go`:降级镜像 + R4 strict_capacity 保守限额
|
||||
- [ ] `config.go`:policy 加载 + Schema 校验(非法拒绝生效沿用上一份)+ 热更新
|
||||
- [ ] Key 选取改造:`getChannelKeyExcluding`(复用现有解密/OAuth 刷新,`internal/logic/relay/provider.go` 内新增,不动原函数)
|
||||
|
||||
**测试**:miniredis 集成——Lua 原子性(100 并发失败 → 计数精确 =100)、CAS 绑定竞争收敛、租约过期自愈、credcd 冷却过期恢复、分档衰减数值断言。
|
||||
**验收**:迁移 up/down 幂等;tier 自动映射人工抽查;集成测试全绿。
|
||||
**回滚**:迁移 down + revert;无运行时接线。
|
||||
|
||||
### 阶段 2:影子模式(只算不用)|预估 2~3 人日
|
||||
|
||||
- [ ] handler 在现有调度返回后并行调用新 Coordinator 计算初始决策,新旧结果(channel 是否一致 + weight_breakdown)写对比日志
|
||||
- [ ] 对比统计脚本(一致率、不一致归因分布)
|
||||
|
||||
**验收**:影子运行 ≥ 3 天或 ≥ 10 万请求;每类不一致有解释;影子路径 p99 额外耗时 < 1ms。
|
||||
**回滚**:删影子调用,无状态残留。
|
||||
|
||||
### 阶段 3:切换写路径(一次性)|预估 6~8 人日
|
||||
|
||||
- [ ] handler 重试循环替换为 `Route/Next/Report/Finish`(基线方案 §9.3 模式);DeliveryState 在执行器各错误发生点标注(R2);`ChannelSelection` 增加 `KeyID`
|
||||
- [ ] 健康/绑定/租约全部走新 StatePort;凭证错误走 credcd 冷却链路(R1)
|
||||
- [ ] 决策与重试信息挂 `common.ForwardingTrace`(R5);`SelectionReason` 取值改 `bind`/`hrw`/`overflow`/`cred_rotate`,前端渠道日志展示同步调整
|
||||
- [ ] 移除 `GetChannelForModel` 内调度逻辑(保留租户/模型权限校验与 Key 解密)
|
||||
- [ ] cron:Redis 健康 EWMA → `chn_health_scores` 每 5min 快照;熔断长期 OPEN → 自动禁用落库 + 通知
|
||||
- [ ] 管理后台联动:禁用/删除渠道 → `InvalidateChannelBindings` + pub/sub 目录失效
|
||||
|
||||
**验收**:DoD 全项;压测对比切换前后 p50/p99 与 DB QPS(调度查询归零);故障注入演练——502 风暴(原地消化)、401 单 Key(冷却不熔渠道;唯一 Key 时 <1s 熔断)、504(不原地重试、按可重放性分流)、图片生成 MaybeSent(必 Abort 不重放)、primary 全灭(扩组 secondary)。
|
||||
**回滚**:revert 本阶段合并提交;Redis 新前缀独立,旧逻辑不受污染。
|
||||
**协调**:本阶段与 relaykit 桥接同改 `relay_handler.go`,动工前确认 relaykit 侧无并行改动计划,冲突时调度侧后合。
|
||||
|
||||
### 阶段 4:溢出与成本完整化|预估 4~5 人日
|
||||
|
||||
- [ ] softLimit 429 自动估计器上线
|
||||
- [ ] 管理后台:渠道表单 tier 下拉 + strict_capacity 开关;abilities 列表 cost_ratio 编辑 + CSV 批量导入
|
||||
- [ ] rampFactor 接通熔断恢复与新渠道创建事件
|
||||
- [ ] `dispatch_overflow_total` 等指标接入 ops 仪表盘
|
||||
- [ ] R4 降级演练:断 Redis,验证 strict 渠道保守限额、非 strict fail-open
|
||||
|
||||
**验收**:压测单 primary 至饱和,溢出曲线符合预期;降级演练通过。
|
||||
|
||||
### 阶段 5:清理与收尾|预估 2~3 人日
|
||||
|
||||
- [ ] 删除:`relay/scheduler/`、`internal/logic/relay/affinity.go`、`health.go` 调度耦合部分、legacy 排序路径、`channel_scheduler_v2_enabled` 等四个布尔开关及读取点、影子对比代码
|
||||
- [ ] 文档:运维手册(policy 调优、熔断/溢出/凭证冷却排障)、接入文档补 `X-Session-Id`、`goframe-conventions.md` 如有框架 bug 修复按规范追加记录
|
||||
- [ ] 全仓 grep 无旧开关引用
|
||||
|
||||
**总预估:23~32 人日**(不含影子模式的 3 天观察窗)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 风险登记
|
||||
|
||||
| # | 风险 | 应对 |
|
||||
|---|---|---|
|
||||
| 1 | `metadata.user_id` 格式不符预期 | 阶段 0 前置验证;失败自动降级身份级,`dispatch_session_source_total` 持续监控 |
|
||||
| 2 | 与 relaykit 工作流同分支同文件(relay_handler.go) | 提交前缀隔离;阶段 3 动工前对齐;调度侧后合 |
|
||||
| 3 | tierFactor/γ/衰减分档等参数初值偏差 | 影子模式 weight_breakdown 校准;全部热更新,无需发版 |
|
||||
| 4 | 429 自动估计冷启动 | 可接受(429 触发 failover 用户无感);敏感渠道手动配 max_concurrency |
|
||||
| 5 | 迁移编号 000014 被抢占 | 合入前复查 `migrations/`,顺延即可 |
|
||||
| 6 | Redis 依赖加重 | 降级路径保证可服务(strict 渠道除外,属刻意 fail-closed);生产建议主从/哨兵 |
|
||||
| 7 | 多 Key 渠道当前为空集,R1 轮换路径缺真实流量验证 | 阶段 3 故障注入用测试渠道配双 Key 演练;单 Key 行为已与基线一致 |
|
||||
|
||||
---
|
||||
|
||||
## 7. DoD 对照
|
||||
|
||||
| DoD 项 | 落点 |
|
||||
|---|---|
|
||||
| 手动测试端到端 | 阶段 3 故障注入演练清单 |
|
||||
| 单测覆盖 ≥ 80% | 阶段 0 核心库 ≥ 90%;适配层 miniredis 集成测试 |
|
||||
| 接口文档同步 | 阶段 5(X-Session-Id 接入文档);管理后台新字段随阶段 4 更新 |
|
||||
| 迁移幂等 | 阶段 1 验收项 |
|
||||
| 错误码与文案 | 沿用 relay 原生错误透传规则;管理接口走 `internal/response` |
|
||||
| 日志含 request_id | ForwardingTrace + 决策/重试日志均携带 |
|
||||
| 权限校验 | 阶段 4 管理后台新端点配权限点 |
|
||||
| Code Review | 每阶段独立 PR 评审后合入 |
|
||||
Reference in new issue
Block a user