mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 08:57:57 +00:00
- adapters/opencode.lua: opencode.ai zen free pool adapter — sends the opencode client User-Agent (zen fingerprints clients by UA; non-official clients hit FreeUsageLimitError); pairs with api_key: public - config: no config file ships in the repo; first run generates a default config at the -config path with a random admin key, loopback listen and a keyless zen source (config.EnsureDefault); remove config.example.yaml - lua: seed bundled adapters from the embedded FS instead of a hardcoded name list - ui: widen model kind select (chat was clipped to 'cha') - phase 5 bugfixes: stats ms/s bucket mixing, cleanScopes nil, ctx.Err guards, direct-path ModelAvailable, empty stream body failure, bestImageModel rewrite, transform failure recording, Core.mu, timer, effective model for tool-calls
36 KiB
36 KiB
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 分钟周期的显式探测持续打它产生 502(502 又推高退避)。 - 根因:
permanent无过期、无重置入口;显式请求路径不检查Available(),照打不误。
P4 并发打满 = 排队 60s,而非立即切换
- 证据/源码:
Provider.Acquire(provider.go:306-324)满则等待QueueTimeout(默认 60s,config.go:154);AUTO 逐槽串行,打满的档位拖死整条链。 - 无
TryAcquire语义,路由层无法区分"忙"与"故障"。
P5 探活与调度状态脱节
- 探活(GET /models)不污染调度状态是正确设计,但探活结果没有任何一条通道能恢复调度健康——线上 qijiar/zen "探活通、调度死" 的矛盾即因此产生。
P6 流式成功不清退避
- 证据/源码:
ChatStream(provider.go:406-490)全程无reportOK();流式源一旦退避,后续成功流也不复位。
P7 两套 AUTO 逻辑并存,配置互相矛盾
- 线上:config.yaml 里 qijiar priority 95/90/85、deepseek 40/30;runtime 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 错误码策略粗糙
deepseek402 欠费 ×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检查,对比handleAdaptersAPIapi.go:21、handleStatsAPIapi.go:138 均有)→ 任何 user 级 key 可GET /api/sources读取全部上游明文 api_key(config.Source.APIKey带json:"api_key",config.go:42;GET 分支直接返回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 条 120scontext canceled均为客户端主动断开)。补默认超时需注意http.Client.Timeout对 SSE 流式同样生效,需区分流式/非流式策略。 - P10-4(低)stats.go:失败请求也计入配额消耗(
aggregateLocked无条件累计 tokens);nhour配额按整点小时桶粒度不精确(WindowTokens按桶过滤);user 视角Snapshot的by_status仍是全局分布(轻微泄露);Record持 mu 时做文件 I/O(append/rotate),高 QPS 下有锁竞争。 - P10-5(低)scheduler.go:
Chat/ChatStream/Image重试次数受len(cands)限制(MaxRetries 超出候选数时无效);chainDrive有界等待期间重跑整档(硬失败会正确 break,可接受)。 - 健全项(评审确认):TryAcquire 忙不记分、chainDrive 硬失败正确打断等待、探活不污染调度状态、冷却必然到期自愈(无永久黑名单)、
ResolvePinned前缀解析(41c9b0e)与超时/退避公式(5s·2ⁿ 封顶 30min)正确。
二、目标架构(定稿)
2.1 分层(横向三层 + 状态基座横切)
┌────────────────────────────────────────────────┐
│ 直连调度 AUTO 调度 │ ← 同级,共享底座
│ source:model 直派 链快照 + 偏好表 │ 直连失败即返回(不重试)
├────────────────────────────────────────────────┤
│ 源抽象层(每源) │
│ · 信号量 = max_concurrent,TryAcquire 非阻塞 │ ← 唯一"忙/闲"判定
│ · Chat / ChatStream / Image / 事件上报 │
├────────────────────────────────────────────────┤
│ adapter 池层(每适配器,启动预热) │
│ worker = Σ(使用该适配器的源 × max_concurrent) │
└────────────────────────────────────────────────┘
▲ 状态基座(不属任何层)
ModelState 表 (source,model) → {pref, failCount, cooldownUntil}
tier 游标表 tier → atomic next(记录上次分配位置)
事件流:所有成功/失败/冷却由源抽象层上报到状态基座;auto 与直连都只读。直连失败同样写入状态。
2.2 数据结构
// 调度链:保存时整体新建、原子替换;调度期只读快照
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/-5,clamp[-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/403:failCount 一次打高(不设永久位),可被冷却到期/手动重置恢复。
- 流式:失败→切换仅限首 chunk 前;首块后固定。首块前失败 -5,正常结束 +1。
- 生图:不进 AUTO 链,直连
kind:image模型,同一状态机制。
2.4 生命周期(修 P1)
| 事件 | 动作 |
|---|---|
| WebUI 保存 AUTO 链 | 构造新 Chain → 写锁原子替换 → 链内全部 ModelState 冷却清零(偏好保留) |
| 增删/编辑源 | rebuildRegistry 重建 Provider,ModelState 复用/回收 |
| (source,model) 移出配置 | 回收其 ModelState(偏好一并丢弃) |
| 进行中请求 | 不受 swap 影响(入口 Snapshot 已拷贝引用) |
2.5 直连
p := resolve(source, model) // 唯一归属,无 AUTO 逻辑
p==nil → 400/404;!TryAcquire → 429 busy(快速失败)
成功/失败 → RecordSuccess/RecordFailure(同样写状态基座),返回,不重试
三、实施计划(分阶段上线)
Phase 0 — 止血热修(改动最小,当天可上)
core.go:SaveAutoRules成功后调用rebuildRegistry();给Provider增加ResetHealth()(清 failCount/permanent/cooldown),rebuild 时顺带重置。provider.go:ChatStream成功(收到[DONE]或正常结束)调用reportOK()。- 状态页:
/api/status增加last_backoff、permanent展示 + admin 可"重置源健康"按钮(调用 ResetHealth)。
- 验证:改优先级 → AUTO 立即按新链调度,qijiar/zen 场景恢复;回归
go test。
Phase 1 — 状态基座落地(provider.go 重构)
- 引入
ModelState(每 (source,model):pref/failCount/cooldownUntil,原子)。 Provider增加TryAcquire(ctx) error(非阻塞)与RecordFailure(model, code)/RecordSuccess(model);ReportStatus改为按模型记账与 401/403 不再永久化。- 删除
permanent语义(由有界冷却 + 重置通道取代)。
- 验证:单测(退避按模型隔离、429/401 行为、TryAcquire 满即返)。
Phase 2 — 调度层重写(scheduler.go + chat.go)
- 新增
Chain/TierNode/Slot与 Snapshot/原子替换;autoPlans、rotateSameTier删除,singleChatAuto/streamChatAuto按 2.3 伪码重写。 Registry.Resolve移除 AUTO 排序/健康优先职责(只留归属 + pin 解析;AUTOChain/Default不再用于调度)。- 503 响应携带分槽错误汇总(哪个 tier 哪个源什么错)。
- 同 tier 全忙时 ≤2s 有界等待再降级。
- 验证:scheduler 单测(tier 分桶、游标均衡、偏好沉底自愈、配额初筛、忙不记分);e2e 增加"上游 429 → 切同 tier → 再切下 tier"、"编辑优先级后退避清零"用例。
Phase 3 — 收尾
- UI:优先级页展示链上模型的冷却/偏好状态;状态页按模型展示健康。
- WebUI 保存链时前端提示"健康状态已复位"。
- 审计:错误码分类(402 欠费、400 schema 计入独立统计),jsonl 轮转(按天/大小)。
- 运维:更换 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 表;TryAcquire;ReportStatus/ResetHealth;ChatStream 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 — 止血热修
- P0-1(2026-08-10)
core.go:SaveAutoRules持久化后调用rebuildRegistry()——新建 Provider 即退避归零,编辑优先级立即生效(修 P1 主诉);新增Core.ResetHealth()供管理端手动清退避。 - P0-2(2026-08-10)
provider.go:新增Provider.ResetHealth()/Provider.HealthInfo()(failCount/cooldownUntil/permanent 只读暴露);ChatStream流式正常结束([DONE]/EOF、非客户端断开、非读错误)调用reportOK()——流式成功可恢复退避(修 P6)。 - P0-3(2026-08-10)
registry.go+server.go:SourceStatus增加fail_count/backoff_until/permanent字段(状态页可分辨"探活通但调度退避");新增POST /api/status/reset(admin 专属,写审计)。 - P0-4(2026-08-10)验证:
go build ./internal/...通过;go test ./internal/config/...通过(Windows 缺 LuaJIT 库,带 cgo 的包无法本地链接,待生产机验证)。
说明:P0-1 采用"重建 Provider"实现退避归零(非目标架构的 ModelState 粒度),属临时止血;Phase 1 引入 per-(源,模型) ModelState 后,
ResetHealth语义将迁移到模型级。
Phase 1 — 状态基座落地
- P1-1(2026-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)。 - P1-2(2026-08-10)
chat.go:直连/生图路径errors.Is(err, ErrBusy)→ HTTP 429(upstreamErrStatus);AUTO 槽改用ModelAvailable(slot.model)模型级冷却跳过(frank 401 后其模型不被 AUTO 反复打,修 P3 的一半——永久黑名单已随 P1-1 移除)。 - P1-3(2026-08-10)单测(
provider_test.go):模型级退避隔离(m1 失败 m2 照常)、401 → failCount 打满 capN + 冷却 ~30min + 偏好 -10(非永久,reset 即恢复)、单次失败冷却 ≈5s 且成功复位 +1、TryAcquire 满即返、Chat 忙时快速 ErrBusy、流式成功清理冷却(P6 回归)。 - P1-4(2026-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 — 调度层重写
- P2-1(2026-08-10)
scheduler.go:Chain/TierNode/Slot/Rule落地(tier 降序;槽静态配置,同 tier 保持配置序;per-tiernext atomic.Int64游标始 -1,首请求从配置序开始);BuildChain丢弃 provider 解析失败的槽;runTier按 2.3 语义重写——冷却复验硬跳过、busy 软跳过不记分、硬失败记账后同档继续下一槽(单请求不重试已失败槽)、整档遍历完才顺延;chainDrive初筛(配额/冷却)→ 同 tier 按Pref稳定降序排序 →NextStart()起步轮转 → 全忙/全冷却有界等待(busyWait2s/busyPoll100ms,期间刷新冷却)再降级;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 链接。 - P2-2(2026-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 主诉(编辑优先级立即生效)。 - P2-3(2026-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;直连无候选→404model_not_found。 - P2-4(2026-08-10)单测(
scheduler_test.go):tier 分桶/降序、同 tier 游标轮转交替(s1,s2,s1,s2)、负偏好沉底仍可达(硬失败换槽后由负面槽承接)、busy 跳过不记分、整档全忙有界等待(实测 <1s)后降级、配额耗尽槽不调度、全灭 503 汇总(Tiers+Skipped 文本断言)、流式首 chunk 前失败换槽、游标首帧从 0 起。 - P2-5(2026-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,真故障转移);e2ebuildBinaryWindows 回退无 tag 构建(bundled Lua,透传适配器行为一致),生产机仍优先-tags luajit。 - P2-6(2026-08-10)Phase 2 场景测试:gateway 新增——同 tier 硬失败顺延承接(
TestChatAutoChainTierFailover)、全灭 503 含a/a-m分档汇总(TestChatAutoChain503Summary)、配额耗尽槽跳过且不再打上游(TestChatAutoQuotaSkip)、PUT /api/auto后冷却立即复位并恢复调度(TestAutoSaveResetsCooldown,修 P1 回归);e2e 新增TestEndToEndAuto503(真实二进制 503 汇总)。全部本地跑通。 - P2-7(2026-08-11):生产机
go test -tags luajit ./...最终回归全绿(含 e2e),luajit 版二进制(11.4MB)部署/usr/local/bin/llmsproxy(备份.bak.20260811h)+ 重启,在线验证非流式/流式 AUTO、权限回归全通过。
Phase 3 — 收尾
- P3-1(2026-08-10)UI 链上健康展示:
GET /api/auto增加states(Core.AutoSlotStates遍历当前 Chain 槽 ×Provider.ModelHealthInfo:pref/failCount/cooldownUntil/cooling);优先级页每块按model|source渲染徽标(冷却红/失败橙×N/±偏好蓝,title 说明);状态页新增"状态码分布"卡片。 - P3-2(2026-08-10)保存链健康复位提示:
saveSorttoast 变更为排序已保存并热重载 · 链上冷却已复位(zh/en)。 - P3-3(2026-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内触发。 - P3-4(2026-08-10)运维项(代码部分):
main.go启动告警——gateway_keys 为空 / 命中种子 key(sk-gw-local-0001 等)提示轮换、listen 绑定 0.0.0.0/:: 提示收敛内网。生产实践(换 admin key、内网绑定)随本次上线执行。 - P3-5(2026-08-10)测试:新增
stats_test.go(by_status 断言、轮转→保留上限→Record 路径轮转);gateway 新增TestAutoStatesReportChainHealth(states 契约:失败后 fail_count>0+cooling,PUT 复位归零);go vet ./...+go test ./...全绿。 - P3-6(2026-08-10)WebUI 右键菜单无法关闭(用户实测):根因——
showCtx创建菜单后从未赋值ctxEl(hideCtx()恒为空操作),任何路径(点菜单项/点外部/二次右键)都关不掉;补ctxEl = w,优先级页与密钥页共用showCtx一并修复(这也是最初版本就存在的 bug)。 - P3-7:推送 origin → 生产机 pull →
-tags luajit全量回归(含 P2-7)→ 部署/usr/local/bin/llmsproxy+ 重启 service → 观察。 - P3-8(2026-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 修复与全量代码审查(今日会话)
- P4-1(2026-08-11)WebUI 聊天"每次回复都失败"(用户实测):根因——
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/...通过。 - P4-2(2026-08-11)41s AUTO 延迟归因:审计
09:54:42 stream lat=41810 model=deepseek-v4-flash-free src=zen ok=200 pt=17 ct=109——链上调度仅 ~0.6s(tier1 qijiar 失败 lat=657ms 后顺延),其余 ~41s 为 zen 上游生成耗时(~380ms/token),非网关调度问题。 - P4-3(2026-08-11)全量代码审查:provider.go(812 行)/stats.go(459 行)/scheduler.go(398 行)/registry.go(258 行)/api.go/chat.go/server.go/config 全部通读完毕,确认 P10-1~P10-5(见上)。
- P4-4(2026-08-11)
handleSourcesAPI补 admin 校验(P10-1):api.go方法开头加reqRole(r.Context()) != "admin"检查(同 handleAdaptersAPI);线上实测 user 级 key GET/POST/api/sources均 403,admin 200,防越权读取明文 api_key 与篡改源。 - P4-5(2026-08-11)
hasScopeModel剥前缀比对(P10-2):改为 Gateway 方法,先用Registry.EffectiveModel严格剥source-/source:/source/前缀(仅当前缀是真实源名且该源确实服务裸模型时才剥,避免误伤deepseek-v4-flash-free这类自带-的模型 ID);单测TestHasScopeModelWithSourcePrefix覆盖前缀剥离与勿误伤。 - P4-6(2026-08-11)runtime 源默认超时策略(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。单测 + 线上流/非流式验证通过。 - P4-7(2026-08-11)AUTO 链 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)。 - P4-8(2026-08-11)调度延滞排查结论:homeagent AUTO 请求"看起来停在 opus"实为调度正常降级——完整 ChainErr 列出 tier1→2→3→4 全部尝试,审计只记
ce.Tiers[0](首档)故 UI 显 opus;zen 源opencode.ai/zen/v1直连即 403[server_error] Upstream response was not valid JSON(间歇)/ nemotron 404,确认为上游源自身问题,非网关透传或 homeagent 适配器。 - P4-9(2026-08-11)WebUI 统计可视化(多轮迭代定稿):① 模型用量%改为占总请求的比例(
r.reqs/total,替代原"相对最大模型"的误导性 100%/97%);② 顶部五宫格统一为同构卡片——标题 + 大数值 + 副标题 + 86px 图表区:活跃请求=折线 sparkline(实时)、总请求=按天柱状图、tokens=各模型占比堆叠条(大数值示总用量)、平均延迟=折线 sparkline(实时)、状态码=按码值堆叠条 + chip 副标题;全部 canvas 2D 手绘(无第三方库),去掉灰色空壳占位与独立全宽状态卡/冗余副标题。 - P4-10(2026-08-11)claude "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-5),AUTO 链/直连的 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.6min(stats.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 | 高 | 客户端取消/断连被记失败:Chat(provider.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/Image(scheduler.go:334-398)不查 → 401 打满 30min 后直连照打,RecordFailure(auth)(provider.go:79-101)每次重写冷却截止 → 无限续期,"无永久黑名单"被持续直连流量打破 |
逐行确认 |
| M1 | 中 | AUTO 槽模型名大小写不一致:buildAutoChain 保留规则原文(core.go:415,Image 过滤用精确匹配 :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 失败不上账:Chat(provider.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 并发 → race;core 包零单测,go test -race 全绿属"无证据"非"安全" |
确认+race 全绿 |
| L1 | 低 | busyWait 每轮 time.After 不 Stop(scheduler.go:273) |
确认 |
| L6 | 低 | pinned(src:model)tool-call 请求:resolveCands 工具分支(chat.go:95-107)用原始带前缀串 ModelFor(不解前缀)→ 回退 bestChatModel |
确认 |
5.2 修复计划(按序推进,每项完成即勾选)
- P5-1(H1)stats 窗口单位修正:
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不受影响。 - P5-2(H2)空 scope 归一化:
cleanScopes空输入返回 nil(进程内=重启后语义一致);UpdateKey仅当models != nil才覆盖 scope(未传保留、显式[]清空);单测TestCleanScopesNilForEmpty/TestCreateKeyWithoutScopeUnrestricted/TestUpdateKeyPreservesScopeWhenOmitted。 - P5-3(H3)断开不记失败:Chat/ChatStream 首块前/Image 三处
err != nil分支改为err != nil && ctx.Err() == nil才RecordFailure;新增TestChatClientCancelNotRecorded(注意:测试 handler 不能依赖服务端 ctx 取消传播——HTTP/2 下客户端取消不会触发服务端 ctx.Done,用显式 release 通道)。 - P5-4(H4)直连冷却检查:
Scheduler.Chat/ChatStream/Image调用前查ModelAvailable,冷却中记 "cooling down" 错误顺延下一候选;全部冷却即失败返回——与 AUTO 一致"冷却=唯一硬跳过";auth 401 冷却不再被直连流量续期;新增TestDirectSkipsCooledCandidate(冷却候选零命中 + 全冷报错)。已知取舍:AUTO 生图直连的可用性检查用ModelFor(chat 模型)近似,精确 image 模型冷却检查待后续。 - P5-5(M1)槽模型名规范化:
Provider.ModelIDFold(大小写无关匹配)+buildAutoChain用其回填真实模型 ID 到r.Model,image-kind 过滤也基于规范化后的精确 ID;冷却/配额/上游模型三者键一致;单测TestSaveAutoRulesNormalizesModelCase(GPT-4O → gpt-4o)。 - P5-6(M2)空流不记成功:ChatStream 统计有效 chunk 与
[DONE];干净 EOF 且 0 chunk 且无[DONE]→RecordFailure,有[DONE]或 ≥1 chunk →RecordSuccess;新增TestChatStreamEmptyBodyNotSuccess。 - P5-7(M3)AUTO 生图选 image 模型:
bestImageModel()(Kind==image 最高 priority,兜底 Models[0]);Image对 AUTO/未知模型改写请求体(克隆 req 回填 image 模型 ID,非仅记账);新增TestImageAutoUsesImageModel(混合源断言上游收到 img 模型而非 chat 模型——初版只改记账导致 body 仍为 AUTO,测试当场抓出)。 - P5-8(M4)transform 失败记账:Chat/Image 的 transform_response/unmarshal 失败分支补
RecordFailure(model,0)(组合 ctx.Err 守卫)。 - P5-9(M5)Core 加锁:
Core.mu sync.Mutex包裹全部 cfg 读写公开方法(ListKeys/FindKey/CreateKey/UpdateKey/DeleteKey/AutoRules/SaveAutoRules/Sources/AddSource/RemoveSource/Reload),私有内部函数不持锁防重入;Config()仅测试读 Path 无需锁;新增core_test.go(含TestConcurrentKeyMutation16 并发增删查)——-race全绿。 - P5-10(L1/L6):busyWait 改
time.NewTimer+Reset/defer Stop(修 20+ 计时器泄漏);resolveCands工具分支改用剥前缀后的effective调ModelFor(pinned tool-call 不再回退 bestChatModel)。 - P5-11:
go build ./...、go vet ./...、go test ./...(含 e2e)全绿;go test -race(core/scheduler/gateway)全绿。待推送 + 生产机-tags luajit回归部署(P3-7 流程)。