feat: add opt-in cross-source hedged requests for chat

This commit is contained in:
2026-09-10 14:00:50 -04:00
parent 463eca380d
commit adc069447a
7 changed files with 1018 additions and 13 deletions
+16 -1
View File
@@ -19,7 +19,7 @@ from typing import TYPE_CHECKING, Any, Literal
from polygateway.backends.memory.breaker import InMemoryGate
from polygateway.backends.memory.cache import InMemoryCache
from polygateway.backends.memory.limiter import InMemoryLimiter
from polygateway.config import GatewaySettings
from polygateway.config import GatewaySettings, check_hedge_assembly
from polygateway.deadline import ensure_call_deadline, with_call_deadline
from polygateway.errors import PolyGatewayError
from polygateway.middleware.base import compose
@@ -237,6 +237,8 @@ class GatewayClient:
structured_escalation: StructuredOutputStrategy | None = None,
structured_max_retries: int = 1,
call_deadline_s: float | None = None,
hedge_after_s: float | None = None,
hedge_max_extra: int = 1,
now: Any = time.monotonic,
sleep: Any = asyncio.sleep,
rng: Any = random.random,
@@ -245,6 +247,15 @@ 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(
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=...)",
)
emitter = (
TelemetryEmitter(telemetry, scope=scope, pricing=pricing, text_cap=text_cap)
if telemetry is not None
@@ -267,6 +278,8 @@ class GatewayClient:
ceiling=float(max([64, *(s.max_concurrency for s in sources if s.max_concurrency)]))
),
emitter=emitter,
# max_extra 不下传(issue #24 H5): v1 编排固定单路对冲,无消费者
hedge_after_s=self._hedge_after_s,
now=now,
sleep=sleep,
rng=rng,
@@ -509,6 +522,8 @@ class GatewayClient:
structured_escalation=escalation,
structured_max_retries=settings.structured_max_retries,
call_deadline_s=settings.call_deadline_s,
hedge_after_s=settings.hedge_after_s,
hedge_max_extra=settings.hedge_max_extra,
)
_mark_owned_components(client, limiter=limiter, breaker=breaker, telemetry=telemetry)
client._owns_cache = cache is None # 缓存后端可以是 None(backend=none),helper 会跳过
+127 -2
View File
@@ -30,7 +30,7 @@ from polygateway.types import (
)
if TYPE_CHECKING:
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
# FIELD → (SourceConfig 属性, 类型);CHS config.py:95-104 全集 + M1 新增
_SOURCE_FIELDS: dict[str, tuple[str, str]] = {
@@ -54,7 +54,7 @@ _SOURCE_FIELDS: dict[str, tuple[str, str]] = {
"TRUST_ENV": ("trust_env", "bool"),
"EXTRA_BODY": ("extra_body", "json"),
}
_RESERVED_SEGMENTS = frozenset({"GLOBAL", "RETRY", "BREAKER", "BACKPRESSURE"})
_RESERVED_SEGMENTS = frozenset({"GLOBAL", "RETRY", "BREAKER", "BACKPRESSURE", "HEDGE"})
_SELECTORS = frozenset({"round_robin", "least_inflight", "health_aware"})
_QUOTA_FULL = frozenset({"wait", "fail_fast"})
# 熔断全拒时的处置(issue #14);值域与 _QUOTA_FULL 相同但语义不同——配额满是
@@ -188,6 +188,12 @@ class GatewaySettings:
# 与 `OcrSettings.gateway` 自动继承。值域由 `_validate_call_deadline` 把关,
# 直接构造、`dataclasses.replace` 与 env 三条路一致
call_deadline_s: float | None = None
# 长尾对冲(issue #24 H4): 挂起超阈值时并发向异源再发一次,先回者赢、输家取消。
# 缺省 None = 关闭,行为逐字等于 1.3.6。`hedge_max_extra` 值域 [1,3] 为 H5 梯次
# 预留,v1 仅单路生效(>1 装配期 warning);**不下传 RetryMW**。值域与交叉守卫
# 由 `check_hedge_assembly` 单一定义点把关,直接构造/replace/env 三路一致
hedge_after_s: float | None = None
hedge_max_extra: int = 1
def __post_init__(self) -> None:
self._normalize()
@@ -199,6 +205,7 @@ class GatewaySettings:
self._validate_stall()
self._validate_probe()
self._validate_call_deadline()
self._validate_hedge()
def _normalize(self) -> None:
"""把 `from_env` 一直在做的规范化补到构造路上,两条路必须产出同一个值。
@@ -365,6 +372,24 @@ class GatewaySettings:
ensure_call_deadline(self.call_deadline_s, "GatewaySettings.call_deadline_s"),
)
def _validate_hedge(self) -> None:
"""对冲装配守卫(issue #24 H4): 与期限同款,盖住直接构造与 replace 两条路。
env 路的值域错误已在 `_load_hedge` 里带真实键名报过;此处对合法值是幂等
空操作,交叉守卫(阈值 vs timeout/deadline/ttft、单源)只在这里有一处。
"""
object.__setattr__(
self,
"hedge_after_s",
check_hedge_assembly(
hedge_after_s=self.hedge_after_s,
hedge_max_extra=self.hedge_max_extra,
sources=self.sources,
call_deadline_s=self.call_deadline_s,
origin="GatewaySettings.hedge_after_s",
),
)
@classmethod
def from_env(
cls,
@@ -394,6 +419,7 @@ class GatewaySettings:
quota_full=_load_choice(env, f"{scope_u}__QUOTA_FULL", _QUOTA_FULL, "wait"),
circuit_open=_load_choice(env, f"{scope_u}__CIRCUIT_OPEN", _CIRCUIT_OPEN, "fail_fast"),
call_deadline_s=_load_call_deadline(scope_u, env),
**_load_hedge(scope_u, env),
**_load_pgw(env),
)
@@ -716,6 +742,105 @@ def _load_call_deadline(scope: str, env: Mapping[str, str]) -> float | None:
return ensure_call_deadline(_cast(found[1], "float", found[0]), found[0])
def _load_hedge(scope: str, env: Mapping[str, str]) -> dict[str, object]:
"""读 `{SCOPE}__HEDGE__AFTER_S`/`{SCOPE}__HEDGE__MAX_EXTRA`(issue #24 H4)。
两键均为 3 段键(`split("__")` 长度 3 ≠ 4),`_load_sources` 的段数判据天然
跳过它们;`HEDGE` 已进 `_RESERVED_SEGMENTS`,4 段的 `{SCOPE}__HEDGE__{N}__*`
也不会被当成 provider 段造出源。`AFTER_S` 未设 = 关闭(缺省逐字等于 1.3.6);
`MAX_EXTRA` 未设 = 1。origin 传实际命中键名(同 `_load_call_deadline` 纪律);
值域的交叉守卫(ttft/单源/max_extra 生效口径)归 `check_hedge_assembly` 一处。
Args:
scope: 已大写的 scope 名。
env: 已合并的环境映射。
Returns:
`{"hedge_after_s": float | None, "hedge_max_extra": int}`,直传构造器。
"""
found_after = _first(env, f"{scope}__HEDGE__AFTER_S")
after = (
ensure_call_deadline(_cast(found_after[1], "float", found_after[0]), found_after[0])
if found_after
else None
)
found_extra = _first(env, f"{scope}__HEDGE__MAX_EXTRA")
extra = int(_cast(found_extra[1], "int", found_extra[0])) if found_extra else 1
return {"hedge_after_s": after, "hedge_max_extra": extra}
def check_hedge_assembly(
*,
hedge_after_s: float | None,
hedge_max_extra: int,
sources: Sequence[SourceConfig],
call_deadline_s: float | None,
origin: str,
) -> float | None:
"""对冲装配守卫的唯一事实源(issue #24 设计 §5 全表,H4 批准)。
`GatewaySettings.__post_init__` 与 `GatewayClient.__init__` 调同一份,两条装配
路的值域/交叉守卫不漂移。返回归一化后的 `hedge_after_s`(None 或有限正数,
复用 `ensure_call_deadline` 的值域校验);`hedge_after_s is None`(未启用)时
值域归一化后直接返回,交叉守卫不查——它们没有可校验的对象。
Args:
hedge_after_s: 对冲触发阈值(秒);None = 关闭。
hedge_max_extra: 每次逻辑调用最多并发对冲路数;v1 仅单路生效(H5)。
sources: 本 scope 的源集合(交叉守卫要读 timeout_s/ttft_timeout_s)。
call_deadline_s: 调用期限(秒);与对冲的组合守卫见设计 §6。
origin: after_s 值域报错的定位串(env 键名 / `GatewaySettings.hedge_after_s` /
`GatewayClient(hedge_after_s=...)`);跨字段守卫与 warning 沿用本模块先例,
消息自带字段名与 env 键型,不挂 origin。
Raises:
ValueError: max_extra 非 int/bool 或出 [1,3];阈值 ≥ 最小源 timeout_s(永不
可能触发);阈值 ≥ call_deadline_s(期限先于对冲触发,对冲形同虚设)。
"""
if isinstance(hedge_max_extra, bool) or not isinstance(hedge_max_extra, int):
raise ValueError(
f"hedge_max_extra({{SCOPE}}__HEDGE__MAX_EXTRA)必须是 int: {hedge_max_extra!r}"
)
if not 1 <= hedge_max_extra <= 3:
raise ValueError(
f"hedge_max_extra({{SCOPE}}__HEDGE__MAX_EXTRA)须在 [1,3]: {hedge_max_extra};"
"每次逻辑调用最多并发对冲路数,v1 仅单路生效"
)
after = ensure_call_deadline(hedge_after_s, origin)
if after is None:
return None
min_timeout = min(s.timeout_s for s in sources)
if after >= min_timeout:
raise ValueError(
f"hedge_after_s({{SCOPE}}__HEDGE__AFTER_S={after})须 < 最小源 timeout_s"
f"({min_timeout});对冲永不可能触发,配置即错误"
)
if call_deadline_s is not None and after >= call_deadline_s:
raise ValueError(
f"hedge_after_s({after})须 < call_deadline_s({call_deadline_s});"
"期限会先于对冲触发,对冲形同虚设(设计 §6)"
)
ttfts = [s.ttft_timeout_s for s in sources if s.ttft_timeout_s is not None]
if ttfts and after >= min(ttfts):
logger.warning(
"hedge_after_s({}) ≥ 最小源 ttft_timeout_s({}): 流式挂起会被 TTFT 看门狗"
"先行切断,对冲对流式形同虚设(非流式仍有效)",
after,
min(ttfts),
)
if len(sources) == 1:
logger.warning(
"单源 scope 配置了对冲阈值 hedge_after_s={};运行期拿不到异源候选,对冲自然静默",
after,
)
if hedge_max_extra > 1:
logger.warning(
"hedge_max_extra={} 已接受,但 v1 仅单路对冲生效(梯次追加为 H5 预留)",
hedge_max_extra,
)
return after
@dataclass(frozen=True)
class EmbeddingSettings:
"""Embedding scope 装配配置(M2 §7): 复用 GatewaySettings + embedding 专用键。
+15 -2
View File
@@ -169,15 +169,28 @@ class SourceAdmission:
# —— 选源与准入(CHS _pick_runnable 120-167)——
async def pick(
self, reasons: dict[str, str], attempt_fails: dict[str, int]
self,
reasons: dict[str, str],
attempt_fails: dict[str, int],
*,
exclude: frozenset[str] | None = None,
) -> tuple[tuple[SourceConfig, Permit, GateDecision] | None, int]:
"""挑出第一个过闸的候选;返回 (选中三元组 | None, 熔断类拒绝计数)。"""
"""挑出第一个过闸的候选;返回 (选中三元组 | None, 熔断类拒绝计数)。
`exclude` 是对冲编排的私有排除参数(issue #24): 以在途源名为排除集,
保证对冲路落在**异源**。被排除不是源的拒绝——不计 gate_rejections、
不写 reasons,否则会污染 `on_no_runnable` 的分派判据与 per_source_reasons
对账。拿不到候选时返回 None,是否放弃由调用方决定(对冲方静默等原路,
**严禁**对这个 None 调 `on_no_runnable`)。
"""
stats = {s.name: await self._quota.stats(s) for s in self._sources}
gate_rejections = 0
ordered = _demote_call_failures(
self._selector.order(self._sources, stats), attempt_fails, self._health_view
)
for cand in ordered:
if exclude and cand.name in exclude:
continue # 对冲排除在途源: 不是拒绝, 不计数不写原因(见 docstring)
if self._memo.active(cand.name):
# 冷却备忘跳过也计入拒绝数,保住 circuit_open 判据(CHS 同款)
gate_rejections += 1
+216 -8
View File
@@ -164,6 +164,17 @@ def _is_rate_limited(outcome: LLMResponse | _Failed) -> bool:
return isinstance(outcome, _Failed) and _failure_reason(outcome.exc) == "rate_limited"
_HEDGE_LOSER_ATTR = "_polygateway_hedge_loser"
"""编排在 cancel() 之前给输家任务置位的标记;_attempt 读它选遥测标签。"""
def _combine_failures(primary_f: _Failed, hedge_f: _Failed) -> _Failed:
"""任一非 429 优先(计预算);两路皆 429 才按 429 免预算退还 stall 账;同类取原路。"""
if _failure_reason(primary_f.exc) == "rate_limited" != _failure_reason(hedge_f.exc):
return hedge_f
return primary_f
class RetryMW:
"""尝试编排器;时钟/睡眠/随机全部注入,纯确定性可测(P6)。"""
@@ -183,6 +194,7 @@ class RetryMW:
cooldown_memo: SourceCooldownMemo | None = None,
pacer: AdaptivePacer | None = None,
emitter: TelemetryEmitter | None = None,
hedge_after_s: float | None = None,
now: Callable[[], float] = time.monotonic,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
rng: Callable[[], float] = random.random,
@@ -200,6 +212,10 @@ class RetryMW:
# M2.5 §3.35: AIMD 自适应并发——429 收紧、成功回涨,超限调用排队不烧预算
self._pacer = pacer or AdaptivePacer(ceiling=64.0)
self._emitter = emitter
# 对冲触发阈值(issue #24): None = 关闭(__call__ 逐字走 1.3.6 单路路径)。
# 值域/交叉守卫在装配层(config.check_hedge_assembly),本类不重复校验;
# hedge_max_extra 不下传——v1 编排固定单路对冲(H5)
self._hedge_after_s = hedge_after_s
self._now = now
self._sleep = sleep
self._rng = rng
@@ -249,16 +265,27 @@ class RetryMW:
# 每轮一个 sink: 裸生成时间由编排裁定归属(T2 单路 = 成功那轮;
# T3 对冲 = 赢家那一路),`_attempt` 只负责把本次 transport 耗时投进来
generation_sink: list[int] = []
outcome = await self._attempt(
request, *picked, reasons, attempt_fails, generation_sink=generation_sink
)
if self._hedge_after_s is None:
# 默认关闭: 逐字 1.3.6 单路路径(默认关闭回归门据此成立)
outcome = await self._attempt(
request,
*picked,
reasons,
attempt_fails,
first_token_event=None,
generation_sink=generation_sink,
)
else:
# 对冲轮 = 一次"超级尝试": 编排内部自建 sink 并按赢家归属登记
outcome = await self._attempt_hedged(request, picked, reasons, attempt_fails)
rate_limited = _is_rate_limited(outcome)
if rate_limited:
# 免了重试预算就得进 stall 账,否则这段耗时无人治理(见 StallClock)
attempt.refund()
if isinstance(outcome, LLMResponse):
# 与 register_attempt 同款 None 守卫: 库内现场构造的请求跳过登记
if request.call_context is not None:
# 与 register_attempt 同款 None 守卫: 库内现场构造的请求跳过登记;
# 对冲轮由 _attempt_hedged 按赢家归属登记,此处外层 sink 恒为空
if request.call_context is not None and generation_sink:
request.call_context.record_generation(generation_sink[0], accumulate=False)
return outcome
if not rate_limited:
@@ -284,6 +311,7 @@ class RetryMW:
reasons: dict[str, str],
attempt_fails: dict[str, int],
*,
first_token_event: asyncio.Event | None,
generation_sink: list[int],
) -> LLMResponse | _Failed:
call_id = str(uuid.uuid4())
@@ -311,8 +339,9 @@ class RetryMW:
# 逐次尝试原样重传: 换源不改变调用方要的档位(源级默认由 transport
# 自己按选中的源解析,两者在 effective_effort 里汇合)
reasoning_effort=request.reasoning_effort,
# T3 对冲编排接线前恒为 None: 调用方不观测首 token(计划 §3.1)
first_token_event=None,
# 对冲编排(计划 §3.4): 原路携带首 token 事件,对冲路恒 None(v1 单路,
# 不再梯次);未启用对冲时 __call__ 传 None = 调用方不观测首 token
first_token_event=first_token_event,
)
generation_sink.append(int((self._now() - gen_started) * 1000))
if result.usage_source == "unavailable":
@@ -347,7 +376,15 @@ class RetryMW:
actual = source.effective_est_tokens()
if entry.is_probe:
await self._record_quietly(self._breaker.release_probe(entry))
await self._emit(request, source, call_id, started, error="cancelled")
# 对冲输家在 cancel() 前被编排置位标记(计划 §3.4): 据此区分"被对冲
# 淘汰"与"外部取消",零新遥测列(设计 §4.5);外部取消与对冲取消同时
# 到达的竞速可能误贴——记账方向一致(est 保留),属已批准的可接受残留
label = (
"hedge_cancelled"
if getattr(asyncio.current_task(), _HEDGE_LOSER_ATTR, False)
else "cancelled"
)
await self._emit(request, source, call_id, started, error=label)
raise
except (SourceDeadError, TransientError) as exc:
dead = isinstance(exc, SourceDeadError)
@@ -369,6 +406,177 @@ class RetryMW:
self._pacer.leave(source.name)
await settle_and_release(permit, actual)
# —— 对冲编排(issue #24 设计 §4.6;默认关闭,`hedge_after_s is None` 不进这里)——
async def _attempt_hedged(
self,
request: ChatRequest,
picked: tuple[SourceConfig, Permit, GateDecision],
reasons: dict[str, str],
attempt_fails: dict[str, int],
) -> LLMResponse | _Failed:
"""一次"超级尝试": 原路 + (触发后)异源对冲路,FIRST_COMPLETED 竞速。
计时只用事件循环相对时长(`asyncio.wait` timeout),绝不读注入 `now`
(设计 §4.1 时钟纪律);两路各持各的 permit,结算/遥测/熔断写回全部沿用
`_attempt` 既有路径,本方法只做编排与赢家裁定。
"""
# Phase 1 启动原路: 事件与 sink 每轮新建(局部状态,严禁实例属性)
source, permit, entry = picked
first_token: asyncio.Event = asyncio.Event()
sink_p: list[int] = []
primary = asyncio.create_task(
self._attempt(
request,
source,
permit,
entry,
reasons,
attempt_fails,
first_token_event=first_token,
generation_sink=sink_p,
)
)
hedge: asyncio.Task[LLMResponse | _Failed] | None = None
try:
# Phase 2 触发窗: 只认"阈值到 + 首 token 未至 + 原路在途"(loop 相对时长)
if not await self._past_hedge_window(primary, first_token):
# 原路已了结/首 token 已至: 等价于未配置对冲
outcome = await primary
if isinstance(outcome, LLMResponse):
self._record_generation(request, sink_p)
return outcome
# Phase 3 异源准入(完整 pick 路径,无旁路): 拿不到候选 = 静默等原路
# (对冲是优化不是权利;严禁对这个 None 调 on_no_runnable,见 §3.5)
hedge_picked, _ = await self._admission.pick(
reasons, attempt_fails, exclude=frozenset({source.name})
)
if hedge_picked is None:
outcome = await primary
if isinstance(outcome, LLMResponse):
self._record_generation(request, sink_p)
return outcome
# Phase 4 启动对冲路(v1 单路,H5): 对冲路恒传 first_token_event=None,
# 不再触发梯次对冲
sink_h: list[int] = []
hedge = asyncio.create_task(
self._attempt(
request,
*hedge_picked,
reasons,
attempt_fails,
first_token_event=None,
generation_sink=sink_h,
)
)
# Phase 5 赢家裁定与收口
done, pending = await asyncio.wait(
{primary, hedge}, return_when=asyncio.FIRST_COMPLETED
)
if pending and not any(self._succeeded(t, done) for t in done):
# 先了结的是失败: 等另一路的结论再裁定(它可能后发先至;
# 原路先败、对冲在途时不重试,设计 §4.6)
more, pending = await asyncio.wait(pending)
done |= more
winner = (
primary
if self._succeeded(primary, done)
else hedge
if self._succeeded(hedge, done)
else None
)
if winner is None:
primary_exc = primary.exception()
hedge_exc = hedge.exception() # 两路都取回,不留 never-retrieved 告警
# 非可重试族(RequestRejected/ResultInvalid)穿透,与单路同口径
if primary_exc is not None:
raise primary_exc
if hedge_exc is not None:
raise hedge_exc
# 两败(H6): 汇合为一次失败,重试预算只计一次;路数照登(无赢家)
outcome = _combine_failures(primary.result(), hedge.result())
self._register_hedge(request, hedge_won=False, winner_sink=None)
return outcome
# 两路同时成功的竞速 → 原路优先(保守不弃原路成果),由上面 primary
# 先判实现;竞速落选者已完成,不置标记——它没被取消,行是正常行
loser = hedge if winner is primary else primary
if not loser.done():
# 先置标记再 cancel: _attempt 的取消分支据标记选 hedge_cancelled
setattr(loser, _HEDGE_LOSER_ATTR, True)
loser.cancel()
# 收口: gather 等输家 finally 的结算/遥测跑完才返回(快照含输家,
# attempts==2);对已完成的输家顺带取回结果,不留 never-retrieved 告警
await asyncio.gather(loser, return_exceptions=True)
self._register_hedge(
request,
hedge_won=winner is hedge,
winner_sink=sink_h if winner is hedge else sink_p,
)
return winner.result()
except BaseException:
# 取消穿透与准入冒泡(GovernanceBackendError)同路: 两任务(存在且未完者)
# 同消,尽力收口后原异常上抛;不 shield,收口 await 允许被再取消(136 清理纪律)
tasks = [t for t in (primary, hedge) if t is not None and not t.done()]
for t in tasks:
t.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
raise
async def _past_hedge_window(self, primary: asyncio.Task, first_token: asyncio.Event) -> bool:
"""对冲触发窗: 阈值到 + 首 token 未至 + 原路在途,三者齐备才放行对冲。
只用事件循环相对时长(`asyncio.wait` timeout),绝不读注入 `now`——测试
伪造注入钟跳变不得触发对冲(设计 §4.1)。waiter 任务必须收口,且**不得吞
外部取消**(finally 里 await 已取消的 waiter 会接住一个 CancelledError,
须靠 cancelling() 区分它是 waiter 自己的还是外面打进来的)。
"""
waiter = asyncio.create_task(first_token.wait())
try:
await asyncio.wait(
{primary, waiter}, timeout=self._hedge_after_s, return_when=asyncio.FIRST_COMPLETED
)
finally:
waiter.cancel()
try:
await waiter
except asyncio.CancelledError:
if asyncio.current_task().cancelling(): # 外部取消,穿透
raise
return not primary.done() and not first_token.is_set()
@staticmethod
def _succeeded(task: asyncio.Task, done: set[asyncio.Task]) -> bool:
"""赢家裁定: 已完成、未被取消、无异常且结果为 LLMResponse。"""
return (
task in done
and not task.cancelled()
and task.exception() is None
and isinstance(task.result(), LLMResponse)
)
@staticmethod
def _record_generation(request: ChatRequest, sink: list[int]) -> None:
"""record_generation 的共享守卫: 上下文缺失(库内现场构造)或空 sink 均跳过。"""
if request.call_context is not None and sink:
request.call_context.record_generation(sink[0], accumulate=False)
@staticmethod
def _register_hedge(
request: ChatRequest, *, hedge_won: bool, winner_sink: list[int] | None
) -> None:
"""对冲登记(赢家裁定后一次性;同 register_attempt 的 None 守卫)。
hedges 计"实际并发发出的对冲路数"——触发但准入失败的静默不计(没走到
这里);两败轮次无赢家: 路数照登(hedge_won=False),裸生成时间无归属不记。
"""
context = request.call_context
if context is None:
return
if winner_sink:
context.record_generation(winner_sink[0], accumulate=False)
context.register_hedge(hedge_won=hedge_won)
async def _on_rejected(
self, exc: RequestRejectedError, source: SourceConfig, entry: GateDecision
) -> None: