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:
+179
-11
@@ -1179,17 +1179,16 @@ class TestHedgeConfig:
|
||||
origin="GatewayClient(hedge_after_s=8)",
|
||||
)
|
||||
assert "hedge_after_s" in str(ei.value) # 定位得到是哪个键
|
||||
# 未启用对冲(None)时空 sources 直接放行: 交叉守卫没有可校验的对象
|
||||
assert (
|
||||
check_hedge_assembly(
|
||||
hedge_after_s=None,
|
||||
hedge_max_extra=1,
|
||||
sources=[],
|
||||
call_deadline_s=None,
|
||||
origin="test",
|
||||
)
|
||||
is None
|
||||
)
|
||||
# 未启用对冲(两阈值皆 None)时空 sources 直接放行: 交叉守卫没有可校验的对象;
|
||||
# 返回契约(1.3.8 审查 B2): tuple(归一化 after_s, 归一化 stall_after_s),
|
||||
# 两阈值皆 None 才早退
|
||||
assert check_hedge_assembly(
|
||||
hedge_after_s=None,
|
||||
hedge_max_extra=1,
|
||||
sources=[],
|
||||
call_deadline_s=None,
|
||||
origin="test",
|
||||
) == (None, None)
|
||||
|
||||
def test_hedge_guard_below_min_timeout_raises(self):
|
||||
"""阈值 ≥ 最小源 timeout_s = 对冲永不可能触发,装配期炸掉(ValueError)。"""
|
||||
@@ -1283,3 +1282,172 @@ class TestHedgeConfig:
|
||||
client = GatewayClient.from_settings(s)
|
||||
assert client._hedge_after_s == 8.0
|
||||
assert client._terminal._hedge_after_s == 8.0
|
||||
|
||||
|
||||
class TestStallConfig:
|
||||
"""1.3.8 两新键: `{SCOPE}__STREAM__STALL_THRESHOLD_MS`(计量,默认 1000 三层可调)
|
||||
与 `{SCOPE}__HEDGE__STALL_AFTER_S`(中段对冲,未设=关闭)及其装配守卫(设计 §6,
|
||||
计划批次 D;单一定义点仍是 `config.check_hedge_assembly`)。"""
|
||||
|
||||
def _two_source_env(self, **overrides):
|
||||
"""双源 env(同 provider 避免注册表依赖): 隔离单源 warning 的干扰。"""
|
||||
return _env(
|
||||
**{
|
||||
"LLM__QWEN__2__BASE_URL": "https://gw-b.example/v1",
|
||||
"LLM__QWEN__2__API_KEY": "sk-b",
|
||||
"LLM__QWEN__2__MODEL": "qwen-plus",
|
||||
"LLM__QWEN__2__TIMEOUT_S": "90",
|
||||
**overrides,
|
||||
}
|
||||
)
|
||||
|
||||
def test_stall_threshold_key_loads_three_layers(self):
|
||||
"""计量阈值三层调整接口(§9-③ 人类要求): env 键 / settings 字段 / client kwarg。
|
||||
|
||||
分发语义: from_settings 恒以 settings 值为权威(覆盖源自带值);client 直传
|
||||
None = 保留各 SourceConfig 自带值,非 None 经 dataclasses.replace 分发。"""
|
||||
from tests.unit.test_client import _client
|
||||
|
||||
# 层 1: env 键 → settings 字段 → from_settings 分发进每个 SourceConfig
|
||||
env = self._two_source_env(**{"LLM__STREAM__STALL_THRESHOLD_MS": "2500"})
|
||||
s = GatewaySettings.from_env("LLM", env=env)
|
||||
assert s.stall_threshold_ms == 2500
|
||||
client = GatewayClient.from_settings(s)
|
||||
assert {src.stall_threshold_ms for src in client._terminal._sources} == {2500}
|
||||
# 层 2: settings 字段直接构造(dataclasses.replace 同路),settings 值权威:
|
||||
# 源自带 500 也被 scope 值覆盖(恒分发,计划 §3.5)
|
||||
base = GatewaySettings.from_env("LLM", env=_env())
|
||||
custom_source = dataclasses.replace(base.sources[0], stall_threshold_ms=500)
|
||||
s2 = dataclasses.replace(base, sources=(custom_source,), stall_threshold_ms=800)
|
||||
client2 = GatewayClient.from_settings(s2)
|
||||
assert client2._terminal._sources[0].stall_threshold_ms == 800
|
||||
# 层 3: client kwarg——非 None 分发,None(缺省)保留源自带值
|
||||
client3 = _client(stall_threshold_ms=600)
|
||||
assert client3._terminal._sources[0].stall_threshold_ms == 600
|
||||
client4 = _client(sources=[custom_source])
|
||||
assert client4._terminal._sources[0].stall_threshold_ms == 500 # 不 stomp
|
||||
# 未设恒 1000(SourceConfig 缺省)
|
||||
assert GatewaySettings.from_env("LLM", env=_env()).stall_threshold_ms == 1000
|
||||
assert _client()._terminal._sources[0].stall_threshold_ms == 1000
|
||||
|
||||
def test_stream_segment_reserved(self):
|
||||
"""`STREAM` 进保留段: 四段 `LLM__STREAM__1__*` 不造源(1.3.7 HEDGE 同款)。"""
|
||||
env_collision = _env(
|
||||
**{
|
||||
"LLM__STREAM__1__BASE_URL": "https://gw-c.example/v1",
|
||||
"LLM__STREAM__1__API_KEY": "sk-c",
|
||||
"LLM__STREAM__1__MODEL": "m-c",
|
||||
"LLM__STREAM__1__TIMEOUT_S": "60",
|
||||
}
|
||||
)
|
||||
s = GatewaySettings.from_env("LLM", env=env_collision)
|
||||
assert [src.name for src in s.sources] == ["qwen_1"]
|
||||
# 三段键天然跳过 `_load_sources` 的段数判据,也不造源
|
||||
env = _env(**{"LLM__STREAM__STALL_THRESHOLD_MS": "2000"})
|
||||
s2 = GatewaySettings.from_env("LLM", env=env)
|
||||
assert [src.name for src in s2.sources] == ["qwen_1"]
|
||||
|
||||
def test_stall_after_s_guard_effective_minimum_raises(self):
|
||||
"""`stall_after_s ≥ min(inter_token_timeout_s or timeout_s)` → ValueError。
|
||||
|
||||
有效值回落与 openai_compat.py:573 看门狗装配同款: 配 watchdog 的源用
|
||||
inter_token_timeout_s,未配的源用 timeout_s;对冲窗永不开装配期直接拒绝。"""
|
||||
# 源 1 配 watchdog(inter_token 15)、源 2 纯 timeout(90): 有效最小值 = 15
|
||||
env = self._two_source_env(
|
||||
**{
|
||||
"LLM__QWEN__1__TTFT_TIMEOUT_S": "30",
|
||||
"LLM__QWEN__1__INTER_TOKEN_TIMEOUT_S": "15",
|
||||
"LLM__HEDGE__STALL_AFTER_S": "15", # ≥ 15 → 拒绝(边界含等号)
|
||||
}
|
||||
)
|
||||
with pytest.raises(ValueError, match="STALL_AFTER_S"):
|
||||
GatewaySettings.from_env("LLM", env=env)
|
||||
s = GatewaySettings.from_env(
|
||||
"LLM",
|
||||
env=self._two_source_env(
|
||||
**{
|
||||
"LLM__QWEN__1__TTFT_TIMEOUT_S": "30",
|
||||
"LLM__QWEN__1__INTER_TOKEN_TIMEOUT_S": "15",
|
||||
"LLM__HEDGE__STALL_AFTER_S": "14",
|
||||
}
|
||||
),
|
||||
)
|
||||
assert s.hedge_stall_after_s == 14.0
|
||||
# 全源未配 watchdog: 有效最小值 = min(timeout_s) = 90
|
||||
with pytest.raises(ValueError, match="STALL_AFTER_S"):
|
||||
GatewaySettings.from_env(
|
||||
"LLM", env=self._two_source_env(**{"LLM__HEDGE__STALL_AFTER_S": "90"})
|
||||
)
|
||||
|
||||
def test_stall_after_s_guard_deadline_conflict_raises(self):
|
||||
"""`stall_after_s ≥ call_deadline_s` → ValueError(与 after_s 同款,§11-N5)。"""
|
||||
env = self._two_source_env(
|
||||
**{"LLM__HEDGE__STALL_AFTER_S": "30", "LLM__CALL_DEADLINE_S": "30"}
|
||||
)
|
||||
with pytest.raises(ValueError, match="call_deadline_s"):
|
||||
GatewaySettings.from_env("LLM", env=env)
|
||||
s = GatewaySettings.from_env(
|
||||
"LLM",
|
||||
env=self._two_source_env(
|
||||
**{"LLM__HEDGE__STALL_AFTER_S": "29", "LLM__CALL_DEADLINE_S": "30"}
|
||||
),
|
||||
)
|
||||
assert s.hedge_stall_after_s == 29.0 and s.call_deadline_s == 30.0
|
||||
|
||||
def test_stall_after_s_single_source_warns(self):
|
||||
"""单源 scope 设 stall 阈值: 装配期 warning 放行(1.3.7 §5 同款)。"""
|
||||
with _captured_warnings() as warnings:
|
||||
s = GatewaySettings.from_env(
|
||||
"LLM", env=_env(**{"LLM__HEDGE__STALL_AFTER_S": "8"})
|
||||
)
|
||||
assert s.hedge_stall_after_s == 8.0
|
||||
assert any("单源" in m for m in warnings)
|
||||
|
||||
def test_embedding_ocr_scope_hedge_keys_warn(self):
|
||||
"""B4: Embedding/OCR scope 配对冲键(AFTER_S 或 STALL_AFTER_S)→ 装配 warning;
|
||||
chat scope 无此 warning(per-call stream,装配期无信号,B4 裁决)。"""
|
||||
# Embedding: env 路(AFTER_S 与 STALL_AFTER_S 各验一次)
|
||||
embed_env = {
|
||||
"EMBED__QWEN__1__BASE_URL": "https://gw.example/v1",
|
||||
"EMBED__QWEN__1__API_KEY": "sk-a",
|
||||
"EMBED__QWEN__1__MODEL": "text-embedding-v3",
|
||||
"EMBED__QWEN__1__TIMEOUT_S": "60",
|
||||
"EMBED__RETRY__MAX_ATTEMPTS": "3",
|
||||
"EMBED__RETRY__BACKOFF_BASE_S": "1.0",
|
||||
"EMBED__RETRY__BACKOFF_MAX_S": "10.0",
|
||||
"EMBED__BREAKER__FAIL_THRESHOLD": "5",
|
||||
"EMBED__BREAKER__COOLDOWN_S": "60",
|
||||
"PGW_CACHE_BACKEND": "none",
|
||||
"PGW_TELEMETRY_BACKEND": "none",
|
||||
"EMBED__BATCH_SIZE": "64",
|
||||
}
|
||||
with _captured_warnings() as warnings:
|
||||
EmbeddingSettings.from_env(
|
||||
"EMBED", env={**embed_env, "EMBED__HEDGE__AFTER_S": "8"}
|
||||
)
|
||||
assert any("仅对 chat scope 生效" in m for m in warnings)
|
||||
with _captured_warnings() as warnings:
|
||||
EmbeddingSettings.from_env(
|
||||
"EMBED", env={**embed_env, "EMBED__HEDGE__STALL_AFTER_S": "8"}
|
||||
)
|
||||
assert any("仅对 chat scope 生效" in m for m in warnings)
|
||||
# Embedding: 直构造路(__post_init__ 那道,非 from_env 专属)
|
||||
gw = GatewaySettings.from_env("EMBED", env=embed_env)
|
||||
gw_hedged = dataclasses.replace(gw, hedge_stall_after_s=8.0)
|
||||
with _captured_warnings() as warnings:
|
||||
EmbeddingSettings(gateway=gw_hedged, batch_size=64)
|
||||
assert any("仅对 chat scope 生效" in m for m in warnings)
|
||||
# OCR: env 路(1.3.7 AFTER_S 同病顺带补齐 + 新 STALL_AFTER_S)
|
||||
ocr_env = dict(TestOcrSettings._OCR_ENV)
|
||||
with _captured_warnings() as warnings:
|
||||
OcrSettings.from_env("OCR", env={**ocr_env, "OCR__HEDGE__AFTER_S": "8"})
|
||||
assert any("仅对 chat scope 生效" in m for m in warnings)
|
||||
with _captured_warnings() as warnings:
|
||||
OcrSettings.from_env("OCR", env={**ocr_env, "OCR__HEDGE__STALL_AFTER_S": "8"})
|
||||
assert any("仅对 chat scope 生效" in m for m in warnings)
|
||||
# chat 侧: 同样的对冲键不产生 B4 warning(双源 env 隔离单源 warning 干扰)
|
||||
with _captured_warnings() as warnings:
|
||||
GatewaySettings.from_env(
|
||||
"LLM", env=self._two_source_env(**{"LLM__HEDGE__STALL_AFTER_S": "8"})
|
||||
)
|
||||
assert not any("仅对 chat scope 生效" in m for m in warnings)
|
||||
|
||||
Reference in New Issue
Block a user