Skip to content

Commit dae0809

Browse files
committed
feat(pacer): 定值间隔向上抖动 25%(避免多实例同拍)
Pacer.next_interval() 原先只在 min != max 时随机;聊天节流器全部装配成 Pacer(5, 5),走 'if low == high: return low' → 每次精确 5s、零抖动, 多实例/多账号会在同一时刻撞上游。 改为:定值间隔只向上抖 [low, low × 1.25](low 是频率风控下限,不可 击穿);min < max 的区间本就均匀随机,不再叠加(否则越出承诺的 [min, max])。抖动源仍是构造期 random.Random(0),测试可复现。
1 parent d29127e commit dae0809

5 files changed

Lines changed: 39 additions & 12 deletions

File tree

‎README.en.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,7 @@ Key variables below; the full list is in `src/config.py` (authoritative), and **
422422
| `CODEBUDDY_SANITIZE_CHANNEL_MARKERS` | `true` | Neutralize official-client fingerprints in outbound `system`/`assistant` text (upstream 11128 content filter); outbound copy only |
423423
| `REFRESH_SKEW_HOURS` | `24` | Pre-refresh window; short-lived channels cap it (CodeArts 45 min) |
424424
| `TOKEN_EXPIRY_WARNING_SECONDS` | `3600` | Admin warning threshold; `≤0` disables (display only) |
425-
| `PACER_MIN_SECONDS` / `PACER_MAX_SECONDS` | `5` / `20` | Global pacer random wait (seconds) |
425+
| `PACER_MIN_SECONDS` / `PACER_MAX_SECONDS` | `5` / `20` | Global pacer random wait (seconds). When the bounds are equal (the `*_CHAT_MIN_INTERVAL` shape) the wait is additionally jittered **upwards** by ±25% so instances do not fire in lockstep; the lower bound is never breached |
426426
| `AUTO_CONTINUE_MAX` | `10` | Auto-continue attempts on `finish_reason=length`; `0` disables |
427427
| `CONTEXT_COMPRESS_ENABLED` | `true` | Trim over-long conversations to the catalog's input limit; models without a known limit are untouched |
428428
| `CONTEXT_COMPRESS_RESERVE_TOKENS` | `4096` | Output tokens reserved by the compression budget |

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -512,7 +512,7 @@ python3 scripts/restore_db.py data/backups/coding2api-20261014-030000.sqlite3 --
512512
| `CODEBUDDY_SANITIZE_CHANNEL_MARKERS` | `true` | 出站 `system`/`assistant` 正文命中「伪装其他厂商官方客户端」指纹串时替换为占位符(上游 11128 内容风控),只改出站副本;`false` 关闭 |
513513
| `REFRESH_SKEW_HOURS` | `24` | token 到期前该小时数窗口内预刷新。短寿命渠道自行封顶(CodeArts STS 只有 2h,封顶 45min) |
514514
| `TOKEN_EXPIRY_WARNING_SECONDS` | `3600` | 管理台 token 到期预警阈值;`≤0` 关闭预警。纯展示,不参与调度 |
515-
| `PACER_MIN_SECONDS` / `PACER_MAX_SECONDS` | `5` / `20` | 全局节流器随机等待区间(秒) |
515+
| `PACER_MIN_SECONDS` / `PACER_MAX_SECONDS` | `5` / `20` | 全局节流器随机等待区间(秒)。区间相等时(聊天节流器的 `*_CHAT_MIN_INTERVAL` 形态)额外**向上**抖动 ±25%,避免多实例精确同拍;下限不击穿 |
516516
| `AUTO_CONTINUE_MAX` | `10` | 上游以 `finish_reason=length` 截断时同凭证自动续写的最多次数;`0` 关闭 |
517517
| `CONTEXT_COMPRESS_ENABLED` | `true` | 按模型目录里的输入上限裁剪过长对话,避免撞上游硬限制;目录里查不到上限的模型不受影响 |
518518
| `CONTEXT_COMPRESS_RESERVE_TOKENS` | `4096` | 压缩预算里为模型回复预留的输出 token 数 |

