Skip to content

Commit c07bfce

Browse files
committed
fix(pacer): CodeArts 节流加滑动窗口,对齐上游「每账号每约 60s 最多 3 会话」
名额泄漏修复后实测仍偶发 400 TM.00001041。受控实验(单账号、真实上游) 确认上游限制的不是「同时在途 HTTP 数」而是「每账号每约 60s 最多 3 个会话」: 会话在流结束后仍滞留数十秒(打满 3 并发后单请求约 68s 才恢复),完全顺序、 零并发、每请求换新 client 也是前 3 OK 第 4 起全 400;间隔 30s 顺序发则 6/6 OK。 故 release 一让位、新请求就立刻再击穿,纯在途上限只对齐了一半。 - Pacer 增 window_seconds(热更取值器,默认 0=旧行为):与 max_concurrency 组合成「同桶最近 window 秒内最多启动 N 次」,满额挂起到最早一次启动滑出 窗口;release 只减在途计数、不提前让出窗口配额;取消路径回滚在途名额并 抹掉未真正发起的窗口登记。 - CodeArts 装配 window_seconds=lambda: runtime.codearts_request_window_seconds (默认 60,实测 68s 的保守下界);config / HotSetting / compose 透传。 - 回归:窗口满后 release 仍不放行、按桶独立、取消回滚、热更窗口、关闭回落。
1 parent 38728ae commit c07bfce

10 files changed

Lines changed: 245 additions & 11 deletions

‎PROPOSAL.md‎

Lines changed: 2 additions & 1 deletion
Large diffs are not rendered by default.

‎README.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ curl http://127.0.0.1:8000/v1/chat/completions \
188188
- **优先消耗**:每日池用完即弃,故当日剩余被登记为「次日本地 0 点到期」的到期额度,调度器会优先把它排在其它渠道之前——只要 CodeArts 还有额度就先走它,用尽后自动回落。上游按 token 计量,管理台统一显示为**积分**(1 积分 = 10000 token,每日池满额 = 1000 积分);升级前落库的历史数据由 `scripts/convert_codearts_credit_unit.py` 一次性折算。
189189
- **签名**:上游要求华为云 `SDK-HMAC-SHA256`(AK/SK + `X-Security-Token`)。白名单 `CODEARTS_ALLOWED_ENDPOINTS` 必须含 snap 引擎、STS、福利网关与门户四个主机;改 `CODEARTS_API_ENDPOINT` 时同步调整。
190190
- **累计全文 SSE**:上游流式 `text` 是**累计全文(替换语义)**而非增量,本服务在解析层还原为增量事件,对客户端透明。
191-
- **节流与并发**:真实账号渠道,默认 `CODEARTS_CHAT_MIN_INTERVAL=5`(独立节流器);上游硬限**每账号并发会话数 3**,故 pacer 另配在途上限 `CODEARTS_MAX_CONCURRENCY=3`(超出即 `400 TM.00001041`)。两者均可在「任务与配置」热更。
191+
- **节流与并发**:真实账号渠道,默认 `CODEARTS_CHAT_MIN_INTERVAL=5`(独立节流器);上游硬限**每账号并发会话数 3**,故 pacer 另配在途上限 `CODEARTS_MAX_CONCURRENCY=3`。实测该限制更接近「每账号每约 60s 最多 3 个会话」(会话在流结束后仍滞留数十秒),故再叠加滑动窗口 `CODEARTS_REQUEST_WINDOW_SECONDS=60`,把突发也挡在 400 之前(`400 TM.00001041`)。均可在「任务与配置」热更。
192192

193193
### Responses API(Codex CLI)
194194

