From 92c240cc2510ebf7aaeed51c8ee2b7ceaea4a5a8 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Wed, 14 Oct 2026 16:00:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(channels):=20=E6=BA=90=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E4=B8=B2=E8=A1=8C=20+=20=E6=8C=89=E6=A8=A1=E5=9E=8B=E8=8A=82?= =?UTF-8?q?=E6=B5=81=EF=BC=88=E7=85=A7=E5=8F=82=E8=80=83=20model-gate.ts?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 照参考的实测依据:这些平台的**并发配额按模型**分别计量,与"还剩多少 token" 无关(那边实测撞 429 时 token 还剩 299.4 万)。同账号下: GLM-5.3-Flash 605 次 200 0 次限流 ← 从未撞过 GLM-5.3 74 次 200 21 次重试 6 次最终 429 两条措施缺一不可: ① **串行**(max_concurrent=1):并发发起必然浪费一个配额; ② **最小间隔**(rpm):串行只保证"不重叠"、不保证"有间隔" —— 连续放行的两次 调用可能只隔几十毫秒,仍会撞并发窗口。 此前插件注册源一律 `max_concurrent = 2` 且**从不设 rpm** ⇒ 既会重叠又无间隔。 现全部改为 `max_concurrent = 1` + `rpm = 20`(默认 3 秒一个,可按平台覆盖)。 ⚠️ 参考的另一条教训也记进注释了:闸门**只包 fetch,不包 captcha**。 那边第一版把 mint 与 fetch 一起放进闸门,mint 耗时从 200-500ms 暴涨到 2500-3100ms(因为 mint 被串行后变成"排在 N 个人后面再 mint")。 我们的 rpm 节流在内核层、只作用于上游 dispatch,天然不涉及这一点。 顺带:`list_sources` 补暴露 `rpm` —— 运维与判据都要能看到它。只看 max_concurrent 无法区分"串行但有间隔"与"串行且无间隔"(后者仍会撞窗口)。 判据 `TestChannelSourcesAreSerialWithThrottle`:源必须 max_concurrent=1 且 rpm>0。 回归:9 个包全绿。 --- internal/core/channels_gate_test.go | 63 +++++++++++++++++++++++++++++ internal/core/kernel_actions.go | 5 +++ internal/lua/plugins/channels.lua | 22 ++++++++-- 3 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 internal/core/channels_gate_test.go diff --git a/internal/core/channels_gate_test.go b/internal/core/channels_gate_test.go new file mode 100644 index 0000000..401419a --- /dev/null +++ b/internal/core/channels_gate_test.go @@ -0,0 +1,63 @@ +package core + +import ( + "testing" +) + +// 源注册必须**串行 + 节流**,而不是并发 2、无间隔。 +// +// 照参考 model-gate.ts 的实测依据:这些平台的**并发配额按模型**分别计量, +// 与"还剩多少 token"无关(那边实测撞 429 时 token 还剩 299.4 万)。同账号下: +// +// GLM-5.3-Flash 605 次 200 0 次限流 ← 从未撞过 +// GLM-5.3 74 次 200 21 次重试 6 次最终 429 +// +// 两条措施缺一不可: +// +// ① **串行**(max_concurrent=1):同一时刻只允许一个上游请求在飞 —— +// 并发发起必然浪费一个配额。 +// ② **最小间隔**(rpm):串行只保证"不重叠",不保证"有间隔" —— 连续放行的 +// 两次调用可能只隔几十毫秒,仍会撞并发窗口。 +// +// 此前插件注册源时一律 max_concurrent=2 且**从不设 rpm** ⇒ 既会重叠又无间隔。 +func TestChannelSourcesAreSerialWithThrottle(t *testing.T) { + c, ps := poolCore(t) + m, err := ps.Call("channels", "account_add", map[string]interface{}{ + "platform": "buddy", "account_id": "gated", + "credential": `{"access_token":"TOK"}`, + }) + if err != nil { + t.Fatalf("account_add: %v", err) + } + j, _ := m.(map[string]interface{})["json"].(map[string]interface{}) + if j == nil || j["error"] != nil { + t.Fatalf("account_add 失败: %#v", m) + } + aid, _ := j["account_id"].(string) + + srcs := listSourcesViaKernel(t, c) + sm, ok := srcHasAccount(srcs, aid) + if !ok { + t.Fatal("账号未出源") + } + // ① 串行 + if mc, _ := toFloat(sm["max_concurrent"]); mc != 1 { + t.Errorf("源必须 max_concurrent=1(并发发起必然浪费一个配额),实得 %v", sm["max_concurrent"]) + } + // ② 节流(rpm>0 才有间隔) + if rpm, _ := toFloat(sm["rpm"]); rpm <= 0 { + t.Errorf("源必须设 rpm>0(串行不保证间隔),实得 %v", sm["rpm"]) + } +} + +func toFloat(v interface{}) (float64, bool) { + switch x := v.(type) { + case float64: + return x, true + case int: + return float64(x), true + case int64: + return float64(x), true + } + return 0, false +} diff --git a/internal/core/kernel_actions.go b/internal/core/kernel_actions.go index d5f9fdc..ffe2722 100644 --- a/internal/core/kernel_actions.go +++ b/internal/core/kernel_actions.go @@ -147,6 +147,11 @@ func (c *Core) actionListSources() interface{} { "has_key": s.APIKey != "" || s.APIKeyEnv != "", "key_hint": maskSecretHint(s.APIKey), "max_concurrent": s.MaxConcurrent, + // rpm must be exposed too: it carries the per-model minimum gap, and + // max_concurrent alone cannot distinguish "serial WITH a gap" from + // "serial with no gap" — the latter still collides with the + // upstream's per-model concurrency window. + "rpm": s.RPM, }) } return out diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index feb5fc3..5d06e4b 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -3050,7 +3050,19 @@ local function syncAccountSource(provider, accountId) base_url = plat.base_url, adapter = plat.adapter or "openai", api_key = tok, - max_concurrent = 2, + -- ⚠️ max_concurrent=1 + rpm 节流,而不是 2。 + -- + -- 照参考 model-gate.ts 的实测依据:这些平台的**并发配额按模型**分别计量, + -- 与"还剩多少 token"无关(那边实测撞 429 时 token 还剩 299.4 万)。同账号下: + -- GLM-5.3-Flash 605 次 200 0 次限流 ← 从未撞过 + -- GLM-5.3 74 次 200 21 次重试 6 次最终 429 + -- 两条措施缺一不可: + -- ① **串行**:同一时刻只允许一个上游请求在飞(并发发起必然浪费一个配额) + -- ② **最小间隔**:串行只保证"不重叠",不保证"有间隔" —— 连续放行的两次 + -- 调用可能只隔几十毫秒,仍会撞并发窗口。故用 rpm 让内核把它们隔开。 + -- 这两条都是**客户端自保**(防患于未然),与"上游明确要求排队"是两回事。 + max_concurrent = 1, + rpm = plat.rpm or 20, -- 默认 3 秒一个请求,可按平台覆盖 models = models, } -- 平台的 chat 端点常与 base_url 不是简单拼接(Gemini 走私有 @@ -4897,7 +4909,9 @@ function plugin.complete_login(req) base_url = plat.base_url, adapter = "openai", api_key = legacyTok, - max_concurrent = 2, + -- 同 syncAccountSource:串行 + 节流(见那里的长注释) + max_concurrent = 1, + rpm = plat.rpm or 20, models = (function() local ms = {} local eff = effectiveModels(plat.id) @@ -5971,7 +5985,7 @@ function plugin.provider_set_enabled(req) if legacy and legacyTok then __llmsproxy_kernel.call("add_source", { source = { name = "channels-" .. plat.id, base_url = plat.base_url, adapter = plat.adapter or "openai", - api_key = legacyTok, max_concurrent = 2, + api_key = legacyTok, max_concurrent = 1, models = (function() local ms = {} local eff = effectiveModels(plat.id) for i, m in ipairs(eff) do ms[i] = { id = m, kind = "chat", priority = 20 } end return ms end)(), } }) @@ -8726,7 +8740,7 @@ function plugin.__sync_source_probe(args) end local test_src = { name = want, base_url = plat.base_url, adapter = plat.adapter or "openai", - api_key = tok, max_concurrent = 2, models = models, + api_key = tok, max_concurrent = 1, models = models, } if plat.endpoint then test_src.endpoint = plat.endpoint end local rr, rerr = __llmsproxy_kernel.call("add_source", { source = test_src })