From 56b4bcdc32bb29da2b30b5f8390aa46f050fcfe7 Mon Sep 17 00:00:00 2001 From: qian Date: Sat, 1 Aug 2026 23:02:19 +0800 Subject: [PATCH] =?UTF-8?q?chore:=E7=A7=BB=E9=99=A4=E8=BF=87=E7=A8=8B?= =?UTF-8?q?=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reference/claude设计的渠道调度方案.md | 805 ---------- docs/reference/glm设计的渠道调度方案.md | 591 ------- docs/reference/gpt设计的渠道调度方案.md | 1661 -------------------- docs/reference/new-api渠道调度分析.md | 254 --- docs/reference/sub2api渠道调度分析.md | 202 --- docs/reference/当前渠道调度存在的问题.md | 85 - docs/reference/渠道调度三方案对比评审.md | 129 -- docs/reference/渠道调度运维手册.md | 120 -- docs/reference/渠道调度重构开发计划.md | 261 --- 9 files changed, 4108 deletions(-) delete mode 100644 docs/reference/claude设计的渠道调度方案.md delete mode 100644 docs/reference/glm设计的渠道调度方案.md delete mode 100644 docs/reference/gpt设计的渠道调度方案.md delete mode 100644 docs/reference/new-api渠道调度分析.md delete mode 100644 docs/reference/sub2api渠道调度分析.md delete mode 100644 docs/reference/当前渠道调度存在的问题.md delete mode 100644 docs/reference/渠道调度三方案对比评审.md delete mode 100644 docs/reference/渠道调度运维手册.md delete mode 100644 docs/reference/渠道调度重构开发计划.md diff --git a/docs/reference/claude设计的渠道调度方案.md b/docs/reference/claude设计的渠道调度方案.md deleted file mode 100644 index 6f1f77a..0000000 --- a/docs/reference/claude设计的渠道调度方案.md +++ /dev/null @@ -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__account__session_ - 用正则提取 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:` | STRING | channelID | bindTTL(默认 30min,成功滑动续期) | -| `dispatch:v1:bind:rev:` | SET | 指向该渠道的绑定 key 集合 | bindTTL + 60s | -| `dispatch:v1:health::` | HASH | succ_ewma, lat_ewma, fail_window_count, window_start_ms | 24h 滑动 | -| `dispatch:v1:breaker:` | HASH | state(0/1/2), opened_at_ms, cooldown_ms, probe_window_start_ms | 24h | -| `dispatch:v1:breaker::` | HASH | 同上(模型级) | 24h | -| `dispatch:v1:inflight:` | ZSET | member=requestID, score=过期时间戳 ms | 租约期 + 60s | -| `dispatch:v1:limit429:` | 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:`,浅合并),配置中心热加载(沿用现有 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_` 段。阶段 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 主从/哨兵(部署层事项,不在本方案范围)。 diff --git a/docs/reference/glm设计的渠道调度方案.md b/docs/reference/glm设计的渠道调度方案.md deleted file mode 100644 index cbd0c67..0000000 --- a/docs/reference/glm设计的渠道调度方案.md +++ /dev/null @@ -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) diff --git a/docs/reference/gpt设计的渠道调度方案.md b/docs/reference/gpt设计的渠道调度方案.md deleted file mode 100644 index 1c4e06b..0000000 --- a/docs/reference/gpt设计的渠道调度方案.md +++ /dev/null @@ -1,1661 +0,0 @@ -# GPT 设计的渠道调度方案 - -> 状态:待审查 -> -> 文档类型:独立设计与实施规格 -> -> 适用范围:模型渠道选择、会话亲和、容量溢出、失败重试、流量控制、健康管理和多实例一致性 -> -> 设计日期:2026-07-30 - -## 1. 文档目标 - -本文定义一套可独立实现的渠道调度系统。实现人员不需要从其他设计文档补充核心语义;本文中的默认值、状态转换、接口边界、数据结构、监控口径和分阶段交付条件共同构成第一版实现基线。 - -本方案解决以下问题: - -1. 同一个 API Key 发起多个会话时,如何优先按会话保持渠道亲和,又如何在没有会话 ID 时合理降级。 -2. 优先渠道失败时,什么情况原渠道重试,什么情况切换渠道,什么情况必须停止。 -3. 如何表达首选、备用、保底渠道,并在首选渠道高负载时逐步把新流量溢出到备用渠道。 -4. 如何把渠道选择、亲和、重试和自动调度拆成低耦合、可测试的独立模块。 -5. 如何保证多实例部署下的容量、亲和、健康、熔断和临时流量策略一致。 -6. 如何观察每次选择的原因,并临时调整某个渠道的流量比例、安全回滚。 - -## 2. 范围与非目标 - -### 2.1 本期范围 - -- 同步 HTTP 模型请求和 SSE 流式请求的渠道调度。 -- 按租户、用户、API Key、模型、会话构造路由上下文。 -- 模型能力、租户范围、渠道状态等硬资格过滤。 -- 会话级软亲和及身份级降级亲和。 -- 首选、备用、保底三级渠道。 -- 基于并发、吞吐、排队和延迟的负载溢出。 -- 渠道级和凭证级错误归因。 -- 原渠道重试、凭证轮换和跨渠道故障转移。 -- Redis 分布式容量租约、亲和、冷却和熔断状态。 -- 临时流量比例、灰度、排空和暂停控制。 -- 指标、日志、Trace、看板、告警和调度解释器。 -- 影子运行、灰度切换和版本化回滚。 - -### 2.2 非目标 - -- 不在第一版实现跨地域全局最优路由。 -- 不根据提示词内容推断业务类型或会话身份。 -- 不保证任意短时间窗口内流量比例数学上完全精确。 -- 不把调度器拆成独立网络微服务;第一版采用进程内核心模块和分布式状态存储。 -- 不负责模型协议转换、计费计算、响应内容转换和审计内容脱敏,这些能力通过执行器边界接入。 -- 不通过请求内容相似度生成会话指纹。 - -## 3. 已确定的核心决策 - -| 主题 | 决策 | -|---|---| -| 调度单位 | 默认按会话分配渠道;无会话信号时按身份级软亲和降级 | -| 渠道层级 | 固定为 `preferred`、`standby`、`emergency` 三层 | -| 层级语义 | 首选低压时独占新会话;高压时渐进溢出到备用;保底只在前两层不可用时使用 | -| 层内算法 | 加权 Rendezvous Hash,保证会话稳定和多实例确定性 | -| 亲和性质 | 软绑定;健康、熔断、硬容量和高压逃逸条件优先于亲和 | -| 重试模型 | 原渠道重试、同渠道凭证轮换、跨渠道切换分别计数 | -| 504 处理 | 不默认原渠道重试;只对可重放且未输出响应的请求允许受限 failover | -| 流式安全 | 向客户端写出任何有效响应字节后禁止切换渠道 | -| 非幂等安全 | 请求可能已送达上游时禁止自动重放,除非上游支持并已使用幂等键 | -| 配置真相源 | PostgreSQL 保存持久策略和审计记录 | -| 运行状态真相源 | Redis 保存容量、亲和、冷却、熔断和实时健康状态 | -| Redis 降级 | 使用本地最近快照和保守容量;严格容量渠道默认 fail-closed | -| 临时流量控制 | 版本化、带 TTL、支持渐进生效和自动回滚,不直接修改永久基础权重 | -| 可观察性 | 每次选择必须有稳定原因码;指标区分首次分配、全部尝试和成功流量 | - -## 4. 术语与不变量 - -### 4.1 术语 - -- **逻辑请求**:客户端发起的一次请求,不因内部重试而改变 Request ID。 -- **尝试**:逻辑请求向某个渠道、某个凭证发送的一次上游请求。 -- **新会话**:没有有效亲和绑定的显式会话或身份级主体。 -- **亲和绑定**:一个路由主体在一段 TTL 内优先使用某个渠道的记录。 -- **逃逸**:亲和渠道不再满足复用条件,当前请求临时或永久离开该渠道。 -- **压力**:渠道并发、Token 吞吐、排队和延迟相对限制的综合负载值。 -- **软限制**:开始溢出新会话的压力阈值。 -- **硬限制**:不再允许获取新容量租约的上限。 -- **基础权重**:渠道的长期容量权重。 -- **有效权重**:基础权重经过健康、容量、恢复爬坡和临时策略修正后的权重。 -- **分配比例**:调度器对首次分配或新会话做出的选择比例,不等同于最终成功请求比例。 - -### 4.2 必须始终成立的不变量 - -1. 禁用、无模型能力、超出租户渠道范围或熔断 OPEN 的渠道不得被选择。 -2. 未成功获得硬容量租约的渠道不得发送请求。 -3. 同一个逻辑请求在所有尝试中使用同一个全局唯一 Request ID。 -4. 向客户端写出有效响应字节后不得跨渠道重试。 -5. 非幂等请求在“可能已送达上游”时不得自动重放。 -6. 人工流量策略不得绕过模型能力、禁用状态、熔断 OPEN 和硬容量限制。 -7. 所有重试都必须受尝试次数、切换次数、总耗时和请求 deadline 限制。 -8. 监控指标不得使用用户 ID、API Key ID、会话 ID 或 Request ID 作为标签。 -9. 多实例对同一显式会话、同一策略版本和同一候选快照应产生相同的初始排序。 -10. 临时策略必须可审计、可过期、可回滚;默认不允许永久生效。 - -## 5. 总体架构 - -```text -┌──────────────────────────────────────────────────────────────┐ -│ Request Normalizer │ -│ 协议解析、会话信号、幂等性、成本类型、deadline、流式状态 │ -└───────────────────────────┬──────────────────────────────────┘ - │ SchedulingRequest -┌───────────────────────────▼──────────────────────────────────┐ -│ Scheduling Coordinator │ -│ 获取目录快照、运行快照、亲和绑定,调用纯调度核心,申请容量租约 │ -└───────────────┬───────────────────────┬──────────────────────┘ - │ │ -┌───────────────▼────────────┐ ┌───────▼──────────────────────┐ -│ Scheduler Core │ │ Runtime Adapters │ -│ 硬过滤、层级溢出、HRW、重试 │ │ PostgreSQL、Redis、本地快照 │ -│ 纯函数、无网络和存储 I/O │ │ Lua、缓存、发布订阅 │ -└───────────────┬────────────┘ └──────────────────────────────┘ - │ Decision / RetryDecision -┌───────────────▼──────────────────────────────────────────────┐ -│ Executor │ -│ 协议适配、HTTP/SSE、凭证注入、响应写入、结果事件上报 │ -└───────────────┬──────────────────────────────────────────────┘ - │ AttemptResult -┌───────────────▼──────────────────────────────────────────────┐ -│ Observer │ -│ 健康、熔断、冷却、亲和提交、指标、日志、Trace、审计 │ -└──────────────────────────────────────────────────────────────┘ -``` - -### 5.1 控制面与数据面 - -控制面负责低频变更: - -- 渠道角色、基础权重、容量限制。 -- 模型能力和租户可用范围。 -- 路由策略和临时流量覆盖。 -- 策略版本发布、审计和回滚。 - -数据面负责每个请求: - -- 读取本地目录快照和 Redis 运行快照。 -- 解析亲和、选择渠道、获取容量租约。 -- 执行请求和重试状态机。 -- 上报结果并原子更新运行状态。 - -控制面故障不应中断已经发布策略的数据面。数据面必须始终能够使用最近一个完整策略版本继续工作。 - -## 6. 标准化请求模型 - -所有协议进入调度器前转换为统一结构: - -```go -type SchedulingRequest struct { - RequestID string - TenantID int64 - UserID int64 - APIKeyID int64 - CanonicalModel string - Operation OperationType - Stream bool - Session SessionIdentity - Replayability Replayability - EstimatedTokens int64 - Deadline time.Time - PolicyVersion int64 -} - -type SessionIdentity struct { - Kind SessionKind // explicit / protocol / identity / none - Token string // 进入存储和日志前必须哈希 -} - -type Replayability uint8 - -const ( - ReplayUnknown Replayability = iota - ReplaySafe - ReplayCostly - ReplayUnsafe -) -``` - -### 6.1 操作类型与默认可重放性 - -| 操作 | 默认分类 | 说明 | -|---|---|---| -| 模型列表、查询类接口 | `ReplaySafe` | 无副作用 | -| Embedding、Rerank | `ReplaySafe` | 结果可重复计算,但仍有成本 | -| 文本 Chat/Completion | `ReplayCostly` | 通常无外部副作用,但可能重复计费 | -| 工具调用生成 | `ReplayCostly` | 只重放模型请求,不得重复执行客户端工具 | -| 图片、视频生成 | `ReplayUnsafe` | 可能产生重复任务和高额成本 | -| 异步任务提交 | `ReplayUnsafe` | 除非上游支持幂等键 | - -当上游支持幂等键且执行器确认已传递同一个逻辑请求幂等键时,可以把对应请求从 `ReplayUnsafe` 提升为 `ReplayCostly`,但不得提升为 `ReplaySafe`。 - -## 7. 会话识别与亲和 - -### 7.1 会话信号优先级 - -按以下顺序选择第一个合法、非空的信号: - -1. `X-Session-Id` 或系统统一定义的等价 Header。 -2. `previous_response_id`。 -3. `conversation_id`。 -4. `thread_id`。 -5. `tenantID:userID:apiKeyID:canonicalModel` 身份级回退。 -6. 若策略关闭身份级回退,则为 `none`,每次按无状态新主体调度。 - -Header 长度默认不超过 256 字节;协议 ID 不超过 512 字节。超长、包含控制字符或不符合允许字符集的值视为无效,并记录低基数原因码,不记录原值。 - -Claude Code 有本地会话概念,但 Anthropic Messages API 不提供网关可依赖的标准会话字段。只有实际出现在请求 Header 或 Body 中的值才能用作会话信号。不能根据 User-Agent 推断会话,也不能把 `metadata.user_id` 默认解释为会话 ID。 - -### 7.2 无会话 ID 的能力边界 - -服务端没有会话信号时无法同时满足以下两个目标: - -- 区分同一 API Key 下的多个独立会话。 -- 保证同一会话稳定命中同一渠道。 - -因此默认采用身份级软亲和,并通过短 TTL 和容量逃逸限制集中风险。不得使用以下不可靠方案: - -- HTTP/TCP 连接作为会话标识。 -- 请求 ID 作为会话标识。 -- 对消息正文做相似度或内容指纹。 -- 使用客户端 IP 或 User-Agent 拼接伪会话。 - -### 7.3 亲和命名空间 - -```text -namespace = tenantID | userID | apiKeyID | canonicalModel | policyRoutingEpoch -affinityKey = SHA256(namespace | sessionKind | sessionToken) -``` - -`policyRoutingEpoch` 只在需要主动重分布所有会话的重大策略变更时递增,普通权重调整不得递增。 - -### 7.4 TTL 默认值 - -| 会话类型 | TTL | 续期方式 | -|---|---:|---| -| 显式 Header 会话 | 60 分钟 | 成功请求滑动续期 | -| 协议原生会话 | 120 分钟 | 成功请求滑动续期 | -| 身份级回退 | 10 分钟 | 成功请求滑动续期 | -| 临时 failover 路由 | 30 秒 | 不续期 | - -### 7.5 亲和复用条件 - -已有绑定只有同时满足以下条件才可复用: - -- 渠道通过所有硬过滤。 -- 熔断器状态为 CLOSED,或本请求持有 HALF_OPEN 探测许可。 -- 渠道压力小于 `affinity_escape_pressure`,默认 0.90。 -- 渠道未被当前请求排除或冷却。 -- 绑定对应的策略路由纪元有效。 - -不满足条件时分两类处理: - -- **临时绕开**:429、短时满载、一次 502/503。保留原绑定,当前请求使用临时 failover 路由。 -- **永久迁移**:渠道禁用、熔断 OPEN、模型能力移除、持续健康失败。新渠道成功后以 CAS 更新绑定。 - -## 8. 渠道目录与候选模型 - -调度核心接收的候选必须是完整快照: - -```go -type Candidate struct { - ChannelID int64 - Role ChannelRole - BaseWeight float64 - Enabled bool - SupportedModels ModelSet - TenantScope Scope - ConcurrencyLimit int64 - TokenRateLimit int64 - QueueLimit int64 - TargetTTFT time.Duration - CredentialAvailable bool - RecoveryStartedAt time.Time -} - -type RuntimeSnapshot struct { - ChannelID int64 - Inflight int64 - TokenRate float64 - QueueDepth int64 - TTFTP95 time.Duration - HealthFactor float64 - CircuitState CircuitState - CooldownUntil time.Time - SnapshotAt time.Time -} -``` - -目录快照必须携带版本号。一次逻辑请求的重试可以读取更新后的运行快照,但必须使用相同的持久策略版本,避免同一请求在策略发布边界出现不可解释的规则变化。 - -## 9. 渠道选择算法 - -### 9.1 第一步:硬过滤 - -按稳定顺序过滤,并记录每个候选的第一个排除原因: - -1. `channel_disabled` -2. `model_unsupported` -3. `tenant_scope_denied` -4. `credential_unavailable` -5. `circuit_open` -6. `channel_cooldown` -7. `request_excluded` -8. `hard_capacity_full` -9. `runtime_snapshot_expired` - -运行快照默认超过 15 秒视为过期。过期时的行为由渠道容量模式决定: - -- 严格容量渠道:排除,原因 `runtime_snapshot_expired`。 -- 弹性容量渠道:使用本地最近快照并施加 0.5 的容量折减系数。 - -### 9.2 第二步:尝试亲和 - -若亲和绑定存在且满足复用条件,调度器返回亲和渠道作为首选,并同时返回按正常算法计算的后备顺序。容量租约申请失败时直接使用后备顺序,不重新访问数据库。 - -### 9.3 第三步:计算压力 - -每个渠道计算四个压力分量: - -```text -concurrencyPressure = inflight / max(concurrencyLimit, 1) -tokenPressure = tokenRate / max(tokenRateLimit, 1) -queuePressure = queueDepth / max(queueLimit, 1) -latencyPressure = TTFTP95 / max(targetTTFT, 1ms) -``` - -未配置某项限制时,该分量不参与。综合压力为: - -```text -pressure = clamp(max(all configured pressure components), 0, 2) -``` - -角色压力使用该角色内健康候选的加权平均值,并额外计算是否所有候选达到硬限制。不得使用单个最差渠道代表整个角色。 - -### 9.4 第四步:选择角色 - -默认阈值: - -```text -overflow_start = 0.70 -overflow_full = 0.90 -overflow_exit = 0.60 -``` - -角色选择规则: - -1. 没有可用首选渠道时,进入备用。 -2. 首选压力低于 0.70 时,新会话进入首选。 -3. 首选压力位于 0.70 到 0.90 时,按线性比例把新会话分配给备用: - - ```text - overflowRatio = (preferredPressure - 0.70) / (0.90 - 0.70) - ``` - -4. 使用 `Hash(sessionToken | policyVersion | "role")` 映射到 `[0,1)`;小于 `overflowRatio` 的新会话进入备用,否则进入首选。 -5. 首选压力达到或超过 0.90 时,新会话进入备用。 -6. 备用不可用时仍允许首选在硬容量以内接收请求。 -7. 首选和备用均不可用时进入保底。 -8. 保底层默认不接收常规探活以外的业务流量。 - -`overflow_exit` 用于状态迟滞:系统已经进入溢出状态后,首选角色压力必须降到 0.60 以下并持续 30 秒,才恢复“首选独占新会话”。溢出状态保存在 Redis,避免各实例各自判断产生抖动。 - -### 9.5 第五步:应用临时流量覆盖 - -临时策略可以在角色选择前或角色内生效: - -- `global`:参与全局角色分配,能够让备用渠道在低压时接收指定比例。 -- `within_role`:只调整已经选中角色内的比例,默认模式。 -- `overflow_only`:只有系统处于溢出状态时才生效。 - -硬过滤始终先于临时策略。任何人工策略都不能强制选择不合格渠道。 - -### 9.6 第六步:计算有效权重 - -```text -capacityHeadroom = clamp(1 - pressure, 0.05, 1) -recoveryRamp = clamp(recoveryAge / recoveryRampDuration, 0.05, 1) - -effectiveWeight = baseWeight - × healthFactor - × capacityHeadroom - × recoveryRamp - × temporaryMultiplier -``` - -默认 `recoveryRampDuration` 为 10 分钟。刚恢复的渠道至少有 0.05 的爬坡因子,保证能够获得少量验证流量,但不能立即承接满额流量。 - -### 9.7 第七步:加权 Rendezvous Hash - -对角色内每个候选计算: - -```text -u = uniformHash(sessionRoutingKey, channelID) in (0, 1) -score = -ln(u) / effectiveWeight -selected = candidate with minimum score -``` - -排序所有候选而不是只返回一个,得到稳定的后备顺序。分数相同时按 Channel ID 升序,确保所有实例一致。 - -当 `SessionKind=none` 时,`sessionRoutingKey` 使用 Request ID;这会按请求分散流量,不提供会话亲和。 - -### 9.8 第八步:获取容量租约 - -协调器按候选顺序原子获取容量租约: - -1. 获取成功:执行请求。 -2. 获取失败:记录 `lease_denied`,尝试下一候选;不消耗上游重试次数。 -3. 当前角色全部租约失败:重新评估下一角色。 -4. 所有角色失败:返回明确的 `capacity_exhausted`,不得伪装为上游 502。 - -### 9.9 选择伪代码 - -```go -func Decide(in DecisionInput) Decision { - eligible := HardFilter(in.Candidates, in.Runtime, in.Request) - if len(eligible) == 0 { - return Decision{Reason: ReasonNoEligibleChannel} - } - - if bound := KeepableAffinity(in.Affinity, eligible, in.Policy); bound != nil { - ordered := RankFallbacks(eligible, in.Request, in.Policy, in.Overrides) - return Decision{Primary: *bound, Fallbacks: ordered, Reason: ReasonAffinityHit} - } - - role := SelectRole(eligible, in.Request, in.Runtime, in.Policy, in.Overrides) - ranked := WeightedRendezvous(eligible.ForRole(role), in.Request.Session, in.Overrides) - return Decision{Primary: ranked[0], Fallbacks: ranked[1:], Reason: ReasonRoleSelected} -} -``` - -## 10. 临时流量比例与操控性 - -### 10.1 支持的操作 - -| 模式 | 参数 | 行为 | -|---|---|---| -| `weight_multiplier` | `multiplier` | 在基础有效权重上乘以临时系数 | -| `target_share` | `share` | 指定目标分配比例 | -| `canary` | `share` | `target_share` 的受限形式,默认不超过 5% | -| `drain` | 无 | 不分配新会话,已有亲和会话继续 | -| `force_drain` | 无 | 不分配任何请求,并使已有绑定迁移 | -| `pause` | 无 | 临时从候选集中摘除,不修改永久渠道状态 | -| `ramp` | 起止比例、时间 | 在时间区间内线性或分段调整比例 | - -### 10.2 分配单位 - -```text -allocation_unit = session | first_attempt | request -``` - -- `session`:默认。调整新会话比例,保留会话亲和。 -- `first_attempt`:每个逻辑请求首次尝试按目标比例分配,不统计内部重试。 -- `request`:每个请求独立分配,比例收敛最快,但主动破坏亲和。 - -管理 API 使用 `request` 时必须要求 `acknowledge_affinity_break=true`。 - -### 10.3 目标比例计算 - -同一作用域内存在一个或多个固定目标比例时: - -```text -fixedTotal = sum(all fixed target shares) -remaining = 1 - fixedTotal -``` - -- `fixedTotal > 1`:拒绝发布。 -- 指定目标的渠道使用目标比例作为归一化权重。 -- 未指定目标的渠道按各自基础有效权重瓜分 `remaining`。 -- 目标渠道不可用时不把流量强行发送给它;其份额按剩余可用渠道有效权重重新分配。 -- 目标渠道恢复后必须经过恢复爬坡,逐步回到目标比例。 - -目标比例控制的是统计意义上的分配结果。短窗口、会话请求量差异、重试、渠道满载和健康过滤都会使“全部请求占比”偏离目标。 - -### 10.4 渐进生效 - -所有提高流量的操作默认要求 `ramp_up >= 60s`。当前比例 `p0`、目标比例 `p1`、开始时间 `t0`、结束时间 `t1`: - -```text -p(t) = p0 + (p1 - p0) × clamp((t - t0) / (t1 - t0), 0, 1) -``` - -降低流量允许立即生效,但 `force_drain` 必须使用高权限。 - -### 10.5 过期与回滚 - -- 临时策略默认最长 24 小时。 -- 创建时必须设置 `expires_at`;永久变更必须走基础策略发布流程。 -- 到期由 Redis TTL 和控制面定时任务双重保证。 -- 每次发布生成不可变版本,回滚通过重新激活上一版本完成。 -- 数据面发现策略版本缺失或内容校验失败时继续使用上一完整版本。 - -### 10.6 比例观察口径 - -控制面必须同时展示: - -- `new_session_share`:新会话分配比例。 -- `first_attempt_share`:逻辑请求首次尝试比例。 -- `attempt_share`:包括重试在内的全部上游尝试比例。 -- `success_share`:成功请求比例。 -- `token_share`:处理 Token 比例。 -- `cost_share`:估算或实际成本比例。 - -用户设置的目标必须明确绑定其中一个口径。默认是 `new_session_share`。 - -## 11. 重试和故障转移状态机 - -### 11.1 独立预算 - -默认预算: - -```text -max_total_attempts = 4 -max_same_channel_retries = 1 -max_credential_rotations = 1 -max_channel_switches = 2 -max_retry_elapsed = min(15s, request_deadline_remaining) -``` - -图片、视频和异步任务提交默认: - -```text -max_total_attempts = 1 -max_same_channel_retries = 0 -max_channel_switches = 0 -``` - -只有明确未送达或已使用上游幂等键时,才允许策略提高这些上限。 - -### 11.2 送达状态 - -执行器必须把失败标记为以下之一: - -```go -type DeliveryState uint8 - -const ( - DeliveryNotSent DeliveryState = iota - DeliveryMaybeSent - DeliveryResponseReceived - DeliveryResponseStarted -) -``` - -- `NotSent`:DNS 失败、连接拒绝、TLS 建连失败、请求体尚未发送。 -- `MaybeSent`:写入后连接重置、读取超时、context deadline、无响应 EOF。 -- `ResponseReceived`:收到完整 HTTP 状态行和错误响应,尚未写给客户端。 -- `ResponseStarted`:已向客户端写出有效响应字节。 - -### 11.3 错误归因 - -```go -type FailureScope uint8 - -const ( - FailureRequest FailureScope = iota - FailureCredential - FailureChannel - FailureProvider - FailureClient -) -``` - -- 400、413、422:请求级,不降低渠道健康。 -- 401、403:优先归因凭证;同渠道所有凭证均失败后才提升为渠道级。 -- 429:根据响应头和供应商语义归因到凭证、渠道或 Provider。 -- 500、502、503、504:渠道或 Provider 级。 -- 客户端取消:客户端级,不降低渠道健康。 - -### 11.4 决策矩阵 - -| 错误 | 原渠道 | 凭证轮换 | 跨渠道 | 亲和处理 | -|---|---|---|---|---| -| 400/404/409/413/422 | 停止 | 否 | 否 | 保留 | -| 401/403 | 不原样重试 | 是,最多一次 | 凭证耗尽后允许 | 连续凭证失败后迁移 | -| 408 | 可重放且未输出时一次 | 否 | 允许 | 临时绕开 | -| 429 | 不立即原地重试 | 凭证级限流时允许 | 允许 | 保留,设置临时路由 | -| 500 | `ReplaySafe/Costly` 一次 | 否 | 允许 | 单次保留,持续失败迁移 | -| 502/503 | 一次快速重试 | 否 | 允许 | 单次保留,持续失败迁移 | -| 504 | 默认不原地重试 | 否 | 仅 `ReplaySafe/Costly` | 临时绕开或持续失败迁移 | -| DNS/连接拒绝/TLS 建连失败 | 一次快速重试 | 否 | 允许 | 临时绕开 | -| 写后 RST/EOF/读取超时 | 仅 `ReplaySafe` | 否 | `ReplaySafe/Costly` 受限 | 不立即改绑 | -| 客户端取消 | 停止 | 否 | 否 | 保留 | -| 已输出流式响应 | 停止 | 否 | 否 | 保留 | - -对于 `ReplayUnsafe + DeliveryMaybeSent`,状态码是否可重试不再重要,必须停止。 - -### 11.5 退避 - -```text -same-channel retry: 100ms × 2^n + random(0, 100ms) -cross-channel retry: 50ms + random(0, 100ms) -429: honor Retry-After; 当前请求直接切换,不等待超长 Retry-After -``` - -所有等待使用可取消 Timer,并在等待前检查剩余 deadline。预测下一次尝试无法在 deadline 前完成时立即停止。 - -### 11.6 重试状态 - -```go -type AttemptState struct { - TotalAttempts int - SameChannelRetries map[int64]int - CredentialRotations map[int64]int - ChannelSwitches int - ExcludedChannels map[int64]Exclusion - ExcludedCredentials map[int64]Exclusion - StartedAt time.Time - ResponseStarted bool -} -``` - -### 11.7 状态转换 - -```text -SELECT_CHANNEL - → ACQUIRE_LEASE - → lease denied → SELECT_NEXT - → acquired → EXECUTE - → success → COMMIT_AFFINITY → COMPLETE - → credential failure → ROTATE_CREDENTIAL - → same-channel retry → BACKOFF → EXECUTE - → channel failover → RELEASE → SELECT_NEXT - → non-retryable/预算耗尽/响应已开始 → FAIL -``` - -## 12. 健康度与熔断 - -### 12.1 健康信号 - -每个渠道维护: - -- 请求成功率 EWMA。 -- 500/502/503/504 分类错误率。 -- 429 比例及作用域。 -- TTFT EWMA 和 P95。 -- 总响应延迟 EWMA 和 P95。 -- 连续渠道级失败次数。 -- 当前并发、Token 速率和队列长度。 -- 最近一次成功、失败和探测时间。 - -请求错误和客户端取消不得计入渠道失败率。凭证错误先进入凭证健康状态,避免一个坏 Key 直接熔断整个渠道。 - -### 12.2 EWMA - -默认 `alpha=0.2`: - -```text -newValue = alpha × observation + (1 - alpha) × oldValue -``` - -健康因子不需要追求一个绝对“健康分”,但选择算法需要 `[0,1]` 因子。默认计算: - -```text -successFactor = clamp(successEWMA, 0, 1) -latencyFactor = clamp(targetTTFT / max(ttftEWMA, targetTTFT), 0.2, 1) -stabilityFactor = clamp(1 - latencyCV, 0.5, 1) - -healthFactor = clamp( - 0.60 × successFactor + - 0.25 × latencyFactor + - 0.15 × stabilityFactor, - 0.05, - 1, -) -``` - -熔断状态单独作为硬门控,不依赖健康因子降到零。 - -### 12.3 熔断规则 - -默认参数: - -```text -window = 30s -minimum_observations = 20 -open_error_rate = 50% -open_consecutive_failures = 5 -open_duration = 30s -half_open_max_probes = 1 -close_successful_probes = 2 -``` - -状态转换: - -- CLOSED → OPEN:窗口内达到最小样本且渠道级错误率不低于 50%,或连续渠道级失败达到 5。 -- OPEN → HALF_OPEN:OPEN 持续 30 秒后允许竞争探测许可。 -- HALF_OPEN → OPEN:任一探测失败。 -- HALF_OPEN → CLOSED:连续两个探测成功。 - -HALF_OPEN 探测许可通过 Redis 原子获取,同一渠道全局最多一个并发探测。探测成功前普通请求仍不得进入。 - -### 12.4 冷却与熔断的区别 - -- **冷却**:短时间不选,通常来自 429、容量提示或维护窗口;不代表渠道故障。 -- **熔断**:渠道级故障保护,需要 HALF_OPEN 探测恢复。 -- **暂停**:人工控制面指令,优先于冷却和熔断。 - -## 13. 容量与负载状态 - -### 13.1 容量租约 - -每次上游尝试在发送前获取租约: - -```go -type Lease struct { - LeaseID string - ChannelID int64 - RequestID string - AcquiredAt time.Time - ExpiresAt time.Time - FencingToken uint64 -} -``` - -- Lease ID 使用 UUIDv7。 -- 默认租约 90 秒,长请求每 30 秒续租。 -- 续租必须确认 Lease ID 仍存在,不能复活已经释放的租约。 -- 请求结束立即释放;实例崩溃后依靠 TTL 回收。 -- Lua 脚本在一个原子操作中清理过期租约、检查硬上限、增加租约并返回 fencing token。 - -### 13.2 Token 吞吐 - -并发数无法反映长上下文请求的真实压力。请求进入时使用 `EstimatedTokens` 预留短时 Token 预算,完成后用实际 Token 修正滑动窗口。估算缺失时使用模型和操作类型的保守默认值。 - -### 13.3 排队策略 - -第一版默认不在网关内部建立无界等待队列: - -- 有其他渠道时立即溢出。 -- 所有渠道满载时返回 `capacity_exhausted` 或进入有界短等待。 -- 可选短等待默认不超过 200ms,队列有明确长度上限。 -- 等待必须计入请求 deadline。 - -## 14. 分布式状态设计 - -### 14.1 Redis Key - -| Key | 类型 | 用途 | -|---|---|---| -| `sched:v1:affinity:{hash}` | String/Hash | 亲和渠道、版本、绑定时间,带 TTL | -| `sched:v1:affidx:{channelID}` | Set | 渠道到亲和键的反向索引 | -| `sched:v1:leases:{channelID}` | ZSET | 容量租约,score 为过期时间 | -| `sched:v1:load:{channelID}` | Hash | 并发、Token、队列、压力快照 | -| `sched:v1:health:{channelID}` | Hash | EWMA、分类错误、连续失败 | -| `sched:v1:circuit:{channelID}` | Hash | 熔断状态、打开时间、探测计数 | -| `sched:v1:cooldown:{scope}:{id}` | String | 凭证/渠道/Provider 冷却截止时间 | -| `sched:v1:overflow:{policyScope}` | Hash | 当前溢出状态和迟滞时间 | -| `sched:v1:policy:active` | String | 当前策略版本 | -| `sched:v1:policy:{version}` | String | 已发布策略快照 | -| `sched:v1:override:{id}` | String | 临时策略快照,TTL 到期 | - -所有 Key 必须有明确 TTL 或清理责任。反向亲和索引允许渠道永久下线时批量失效绑定,但批量操作必须分页,不能在一个 Lua 脚本中遍历无界集合。 - -### 14.2 原子操作 - -必须使用 Lua 或等价原子命令实现: - -- `AcquireLease` -- `RefreshLease` -- `ReleaseLease` -- `ObserveAttempt` -- `TryHalfOpenProbe` -- `CommitAffinityCAS` -- `SetCooldown` -- `TransitionOverflowState` - -Lua 脚本需要返回结构化结果码,不得仅返回布尔值。例如容量失败要区分 `hard_full`、`strict_state_missing` 和 `lease_conflict`。 - -### 14.3 PostgreSQL 持久模型 - -建议新增逻辑表;实际命名可按项目数据库规范调整。 - -```sql -CREATE TABLE sched_policies ( - id BIGSERIAL PRIMARY KEY, - name VARCHAR(128) NOT NULL, - version BIGINT NOT NULL, - scope JSONB NOT NULL, - policy JSONB NOT NULL, - status VARCHAR(16) NOT NULL, - routing_epoch BIGINT NOT NULL DEFAULT 1, - created_by BIGINT NOT NULL, - created_at TIMESTAMPTZ NOT NULL, - activated_at TIMESTAMPTZ, - UNIQUE (name, version) -); - -CREATE TABLE sched_traffic_overrides ( - id BIGSERIAL PRIMARY KEY, - scope JSONB NOT NULL, - channel_id BIGINT NOT NULL, - mode VARCHAR(32) NOT NULL, - allocation_unit VARCHAR(16) NOT NULL, - apply_scope VARCHAR(16) NOT NULL, - value NUMERIC(8,6), - ramp_up_seconds INT NOT NULL DEFAULT 60, - preserve_affinity BOOLEAN NOT NULL DEFAULT TRUE, - reason VARCHAR(512) NOT NULL, - status VARCHAR(16) NOT NULL, - starts_at TIMESTAMPTZ NOT NULL, - expires_at TIMESTAMPTZ NOT NULL, - version BIGINT NOT NULL, - created_by BIGINT NOT NULL, - created_at TIMESTAMPTZ NOT NULL -); - -CREATE TABLE sched_policy_audit_logs ( - id BIGSERIAL PRIMARY KEY, - object_type VARCHAR(32) NOT NULL, - object_id BIGINT NOT NULL, - action VARCHAR(32) NOT NULL, - before_data JSONB, - after_data JSONB, - operator_id BIGINT NOT NULL, - reason VARCHAR(512) NOT NULL, - request_id VARCHAR(64) NOT NULL, - created_at TIMESTAMPTZ NOT NULL -); -``` - -数据库约束或服务层验证必须保证: - -- `target_share` 位于 `(0,1]`。 -- 同一作用域固定目标比例之和不超过 1。 -- `expires_at > starts_at`。 -- 临时策略最长持续时间符合权限规则。 -- 发布版本严格递增且不可修改。 - -## 15. 多实例一致性与降级 - -### 15.1 一致性边界 - -- 持久策略:强一致发布。先写完整版本,再原子切换 active version。 -- 容量:Redis 原子强约束。 -- 熔断和 HALF_OPEN:Redis 原子状态机。 -- 亲和:首次写入使用 `SET NX` 或 CAS;并发首请求读取最终胜者。 -- 指标:最终一致,不参与正确性判断。 -- 本地目录:版本化最终一致,实例不得组合两个版本的局部配置。 - -### 15.2 策略发布 - -1. 控制面校验策略。 -2. 写 PostgreSQL 不可变版本。 -3. 写 Redis 完整快照。 -4. 原子切换 `sched:v1:policy:active`。 -5. 发布 Pub/Sub 通知。 -6. 实例收到通知后拉取并校验完整快照。 -7. 未收到通知的实例通过 5 秒轮询发现新版本。 - -### 15.3 Redis 故障降级 - -| 能力 | 降级行为 | -|---|---| -| 策略 | 使用本地最近完整版本 | -| 会话亲和 | 使用会话种子的无状态 HRW,不能提交新绑定 | -| 健康 | 使用本地最近快照,超过 60 秒施加健康折减 | -| 熔断 | 保留本地 OPEN 状态,不自行提前恢复 | -| 临时比例 | 使用本地最后版本直到其本地过期时间 | -| 弹性容量渠道 | 使用实例级保守并发限制,可配置 fail-open | -| 严格容量/昂贵渠道 | 默认 fail-closed | - -实例级保守限制必须由部署配置给出,例如: - -```text -localFallbackLimit = floor(globalHardLimit / configuredMaxReplicas) -``` - -宁可在 Redis 故障期间少用容量,也不能让多实例同时无界放行昂贵渠道。 - -### 15.4 时钟 - -- 所有实例和 Redis 节点必须启用时间同步。 -- TTL 和租约优先使用 Redis `TIME` 或在 Lua 内取服务器时间。 -- 策略生效时间允许最多 2 秒时钟偏差。 - -## 16. Go 模块边界 - -建议包结构: - -```text -relay/scheduler/ - types.go 领域类型和原因码 - filter.go 硬过滤 - affinity.go 亲和复用和逃逸规则 - pressure.go 压力与角色溢出 - rendezvous.go 加权 HRW 排序 - override.go 临时流量策略计算 - retry.go 重试状态机 - circuit.go 纯熔断转换规则 - policy.go 策略校验和默认值 - -internal/logic/scheduling/ - coordinator.go 组织一次选择和租约申请 - catalog.go PostgreSQL 目录及本地缓存 - runtime_store.go Redis 运行状态适配器 - affinity_store.go Redis 亲和适配器 - lease_store.go Redis 容量适配器 - policy_store.go 策略发布和订阅 - observer.go 结果事件消费 - -relay/handler/ - executor.go HTTP/SSE 执行边界 - retry_executor.go 按 RetryDecision 执行尝试 -``` - -`relay/scheduler` 不允许导入 ORM、Redis Client、HTTP Client 或应用配置全局单例。 - -### 16.1 核心接口 - -```go -type Scheduler interface { - Decide(input DecisionInput) Decision - NextRetry(input RetryInput) RetryDecision -} - -type Catalog interface { - Snapshot(ctx context.Context, req SchedulingRequest) (CatalogSnapshot, error) -} - -type RuntimeStore interface { - Snapshot(ctx context.Context, channelIDs []int64) (map[int64]RuntimeSnapshot, error) - Observe(ctx context.Context, event AttemptEvent) error -} - -type AffinityStore interface { - Get(ctx context.Context, key AffinityKey) (AffinityBinding, bool, error) - CommitCAS(ctx context.Context, key AffinityKey, old, next AffinityBinding) (bool, error) - InvalidateChannel(ctx context.Context, channelID int64, cursor string, limit int) (next string, error) -} - -type LeaseStore interface { - Acquire(ctx context.Context, req LeaseRequest) (Lease, LeaseResult, error) - Refresh(ctx context.Context, lease Lease) error - Release(ctx context.Context, lease Lease) error -} -``` - -接口定义在消费方。构造函数返回具体类型。所有外部调用接收 `context.Context`,并有小于请求剩余 deadline 的独立超时。 - -### 16.2 Decision 结果 - -```go -type Decision struct { - Selected *RankedCandidate - Fallbacks []RankedCandidate - Reason ReasonCode - PolicyVersion int64 - RoutingEpoch int64 - AffinityAction AffinityAction - Role ChannelRole - OverflowRatio float64 - Exclusions []CandidateExclusion -} -``` - -`ReasonCode` 必须是稳定枚举,至少包含: - -- `affinity_hit` -- `affinity_escape_pressure` -- `affinity_escape_health` -- `preferred_normal` -- `standby_overflow` -- `standby_primary_unavailable` -- `emergency_failover` -- `temporary_target_share` -- `canary` -- `capacity_fallback` -- `retry_failover` -- `no_eligible_channel` -- `capacity_exhausted` - -## 17. 管理 API - -### 17.1 创建临时流量策略 - -```http -POST /admin/scheduler/traffic-overrides -``` - -```json -{ - "scope": { - "model": "claude-sonnet", - "tenant_id": 0, - "operation": "chat" - }, - "channel_id": 12, - "mode": "target_share", - "allocation_unit": "session", - "apply_scope": "within_role", - "value": 0.20, - "ramp_up_seconds": 300, - "preserve_affinity": true, - "starts_at": "2026-07-30T10:00:00+08:00", - "expires_at": "2026-07-30T10:30:00+08:00", - "reason": "临时验证新线路" -} -``` - -返回内容必须包含策略 ID、版本、校验后的实际生效范围、预计影响和回滚版本。 - -### 17.2 排空渠道 - -```http -POST /admin/scheduler/channels/{channelID}/drain -``` - -请求参数: - -- `mode=drain|force_drain` -- `expires_at` -- `reason` -- `acknowledge_affinity_break`,强制排空时必填 `true` - -### 17.3 回滚 - -```http -POST /admin/scheduler/traffic-overrides/{id}/rollback -``` - -回滚是一次新的审计动作,不删除历史记录。 - -### 17.4 调度解释器 - -```http -POST /admin/scheduler/explain -``` - -输入脱敏后的请求上下文或指定测试主体,返回: - -- 策略版本。 -- 会话信号类型,不返回原始 Token。 -- 所有候选及排除原因。 -- 压力分量。 -- 角色和溢出比例。 -- 基础权重、所有修正因子和最终分数。 -- 亲和复用或逃逸原因。 -- 最终选择和后备顺序。 -- 相关临时策略。 - -解释器默认只读,不申请租约、不写亲和、不更新健康。 - -### 17.5 调度模拟器 - -```http -POST /admin/scheduler/simulate -``` - -输入候选策略和脱敏流量样本,输出: - -- 预计新会话分布。 -- 首选到备用的溢出比例。 -- 各渠道预计并发和 Token 负载。 -- 目标比例偏差。 -- 因硬容量无法完成的流量。 -- 相对当前策略的会话迁移比例。 - -模拟结果不得自动发布。 - -## 18. 可观察性 - -### 18.1 指标 - -建议使用以下 Prometheus 指标;标签必须保持低基数。 - -```text -scheduler_decisions_total{ - model, channel, role, reason, affinity_action -} - -scheduler_attempts_total{ - model, channel, attempt_kind, outcome, error_class -} - -scheduler_retries_total{ - model, from_channel, action, error_class -} - -scheduler_affinity_total{ - model, action, session_kind, reason -} - -scheduler_channel_inflight{channel} -scheduler_channel_pressure{channel, component} -scheduler_channel_effective_weight{model, channel} -scheduler_channel_target_share{model, channel, allocation_unit} -scheduler_channel_actual_share{model, channel, share_kind} -scheduler_channel_token_rate{channel} -scheduler_channel_queue_depth{channel} -scheduler_channel_health_factor{channel} -scheduler_circuit_state{channel, state} -scheduler_lease_operations_total{channel, operation, result} -scheduler_policy_version{instance} -scheduler_override_active{channel, mode} - -scheduler_decision_duration_seconds{result} -scheduler_attempt_duration_seconds{channel, outcome} -scheduler_ttft_seconds{channel, model} -scheduler_retry_elapsed_seconds{model, final_outcome} -``` - -如果模型数量不可控,指标中的 `model` 标签使用模型族或受控规范名;完整模型名写入结构化日志。 -策略版本不得放入 Counter/Histogram 标签,否则版本持续增长会造成时间序列累积;具体版本写入日志和 Trace,实例当前版本只通过 `scheduler_policy_version` Gauge 表示。 - -### 18.2 结构化日志 - -每个逻辑请求默认记录一条汇总日志;完整候选分数只在错误、采样或 Explain 模式记录。 - -```json -{ - "event": "scheduler_decision", - "request_id": "019...", - "trace_id": "...", - "policy_version": 42, - "model": "claude-sonnet", - "session_kind": "explicit", - "session_hash_prefix": "7f3a91c2", - "selected_channel": 12, - "role": "standby", - "reason": "standby_overflow", - "preferred_pressure": 0.84, - "overflow_ratio": 0.70, - "affinity_action": "new_binding", - "target_share": 0.20, - "attempts": 2, - "final_outcome": "success" -} -``` - -不得记录原始 API Key、原始会话 Token、完整请求正文或上游凭证。 - -### 18.3 Trace - -一个逻辑请求一个根 Span,每次尝试一个子 Span: - -```text -relay.request - scheduler.decide - scheduler.lease.acquire - upstream.attempt[0] - scheduler.retry - scheduler.lease.acquire - upstream.attempt[1] - scheduler.observe -``` - -Span 属性包含策略版本、渠道、角色、原因码、尝试序号、送达状态和错误分类,不包含高敏感标识。 - -### 18.4 看板 - -至少提供四个看板: - -1. **渠道总览**:QPS、成功率、TTFT、并发、Token 速率、压力、熔断状态。 -2. **调度分布**:目标比例与新会话、首次请求、全部尝试、成功和 Token 实际比例。 -3. **重试与故障转移**:原渠道重试、凭证轮换、跨渠道切换、最终失败、重试增加延迟。 -4. **策略与操控**:当前版本、实例版本一致性、临时策略、到期时间、实际偏差和操作审计。 - -### 18.5 告警 - -默认告警建议: - -- 目标新会话比例偏差超过 10 个百分点并持续 10 分钟。 -- 设置目标比例的渠道 5 分钟没有首次分配,且渠道未被硬过滤。 -- 跨渠道重试率超过 5% 持续 5 分钟。 -- 任一首选角色可用渠道数为 0。 -- 保底渠道业务流量持续超过 1%。 -- 渠道熔断超过 5 分钟未恢复。 -- 容量租约拒绝率超过 10%。 -- Redis 调度操作错误持续 1 分钟。 -- 实例策略版本不一致超过 30 秒。 -- 临时策略将在 10 分钟内到期且仍承载超过 10% 流量。 -- 实际 Token 比例显著高于请求比例,提示大请求集中。 - -## 19. 安全与权限 - -角色建议: - -- `scheduler.viewer`:查看状态、Explain 和模拟。 -- `scheduler.operator`:创建有 TTL 的权重、目标比例和 canary。 -- `scheduler.admin`:暂停、强制排空、修改永久策略和回滚。 - -安全规则: - -- `force_drain`、请求级分配、超过 50% 的全局目标比例需要管理员权限。 -- 所有写操作必须包含原因。 -- 高风险操作可配置双人审批。 -- 审计日志不可被普通管理员删除。 -- 会话 Token 只保存 SHA-256;日志最多展示不可反推的短前缀。 -- 管理 API 必须防止跨租户作用域误配置。 - -## 20. 测试策略 - -### 20.1 单元测试 - -纯调度包使用表驱动测试覆盖: - -- 所有硬过滤原因。 -- 显式会话、协议会话、身份回退和无亲和模式。 -- 亲和保留、临时绕开和永久迁移。 -- 0.70、0.90、0.60 阈值边界。 -- 首选缺失、备用缺失、保底启用。 -- 权重为零、健康因子极低、恢复爬坡。 -- 单个和多个固定目标比例。 -- 多个目标比例之和超过 1 的校验。 -- drain、force_drain、pause 和过期策略。 -- 每个错误和送达状态组合的 RetryDecision。 -- deadline 不足、预算耗尽和响应已开始。 - -### 20.2 属性测试 - -验证以下属性: - -- 候选顺序变化不改变相同输入的选择结果。 -- 相同会话、策略版本和快照始终得到相同排序。 -- 禁用候选永远不会被返回。 -- 增加一个渠道只迁移有限比例会话。 -- 7:3 权重在足够多会话样本上落入容差区间。 -- 固定目标比例在足够多新会话样本上收敛。 -- 任意错误序列都不会超过重试预算。 -- `ResponseStarted=true` 时 NextRetry 永远返回 Stop。 - -### 20.3 Redis 集成测试 - -- 多实例并发争抢最后一个租约,只允许一个成功。 -- 续租和释放不会复活旧租约。 -- 实例崩溃后租约按 TTL 回收。 -- 并发首次亲和提交得到唯一绑定。 -- 亲和 CAS 防止旧请求覆盖新迁移。 -- HALF_OPEN 全局只放行一个探测。 -- 并发健康事件无丢失更新。 -- 临时策略 TTL 到期后所有实例恢复上一策略。 - -### 20.4 仿真测试 - -构造至少以下流量: - -- 10 万个等长会话,验证基础比例。 -- 少量超长会话和大量短会话,比较会话比例与请求比例差异。 -- 首选压力从 40% 缓慢升到 100%,验证渐进溢出。 -- 压力在阈值附近波动,验证迟滞不抖动。 -- 渠道突然 100% 返回 503,验证熔断和 failover。 -- 429 携带不同 Retry-After,验证冷却范围。 -- 临时把渠道调到 20%,5 分钟爬坡后自动到期。 - -### 20.5 故障与压测 - -- Redis 延迟、超时、主从切换和完全不可用。 -- PostgreSQL 不可用但本地已有完整目录。 -- 单实例和多实例突然退出。 -- 策略发布过程中实例网络分区。 -- 上游慢响应、半开连接、SSE 中断。 -- 最大配置实例数下验证硬容量不被突破。 - -## 21. 分阶段实施计划 - -每个阶段都必须可独立发布、可观察、可回滚。阶段编号表示依赖顺序,不表示必须一次完成该阶段所有增强项。 - -### 阶段 0:冻结语义与建立基线 - -**目标**:在写调度代码前固定协议和验收口径。 - -**任务**: - -- 审查并确认本文中的角色、比例口径、重试矩阵和 Redis 降级策略。 -- 确认哪些请求属于 `ReplaySafe/Costly/Unsafe`。 -- 定义统一 `X-Session-Id` 规范和 SDK 接入要求。 -- 收集当前请求量、成功率、P95/P99、TTFT、错误分类、重试率和渠道占比基线。 -- 定义 SLO 和错误预算。 -- 确认生产最大实例数,用于 Redis 故障时的本地保守容量。 - -**交付物**: - -- 已批准的设计版本。 -- 错误分类表和操作类型表。 -- 基线看板。 -- 配置默认值清单。 - -**退出条件**: - -- 所有存在歧义的协议字段都有唯一解释。 -- 可重放性分类覆盖所有公开请求入口。 -- 能量化后续新旧调度决策差异。 - -**回滚**:无运行变更。 - -### 阶段 1:纯调度核心与影子决策 - -**目标**:实现无 I/O 的调度核心,不接管生产流量。 - -**任务**: - -- 建立 `relay/scheduler` 领域类型、策略默认值和校验。 -- 实现硬过滤、压力计算、角色溢出、加权 HRW 和目标比例。 -- 实现亲和复用判断和 RetryDecision 纯状态机。 -- 实现稳定原因码和 Decision 序列化。 -- 在现有请求路径旁路调用新调度器,只记录影子决策,不改变实际渠道。 -- 建立新旧决策差异指标,不把用户或会话放入指标标签。 - -**测试**: - -- 完成本章单元测试和属性测试。 -- 使用脱敏历史样本离线回放。 -- 对相同输入跨进程验证确定性。 - -**交付物**: - -- 纯调度包。 -- Shadow Coordinator。 -- 决策差异看板。 - -**退出条件**: - -- 调度核心无数据库、Redis 和 HTTP 依赖。 -- 单元和属性测试通过。 -- 影子计算增加的 P99 延迟低于 1ms。 -- 连续观察至少一个完整业务周期,无 Panic 和无不可解释 Decision。 - -**回滚**:关闭 `scheduler_shadow_enabled`,不影响实际转发。 - -### 阶段 2:分布式运行状态和容量租约 - -**目标**:先建立多实例安全的底座,再接管选择。 - -**任务**: - -- 实现 Redis Key、Lua 脚本和结果码。 -- 实现容量获取、续租、释放和实例崩溃回收。 -- 实现运行快照批量读取,禁止每候选一次 Redis 往返。 -- 实现本地最近快照和 Redis 故障降级。 -- 实现 Token 速率估算和实际值修正。 -- 增加租约、压力、Redis 错误指标和告警。 - -**测试**: - -- Redis 集成测试和多实例竞争测试。 -- Race Detector。 -- Redis 故障注入。 -- 生产流量等比例压测。 - -**交付物**: - -- RuntimeStore 和 LeaseStore。 -- Lua 脚本版本管理。 -- Redis 降级 Runbook。 - -**退出条件**: - -- 多实例不能突破严格硬容量。 -- 租约泄漏能在 TTL 内回收。 -- Redis 故障时严格和弹性渠道行为符合策略。 -- Redis 批量读取延迟满足数据面预算。 - -**回滚**:关闭 `scheduler_distributed_capacity_enabled`,保留影子指标。 - -### 阶段 3:健康、熔断和凭证归因 - -**目标**:让调度能够安全识别坏渠道,并避免错误归因扩大故障。 - -**任务**: - -- 实现标准 AttemptEvent 和 DeliveryState。 -- 实现请求、凭证、渠道、Provider、客户端错误归因。 -- 实现 Redis 原子 EWMA、连续失败和滑动窗口。 -- 实现 CLOSED/OPEN/HALF_OPEN 和全局探测许可。 -- 实现 429 冷却及 `Retry-After` 解析。 -- 实现恢复爬坡。 -- 将健康状态接入影子选择,暂不强制生产摘除。 - -**测试**: - -- 错误矩阵全组合测试。 -- 并发 Observe 无丢失测试。 -- HALF_OPEN 多实例竞争测试。 -- 凭证 401 不误伤健康凭证测试。 - -**交付物**: - -- Observer、熔断状态机和健康看板。 -- 错误归因说明和运维 Runbook。 - -**退出条件**: - -- 所有上游失败都有明确 error class、failure scope 和 delivery state。 -- 自动熔断与人工判断样本一致性达到审查目标。 -- 不因客户端取消或请求错误降低渠道健康。 - -**回滚**:关闭 `scheduler_circuit_enforcement_enabled`,继续只观察健康状态。 - -### 阶段 4:会话亲和和三级溢出接管 - -**目标**:让新调度器开始控制生产渠道选择。 - -**任务**: - -- 实现会话信号解析、校验和哈希。 -- 实现 AffinityStore、CAS 提交、TTL 续期和分页失效。 -- 实现首选、备用、保底及溢出迟滞。 -- 实现容量拒绝后的同角色后备和跨角色降级。 -- 为自研 SDK 增加 `X-Session-Id`。 -- 按租户或 API Key 做 1%、5%、20%、50%、100% 灰度。 - -**测试**: - -- 会话稳定性和逃逸测试。 -- 10 万会话分布仿真。 -- 阈值爬升和抖动仿真。 -- 灰度租户端到端测试。 - -**交付物**: - -- 生产 Selector 和 AffinityStore。 -- 亲和/溢出看板。 -- SDK 会话 Header 规范。 - -**退出条件**: - -- 健康会话渠道稳定率达到目标。 -- 首选低压时备用仅有规定的探活流量。 -- 首选高压时新会话按公式进入备用。 -- 保底使用都有明确原因码和告警。 -- 灰度期间成功率和延迟不劣于基线容差。 - -**回滚**:按租户关闭 `scheduler_selection_enabled`,恢复旧选择;新状态继续写入但不参与决策。 - -### 阶段 5:分层重试状态机接管 - -**目标**:统一原渠道重试、凭证轮换和跨渠道 failover。 - -**任务**: - -- 执行器返回标准 AttemptResult 和 DeliveryState。 -- 接入独立重试预算和可取消退避。 -- 实现凭证轮换但不重复计入渠道切换。 -- 实现流式响应开始后的硬停止。 -- 实现非幂等请求保护和上游幂等键传递。 -- 记录完整尝试链 Trace 和逻辑请求汇总日志。 - -**测试**: - -- 决策矩阵端到端测试。 -- SSE 首字节前后故障测试。 -- 504、写后 RST、读取超时和客户端取消测试。 -- 图片、视频和任务提交不重复执行测试。 -- 重试预算和 deadline 属性测试。 - -**交付物**: - -- Retry Executor。 -- 重试和故障转移看板。 -- 504、429、流式中断 Runbook。 - -**退出条件**: - -- 任意错误序列不突破预算。 -- 已输出响应后不存在跨渠道尝试。 -- 非幂等模糊送达不存在自动重放。 -- 重试带来的成功率收益和延迟/成本增量可量化。 - -**回滚**:关闭 `scheduler_retry_fsm_enabled`,选择器仍可继续工作。 - -### 阶段 6:控制面和临时流量比例 - -**目标**:允许运维安全、可观察地调整流量。 - -**任务**: - -- 创建策略、临时覆盖和审计表。 -- 实现发布、版本切换、Pub/Sub、轮询和回滚。 -- 实现 target share、multiplier、canary、drain、force drain、pause 和 ramp。 -- 实现目标比例冲突校验和过期回收。 -- 实现管理 UI:目标与实际比例、预计影响、到期时间、一键回滚。 -- 实现 Explain 和离线模拟 API。 -- 接入 RBAC 和高风险操作确认。 - -**测试**: - -- 多实例策略原子切换。 -- 固定目标比例收敛测试。 -- TTL 到期和回滚测试。 -- 权限、审计和跨租户作用域测试。 -- 渠道满载时目标无法完成的展示测试。 - -**交付物**: - -- 调度控制面 API 和 UI。 -- 策略版本与审计记录。 -- 比例控制 Runbook。 - -**退出条件**: - -- 可在不改永久权重的情况下把指定渠道的新会话目标比例临时调整到指定值。 -- 操作到期自动恢复,所有实例版本一致。 -- 管理端明确区分目标比例与五种实际比例。 -- 硬过滤导致目标无法完成时有明确原因和告警。 - -**回滚**:原子激活上一策略版本;紧急情况下关闭 `scheduler_overrides_enabled`。 - -### 阶段 7:全面切换和收尾 - -**目标**:将新调度路径设为唯一生产路径,完成运维交接。 - -**任务**: - -- 逐步扩大到所有租户和模型。 -- 完成生产故障演练:Redis、数据库、上游、实例退出和错误策略发布。 -- 固化 SLO、告警值、值班手册和应急操作权限。 -- 观察至少两个完整业务周期。 -- 停止旧路径写入冲突状态。 -- 在确认无需回滚后移除旧路径和临时兼容开关。 - -**退出条件**: - -- 全量流量达到成功率、延迟、成本和容量 SLO。 -- 所有 P1 级故障场景完成演练。 -- 值班人员能够通过 Explain、Trace 和看板解释异常选择。 -- 临时比例调整、排空、熔断恢复和策略回滚均有演练记录。 - -**回滚**:在旧路径移除前保留快速切换能力;移除必须单独发布,不与首次全量切换同一版本完成。 - -## 22. 建议默认配置 - -```yaml -scheduler: - affinity: - explicit_ttl: 60m - protocol_ttl: 120m - identity_ttl: 10m - identity_fallback: true - escape_pressure: 0.90 - - overflow: - start: 0.70 - full: 0.90 - exit: 0.60 - exit_hold: 30s - - retry: - max_total_attempts: 4 - max_same_channel_retries: 1 - max_credential_rotations: 1 - max_channel_switches: 2 - max_elapsed: 15s - - circuit: - window: 30s - minimum_observations: 20 - open_error_rate: 0.50 - consecutive_failures: 5 - open_duration: 30s - half_open_max_probes: 1 - close_successful_probes: 2 - - capacity: - lease_ttl: 90s - lease_refresh: 30s - max_short_queue_wait: 200ms - runtime_snapshot_max_age: 15s - - recovery: - ramp_duration: 10m - - overrides: - default_ramp_up: 60s - max_ttl: 24h - default_allocation_unit: session - default_apply_scope: within_role -``` - -这些值是上线初始值,不应在缺少指标的情况下频繁调节。阈值变更必须绑定策略版本,并在变更前后对比目标指标。 - -## 23. 上线验收标准 - -### 23.1 正确性 - -- [ ] 禁用、无能力、越权、熔断和满载渠道不会被执行。 -- [ ] 多实例不突破严格硬容量。 -- [ ] 已输出流式响应后不会重试。 -- [ ] 非幂等模糊送达不会自动重放。 -- [ ] 临时策略不能绕过硬过滤。 - -### 23.2 亲和和分布 - -- [ ] 同一显式会话在健康低压期间保持渠道稳定。 -- [ ] 无会话 ID 时明确采用身份级短 TTL 或无亲和模式。 -- [ ] 权重和目标比例在大样本下达到规定容差。 -- [ ] 首选高压时只迁移新会话,已有健康会话不发生大规模抖动。 - -### 23.3 可用性 - -- [ ] 502/503 等短时故障按预算恢复或 failover。 -- [ ] 504 和网络模糊错误遵守可重放性。 -- [ ] 渠道故障能够熔断并通过单探测恢复。 -- [ ] Redis 故障行为符合严格/弹性容量策略。 - -### 23.4 操控性 - -- [ ] 可临时调整渠道的新会话目标比例。 -- [ ] 可灰度、排空、暂停和自动过期。 -- [ ] 所有操作有版本、原因、操作者和回滚记录。 -- [ ] 管理端显示目标比例与实际偏差及无法完成原因。 - -### 23.5 可观察性 - -- [ ] 每个逻辑请求可通过 Request ID 还原所有尝试。 -- [ ] 每次选择有稳定原因码和策略版本。 -- [ ] 看板区分首次分配、全部尝试、成功、Token 和成本比例。 -- [ ] 高基数和敏感字段未进入 Prometheus 标签或普通日志。 -- [ ] 关键告警完成演练。 - -## 24. 运维操作参考 - -### 24.1 临时把渠道调整到 20% - -推荐操作: - -1. 在模拟器中以 `allocation_unit=session`、`target_share=0.20` 运行最近流量样本。 -2. 检查渠道硬容量能否承接预计 Token 和并发。 -3. 创建 5 分钟 ramp、30 分钟 TTL 的临时策略。 -4. 观察新会话比例、Token 比例、TTFT、错误率和租约拒绝。 -5. 偏差较大时先检查亲和存量和硬过滤原因,不要立即反复改权重。 -6. 验证结束后手动回滚或等待自动过期。 - -### 24.2 渠道出现大量 502/503 - -1. 查看错误是渠道级还是 Provider 级。 -2. 查看熔断是否打开、failover 是否成功、备用是否有容量。 -3. 不要立即清除全部亲和;当前请求会临时绕开。 -4. 确认持续故障后执行 drain 或 pause。 -5. 恢复后通过 HALF_OPEN 和 ramp 自动回流。 - -### 24.3 渠道出现大量 504 - -1. 区分连接建立超时、上游处理超时和响应读取超时。 -2. 查看请求的 Replayability 和 DeliveryState。 -3. 对图片、视频和任务提交检查是否存在重复任务风险。 -4. 不通过扩大重试次数掩盖上游容量问题。 -5. 优先降低新会话比例或排空,再分析 TTFT、总延迟和 Token 分布。 - -### 24.4 紧急下线渠道 - -1. 优先使用 `force_drain`,设置明确原因和 TTL。 -2. 系统停止新租约并使已有亲和在后续请求迁移。 -3. 已经执行中的请求不被强制中断,除非另有安全要求。 -4. 观察备用和保底压力,必要时调整容量或目标比例。 -5. 恢复时先 canary,再 ramp,不直接恢复满量。 - -## 25. 审查时需要确认的业务参数 - -以下参数不影响架构,但必须在阶段 0 由业务和运维确认: - -1. 每类模型请求的 Replayability,尤其是工具调用、图片、视频和异步任务。 -2. 哪些渠道属于严格容量或昂贵渠道,Redis 故障时必须 fail-closed。 -3. 生产最大副本数和每个渠道的并发、Token 速率、目标 TTFT。 -4. 默认是否开启身份级回退亲和。 -5. 临时策略的最大 TTL、审批规则和操作角色。 -6. 目标比例默认按新会话、首次请求还是请求计算;本方案默认新会话。 -7. 成功率、P95/P99、TTFT、成本和故障恢复的正式 SLO。 - -上述参数确认后只需要形成策略配置,不需要修改调度核心算法。 diff --git a/docs/reference/new-api渠道调度分析.md b/docs/reference/new-api渠道调度分析.md deleted file mode 100644 index b9ac969..0000000 --- a/docs/reference/new-api渠道调度分析.md +++ /dev/null @@ -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` | diff --git a/docs/reference/sub2api渠道调度分析.md b/docs/reference/sub2api渠道调度分析.md deleted file mode 100644 index 26bc674..0000000 --- a/docs/reference/sub2api渠道调度分析.md +++ /dev/null @@ -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` | diff --git a/docs/reference/当前渠道调度存在的问题.md b/docs/reference/当前渠道调度存在的问题.md deleted file mode 100644 index 65c0ed9..0000000 --- a/docs/reference/当前渠道调度存在的问题.md +++ /dev/null @@ -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 缓存)。 diff --git a/docs/reference/渠道调度三方案对比评审.md b/docs/reference/渠道调度三方案对比评审.md deleted file mode 100644 index 2cdaa38..0000000 --- a/docs/reference/渠道调度三方案对比评审.md +++ /dev/null @@ -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_` 段,标注为待验证风险(§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_` 段(待验证 + 降级 + 监控) | ✅ §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 方案的熔断粒度设计(渠道级 + 渠道×模型级),可能需要补充「凭证级冷却」状态,实现前需细化。 diff --git a/docs/reference/渠道调度运维手册.md b/docs/reference/渠道调度运维手册.md deleted file mode 100644 index 85188a6..0000000 --- a/docs/reference/渠道调度运维手册.md +++ /dev/null @@ -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:`)?tier=0(层级因子配 0)? -4. **Key 全部冷却?** 401/403 会冷却对应 Key 5 分钟:`EXISTS dispatch:v1:credcd:`。全部 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: 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 定时全量刷新替代——效果等价(管理操作即时失效 + 秒级兜底收敛),且省去版本比对逻辑;如未来目录规模大到全量刷新成为负担,再引入版本号做增量跳过。 diff --git a/docs/reference/渠道调度重构开发计划.md b/docs/reference/渠道调度重构开发计划.md deleted file mode 100644 index c83977f..0000000 --- a/docs/reference/渠道调度重构开发计划.md +++ /dev/null @@ -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:`,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:` | 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_` 段是否稳定),结论回写基线方案 §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 评审后合入 |