Files
ModelRouter/plan.md

229 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ModelRouter 调度层重构方案(基于线上实测)
> 调研对象192.168.2.60 生产实例(`/usr/local/bin/llmsproxy -config /etc/llmsproxy/config.yaml`systemd 托管)
> 调研时间2026-08-10审计文件 `/etc/llmsproxy/runtime.json.audit.jsonl`3978 行 / 1042 条请求记录)
---
## 一、线上实测问题(证据链)
### P1核心用户实测编辑 AUTO 优先级不重载退避状态
- 证据:`/api/status` 实时返回 `qijiar available=false, live_available=true``zen available=false, live_available=true`
- 根因:`Core.SaveAutoRules`core.go:232只写 runtime.json不重建 Provider退避状态存在 `Provider.health`provider.go:24-51只有 `rebuildRegistry`core.go:290会重建。**用户在 WebUI 改完优先级后,被退避锁死的源依旧被跳过。**
- 线上复现AUTO 链 4 槽 `[qijiar/gpt-5.5→zen/flash-free→frank/gpt-5.6-sol→zen/nemotron]`,实时请求 `POST /v1/chat/completions {"model":"AUTO"}``{"error":{"message":"no provider available","type":"upstream_error"}}`0ms 返回;其中 2 个源探活确认在线。
### P2 源级黑名单(粒度错)
- 证据:`zen``429 FreeUsage` 退避(审计 ×4→ 其 9 个配置模型全部不可调度;`gpt-5.5` 的 qijiar 一次失败 → 源整体跳过。
- 根因:健康标志是 `(源)` 级而非 `(源, 模型)`provider.go:24一个模型失败黑掉全源。
### P3 永久黑名单(无法自愈)
- 证据:`frank` 收到一次 `401 API_KEY_DISABLED``markPermanent()`provider.go:48-51, 281-283此后 AUTO 永远跳过它,且有 30 分钟周期的显式探测持续打它产生 502502 又推高退避)。
- 根因:`permanent` 无过期、无重置入口;显式请求路径不检查 `Available()`,照打不误。
### P4 并发打满 = 排队 60s而非立即切换
- 证据/源码:`Provider.Acquire`provider.go:306-324满则等待 `QueueTimeout`(默认 60sconfig.go:154AUTO 逐槽串行,打满的档位拖死整条链。
-`TryAcquire` 语义,路由层无法区分"忙"与"故障"。
### P5 探活与调度状态脱节
- 探活GET /models不污染调度状态是正确设计但**探活结果没有任何一条通道能恢复调度健康**——线上 qijiar/zen "探活通、调度死" 的矛盾即因此产生。
### P6 流式成功不清退避
- 证据/源码:`ChatStream`provider.go:406-490全程无 `reportOK()`;流式源一旦退避,后续成功流也不复位。
### P7 两套 AUTO 逻辑并存,配置互相矛盾
- 线上config.yaml 里 qijiar priority 95/90/85、deepseek 40/30runtime AUTO 链却是 `tier1 gpt-5.5 → tier2 zen/flash-free → tier3 frank/gpt-5.6-sol → tier4 zen/nemotron`
- `Registry.Resolve("AUTO")`registry.go:83-121按"源的最大模型 priority + 健康优先")与 `autoPlans`chat.go:630按 runtime 槽位行为不一致deepseek 全部模型不在 AUTO 链中,但运维以为"priority 高"会被 AUTO 选中。
### P8 错误码策略粗糙
- `deepseek` 402 欠费 ×17`ReportStatus` 不触发任何退避也不在 UI 提示402 不属于 401/403/429/5xx 分支),欠费源被无限重试。
- 400 Invalid schema ×19客户端工具定义问题同样无区分。
- 链全灭时只返回 `no provider available`12 条),无分槽错误汇总,无法定位是谁挂了。
### P9 其他(顺带)
- 种子 admin key `sk-gw-local-0001` 未更换README 明确要求换);`listen: 0.0.0.0:8081` 全端口暴露。
- AUTO 链只有 4 槽13+ 配置模型不参与 AUTO且 webui 编辑链后无任何"健康复位"提示。
- 审计文件 jsonl 无限增长(当前 547KB`AppendAudit` 每事件一次文件 open。
---
## 二、目标架构(定稿)
### 2.1 分层(横向三层 + 状态基座横切)
```
┌────────────────────────────────────────────────┐
│ 直连调度 AUTO 调度 │ ← 同级,共享底座
│ source:model 直派 链快照 + 偏好表 │ 直连失败即返回(不重试)
├────────────────────────────────────────────────┤
│ 源抽象层(每源) │
│ · 信号量 = max_concurrentTryAcquire 非阻塞 │ ← 唯一"忙/闲"判定
│ · Chat / ChatStream / Image / 事件上报 │
├────────────────────────────────────────────────┤
│ adapter 池层(每适配器,启动预热) │
│ worker = Σ(使用该适配器的源 × max_concurrent) │
└────────────────────────────────────────────────┘
▲ 状态基座(不属任何层)
ModelState 表 (source,model) → {pref, failCount, cooldownUntil}
tier 游标表 tier → atomic next记录上次分配位置
```
事件流:所有成功/失败/冷却由**源抽象层**上报到状态基座auto 与直连都只读。直连失败同样写入状态。
### 2.2 数据结构
```go
// 调度链:保存时整体新建、原子替换;调度期只读快照
type Chain struct { Tiers []TierNode } // 按 tier 降序
type TierNode struct { Tier int; Slots []*Slot }
type Slot struct { // 静态配置,链构建时冻结配额
Model, Source string
Kind string
Quota int64; Period string; Hours int64
state *ModelState // 指针 → 跨链存活
}
// 状态基座key=(source,model),配置移除后回收)
type ModelState struct {
pref atomic.Int64 // +1/-5clamp[-20,+20]
failCount atomic.Int64
cooldownUntil atomic.Int64 // 惰性指数退避,无定时器
}
```
### 2.3 调度算法AUTO
```
snap := chain.Snapshot() // O(n) 拷指针,调度期链只读
for tier := snap.Tiers 降序 {
初筛 := 剔除 [冷却中 | 配额超限(slot.Quota>0 && windowTokens>=Quota)] 的槽
if 初筛为空 → 该 tier 顺延,记录原因
base := cursor[tier].Add(1) // per-tier 原子游标(记录上次位置)
ordered := 稳定排序(初筛, 按 pref 降序) // 负面模型沉底
for i := base; i < base+len(ordered); i++ {
slot := ordered[i % n]
if slot.state 冷却中 → continue // 硬跳过(复验)
if !src.TryAcquire() → continue // 忙 = 软跳过(不记分)
resp, err := src.Chat(slot.model)
if err != nil {
src.Release()
state.RecordFailure() // failCount++, pref-5, 冷却 5s·2^n(≤30min)
continue // 单请求内不重试已失败槽
}
state.RecordSuccess() // pref+1, failCount/cooldown 清零
return resp, slot.model, slot.source
}
}
return 503 { 各 tier 错误汇总 } // 全部档位失败才报错,可定位
```
关键语义:
- **主序 = 游标轮转(均衡),偏好只做同起点排序(自适应)**;负偏好"沉底不跳过"——仅当同 tier 无非负候选时才尝试负面模型,成功 +1 自愈。**无永久黑名单、无定时器**。
- **忙 ≠ 失败**:不扣分、不计冷却;同 tier 全忙 → 有界等待≤2s 轮询 TryAcquire尊重 ctx再顺延避免高 QPS 时全线降级。
- **冷却是唯一硬跳过**`cooldownUntil = lastFail + min(5s·2^failCount, 30min)`,必然到期 → 自愈。
- 401/403failCount 一次打高(不设永久位),可被冷却到期/手动重置恢复。
- 流式:失败→切换仅限首 chunk 前;首块后固定。首块前失败 -5正常结束 +1。
- 生图:不进 AUTO 链,直连 `kind:image` 模型,同一状态机制。
### 2.4 生命周期(修 P1
| 事件 | 动作 |
|---|---|
| WebUI 保存 AUTO 链 | 构造新 `Chain` → 写锁原子替换 → **链内全部 ModelState 冷却清零(偏好保留)** |
| 增删/编辑源 | `rebuildRegistry` 重建 ProviderModelState 复用/回收 |
| (source,model) 移出配置 | 回收其 ModelState偏好一并丢弃 |
| 进行中请求 | 不受 swap 影响(入口 Snapshot 已拷贝引用) |
### 2.5 直连
```
p := resolve(source, model) // 唯一归属,无 AUTO 逻辑
p==nil → 400/404!TryAcquire → 429 busy快速失败
成功/失败 → RecordSuccess/RecordFailure同样写状态基座返回不重试
```
---
## 三、实施计划(分阶段上线)
### Phase 0 — 止血热修(改动最小,当天可上)
1. `core.go``SaveAutoRules` 成功后调用 `rebuildRegistry()`;给 `Provider` 增加 `ResetHealth()`(清 failCount/permanent/cooldownrebuild 时顺带重置。
2. `provider.go``ChatStream` 成功(收到 `[DONE]` 或正常结束)调用 `reportOK()`
3. 状态页:`/api/status` 增加 `last_backoff``permanent` 展示 + admin 可"重置源健康"按钮(调用 ResetHealth
- 验证:改优先级 → AUTO 立即按新链调度qijiar/zen 场景恢复;回归 `go test`
### Phase 1 — 状态基座落地provider.go 重构)
1. 引入 `ModelState`(每 (source,model)pref/failCount/cooldownUntil原子
2. `Provider` 增加 `TryAcquire(ctx) error`(非阻塞)与 `RecordFailure(model, code)` / `RecordSuccess(model)``ReportStatus` 改为按模型记账与 401/403 不再永久化。
3. 删除 `permanent` 语义(由有界冷却 + 重置通道取代)。
- 验证单测退避按模型隔离、429/401 行为、TryAcquire 满即返)。
### Phase 2 — 调度层重写scheduler.go + chat.go
1. 新增 `Chain/TierNode/Slot` 与 Snapshot/原子替换;`autoPlans``rotateSameTier` 删除,`singleChatAuto`/`streamChatAuto` 按 2.3 伪码重写。
2. `Registry.Resolve` 移除 AUTO 排序/健康优先职责(只留归属 + pin 解析;`AUTOChain`/`Default` 不再用于调度)。
3. 503 响应携带分槽错误汇总(哪个 tier 哪个源什么错)。
4. 同 tier 全忙时 ≤2s 有界等待再降级。
- 验证scheduler 单测tier 分桶、游标均衡、偏好沉底自愈、配额初筛、忙不记分e2e 增加"上游 429 → 切同 tier → 再切下 tier"、"编辑优先级后退避清零"用例。
### Phase 3 — 收尾
1. UI优先级页展示链上模型的冷却/偏好状态;状态页按模型展示健康。
2. WebUI 保存链时前端提示"健康状态已复位"。
3. 审计错误码分类402 欠费、400 schema 计入独立统计jsonl 轮转(按天/大小)。
4. 运维:更换 admin key`listen` 收敛到内网地址0.0.0.0:8081 → 127.0.0.1 或内网 IP + 反向代理)。
### 回归与上线
- 构建:`go build -tags luajit`(生产机 Debian amd64 已具备工具链,可直接在 60 上编译);本地 Windows 需先补 LuaJIT 库才能链接。
- 测试:`go test -tags luajit ./...` 全绿后按 Phase 0→3 灰度,每个 Phase 观察审计中 `no provider available` 计数与 502 分布。
- 观察指标:`no provider available` 计数归零qijiar/zen 由 `available=false` 恢复;`zen 429` 触发时仅 zen 对应模型短冷却deepseek/qijiar 不受牵连。
---
## 四、与既有代码的对应改动文件
| 文件 | 改动 |
|---|---|
| internal/provider/provider.go | health→ModelState 表TryAcquireReportStatus/ResetHealthChatStream reportOK |
| internal/provider/registry.go | 删除 Resolve(AUTO) 排序/健康逻辑,保留归属/pin |
| internal/scheduler/scheduler.go | 新增 Chain/Slot/游标/偏好排序迭代器 |
| internal/gateway/chat.go | 删除 autoPlans/rotateSameTier重写单次/流式 AUTO错误汇总 |
| internal/core/core.go | SaveAutoRules 原子换链 + 冷却清零ModelState 生命周期 |
| internal/gateway/api.go / keys.go | 状态页/优先级页 UI 展示与重置接口 |
| e2e/e2e_test.go 等 | 新场景用例 |
---
## 五、实施状态追踪(每项改动后更新)
### Phase 0 — 止血热修
- [x] **P0-12026-08-10`core.go`**`SaveAutoRules` 持久化后调用 `rebuildRegistry()`——新建 Provider 即退避归零,**编辑优先级立即生效**(修 P1 主诉);新增 `Core.ResetHealth()` 供管理端手动清退避。
- [x] **P0-22026-08-10`provider.go`**:新增 `Provider.ResetHealth()` / `Provider.HealthInfo()`failCount/cooldownUntil/permanent 只读暴露);`ChatStream` 流式正常结束(`[DONE]`/EOF、非客户端断开、非读错误调用 `reportOK()`——流式成功可恢复退避(修 P6
- [x] **P0-32026-08-10`registry.go` + `server.go`**`SourceStatus` 增加 `fail_count`/`backoff_until`/`permanent` 字段(状态页可分辨"探活通但调度退避");新增 `POST /api/status/reset`admin 专属,写审计)。
- [x] **P0-42026-08-10验证**`go build ./internal/...` 通过;`go test ./internal/config/...` 通过Windows 缺 LuaJIT 库,带 cgo 的包无法本地链接,待生产机验证)。
> 说明P0-1 采用"重建 Provider"实现退避归零(非目标架构的 ModelState 粒度属临时止血Phase 1 引入 per-(源,模型) ModelState 后,`ResetHealth` 语义将迁移到模型级。
### Phase 1 — 状态基座落地
- [x] **P1-12026-08-10`provider.go`**`ModelState` 表落地pref±1/-5、failCount、cooldownUntil 原子,惰性求值无定时器);新增 `ErrBusy``TryAcquire`(非阻塞,满即返)、`ModelAvailable(model)``RecordFailure(model, code)` / `RecordSuccess(model)``Provider.Pref(model)``ReportStatus` 改按模型记账401/403 → 打满档冷却 + 双倍偏好惩罚,**不再永久化**5xx/429 → 指数退避400/402 不惩罚);`ResetHealth` 清全源模型状态;`HealthInfo` 源级聚合permanent 恒 false`Chat/ChatStream/Image` 全部按命中模型记账 + 内部 `Acquire``TryAcquire`(忙即 429不再排队 60s修 P4流式干净收尾 `RecordSuccess`(修 P6
- [x] **P1-22026-08-10`chat.go`**:直连/生图路径 `errors.Is(err, ErrBusy)` → HTTP 429`upstreamErrStatus`AUTO 槽改用 `ModelAvailable(slot.model)` 模型级冷却跳过frank 401 后其模型不被 AUTO 反复打,修 P3 的一半——永久黑名单已随 P1-1 移除)。
- [x] **P1-32026-08-10单测`provider_test.go`**模型级退避隔离m1 失败 m2 照常、401 → failCount 打满 capN + 冷却 ~30min + 偏好 -10非永久reset 即恢复)、单次失败冷却 ≈5s 且成功复位 +1、TryAcquire 满即返、Chat 忙时快速 ErrBusy、流式成功清理冷却P6 回归)。
- [x] **P1-42026-08-10本地验证**`go build ./internal/...``go vet ./internal/...``go test ./internal/config/...` 全绿provider/gateway 测试因 Windows 缺 `-llua` 仅静态检查通过,待生产机 `-tags luajit` 全量跑(同 P0-4 约束)。
- [ ] 生产机192.168.2.60`go test -tags luajit ./internal/provider/...` 回归 — 待 Phase 2/3 完成后一并部署验证。
### Phase 2 — 调度层重写
- [x] **P2-12026-08-10`scheduler.go`**`Chain/TierNode/Slot/Rule` 落地tier 降序;槽静态配置,同 tier 保持配置序per-tier `next atomic.Int64` 游标始 -1首请求从配置序开始`BuildChain` 丢弃 provider 解析失败的槽;`runTier` 按 2.3 语义重写——冷却复验硬跳过、busy 软跳过不记分、**硬失败记账后同档继续下一槽**(单请求不重试已失败槽)、整档遍历完才顺延;`chainDrive` 初筛(配额/冷却)→ 同 tier 按 `Pref` 稳定降序排序 → `NextStart()` 起步轮转 → 全忙/全冷却有界等待(`busyWait` 2s/`busyPoll` 100ms期间刷新冷却再降级`ChainErr` 携带各档 `TierError` + 顺延原因Error() 输出 `all auto tiers failed: tier N src/model: err; ...``ChainChat/ChainChatStream` 返回 (resp, source, model, err);直连 `Chat/ChatStream/Image` 保持不变;**scheduler 不再 import provider**`FromRegistry` 移入 gateway 为 `toScheduler`scheduler 单测不拉 LuaJIT 链接。
- [x] **P2-22026-08-10`registry.go` + `core.go`**`Registry.Resolve` 移除 AUTO 排序/健康优先AUTO→按配置序全量仅用于生图/tool 锚定未知模型→nil→gateway 404`AUTOChain`/`Default` 删除);`Core``autoChain atomic.Pointer[scheduler.Chain]` + `AutoChain()``buildAutoChain`(过滤 image-kind 与已不存在槽,**槽 Source 规范化为所有权源**使汇总/audit/配额窗口键一致)随 `rebuildRegistry``SaveAutoRules` 重建;`SaveAutoRules` = 持久化 → 原子换链 → **链内每槽 `ResetModelCooldown`(偏好保留,不重建 Provider**——修 P1 主诉(编辑优先级立即生效)。
- [x] **P2-32026-08-10`chat.go`**AUTO 分支改走 `AutoChain()`+`quotaExhausted``Quota<=0` 不过滤;`AutoPeriodSeconds`+`stats.WindowTokens` 实时判定);`singleChatAuto`/`streamChatAuto``ChainChat`/`ChainChatStream` 重写,总失败在**任何 SSE 字节前**写 JSON`upstreamErrStatus``ChainErr`→503错误消息即分档汇总`ErrBusy`→429、其余→502失败记录取首个 `TierError` 填 audit source/model直连无候选→404 `model_not_found`
- [x] **P2-42026-08-10单测`scheduler_test.go`**tier 分桶/降序、同 tier 游标轮转交替s1,s2,s1,s2、负偏好沉底仍可达硬失败换槽后由负面槽承接、busy 跳过不记分、整档全忙有界等待(实测 <1s后降级配额耗尽槽不调度全灭 503 汇总Tiers+Skipped 文本断言)、流式首 chunk 前失败换槽游标首帧从 0
- [x] **P2-52026-08-10本地验证Windows 已补齐 Lua** golua 自带 Lua 5.1 头对应的源码编成 `liblua.a` 放入 golua 模块目录golua 官方 Windows 做法本机 `go build/vet/test ./...` 全绿——**provider/gateway/lua/e2e 全部首次真正跑通**并借此揪出三处从未被发现的存量问题:① `RecordFailure(auth)` 只把 failCount 上限用于冷却算式未落盘计数器已修auth `failCount.Store(backoffCapN)`);② gateway 测试种子 key 未进 runtime store 导致全 401已修测试 cfg `GatewayKeys`);③ e2e failover 用例只有一个 chat AUTO 全灭必然 503已修新增第二 chat `fallback`真故障转移e2e `buildBinary` Windows 回退无 tag 构建bundled Lua透传适配器行为一致生产机仍优先 `-tags luajit`
- [x] **P2-62026-08-10Phase 2 场景测试**gateway 新增—— tier 硬失败顺延承接`TestChatAutoChainTierFailover`)、全灭 503 `a/a-m` 分档汇总`TestChatAutoChain503Summary`)、配额耗尽槽跳过且不再打上游`TestChatAutoQuotaSkip`)、`PUT /api/auto` 后冷却立即复位并恢复调度`TestAutoSaveResetsCooldown` P1 回归e2e 新增 `TestEndToEndAuto503`真实二进制 503 汇总)。全部本地跑通
- [ ] **P2-7**生产机192.168.2.60`go test -tags luajit ./...` 最终回归 + 灰度部署luajit bundled Lua 的适配器行为差异由生产验证兜底)。
### Phase 3 — 收尾
- [x] **P3-12026-08-10UI 链上健康展示**`GET /api/auto` 增加 `states``Core.AutoSlotStates` 遍历当前 Chain × `Provider.ModelHealthInfo`pref/failCount/cooldownUntil/cooling优先级页每块按 `model|source` 渲染徽标冷却红/失败橙×N偏好蓝title 说明状态页新增"状态码分布"卡片
- [x] **P3-22026-08-10保存链健康复位提示**`saveSort` toast 变更为 `排序已保存并热重载 · 链上冷却已复位`zh/en)。
- [x] **P3-32026-08-10审计分类与 jsonl 轮转**`Stats.byStatus map[int]*Stat`402 欠费/400 schema 等按状态码独立计数不触发 provider 退避`Snapshot.by_status` 有序输出audit 文件超 `auditRotateBytes`(64MB, var 可测) 轮转 `rename <path>.<unix>.old` 并保留最新 `auditKeepOld`(10) ——`rotateAuditLocked` mu `Record`/`AppendAudit` 内触发
- [x] **P3-42026-08-10运维项代码部分**`main.go` 启动告警——gateway_keys 为空 / 命中种子 keysk-gw-local-0001 提示轮换listen 绑定 0.0.0.0/:: 提示收敛内网生产实践 admin key内网绑定随本次上线执行
- [x] **P3-52026-08-10测试**新增 `stats_test.go`by_status 断言轮转保留上限Record 路径轮转gateway 新增 `TestAutoStatesReportChainHealth`states 契约失败后 fail_count>0+coolingPUT 复位归零);`go vet ./...` + `go test ./...` 全绿。
- [x] **P3-62026-08-10WebUI 右键菜单无法关闭(用户实测)**:根因——`showCtx` 创建菜单后从未赋值 `ctxEl``hideCtx()` 恒为空操作),任何路径(点菜单项/点外部/二次右键)都关不掉;补 `ctxEl = w`,优先级页与密钥页共用 `showCtx` 一并修复(这也是最初版本就存在的 bug
- [ ] **P3-7**:推送 origin → 生产机 pull → `-tags luajit` 全量回归(含 P2-7→ 部署 `/usr/local/bin/llmsproxy` + 重启 service → 观察。
- [x] **P3-82026-08-11审计回放修复用户实测**:重启后统计只剩最后 3000 行≈5.3M tokens历史 273M 消失、且记录里全是 access 脏行——根因 `LoadAudit`:① 只取文件尾 3000 行(而 92% 行是每 3s 的 access 事件);② access/事件行type 空)被当请求灌入聚合;③ Scanner 默认 64KB 截断风险。修复:回放**全部真实请求行**进聚合(恢复 totals/配额窗口),仅视图环形缓冲限 maxRecs跳过 type 空的行;`sc.Buffer` 抬到 16MB。新增 `TestLoadAuditFullReplay`access/坏 JSON/超大行混合回放断言)。