diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ba60bd..87d5d47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 未发布 +## 1.1.1(2026-08-06) stall 判定改为非生产性等待口径(issue #8)。`timeout_s ≥ stall_window_s` 时,**一次耗满超时的请求就会让整个 scope 被判死,配置的重试次数一次都用不上**——而且没有任何报错或 warning,配置方以为自己配了 3 次重试。`stall_window_s` 默认 300 恰是个很容易被 `TIMEOUT_S` 追平的值,"只配 timeout、不配 stall"这种最常见的写法正好踩中。 @@ -10,7 +10,8 @@ stall 判定改为非生产性等待口径(issue #8)。`timeout_s ≥ stall_wind - **stall 判定的"本地超窗"条件现在只累计非生产性等待**——429 退避、配额 wait 轮询、熔断冷却;消耗重试预算的真实尝试不再计入。两个预算自此正交,划分依据是**谁消耗重试预算**: 烧 `max_attempts` 的时间不烧 `stall_window_s`,不烧 `max_attempts` 的时间(含 429 尝试本身)归 `stall_window_s` 治理。 - **`stall_window_s` 与 `timeout_s` 不再有任何耦合**,无需按 `timeout × retries` 放大。若你此前为绕开本 bug 把 `STALL_WINDOW_S` 调大过,现在可以回到默认值。 -- **单次调用的最坏耗时由 `stall_window_s` 抬升到约 `max_attempts × timeout_s`**(默认配置下 3 × `TIMEOUT_S`,再加各次退避)。这是重试预算恢复生效的正确表现,但如果你的上游有调用超时,请据此复核。**429 路径不会突破这个量级**: 429 虽免重试预算,但它的尝试耗时计入 stall 账,一次持满超时的 429 即耗尽 stall 窗口。 +- **单次调用的最坏耗时由 `stall_window_s` 抬升到约 `max_attempts × timeout_s`**(默认配置下 3 × `TIMEOUT_S`,再加各次退避)。这是重试预算恢复生效的正确表现,但如果你的上游有调用超时,请据此复核。429 路径同样不突破这个量级——429 虽免重试预算,但其尝试耗时计入 stall 账。 + **上述量级的前提是 stall 判死能够触发**,即整个 scope 无进展(`progress_age_s() > stall_window_s`)。判死是**双条件合取**,这一条未变: 若同 scope 里其他调用仍在正常出餐,本调用会继续等待换源而不判死——这正是双条件的设计意图("别人还活着,不该因我一路不顺就宣告整个 scope 死亡")。**代价是这种情形下调用级没有硬上限**,持续遭遇慢 429 的调用可以等很久。该性质由条件 B 单独门控,**早于本次修复即如此**(旧口径实测同样无界),不是本次引入;但若你需要调用级硬上限,请在调用方用 `asyncio.wait_for` 自行设置。 - 三条治理循环(chat / embedding / ocr)口径一致。**embedding 与 ocr 此前有同一缺陷**(经"先超时一次、再遇到无可用源"触发),issue 只记录了 chat 路径。 - 遥测收尾属"真实尝试"边界之内,**遥测抖动不会把一次调用推进 stalled 判决**。 diff --git a/pyproject.toml b/pyproject.toml index a66968a..8ac7e56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "polygateway" -version = "1.1.0" +version = "1.1.1" description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测" requires-python = ">=3.11" dependencies = [ diff --git a/research-wiki/ARCHITECTURE.md b/research-wiki/ARCHITECTURE.md index e44ce68..bb8dbac 100644 --- a/research-wiki/ARCHITECTURE.md +++ b/research-wiki/ARCHITECTURE.md @@ -427,7 +427,7 @@ flowchart TB - `RedisLimiter`: 移植 CHSAnalyzer 六道闸——单条 Lua 原子检查全局并发/单源并发(ZSET 租约)/全局 RPM/单源 RPM/全局 TPM/单源 TPM;窗口 id 用 **Redis 服务器时钟**(TIME 命令)统一多进程口径。随实现移植契约测试。 - `InMemoryLimiter`: 同一契约的进程内实现(semaphore + 滑动窗口计数);单进程场景下语义等价。 - **配额满行为可配**: `wait`(等待,配 stall 判定——本地等待超窗 + 全局无进展超窗双条件才判卡死)或 `fail-fast`(立即抛)。 -- **stall 计时口径(2026-08-06 修正,issue #8,设计 `designs/2026-08-06-issue8-stall-budget-design.md`)**: 双条件的**条件 A 只累计非生产性等待**(429 退避、配额 wait 轮询、熔断冷却),真实尝试的耗时由 `StallClock.attempting()` 从 stall 账中扣除。原实现用墙钟总耗时,使真实尝试同时向重试预算与 stall 预算计费;而 stall 预算(默认 300s)小于重试预算(`max_attempts × timeout_s`),必然先耗尽——`timeout_s ≥ stall_window_s` 时一次超时即判 scope 死,`max_attempts` **静默失效**。修正后两个预算正交,**划分依据是"谁消耗重试预算"而非"是否发出请求"**: 烧 `max_attempts` 的时间不烧 `stall_window_s`,不烧 `max_attempts` 的时间归 `stall_window_s`。**429 尝试因此也计入 stall 账**——它免重试预算,若其耗时又算生产性就两个预算都不烧,排队型网关(持满 timeout 才回 429)下调用可挂 25 小时(实施期独立验证实测,见设计 §3.6)。生产性边界即 `_attempt` 边界(含该次记账与遥测收尾),故遥测抖动不参与判死。`stall_window_s` 与 `timeout_s` 自此**无耦合**,无需按 `timeout × retries` 放大。三条治理循环(chat/embedding/ocr)共用 `middleware/retry.py` 的 `StallClock`。条件 B 的 `inf` 语义未动——新口径下"非生产性排队耗满窗口且 scope 从未出餐"判死本就正当。 +- **stall 计时口径(2026-08-06 修正,issue #8,设计 `designs/2026-08-06-issue8-stall-budget-design.md`)**: 双条件的**条件 A 只累计非生产性等待**(429 退避、配额 wait 轮询、熔断冷却),真实尝试的耗时由 `StallClock.attempting()` 从 stall 账中扣除。原实现用墙钟总耗时,使真实尝试同时向重试预算与 stall 预算计费;而 stall 预算(默认 300s)小于重试预算(`max_attempts × timeout_s`),必然先耗尽——`timeout_s ≥ stall_window_s` 时一次超时即判 scope 死,`max_attempts` **静默失效**。修正后两个预算正交,**划分依据是"谁消耗重试预算"而非"是否发出请求"**: 烧 `max_attempts` 的时间不烧 `stall_window_s`,不烧 `max_attempts` 的时间归 `stall_window_s`。**429 尝试因此也计入 stall 账**——它免重试预算,若其耗时又算生产性就两个预算都不烧,排队型网关(持满 timeout 才回 429)下调用可挂 25 小时(实施期独立验证实测,见设计 §3.6)。生产性边界即 `_attempt` 边界(含该次记账与遥测收尾),故遥测抖动不参与判死。`stall_window_s` 与 `timeout_s` 自此**无耦合**,无需按 `timeout × retries` 放大。三条治理循环(chat/embedding/ocr)共用 `middleware/retry.py` 的 `StallClock`。条件 B 的 `inf` 语义未动——新口径下"非生产性排队耗满窗口且 scope 从未出餐"判死本就正当。**残余性质(非本次引入,由条件 B 单独门控)**: 判死是双条件合取,故当同 scope 其他调用仍在正常出餐时本调用不判死(设计意图: 别人还活着就不该宣告 scope 死亡),代价是**该情形下调用级无硬上限**——持续遭遇慢 429 的调用可以等很久;需要硬上限的调用方应自行 `asyncio.wait_for`。 - 全局活性信号: `mark_progress()`/`progress_age_s()`("最近一次出餐"时刻)供背压 stall 判定,移植 `CHSAnalyzer limiter.py:193`。 - **契约补强(2026-07-20,CHS 迁移缺口 G6)**: `settle()`/`release()` 幂等(重复调用无副作用);装配期守卫——`timeout_s ≤ permit 租约 TTL`(防租约先于请求过期)、`stall_window ≥ 最慢源 TTFT 上限`(防误判卡死;**issue #8 后为保守冗余**——TTFT 等待属生产性时间已不计入 stall,该误判在机制上不再可能,校验保留因其无害且不误拒合理配置),违反直接报错拒绝装配。降级方向细化(2026-07-20 M1): "报错不放行"适用于**准入侧**(try_acquire/try_enter 及选源路径消费的 source_stats/retry_after_s);已成功调用后的 settle/release 释放侧失败降级 warning——释放失败不构成放行,且不得掩盖主异常与取消。**勘误(2026-07-20 M2 设计,人类批准)**: 记账侧的 `record_success`/`record_failure`/`mark_progress` 同归此类——调用已真实完成,后端失败若冒泡会丢弃真实成功响应或掩盖原始尝试异常,故降级 warning(CHS 原版一律报错,此为有意反转;丢一次熔断记账最多延迟状态迁移且方向偏保守,epoch fencing 防污染)。 diff --git a/src/polygateway/__init__.py b/src/polygateway/__init__.py index 19185ba..eaeec7d 100644 --- a/src/polygateway/__init__.py +++ b/src/polygateway/__init__.py @@ -32,7 +32,7 @@ from polygateway.types import ( SourceConfig, ) -__version__ = "1.1.0" +__version__ = "1.1.1" __all__ = [ "DEFAULT_PROFILES", diff --git a/src/polygateway/middleware/retry.py b/src/polygateway/middleware/retry.py index 0b3a1e7..e46df63 100644 --- a/src/polygateway/middleware/retry.py +++ b/src/polygateway/middleware/retry.py @@ -355,6 +355,8 @@ class RetryMW: await self._settle_and_release(permit, 0) return None, gate_rejections + # —— 背压与 stall 判死(CHS governance.py:270-285)—— + async def _stalled(self, clock: StallClock) -> bool: """双条件 stall 判死(CHS governance.py:270-281): 本地累计等待与全局 无进展**同时**超窗才判死——本地 monotonic 与后端时钟刻意不混用。 diff --git a/tests/unit/test_backpressure.py b/tests/unit/test_backpressure.py index 8bb8f7c..774e904 100644 --- a/tests/unit/test_backpressure.py +++ b/tests/unit/test_backpressure.py @@ -383,7 +383,14 @@ class TestStallBudget: assert (first.content, second.content) == ("first", "second") async def test_concurrent_calls_do_not_share_clock(self): - """并发两路共用同一个 mw: 一路长尝试不得污染另一路的 stall 账。""" + """并发两路共用同一个 mw: 一快一慢都能正常完成(形态冒烟)。 + + **这条不是回归防线**: 实测它在"clock 提为实例属性""去掉 refund""去掉 + 生产性扣减"三种变异下均保持绿色——共享 clock 时慢调用的耗时是作为 + credit 记进共享账的,污染方向是让 stall 账**变小**(更宽松),而本用例 + 断言两路都成功。真正钉住调用级隔离的是上面那条 + `test_clock_is_per_call_not_per_instance`。保留此条只为覆盖并发形态。 + """ clock = FakeClock() src = make_source(max_concurrency=2) limiter = InMemoryLimiter(