feat(channels): 源改为串行 + 按模型节流(照参考 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** ⇒ 既会重叠又无间隔。
现全部改为 `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 个包全绿。
This commit is contained in:
JianFeeeee
2026-10-14 16:00:34 +08:00
parent 5720e60fc9
commit 92c240cc25
3 changed files with 86 additions and 4 deletions

View File

@ -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
}

View File

@ -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

View File

@ -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 })