‎TECHNICAL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -480,7 +480,7 @@ PRAGMA foreign_keys = ON; -- 仅 api_keys 之外无外键(users 与 api_k
480480
所有 `GET` 列表端点接受统一的 `sort`(白名单键)+ `order`(`asc`/`desc`)。**未知 `sort` 键或非法 `order` 值回落到默认排序,不报错**;`sort` 是白名单键而非 SQL 列名,SQL 片段只由受控常量拼出,用户输入永不进 SQL,注入面为零。契约在 `src/sorting.py`(db 与 api 共用,避免 db → api 反向依赖):`parse_sort_order`(唯一解析入口)、`sql_order`(`tiebreak` 兜底稳定次序)、`sort_rows`(组装后再排,`None` 值无论升降序都排**最后**)。逐请求明细默认(`time` 降序)走 rowid 游标分页返回 `next_before`;按其它列排序 / 时间升序 / 显式 `offset` 时改用 `LIMIT/OFFSET` 并返回 `total`。适用端点:`/api/credentials`、`/api/users`、`/api/api-keys`、`/api/audit`、`/api/alerts`、`/api/model-catalog`、`/api/credentials/{id}/credit-events`、`/api/credentials/{id}/accounts`、`/api/stats/by-*`、`/api/stats/events`。
481481

482482
## 10. 后台任务
483-
`TaskRunner`(`runner.py`)每类任务一个独立 asyncio 循环,失败只记日志不拖垮服务;间隔有安全下限,避免打爆上游。`Pacer` 有两种模式:后台任务用**严格模式**(默认,单锁串行化跨任务/跨凭证的相邻请求);聊天用**并发模式**(`allow_concurrent=True`),按「渠道 + 凭证身份」分桶并在桶内放行并发——同渠道同模型的并发请求不再被逐级串行化,只有同凭证「上一请求已结束、紧接着又来一个」的顺序连发才补足最小间隔。请求结束由 provider `stream_chat` 的 `finally` 调 `release(key)` 归还名额。
483+
`TaskRunner`(`runner.py`)每类任务一个独立 asyncio 循环,失败只记日志不拖垮服务;间隔有安全下限,避免打爆上游。`Pacer` 有两种模式:后台任务用**严格模式**(默认,单锁串行化跨任务/跨凭证的相邻请求);聊天用**并发模式**(`allow_concurrent=True`),按「渠道 + 凭证身份」分桶并在桶内放行并发——同渠道同模型的并发请求不再被逐级串行化,只有同凭证「上一请求已结束、紧接着又来一个」的顺序连发才补足最小间隔。请求结束由 provider `stream_chat` 的 `finally` 调 `release(key)` 归还名额。**间隔抖动(`JITTER_RATIO = 0.25`)**:定值间隔(`min == max`,聊天节流器的形态,如 `Pacer(5, 5)`)逐个账号精确同拍,多实例 / 多账号会同时刻撞上游,故 `next_interval()` 对定值**只向上**抖 `[low, low × 1.25]`——`low` 是频率风控下限,不可击穿;`min < max` 的区间本就均匀随机(文档承诺的 `[min, max]`),不再叠加。抖动源是构造期的 `random.Random(0)`(测试可复现),非热更项。
484484

485485
| 任务 | 周期 | 行为 |
486486
|---|---|---|

‎src/tasks/pacer.py‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
上限(CodeArts 每账号 3),无上限放行会把第 4 个起全部打成 400 并发超限。
1818
0/None 表示不限(保持旧行为)。
1919
20+
间隔本身**只向上扰动 ±`JITTER_RATIO`**(见 `next_interval`):定值间隔
21+
(`min == max`,所有聊天节流器的形态)逐个账号精确 5s 同拍,多个实例
22+
会在同一时刻撞上上游;区间(`min < max`)本就随机,不再叠加。
23+
2024
`window_seconds > 0` 时上限改用**滑动窗口**口径:同一个桶在最近
2125
`window_seconds` 秒内最多放行 `max_concurrency` 次请求启动。这是对齐
2226
CodeArts 实测行为的修正——它限制的不是「同时在途」,而是「每账号每约 60s
@@ -44,6 +48,13 @@
4448

4549
from ..config import live
4650

51+
# 固定间隔(min == max)的抖动幅度:聊天节流器都装配成 `Pacer(5, 5)` 这类定值,
52+
# 不加抖动时相邻请求精确间隔 5s,跨进程 / 跨实例的请求会在同一时刻撞上上游。
53+
# **只向上抖**(`[low, low * (1 + ratio)]`)而不向下:low 是「最小间隔」下限,
54+
# 向下抖会击穿频率风控的护栏。`low < max` 的区间本身已均匀随机(文档承诺的
55+
# `[min, max]`),不再叠加,否则会越出该区间。
56+
JITTER_RATIO = 0.25
57+
4758

4859
def stable_key(provider: str, identity: str | None) -> str:
4960
"""聊天节流桶键:渠道 + 身份摘要;身份缺失时退化为「该渠道单桶」。
@@ -135,11 +146,17 @@ def window_seconds(self) -> float:
135146
return value if value > 0 else 0.0
136147

137148
def next_interval(self) -> float:
149+
"""本次相邻请求要间隔的秒数。
150+
151+
`min != max`:在 `[min, max]` 内均匀取值(文档承诺的区间,原样保留)。
152+
`min == max`(聊天节流器的常态):定值无从随机,加 `JITTER_RATIO`
153+
的**向上**抖动避免精确同拍;下限(`min`)不可击穿。
154+
"""
138155
low, high = self._validate()
139156
if low == 0 and high == 0:
140157
return 0.0
141158
if low == high:
142-
return low
159+
return low * (1 + JITTER_RATIO * self._random.random())
143160
return self._random.uniform(low, high)
144161

145162
def _capped(self) -> bool:

‎tests/tasks/test_pacer.py‎

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import pytest
88

99
from src.tasks import TaskReport
10-
from src.tasks.pacer import Pacer, stable_key
10+
from src.tasks.pacer import JITTER_RATIO, Pacer, stable_key
1111

1212

1313
async def test_pacer_disabled_is_noop():
@@ -32,8 +32,9 @@ async def fake_sleep(seconds: float) -> None:
3232

3333
pacer = Pacer(5, 5, sleep=fake_sleep, now=lambda: clock["t"])
3434
await pacer.wait_turn() # 首次不睡
35-
await pacer.wait_turn() # 第二次补足 5s
36-
assert slept == [5.0]
35+
await pacer.wait_turn() # 第二次补足 5s(含抖动)
36+
assert len(slept) == 1
37+
assert 5.0 <= slept[0] <= 5.0 * (1 + JITTER_RATIO)
3738

3839
async def test_pacer_random_interval_within_bounds():
3940
pacer = Pacer(5, 20)
@@ -91,8 +92,9 @@ async def fake_sleep(seconds: float) -> None:
9192
now=lambda: clock["t"])
9293
await pacer.wait_turn("k")
9394
pacer.release("k") # 上一请求结束
94-
await pacer.wait_turn("k") # 紧接又来一个 → 补 5s
95-
assert slept == [5.0]
95+
await pacer.wait_turn("k") # 紧接又来一个 → 补 5s(含抖动)
96+
assert len(slept) == 1
97+
assert 5.0 <= slept[0] <= 5.0 * (1 + JITTER_RATIO)
9698

9799
async def test_pacer_concurrent_buckets_are_independent():
98100
"""不同凭证分属不同桶:一个账号在途不拖住另一个账号。"""
@@ -382,11 +384,19 @@ def test_task_report_dict():
382384

383385
def test_pacer_random_single_value_path():
384386
pacer = Pacer(3, 3)
385-
assert pacer.next_interval() == 3
387+
intervals = [pacer.next_interval() for _ in range(20)]
388+
assert all(3 <= value <= 3 * (1 + JITTER_RATIO) for value in intervals)
389+
assert len(set(intervals)) > 1 # 定值间隔也抖动,非同拍
386390
assert pacer.disabled is False
387391

388-
def test_pacer_fixed_interval_returns_min_directly():
389-
assert Pacer(7, 7).next_interval() == 7
392+
def test_pacer_fixed_interval_jitter_is_upward_only():
393+
"""定值间隔的抖动**只向上**:`min` 是频率风控护栏,不可击穿。"""
394+
assert all(Pacer(7, 7).next_interval() >= 7 for _ in range(20))
395+
396+
def test_pacer_range_interval_is_not_jittered_further():
397+
"""`min < max` 的区间本就均匀随机,抖动不叠加(否则越出 `[min, max]`)。"""
398+
intervals = [Pacer(5, 20).next_interval() for _ in range(50)]
399+
assert all(5 <= value <= 20 for value in intervals)
390400

391401
def test_pacer_disabled_next_interval_is_zero():
392402
assert Pacer(0, 0).next_interval() == 0.0

0 commit comments

Comments
 (0)