delete: 删除文件 plan.md

Signed-off-by: JianFeeeee <2198972886@qq.com>
This commit is contained in:
2026-08-16 15:10:59 +08:00
parent 3061a70087
commit a1d119f513

283
plan.md
View File

@ -1,283 +0,0 @@
# 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。
### P10 全量代码审查发现2026-08-11全部核心文件通读后确认
- **P10-1已确认`handleSourcesAPI` 无 admin 校验**api.go:77-123全方法无 `reqRole` 检查,对比 `handleAdaptersAPI` api.go:21、`handleStatsAPI` api.go:138 均有)→ 任何 user 级 key 可 `GET /api/sources` 读取全部上游**明文 api_key**`config.Source.APIKey``json:"api_key"`config.go:42GET 分支直接返回 `g.core.Sources()`),并可 POST/DELETE 增删改源配置、篡改路由。**越权最高优先级修复项。**
- **P10-2推断`hasScopeModel` 不剥前缀**chat.go:219直接用原始模型串与 scope 比对 → 受限 user key 用 `zen:model` 前缀调用会被误判 403未实测待确认修复
- **P10-3已确认runtime.json 源无超时**JSON 源不序列化 timeout`mergedSources` 后 Timeout=0无限→ 生产 4 个源全部无超时;上游假死不返回时请求永久挂起、占满 `max_concurrent` 槽(历史上 2 条 120s `context canceled` 均为客户端主动断开)。补默认超时需注意 `http.Client.Timeout` 对 SSE 流式同样生效,需区分流式/非流式策略。
- **P10-4stats.go**:失败请求也计入配额消耗(`aggregateLocked` 无条件累计 tokens`nhour` 配额按整点小时桶粒度不精确(`WindowTokens` 按桶过滤user 视角 `Snapshot``by_status` 仍是全局分布(轻微泄露);`Record` 持 mu 时做文件 I/Oappend/rotate高 QPS 下有锁竞争。
- **P10-5scheduler.go**`Chat`/`ChatStream`/`Image` 重试次数受 `len(cands)` 限制MaxRetries 超出候选数时无效);`chainDrive` 有界等待期间重跑整档(硬失败会正确 break可接受
- **健全项(评审确认)**TryAcquire 忙不记分、chainDrive 硬失败正确打断等待、探活不污染调度状态、冷却必然到期自愈(无永久黑名单)、`ResolvePinned` 前缀解析41c9b0e与超时/退避公式5s·2ⁿ 封顶 30min正确。
---
## 二、目标架构(定稿)
### 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 汇总)。全部本地跑通
- [x] **P2-72026-08-11**生产机 `go test -tags luajit ./...` 最终回归全绿 e2eluajit 版二进制11.4MB部署 `/usr/local/bin/llmsproxy`备份 `.bak.20260811h`+ 重启在线验证非流式/流式 AUTO权限回归全通过
### 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/超大行混合回放断言)。
### Phase 4 — 2026-08-11 WebUI 修复与全量代码审查(今日会话)
- [x] **P4-12026-08-11WebUI 聊天"每次回复都失败"(用户实测)**:根因——`sendChat()``addMsg('assistant', '')` 未传 reason 参数 → `thinkEl`/`hintEl` 为 null → 首个携带 `reasoning_content` 的流式 chunk 到达时 index.html 抛 `Cannot set properties of null`,前端表现为请求失败。修复:`addMsg('assistant', '', true)` + 初始隐藏 think 框;提交 `7647b7d`,已推送并部署生产(服务 active、`/api/status` 正常),`go test ./internal/gateway/...` 通过。
- [x] **P4-22026-08-1141s AUTO 延迟归因**:审计 `09:54:42 stream lat=41810 model=deepseek-v4-flash-free src=zen ok=200 pt=17 ct=109`——链上调度仅 ~0.6stier1 qijiar 失败 lat=657ms 后顺延),其余 ~41s 为 zen 上游生成耗时(~380ms/token**非网关调度问题**。
- [x] **P4-32026-08-11全量代码审查**provider.go812 行)/stats.go459 行)/scheduler.go398 行)/registry.go258 行)/api.go/chat.go/server.go/config 全部通读完毕,确认 P10-1~P10-5见上
- [x] **P4-42026-08-11`handleSourcesAPI` 补 admin 校验P10-1**`api.go` 方法开头加 `reqRole(r.Context()) != "admin"` 检查(同 handleAdaptersAPI线上实测 user 级 key GET/POST `/api/sources` 均 403admin 200防越权读取明文 api_key 与篡改源。
- [x] **P4-52026-08-11`hasScopeModel` 剥前缀比对P10-2**:改为 Gateway 方法,先用 `Registry.EffectiveModel` 严格剥 `source-`/`source:`/`source/` 前缀(仅当前缀是真实源名且该源确实服务裸模型时才剥,避免误伤 `deepseek-v4-flash-free` 这类自带 `-` 的模型 ID单测 `TestHasScopeModelWithSourcePrefix` 覆盖前缀剥离与勿误伤。
- [x] **P4-62026-08-11runtime 源默认超时策略P10-3区分流式/非流式)**:新增 `config.DefaultSourceTimeout=120s/DefaultSourceQueueTimeout=60s/DefaultSourceConcurrency=8``Core.mergedSources` 对 runtime 源兜底JSON 源不序列化 timeout`Provider.New` 拆两个 client——非流式 `client{Timeout}` 整请求限时、流式 `stream` 无总超时(共享 Transport 仅 `ResponseHeaderTimeout` 限首字节),长 SSE 不被掐;`ChatStream` 改用 `doRawStream`。单测 + 线上流/非流式验证通过。
- [x] **P4-72026-08-11AUTO 链 tier 序 + 审计导出修复 + UI 密钥视图(今日会话)**:① `BuildChain` 排序由降序改升序tier1=最高优先级先试,修 P10-5 中的序颠倒),单测同步;② 统计/CSV 全量limit 20000、`Stats.AuditRecords` 读磁盘含 `*.old`、key_names 掩码键、非 admin 过滤掩码);③ UI 卡片滚动区修正 + 密钥视图 toggle再点同一密钥回全局+ `rec-exit` 退出入口;④ WebUI 测试"所有档位失败"观察AUTO 链降级正常opus 必死→deepseek 撞 zen 间歇故障→frank key 失效→nemotron 404 全灭 503
- [x] **P4-82026-08-11调度延滞排查结论**homeagent AUTO 请求"看起来停在 opus"实为调度正常降级——完整 ChainErr 列出 tier1→2→3→4 全部尝试,审计只记 `ce.Tiers[0]`(首档)故 UI 显 opuszen 源 `opencode.ai/zen/v1` 直连即 403 `[server_error] Upstream response was not valid JSON`(间歇)/ nemotron 404确认为上游源自身问题非网关透传或 homeagent 适配器。
- [x] **P4-92026-08-11WebUI 统计可视化**(多轮迭代定稿):① 模型用量%改为占总请求的比例(`r.reqs/total`,替代原"相对最大模型"的误导性 100%/97%);② 顶部五宫格统一为同构卡片——**标题 + 大数值 + 副标题 + 86px 图表区**:活跃请求=折线 sparkline实时、总请求=按天柱状图、tokens=各模型占比堆叠条(大数值示总用量)、平均延迟=折线 sparkline实时、状态码=按码值堆叠条 + chip 副标题;全部 canvas 2D 手绘(无第三方库),去掉灰色空壳占位与独立全宽状态卡/冗余副标题。
- [x] **P4-102026-08-11claude "Third-party apps now draw from your extra usage" 400 结论**:该报错为 **Anthropic 官方配额提示**(账号第三方 app 用量额度耗尽,提示到 Anthropic usage 设置充值),**非流量特征被识破**;伪装流量/换 adaptive 适配器解决不了(请求已被 Anthropic 接受并按账号用量计费)。且 qijiar+openai 适配器下 claude 模型本就能 200 成功(审计 16 次,含 claude-opus-4-8/claude-sonnet-5AUTO 链/直连的 claude 失败多为上游 `model_not_found`(无 distributor 渠道)、`Concurrency limit exceeded``extra usage` 配额——均为上游账号/渠道级问题,非网关。若确需走 Anthropic 原生 `/v1/messages`,可为其源切换内置 `anthropic` 适配器(仅改变量 wire 格式,不影响配额)。
### Phase 5 — 2026-08-13 全量复查核实 & 修复(本轮会话)
> 三路并行审查provider/registry/scheduler、gateway、config/store/core+ 逐项对照源码核实,全部条目经本人亲自读取代码确认。
#### 5.1 已核实的未修正 bug均属实
| 编号 | 级别 | 问题 | 证据 |
|---|---|---|---|
| H1 | 高 | **stats 窗口单位错乱**`Req.Time` 为毫秒chat.go:572 `UnixMilli``stats.go:220` `h := r.Time/hourSec`hourSec=3600 秒)→ 桶宽实为 3.6s`WindowTokens`stats.go:352`h*hourSec >= cut`cut 为秒)毫秒恒 ≥ 秒 → **任何 sec>0 窗口返回全部记录**;剪枝 960 桶 ≈ 仅保留 57.6minstats.go:227-233。槽配额chat.go:326/key 配额scopeTokens/UI 周月曲线全部失真LoadAudit 回放同路径;`stats_test.go:125` 断言 372=全计,固化错误行为 | 逐行确认 |
| H2 | 高 | **空 scope 语义分裂**`cleanScopes`core.go:276-288返回非 nil 空切片 → CreateKey/UpdateKey 后 `allowedModels` 非 nil → 空 scope 无匹配 → **403 锁死**config 序列化 `models,omitempty`config.go:275→ 重启后 nil → **全部放行**。另 `UpdateKey`core.go:245对未传 models 的 PUT **静默清空已有 scope** | 逐行确认 |
| H3 | 高 | **客户端取消/断连被记失败**Chatprovider.go:611-613/Stream 首块前(:666-669/Image:751-753`err != nil` 无条件 `RecordFailure`,无 `ctx.Err()==nil` 守卫 → SDK 超时/关页把健康源反复误冷却、退避翻倍至 30min。流式中段:724已有守卫 → "断开≠成败"只实现一半 | 逐行确认 |
| H4 | 高 | **直连路径无视冷却P3 残留)**AUTO 走 `chainDrive``ModelAvailable`scheduler.go:242直连 Chat/ChatStream/Imagescheduler.go:334-398不查 → 401 打满 30min 后直连照打,`RecordFailure(auth)`provider.go:79-101每次重写冷却截止 → **无限续期**"无永久黑名单"被持续直连流量打破 | 逐行确认 |
| M1 | 中 | **AUTO 槽模型名大小写不一致**`buildAutoChain` 保留规则原文core.go:415Image 过滤用精确匹配 :406`ProviderForSlot` 用 EqualFold`runTier:194` 原样下发 → `ModelFor` 精确匹配失败回退 `bestChatModel`provider.go:214-222**静默发错模型**`ModelAvailable` 未知模型恒 true:303-310→ 冷却失效配额键chat.go:326 vs 实际记账模型)错位 → 配额永不生效 | 逐行确认 |
| M2 | 中 | **200-空流记为成功**ChatStream 无 chunk 计数provider.go:678-727`:724` 对"0 chunk 干净 EOF"照记 `RecordSuccess`→ 空成功流不降级 | 逐行确认 |
| M3 | 中 | **AUTO 生图错模型(仅混合源触发)**`Image`provider.go:738`ModelFor``bestChatModel`(跳过 image 模型)→ 混合源chat+image把 chat 模型发往 /images/generations`effectiveImageModel`chat.go:447-459只用于配额/scope 检查不修正请求体 | 逐行确认 |
| M4 | 中 | **transform/unmarshal 失败不上账**Chatprovider.go:619-626/Image:761-771适配器产出坏数据直接 return → **适配器故障源永不退避**,每请求全额重打 | 逐行确认 |
| M5 | 中 | **Core cfg 并发写无锁(潜伏)**`Core``autoChain` 原子core.go:24-31`c.cfg.Keys/Sources/Auto` 裸写CreateKey:228/UpdateKey:237-251/SaveAutoRules:295/AddSource:516-525与请求路径 `FindKey:201-208`/`mergedSources` 并发 → racecore 包零单测,`go test -race` 全绿属"无证据"非"安全" | 确认+race 全绿 |
| L1 | 低 | busyWait 每轮 `time.After` 不 Stopscheduler.go:273 | 确认 |
| L6 | 低 | pinned`src:model`tool-call 请求:`resolveCands` 工具分支chat.go:95-107用原始带前缀串 `ModelFor`(不解前缀)→ 回退 bestChatModel | 确认 |
#### 5.2 修复计划(按序推进,每项完成即勾选)
- [x] **P5-1H1stats 窗口单位修正**`aggregateLocked`stats.go:220毫秒转秒后再分桶`h := r.Time/1000/hourSec`窗口语义0=全量;>0 滑动秒窗整桶计引入截断误差≤1h注释说明剪枝阈值 960 小时桶 ≈ 40 天保留(注释注明);重写 `TestLoadAuditFullReplay`(相对时间戳 + 全量/3h/24h=372、1h ∈ [2,372)、m2=10 边界断言,旧断言 372=全计恰固化错误行为);新增 `TestStatsByStatus` 不受影响。
- [x] **P5-2H2空 scope 归一化**`cleanScopes` 空输入返回 nil进程内=重启后语义一致);`UpdateKey` 仅当 `models != nil` 才覆盖 scope未传保留、显式 `[]` 清空);单测 `TestCleanScopesNilForEmpty`/`TestCreateKeyWithoutScopeUnrestricted`/`TestUpdateKeyPreservesScopeWhenOmitted`
- [x] **P5-3H3断开不记失败**Chat/ChatStream 首块前/Image 三处 `err != nil` 分支改为 `err != nil && ctx.Err() == nil``RecordFailure`;新增 `TestChatClientCancelNotRecorded`(注意:测试 handler 不能依赖服务端 ctx 取消传播——HTTP/2 下客户端取消不会触发服务端 ctx.Done用显式 release 通道)。
- [x] **P5-4H4直连冷却检查**`Scheduler.Chat/ChatStream/Image` 调用前查 `ModelAvailable`,冷却中记 "cooling down" 错误顺延下一候选;全部冷却即失败返回——与 AUTO 一致"冷却=唯一硬跳过"auth 401 冷却不再被直连流量续期;新增 `TestDirectSkipsCooledCandidate`(冷却候选零命中 + 全冷报错。已知取舍AUTO 生图直连的可用性检查用 `ModelFor`chat 模型)近似,精确 image 模型冷却检查待后续。
- [x] **P5-5M1槽模型名规范化**`Provider.ModelIDFold`(大小写无关匹配)+ `buildAutoChain` 用其回填真实模型 ID 到 `r.Model`image-kind 过滤也基于规范化后的精确 ID冷却/配额/上游模型三者键一致;单测 `TestSaveAutoRulesNormalizesModelCase`GPT-4O → gpt-4o
- [x] **P5-6M2空流不记成功**ChatStream 统计有效 chunk 与 `[DONE]`;干净 EOF 且 0 chunk 且无 `[DONE]``RecordFailure`,有 `[DONE]` 或 ≥1 chunk → `RecordSuccess`;新增 `TestChatStreamEmptyBodyNotSuccess`
- [x] **P5-7M3AUTO 生图选 image 模型**`bestImageModel()`Kind==image 最高 priority兜底 Models[0]`Image` 对 AUTO/未知模型**改写请求体**(克隆 req 回填 image 模型 ID非仅记账新增 `TestImageAutoUsesImageModel`(混合源断言上游收到 img 模型而非 chat 模型——初版只改记账导致 body 仍为 AUTO测试当场抓出
- [x] **P5-8M4transform 失败记账**Chat/Image 的 transform_response/unmarshal 失败分支补 `RecordFailure(model,0)`(组合 ctx.Err 守卫)。
- [x] **P5-9M5Core 加锁**`Core.mu sync.Mutex` 包裹全部 cfg 读写公开方法ListKeys/FindKey/CreateKey/UpdateKey/DeleteKey/AutoRules/SaveAutoRules/Sources/AddSource/RemoveSource/Reload私有内部函数不持锁防重入`Config()` 仅测试读 Path 无需锁;新增 `core_test.go`(含 `TestConcurrentKeyMutation` 16 并发增删查)——`-race` 全绿。
- [x] **P5-10L1/L6**busyWait 改 `time.NewTimer`+`Reset`/defer Stop修 20+ 计时器泄漏);`resolveCands` 工具分支改用剥前缀后的 `effective``ModelFor`pinned tool-call 不再回退 bestChatModel
- [x] **P5-11**`go build ./...``go vet ./...``go test ./...`(含 e2e全绿`go test -race`core/scheduler/gateway全绿。待推送 + 生产机 `-tags luajit` 回归部署P3-7 流程)。