feat: gate in-call demotion on credible alternative health

Round 3 showed unconditional yield-after-two-failures pushes the third
attempt onto known-bad sources in heterogeneous pools (83% vs 10%
expected success). OutcomeAwareSelector now exposes health(); a failed
source only yields when some untried candidate scores at least half its
health. Health-blind selectors keep the unconditional rule.
This commit is contained in:
2026-07-21 11:24:52 -04:00
parent 58061c7536
commit 0e17f71482
6 changed files with 95 additions and 4 deletions
@@ -94,6 +94,12 @@
**测试**: 单测削减/增长/上下限;RetryMW 集成——429 后准入收紧、被 paced 源跳过不耗预算、全 paced 走轮询非 CircuitOpen、在途数在异常/取消路径归零。 **测试**: 单测削减/增长/上下限;RetryMW 集成——429 后准入收紧、被 paced 源跳过不耗预算、全 paced 走轮询非 CircuitOpen、在途数在异常/取消路径归零。
### 3.36 迭代 2 补遗: 调用内降权加健康门槛(2026-07-21,P6 第三轮数据驱动)
**动因**: AIMD 后 429 归零,但第三轮 750 次完成仍 21 败(2.8%)。根因: 无条件"失败 ≥2 让位"在**异构池**把第三次尝试推给已知坏源——健康源两次空补全(网关 17% 空补全率)后被降权,替补是 10% 成功率的看门狗源;且该源的"成功"是被 0.5s 看门狗筛出的短促退化响应(均值 128 token vs 健康 262),喂给结构化解析再炸一层。期望值算术: 第三次留在健康源成功率 ~83%,推给坏源 ~10%。
**修正**: `OutcomeAwareSelector` 协议增 `health(source_name) -> float`(EWMA 裸值);RetryMW 降权仅在存在**可信替代**(某未失败候选 health ≥ 0.5 × 失败源 health)时生效,否则原地第三试。无健康视图的选源器(round_robin 等)保持无条件降权(冷启动保护原语义)。
### 3.4 不做与预留(方案 C 组件的接入点) ### 3.4 不做与预留(方案 C 组件的接入点)
- 账号级 429 共享退避: 不做;预留 = 冷却备忘 key 从 source_name 换 account_key 即可接入(LiteLLM 先例,治理粒度=配额粒度原则记入 ARCHITECTURE)。 - 账号级 429 共享退避: 不做;预留 = 冷却备忘 key 从 source_name 换 account_key 即可接入(LiteLLM 先例,治理粒度=配额粒度原则记入 ARCHITECTURE)。
+30 -4
View File
@@ -74,13 +74,36 @@ def backoff_delay(
def _demote_call_failures( def _demote_call_failures(
ordered: list[SourceConfig], attempt_fails: dict[str, int] ordered: list[SourceConfig],
attempt_fails: dict[str, int],
health: Callable[[str], float] | None,
) -> list[SourceConfig]: ) -> list[SourceConfig]:
"""调用内降权(设计 §3.3): 本调用失败 ≥2 次的源移尾;全失败则保持原序。""" """调用内降权(设计 §3.3/§3.36): 失败 ≥2 次且存在可信替代才让位。
可信替代 = 某未失败候选 health ≥ 0.5 × 失败源 health——异构池里健康源
偶发失败不该被推向已知坏源(第三轮教训: 期望成功率 83% vs 10%)。
无健康视图(round_robin 等)保持无条件降权(冷启动保护)。
"""
demoted = [s for s in ordered if attempt_fails.get(s.name, 0) >= 2] demoted = [s for s in ordered if attempt_fails.get(s.name, 0) >= 2]
if not demoted or len(demoted) == len(ordered): if not demoted or len(demoted) == len(ordered):
return ordered return ordered
return [s for s in ordered if attempt_fails.get(s.name, 0) < 2] + demoted if health is not None:
demoted = _credible_demotions(ordered, demoted, attempt_fails, health)
if not demoted:
return ordered
names = {d.name for d in demoted}
return [s for s in ordered if s.name not in names] + demoted
def _credible_demotions(
ordered: list[SourceConfig],
demoted: list[SourceConfig],
attempt_fails: dict[str, int],
health: Callable[[str], float],
) -> list[SourceConfig]:
"""健康门槛过滤: 仅当存在"健康分 ≥ 失败源一半"的未失败候选,让位才有意义。"""
alts = [o for o in ordered if attempt_fails.get(o.name, 0) < 2]
return [s for s in demoted if any(health(o.name) >= 0.5 * health(s.name) for o in alts)]
def _failure_reason(exc: PolyGatewayError) -> str: def _failure_reason(exc: PolyGatewayError) -> str:
@@ -138,6 +161,7 @@ class RetryMW:
self._memo = cooldown_memo or SourceCooldownMemo(now=now) self._memo = cooldown_memo or SourceCooldownMemo(now=now)
# M2.5: 选源器可选健康喂数端口,构造期 isinstance 判定一次(设计 §3.2) # M2.5: 选源器可选健康喂数端口,构造期 isinstance 判定一次(设计 §3.2)
self._outcome_sink = selector if isinstance(selector, OutcomeAwareSelector) else None self._outcome_sink = selector if isinstance(selector, OutcomeAwareSelector) else None
self._health_view = self._outcome_sink.health if self._outcome_sink else None
# M2.5 §3.35: AIMD 自适应并发——429 收紧、成功回涨,超限调用排队不烧预算 # M2.5 §3.35: AIMD 自适应并发——429 收紧、成功回涨,超限调用排队不烧预算
self._pacer = pacer or AdaptivePacer(ceiling=64.0) self._pacer = pacer or AdaptivePacer(ceiling=64.0)
self._emitter = emitter self._emitter = emitter
@@ -180,7 +204,9 @@ class RetryMW:
) -> tuple[tuple[SourceConfig, Permit, GateDecision] | None, int]: ) -> tuple[tuple[SourceConfig, Permit, GateDecision] | None, int]:
stats = {s.name: await self._quota.stats(s) for s in self._sources} stats = {s.name: await self._quota.stats(s) for s in self._sources}
gate_rejections = 0 gate_rejections = 0
ordered = _demote_call_failures(self._selector.order(self._sources, stats), attempt_fails) ordered = _demote_call_failures(
self._selector.order(self._sources, stats), attempt_fails, self._health_view
)
for cand in ordered: for cand in ordered:
if self._memo.active(cand.name): if self._memo.active(cand.name):
# 冷却备忘跳过也计入拒绝数,保住 circuit_open 判据(CHS 同款) # 冷却备忘跳过也计入拒绝数,保住 circuit_open 判据(CHS 同款)
+2
View File
@@ -194,6 +194,8 @@ class OutcomeAwareSelector(Protocol):
def record_outcome(self, source_name: str, ok: bool) -> None: ... def record_outcome(self, source_name: str, ok: bool) -> None: ...
def health(self, source_name: str) -> float: ...
@runtime_checkable @runtime_checkable
class StructuredOutputStrategy(Protocol): class StructuredOutputStrategy(Protocol):
+4
View File
@@ -63,6 +63,10 @@ class HealthAwareSelector:
prev = self._ewma.get(source_name, 1.0) prev = self._ewma.get(source_name, 1.0)
self._ewma[source_name] = prev + _EWMA_ALPHA * ((1.0 if ok else 0.0) - prev) self._ewma[source_name] = prev + _EWMA_ALPHA * ((1.0 if ok else 0.0) - prev)
def health(self, source_name: str) -> float:
"""EWMA 裸值(OutcomeAwareSelector 端口): RetryMW 降权门槛用。"""
return self._ewma.get(source_name, 1.0)
def _score(self, name: str, stats: dict[str, SourceStats]) -> float: def _score(self, name: str, stats: dict[str, SourceStats]) -> float:
ewma = max(self._ewma.get(name, 1.0), _SCORE_FLOOR) ewma = max(self._ewma.get(name, 1.0), _SCORE_FLOOR)
inflight = stats[name].inflight if name in stats else 0 inflight = stats[name].inflight if name in stats else 0
+7
View File
@@ -69,6 +69,13 @@ class TestHealthAwareSelector:
assert order[0].name == "s1" # 两候选中 s1 胜出 assert order[0].name == "s1" # 两候选中 s1 胜出
assert order[-1].name == "s3" # 塌陷源垫底 assert order[-1].name == "s3" # 塌陷源垫底
def test_health_exposes_raw_ewma(self):
sel = HealthAwareSelector(rng=lambda: 0.0)
assert sel.health("s1") == pytest.approx(1.0) # 乐观初始
for _ in range(3):
sel.record_outcome("s1", ok=False)
assert sel.health("s1") == pytest.approx(1.0 * 0.8**3)
def test_missing_stats_defaults_to_zero_inflight(self): def test_missing_stats_defaults_to_zero_inflight(self):
sel = HealthAwareSelector(rng=lambda: 0.0) sel = HealthAwareSelector(rng=lambda: 0.0)
assert [s.name for s in sel.order([_src("s1")], {})] == ["s1"] assert [s.name for s in sel.order([_src("s1")], {})] == ["s1"]
+46
View File
@@ -370,11 +370,17 @@ class RecordingSelector(StaticSelector):
def record_outcome(self, source_name, ok): def record_outcome(self, source_name, ok):
self.outcomes.append((source_name, ok)) self.outcomes.append((source_name, ok))
def health(self, source_name):
return 1.0
class ExplodingSelector(StaticSelector): class ExplodingSelector(StaticSelector):
def record_outcome(self, source_name, ok): def record_outcome(self, source_name, ok):
raise RuntimeError("sink boom") raise RuntimeError("sink boom")
def health(self, source_name):
return 1.0
class TestM25Orchestration: class TestM25Orchestration:
"""M2.5 设计 §3.3: 调用内失败降权 + 健康喂数(先红后绿)。""" """M2.5 设计 §3.3: 调用内失败降权 + 健康喂数(先红后绿)。"""
@@ -491,3 +497,43 @@ class TestAdaptivePacing:
await mw(_REQ) await mw(_REQ)
assert pacer._inflight.get("a", 0) == 0 assert pacer._inflight.get("a", 0) == 0
assert pacer._inflight.get("b", 0) == 0 assert pacer._inflight.get("b", 0) == 0
class HealthySink(StaticSelector):
"""带健康视图的选源器桩(OutcomeAwareSelector 全量实现)。"""
def __init__(self, health):
self._health = health
self.outcomes = []
def record_outcome(self, source_name, ok):
self.outcomes.append((source_name, ok))
def health(self, source_name):
return self._health.get(source_name, 1.0)
class TestHealthGatedDemotion:
"""迭代 2(设计 §3.36): 降权需可信替代,否则原地重试。"""
async def test_no_credible_alternative_stays_on_healthy(self):
# 替补健康分 0.08 < 0.5×0.9 → 不让位,第三次仍打 a
sel = HealthySink({"a": 0.9, "b": 0.08})
mw, _, _, transport, _, _ = _harness(
[_src("a"), _src("b")],
[TransientError("1"), TransientError("2"), _ok()],
selector=sel,
)
resp = await mw(_REQ)
assert [n for n, _ in transport.calls] == ["a", "a", "a"]
assert resp.source_name == "a"
async def test_credible_alternative_still_yields(self):
sel = HealthySink({"a": 0.9, "b": 0.9})
mw, _, _, transport, _, _ = _harness(
[_src("a"), _src("b")],
[TransientError("1"), TransientError("2"), _ok()],
selector=sel,
)
await mw(_REQ)
assert [n for n, _ in transport.calls] == ["a", "a", "b"]