diff --git a/.env.example b/.env.example index 1f726fe..c3e31ea 100644 --- a/.env.example +++ b/.env.example @@ -54,7 +54,7 @@ PGW_TELEMETRY_BACKEND=none # sqlite | postgres | none(必填) # PGW_PRICING_PATH=config/prices.json # 可选: {"": {"input_per_1m": x, "output_per_1m": y}};缺省 cost 恒 None # PGW_CACHE_NAMESPACE=<项目名或租户前缀> # 缓存启用时必填(防跨项目毒化) # PGW_CACHE_TTL_S=604800 # 缓存启用时必填,须 > 0 -# PGW_STRUCTURED_MAX_RETRIES=1 # 0 = 解析失败不重问(CHS 策略) +# PGW_STRUCTURED_MAX_RETRIES=2 # 缺省 2(M2.5);0 = 解析失败不重问(CHS 策略) # PGW_LEASE_TTL_S=1500 # permit 租约;须 ≥ 最大源 timeout # ══ Redis(缓存 + 分布式限流/熔断)══ diff --git a/research-wiki/designs/2026-07-21-m25-resilience-design.md b/research-wiki/designs/2026-07-21-m25-resilience-design.md index 81fb7c1..04f0426 100644 --- a/research-wiki/designs/2026-07-21-m25-resilience-design.md +++ b/research-wiki/designs/2026-07-21-m25-resilience-design.md @@ -100,6 +100,11 @@ **修正**: `OutcomeAwareSelector` 协议增 `health(source_name) -> float`(EWMA 裸值);RetryMW 降权仅在存在**可信替代**(某未失败候选 health ≥ 0.5 × 失败源 health)时生效,否则原地第三试。无健康视图的选源器(round_robin 等)保持无条件降权(冷启动保护原语义)。 +### 3.37 迭代 4 补遗: 结构化重问缺省 2 + AIMD 削减 0.5(2026-07-21,第五轮数据驱动) + +**动因**: 第五轮(正午高峰,外部条件比首跑更严苛)剩余失败三分: 阶梯耗尽 ~1.1%(结构化档死亡率 3.2%,重问仅 1 次)、retry_exhausted ~0.9%(源1 尝试失败率 20.5% = 空补全 14.4% + **429 残漏 5.8%**,后者说明 AIMD 0.7 削减在临界点上方震荡)、400 拒绝 ~0.5%(语料固有毒负载,环境常量)。 +**修正**: ① `PGW_STRUCTURED_MAX_RETRIES` 库缺省 1→2(instructor 库缺省 3 的保守版;成本只在解析失败时新增一跳);② AIMD `_CUT` 0.7→0.5(更快收敛到网关水位之下,Netflix 建议区间 0.5-0.9 内取激进端)。预期: 阶梯死亡 ~3.2%→~0.6%、429 出链后 retry_exhausted ~0.9%→~0.3%,合计残余 ≈ 1.0-1.4%(含 0.5% 环境常量)。 + ### 3.4 不做与预留(方案 C 组件的接入点) - 账号级 429 共享退避: 不做;预留 = 冷却备忘 key 从 source_name 换 account_key 即可接入(LiteLLM 先例,治理粒度=配额粒度原则记入 ARCHITECTURE)。 diff --git a/src/polygateway/config.py b/src/polygateway/config.py index d87663c..c08ecab 100644 --- a/src/polygateway/config.py +++ b/src/polygateway/config.py @@ -332,7 +332,7 @@ def _load_cache_keys( def _load_structured_retries(env: Mapping[str, str]) -> int: found = _first(env, "PGW_STRUCTURED_MAX_RETRIES") - value = int(_cast(found[1], "int", found[0])) if found else 1 + value = int(_cast(found[1], "int", found[0])) if found else 2 # M2.5 迭代4: 1→2 if value < 0: raise ValueError("PGW_STRUCTURED_MAX_RETRIES 不能为负") return value diff --git a/src/polygateway/sources.py b/src/polygateway/sources.py index ca24736..e94212a 100644 --- a/src/polygateway/sources.py +++ b/src/polygateway/sources.py @@ -95,7 +95,7 @@ class AdaptivePacer: """ _INITIAL = 8.0 - _CUT = 0.7 + _CUT = 0.5 # M2.5 迭代4: 0.7→0.5,残漏 5.8% 的 429 证明在临界点上方震荡 _FLOOR = 1.0 def __init__(self, *, ceiling: float) -> None: diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 8304fd7..6322bae 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -148,6 +148,13 @@ class TestResilienceKeys: class TestAssemblyGuards: + def test_structured_retries_default_two(self): + # M2.5 迭代 4: 缺省重问 1→2(instructor 缺省 3 的保守版;P6 阶梯死亡 3.2% 实证) + s = GatewaySettings.from_env("LLM", env=_env()) + assert s.structured_max_retries == 2 + s2 = GatewaySettings.from_env("LLM", env=_env(PGW_STRUCTURED_MAX_RETRIES="0")) + assert s2.structured_max_retries == 0 + def test_cache_requires_namespace_and_ttl(self): env = _env(PGW_CACHE_BACKEND="memory") with pytest.raises(ValueError, match="NAMESPACE"): diff --git a/tests/unit/test_health_selector.py b/tests/unit/test_health_selector.py index c4402e8..0eb4dc9 100644 --- a/tests/unit/test_health_selector.py +++ b/tests/unit/test_health_selector.py @@ -101,7 +101,7 @@ class TestAdaptivePacer: pacer = AdaptivePacer(ceiling=32.0) pacer.on_backpressure("s1") - assert pacer.limit("s1") == pytest.approx(8.0 * 0.7) + assert pacer.limit("s1") == pytest.approx(8.0 * 0.5) before = pacer.limit("s1") pacer.on_success("s1") assert pacer.limit("s1") == pytest.approx(before + 1.0 / before) diff --git a/tests/unit/test_retry.py b/tests/unit/test_retry.py index 8b8823a..9b3b69f 100644 --- a/tests/unit/test_retry.py +++ b/tests/unit/test_retry.py @@ -482,8 +482,8 @@ class TestAdaptivePacing: pacer=pacer, ) await mw(_REQ) - # 429 削减一次(8→5.6),随后成功加性增长(5.6 + 1/5.6) - assert pacer.limit("a") == pytest.approx(8.0 * 0.7 + 1.0 / (8.0 * 0.7)) + # 429 削减一次(8→4),随后成功加性增长(4 + 1/4) + assert pacer.limit("a") == pytest.approx(8.0 * 0.5 + 1.0 / (8.0 * 0.5)) async def test_inflight_returns_to_zero_after_call(self): from polygateway.sources import AdaptivePacer