@@ -353,6 +353,7 @@ CodeBuddy 成长中心的「连登天数 / 活跃地图」按日统计客户端
353353
| `QODER_CHAT_MIN_INTERVAL` | `5` | Qoder 聊天最小间隔(秒),独立节流器(真实账号渠道,上游有账号级频率风控);`0` 关闭 |
354354
| `CODEARTS_CHAT_MIN_INTERVAL` | `5` | CodeArts 聊天最小间隔(秒),独立节流器(真实账号渠道);`0` 关闭 |
355355
| `CODEARTS_MAX_CONCURRENCY` | `3` | CodeArts 每账号**在途并发上限**(热更项)。上游硬限每账号并发会话数 3,超出即 `400 TM.00001041`;桶内名额满时请求挂起直到有请求结束;`0` 关闭上限(回到「有在途即放行」,会再次击穿)。名额在每次尝试结束时**同步**归还,不依赖 GC |
356+
| `CODEARTS_REQUEST_WINDOW_SECONDS` | `60` | CodeArts **账号滑动窗口**(秒,热更项)。与上一项组合成「每账号每 60s 最多启动 3 次」——实测上游限制的是「每账号每约 60s 最多 3 个会话」(会话在流结束后仍滞留数十秒,实测约 68s 才恢复),纯在途上限挡不住「3 个刚结束就再发 3 个」的突发。窗口内满额时请求挂起到最早一次启动滑出窗口;`0` 关闭窗口口径,退回纯在途上限 |
356357
| `CODEBUDDY_SANITIZE_CHANNEL_MARKERS` | `true` | 出站 `system`/`assistant` 正文命中「伪装其他厂商官方客户端」指纹串时替换为占位符(上游 11128 内容风控:换号无效、会话带入即持续报错);只改出站副本,客户端历史不受影响;`false` 关闭(见 TECHNICAL.md §3.2) |
357358
| `REFRESH_SKEW_HOURS` | `24` | token 到期前该小时数窗口内预刷新。到期时间取凭证显式 `expires_at`,缺失时回落 access token 的 JWT `exp`(CodeBuddy 实测不带显式到期字段) |
358359
| `TOKEN_EXPIRY_WARNING_SECONDS` | `3600` | 管理台 token 到期预警阈值:剩余低于该值时标红;`≤0` 关闭预警(仍显示剩余时间)。纯展示,不参与调度 |
@@ -368,7 +369,7 @@ CodeBuddy 成长中心的「连登天数 / 活跃地图」按日统计客户端
368369

369370
上表中带「可热更」语义的 19 项可不改 `.env`、不重启,直接在管理台「任务与配置」页修改:
370371

371-
`DEFAULT_MODEL`、`MODEL_BLOCKLIST`、`QUOTA_EXPIRY_WINDOW_SECONDS`、`QUOTA_EXPIRY_SECONDARY_WINDOW_SECONDS`、`CONVERSATION_STICKY_SECONDS`、`GROWTH_IRREVERSIBLE_ACTIONS`、`GROWTH_INTERVAL_MINUTES`、`QUOTA_PROBE_MINUTES`、`MODEL_CATALOG_MINUTES`、`CODEBUDDY_CHAT_MIN_INTERVAL`、`ZEN_CHAT_MIN_INTERVAL`、`KILO_CHAT_MIN_INTERVAL`、`QODER_CHAT_MIN_INTERVAL`、`CODEARTS_CHAT_MIN_INTERVAL`、`CODEARTS_MAX_CONCURRENCY`、`PACER_MIN_SECONDS`、`PACER_MAX_SECONDS`、`ACTIVITY_REPORT_ENABLED`、`ACTIVITY_REPORT_HOUR`。
372+
`DEFAULT_MODEL`、`MODEL_BLOCKLIST`、`QUOTA_EXPIRY_WINDOW_SECONDS`、`QUOTA_EXPIRY_SECONDARY_WINDOW_SECONDS`、`CONVERSATION_STICKY_SECONDS`、`GROWTH_IRREVERSIBLE_ACTIONS`、`GROWTH_INTERVAL_MINUTES`、`QUOTA_PROBE_MINUTES`、`MODEL_CATALOG_MINUTES`、`CODEBUDDY_CHAT_MIN_INTERVAL`、`ZEN_CHAT_MIN_INTERVAL`、`KILO_CHAT_MIN_INTERVAL`、`QODER_CHAT_MIN_INTERVAL`、`CODEARTS_CHAT_MIN_INTERVAL`、`CODEARTS_MAX_CONCURRENCY`、`CODEARTS_REQUEST_WINDOW_SECONDS`、`PACER_MIN_SECONDS`、`PACER_MAX_SECONDS`、`ACTIVITY_REPORT_ENABLED`、`ACTIVITY_REPORT_HOUR`。
372373

