feat: add opt-in mid-stream stall hedging for chat

HEDGE__STALL_AFTER_S triggers a cross-source hedge when the primary
leg stalls mid-stream (independent of HEDGE__AFTER_S); two-phase
hedge window with event recheck discipline; watchdog-killed primary
converges on the surviving hedge leg. STREAM__STALL_THRESHOLD_MS
(1000ms default) adjustable via env/settings/constructor. Assembly
guards: ValueError on never-opening windows, embedding/OCR scope
warning (B4). Batch D 14 tests red-to-green, 1688 unit+contracts.
This commit is contained in:
2026-09-14 01:52:10 -04:00
parent e0b0be09a7
commit 2ac05a20b6
5 changed files with 899 additions and 74 deletions
+20 -3
View File
@@ -239,6 +239,8 @@ class GatewayClient:
call_deadline_s: float | None = None,
hedge_after_s: float | None = None,
hedge_max_extra: int = 1,
hedge_stall_after_s: float | None = None,
stall_threshold_ms: int | None = None,
now: Any = time.monotonic,
sleep: Any = asyncio.sleep,
rng: Any = random.random,
@@ -247,15 +249,25 @@ class GatewayClient:
self._call_deadline_s = ensure_call_deadline(
call_deadline_s, "GatewayClient(call_deadline_s=...)"
)
# 对冲守卫与 GatewaySettings 共用同一份(issue #24 H4): 直接构造这条路
# 不经过 settings,值域/交叉守卫若只挂在 settings 上就会被它绕过
self._hedge_after_s = check_hedge_assembly(
# 对冲守卫与 GatewaySettings 共用同一份(issue #24 H4 / 1.3.8 #28): 直接构造这条路
# 不经过 settings,值域/交叉守卫若只挂在 settings 上就会被它绕过。
# 返回契约(1.3.8 审查 B2): tuple(归一化 after_s, 归一化 stall_after_s)
self._hedge_after_s, self._hedge_stall_after_s = check_hedge_assembly(
hedge_after_s=hedge_after_s,
hedge_max_extra=hedge_max_extra,
sources=sources,
call_deadline_s=self._call_deadline_s,
origin="GatewayClient(hedge_after_s=...)",
stall_after_s=hedge_stall_after_s,
stall_origin="GatewayClient(hedge_stall_after_s=...)",
)
# 沉默计量阈值分发(1.3.8 设计 §6): None = 保留各 SourceConfig 自带值
# (不 stomp 直构造调用方的 per-source 设置);非 None 经 replace 写进每个源
# (replace 触发 SourceConfig.__post_init__ 重跑,值域校验复用同一处)
if stall_threshold_ms is not None:
sources = [
dataclasses.replace(s, stall_threshold_ms=stall_threshold_ms) for s in sources
]
emitter = (
TelemetryEmitter(telemetry, scope=scope, pricing=pricing, text_cap=text_cap)
if telemetry is not None
@@ -280,6 +292,8 @@ class GatewayClient:
emitter=emitter,
# max_extra 不下传(issue #24 H5): v1 编排固定单路对冲,无消费者
hedge_after_s=self._hedge_after_s,
# 中段沉默对冲阈值(1.3.8 #28): 独立于 hedge_after_s 生效,守卫归一化值
stall_after_s=self._hedge_stall_after_s,
now=now,
sleep=sleep,
rng=rng,
@@ -524,6 +538,9 @@ class GatewayClient:
call_deadline_s=settings.call_deadline_s,
hedge_after_s=settings.hedge_after_s,
hedge_max_extra=settings.hedge_max_extra,
hedge_stall_after_s=settings.hedge_stall_after_s,
# settings 值为权威,恒分发(1.3.8 §3.5): 含缺省 1000 在内
stall_threshold_ms=settings.stall_threshold_ms,
)
_mark_owned_components(client, limiter=limiter, breaker=breaker, telemetry=telemetry)
client._owns_cache = cache is None # 缓存后端可以是 None(backend=none),helper 会跳过