373374
要点:
374375

‎TECHNICAL.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,8 @@ UA 版本走 `ZEN_OPENCODE_VERSION` 配置(上游改阈值改 env,不硬编
707707

708708
**名额泄漏修复(2026-10-02)**:上一条引入的 `max_concurrency` 依赖 `release` 与 `wait_turn` 严格配对,而 `release` 在 provider `stream_chat` 的 `finally` 里——`async for ... break` **不关闭** async generator(CPython 只在耗尽 / 显式 `aclose()` / GC 的 asyncgen finalizer 时才跑 `finally`)。executor `_stream_loop` 遇 `EventKind.ERROR` 的两处 `break` 于是让名额推迟归还;轮换重试每次重新 `wait_turn`,`_inflight` 单调累积,满 3 后新请求在 `wait_turn` 无限阻塞——表现为**「用了三次就限制」而非「并发三」**(复现:3 次流内错误后 `inflight=3`,第 4 个请求永久阻塞)。注意这推翻了 Q45 的原始判断(「延迟归还只会让节流略松、不会误排队」):`max_concurrency=0` 时泄漏确实只是变松,配上上限后泄漏即**永久丢失许可**。修复:`provider.base.aclose_stream` 统一关闭,`stream_guarded` / `stream` / `_stream_loop` / `complete` / `ContinuationStream.__aiter__` 五处提前结束消费处全部显式关闭上游流。回归测试 `tests/test_stream_slot_release.py`(10 例,旧代码上 6 例失败)。
709709

710+
**节流窗口对齐上游口径(2026-10-02)**:泄漏修好后实测仍偶发 `TM.00001041`——排查确认上游限制的不是「同时在途 HTTP 数」而是「**每账号每约 60s 最多 3 个会话**」。证据(单账号、真实上游):① 3 并发请求结束后、名额已全部归还,**紧接着**再发 3 个全部 400;② 打满 3 并发后每 6s 探一个单请求,直到约 **68s** 才恢复;③ 完全顺序(零并发)、每个请求都换全新 `httpx` client 并显式关闭,仍是前 3 个 OK、第 4 个起全 400;④ 间隔 30s 顺序发 6 个则 6/6 OK。即会话在 HTTP 流结束后仍滞留数十秒,纯在途上限(`max_concurrency`)挡不住「3 个刚结束就立刻再发 3 个」这类突发,`release` 一让位新请求就再次击穿。故 `Pacer` 增加**滑动窗口**口径:`window_seconds > 0` 时与 `max_concurrency` 组合成「同桶最近 `window_seconds` 秒内最多放行 N 次启动」,窗口内满额则挂起到最早一次启动滑出窗口(睡 `starts[0] + window_seconds - now`,醒来重查,`release` 只减在途计数、不提前让出窗口配额)。CodeArts pacer 装配 `window_seconds=lambda: runtime.codearts_request_window_seconds`(热更项,默认 60s;`0` 关闭窗口口径退回纯在途上限)。取 60s 是**实测下界**(68s 附近恢复,留少量余量)对上「每账号 3 会话」的保守对齐;代价是高频使用时账号吞吐降到约 3 次/分钟,超出部分排队而非报错。窗口模式配 `max_concurrency=0` 时不生效(退回纯放行);`window_seconds` 与在途上限都保留,`0` 均可热更关闭。
711+
710712
---
711713

712714
## 4. Provider 协议(Q16=A 细接口)

‎docker-compose.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ services:
7777
CODEARTS_CHAT_MIN_INTERVAL: ${CODEARTS_CHAT_MIN_INTERVAL:-5}
7878
# CodeArts 每账号在途并发上限(上游硬限会话数 3);0 关闭上限
7979
CODEARTS_MAX_CONCURRENCY: ${CODEARTS_MAX_CONCURRENCY:-3}
80+
# CodeArts 账号滑动窗口(秒):与上限组合成「窗口内最多启动 N 次」,
81+
# 对齐上游「每账号每约 60s 最多 3 个会话」;0 关闭窗口口径
82+
CODEARTS_REQUEST_WINDOW_SECONDS: ${CODEARTS_REQUEST_WINDOW_SECONDS:-60}
8083
# 11128 内容风控自愈:中和出站正文里的伪装客户端指纹,false 关闭
8184
CODEBUDDY_SANITIZE_CHANNEL_MARKERS: ${CODEBUDDY_SANITIZE_CHANNEL_MARKERS:-true}
8285
# token 预刷新窗口(小时)

‎src/config.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,11 @@ class Settings(BaseSettings):
133133
# 400 TM.00001041 并发超限(实测 77% 失败率的主因)。默认 3 对齐上游;
134134
# 0 关闭上限(回到「有在途就放行」,会再次击穿)。可热更。
135135
codearts_max_concurrency: int = 3
136+
# CodeArts 账号滑动窗口(秒,默认 60):与上面的上限组合成「窗口内最多
137+
# 启动 N 次」。实测上游限制的不是「同时在途」而是「每账号每约 60s 最多
138+
# 3 个会话」——会话在 HTTP 流结束后仍滞留数十秒(打满 3 并发后,单请求
139+
# 直到约 68s 才恢复)。0 关闭窗口口径,退回纯在途上限。可热更。
140+
codearts_request_window_seconds: float = 60
136141
# 内容风控自愈(11128):出站 system/assistant 正文命中「伪装其他厂商
137142
# 官方客户端」指纹串时替换为占位符(客户端会话历史不受影响)。该拦截
138143
# 与凭证无关、换号无效,会话一旦带入指纹将持续 11128;false 关闭

‎src/main.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,8 @@ def build_app(settings: Settings | None = None, *, providers: dict | None = None
277277
codearts_pacer = Pacer(lambda: runtime.codearts_chat_min_interval,
278278
lambda: runtime.codearts_chat_min_interval,
279279
allow_concurrent=True,
280-
max_concurrency=lambda: runtime.codearts_max_concurrency)
280+
max_concurrency=lambda: runtime.codearts_max_concurrency,
281+
window_seconds=lambda: runtime.codearts_request_window_seconds)
281282
registry = providers if providers is not None else {
282283
"trae": TraeProvider(pacer=chat_pacer),
283284
"codebuddy": CodeBuddyProvider(

‎src/runtime_settings.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,11 @@ def env_name(self) -> str:
123123
"TM.00001041(实测 77% 失败率主因)。默认 3 对齐上游;0 关闭上限"
124124
"(回到「有在途即放行」,会再次击穿)。", minimum=0,
125125
group="channels"),
126+
HotSetting("codearts_request_window_seconds", float, "CodeArts 账号节流窗口(秒)",
127+
"与「每账号并发上限」组合成滑动窗口:最近这么多秒内最多启动 N 次。"
128+
"上游口径是「每账号每约 60s 最多 3 个会话」(会话在流结束后仍滞留"
129+
"数十秒),纯在途上限挡不住突发,故默认 60s;0 关闭窗口口径。",
130+
minimum=0.0, group="channels"),
126131
HotSetting("pacer_min_seconds", float, "后台任务节流下限(秒)",
127132
"后台任务相邻上游请求的最小间隔;0 关闭节流。", minimum=0.0,
128133
group="pacer"),
@@ -423,6 +428,10 @@ def codearts_chat_min_interval(self) -> float:
423428
def codearts_max_concurrency(self) -> int:
424429
return int(self.get("codearts_max_concurrency"))
425430

431+
@property
432+
def codearts_request_window_seconds(self) -> float:
433+
return float(self.get("codearts_request_window_seconds"))
434+
426435
@property
427436
def pacer_min_seconds(self) -> float:
428437
return float(self.get("pacer_min_seconds"))

‎src/tasks/pacer.py‎

Lines changed: 75 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@
1717
上限(CodeArts 每账号 3),无上限放行会把第 4 个起全部打成 400 并发超限。
1818
0/None 表示不限(保持旧行为)。
1919
20+
`window_seconds > 0` 时上限改用**滑动窗口**口径:同一个桶在最近
21+
`window_seconds` 秒内最多放行 `max_concurrency` 次请求启动。这是对齐
22+
CodeArts 实测行为的修正——它限制的不是「同时在途」,而是「每账号每约 60s
23+
最多 3 个会话」:会话在 HTTP 流结束后仍滞留数十秒(130s 打满 3 并发后,
24+
单请求直到约 68s 才恢复)。纯在途上限挡不住「3 个并发刚结束就立刻再发 3 个」
25+
这类突发。窗口模式下 `release` 不再让出窗口配额(配额按启动时刻自然滑出),
26+
但仍用于计数在途、维持最小间隔。
27+
2028
**`max_concurrency > 0` 时 `release` 必须与 `wait_turn` 严格配对。**
2129
provider 在 `stream_chat` 的 `finally` 里归还名额,而 `async for ... break`
2230
不会关闭 async generator(CPython 只在耗尽 / 显式 `aclose()` / GC 的
@@ -55,6 +63,7 @@ class Pacer:
5563
def __init__(self, min_seconds: float, max_seconds: float, *,
5664
allow_concurrent: bool = False,
5765
max_concurrency: int | Callable[[], int] = 0,
66+
window_seconds: float | Callable[[], float] = 0,
5867
sleep: Callable[[float], Awaitable[None]] | None = None,
5968
now: Callable[[], float] | None = None) -> None:
6069
# 上下限可热更(B3.2):存取值器,每次计间隔读当前值。校验必须放在
@@ -73,12 +82,19 @@ def __init__(self, min_seconds: float, max_seconds: float, *,
7382
if not callable(max_concurrency) and max_concurrency < 0:
7483
raise ValueError("pacer max_concurrency must be non-negative")
7584
self._max_concurrency = live(max_concurrency)
85+
# 滑动窗口长度(秒,0 = 关闭):与 max_concurrency 组合成「窗口内启动
86+
# 次数上限」。同样可热更。
87+
if not callable(window_seconds) and window_seconds < 0:
88+
raise ValueError("pacer window_seconds must be non-negative")
89+
self._window = live(window_seconds)
7690
self._lock = asyncio.Lock()
7791
# 严格模式:全局单桶
7892
self._last_started: float | None = None
7993
# 并发模式:桶 → 上次(预留)开始时刻 / 在途计数
8094
self._bucket_started: dict[str, float] = {}
8195
self._inflight: dict[str, int] = {}
96+
# 窗口模式:桶 → 落在当前窗口内的启动时刻(升序,用于滑动窗口计数)
97+
self._starts: dict[str, list[float]] = {}
8298
# 并发模式 + 上限:名额让出时置位,挂起的请求醒来重查是否有空位
8399
self._wake = asyncio.Event()
84100

@@ -112,6 +128,12 @@ def max_concurrency(self) -> int:
112128
limit = int(self._max_concurrency())
113129
return limit if limit > 0 else 0
114130

131+
@property
132+
def window_seconds(self) -> float:
133+
"""当前滑动窗口长度;0/负 = 关闭窗口口径(每次现读,支持热更)。"""
134+
value = float(self._window())
135+
return value if value > 0 else 0.0
136+
115137
def next_interval(self) -> float:
116138
low, high = self._validate()
117139
if low == 0 and high == 0:
@@ -124,6 +146,22 @@ def _capped(self) -> bool:
124146
"""并发模式且配了在途上限——只有这种组合需要排队等名额。"""
125147
return self._allow_concurrent and self.max_concurrency > 0
126148

149+
def _windowed(self) -> bool:
150+
"""并发 + 在途上限 + 窗口三者齐备时才走滑动窗口口径。"""
151+
return (self._allow_concurrent and self.max_concurrency > 0
152+
and self.window_seconds > 0)
153+
154+
def _prune_starts(self, bucket: str, now: float) -> None:
155+
"""**调用方须持 `_lock`**:丢掉已经滑出窗口的启动时刻。"""
156+
starts = self._starts.get(bucket)
157+
if not starts:
158+
return
159+
cutoff = now - self.window_seconds
160+
while starts and starts[0] <= cutoff:
161+
starts.pop(0)
162+
if not starts:
163+
del self._starts[bucket]
164+
127165
def _reserve(self, bucket: str) -> float:
128166
"""**调用方须持 `_lock`**:登记一次在途并返回本次需补足的间隔秒数。
129167
@@ -151,7 +189,8 @@ async def wait_turn(self, key: str | None = None) -> None:
151189
并发模式下返回即视为「已占用一个在途名额」,调用方必须在请求结束后
152190
用同一 key 调 `release` 归还,否则该桶会被当成永远有请求在途而失去
153191
节流(见模块 docstring)。配了 `max_concurrency` 时,名额满会让新
154-
请求在此挂起,直到有 `release` 让位。
192+
请求在此挂起,直到有 `release` 让位;再配了 `window_seconds` 时按
193+
滑动窗口计数,窗口内启动次数满则挂起到最早一次启动滑出窗口。
155194
"""
156195
if self.disabled and not self._capped():
157196
return
@@ -168,28 +207,57 @@ async def wait_turn(self, key: str | None = None) -> None:
168207
bucket = key or ""
169208
remaining = 0.0
170209
reserved = False
210+
windowed_start: float | None = None
171211
try:
172212
while True:
213+
window_wait = 0.0
173214
async with self._lock:
215+
now = self._now()
174216
limit = self.max_concurrency
175-
if not limit or self._inflight.get(bucket, 0) < limit:
217+
if self._windowed():
218+
self._prune_starts(bucket, now)
219+
starts = self._starts.get(bucket, [])
220+
if limit and len(starts) >= limit:
221+
# 窗口未过期:等到最早一次启动滑出窗口再重查。
222+
window_wait = starts[0] + self.window_seconds - now
223+
self._wake.clear()
224+
else:
225+
self._starts.setdefault(bucket, []).append(now)
226+
windowed_start = now
227+
remaining = self._reserve(bucket)
228+
reserved = True
229+
elif not limit or self._inflight.get(bucket, 0) < limit:
176230
remaining = self._reserve(bucket)
177231
reserved = True
232+
else:
233+
# 名额已满:清事件后到锁外等待,release 会 set 唤醒。
234+
self._wake.clear()
235+
if reserved:
178236
break
179-
# 名额已满:清事件后到锁外等待,release 会 set 唤醒。
180-
self._wake.clear()
181-
await self._wake.wait()
237+
if window_wait > 0:
238+
await self._sleep(window_wait)
239+
else:
240+
await self._wake.wait()
182241
if remaining > 0:
183242
await self._sleep(remaining)
184243
except BaseException:
185244
# 未成功「占用」就退出(取消 / 间隔校验异常):把名额还回去,
186-
# 否则该桶的名额会永久少一个(最终把渠道卡死)。
245+
# 否则该桶的名额会永久少一个(最终把渠道卡死)。窗口模式下还要
246+
# 抹掉刚登记、并未真正发起的启动时刻,否则白占一个窗口配额。
187247
if reserved:
188248
self.release(bucket)
249+
if windowed_start is not None:
250+
starts = self._starts.get(bucket)
251+
if starts and windowed_start in starts:
252+
starts.remove(windowed_start)
189253
raise
190254

191255
def release(self, key: str | None = None) -> None:
192-
"""并发模式归还一个在途名额;严格模式与多余的 release 都是空操作。"""
256+
"""并发模式归还一个在途名额;严格模式与多余的 release 都是空操作。
257+
258+
窗口模式下只减少在途计数、维持最小间隔;窗口配额由启动时刻自然滑出,
259+
不因 `release` 提前让出(否则又退回纯在途口径,挡不住突发)。
260+
"""
193261
if not self._allow_concurrent:
194262
return
195263
bucket = key or ""

0 commit comments

Comments
 (0)