Files
PolyGateway/research-wiki/plans/2026-09-10-24-hedged-requests.md
T

339 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
type: plan
node_id: plan:2026-09-10-24-hedged-requests
title: "issue #24 长尾对冲请求与裸生成时间实施计划"
date: 2026-09-10
---
# issue #24 长尾对冲请求与裸生成时间实施计划
> 设计:`research-wiki/designs/2026-09-10-24-hedged-requests-design.md`,**人类于 2026-09-10 正式批准**(§9 H1H7 及增补 H8 全数获批;H1 取方案 A 并发对冲,H3 取零新列档,H5 取 v1 只允许单路)。
> 计划审核门:Claude 自审 + 独立模型审查;plan 无人类门,审毕直接执行。
> 目标:① chat 链路可选对冲(挂起超阈值时并发向**异源**再发一次,先回者赢、输家取消);② `CallStats` 增 `hedges`/`generation_ms`/`hedge_won`,三条链路 `_attempt` 加 transport 级计时;③ 默认关闭,缺省行为逐字等于 1.3.6。
> 方案:设计 §3 方案 A——对冲轮 = 一次"超级尝试",复用完整准入(QuotaGate + BreakerGate + pacer + 冷却备忘),输家落 1.3.6 取消 S3 格结算,零新错误分类。
> 技术:Python 3.12+、asyncio 任务组编排(`asyncio.wait(FIRST_COMPLETED)` + 显式收口)、frozen dataclass、pytest + 事件驱动假 transport + 真实 loop 钟(410× 余量)、ruff、import-linter。
> 基线 HEAD:`166b286`(main,1.3.6 已发布);分支 `feature/24-hedged-requests`。
**范围纪律**: 不改四分类/熔断语义/429 分账/限流 Lua/`deadline.py`/`Permit` 端口;不做 embedding/OCR 对冲、分位数触发、同源对冲、per-call 对冲参数;不引入 shield/后台任务;不加遥测新列(`hedge_cancelled` 字符串 + `CallStats` 三字段经既有通道带出)。
## 1. 边界、授权与执行纪律
| 项目 | 固定边界 |
| --- | --- |
| 唯一 writer | 一工作区一 writer;父会话负责前台委派与审核派发。1.3.X 合并/发布授权沿用;跨到 1.4 或新公共面变化须停下确认 |
| 公共面 | 只做设计 §9 已批准项:`Transport.complete``first_token_event` 必填 kw(H2)、两配置键 `{SCOPE}__HEDGE__AFTER_S`/`{SCOPE}__HEDGE__MAX_EXTRA`(H4)、`GatewayClient.__init__` 两 keyword-only 参数、`CallStats` 三字段(H3+H8)、输家 `hedge_cancelled` 标签。**不新增其它键/端口方法/遥测列/异常类** |
| 对冲边界 | 仅 chat(RetryMW);EmbeddingClient/OcrClient 不加对冲参数(非目标 A),但它们的 `_attempt` 照样加 generation 计时点(H8);v1 单路对冲(H5) |
| 记账边界 | 输家取消走既有取消路径:`settlement_known=False``settle(est)` 保留预扣(`retry.py:327-331`);输家**不** `record_failure`、不喂熔断/健康分(挂起 ≠ 源死亡);两任务都失败才进重试且 `fails += 1` 只计一次(H6) |
| 取消铁律 | 外部取消到达时两任务同消并穿透;不 shield、不留后台任务;收口 await 允许被再取消(同 136 清理纪律) |
| 降级方向 | 限流/熔断后端不可用 → 照常冒泡(fail-closed);准入失败 → **静默放弃对冲**,原请求继续等(不抛错、不硬等);遥测仍 warning 降级 |
| 时钟纪律 | 对冲触发只用**相对时长 + 事件循环钟**(`asyncio.wait` timeout),绝不读注入 `now`;`generation_ms` 计时用该链路既有注入钟(与 `total_latency_ms` 同钟,差值才有意义;生产即 `time.monotonic`) |
| 证据纪律 | 不打印 `.env`/token/Authorization;不提交 `.pi/``tests/outputs/`;测试事件驱动,**禁 sleep 撞窗口**;对冲阈值用 0.05s 级真实 loop 钟,断言容差 410×(`tests/unit/test_streaming.py` 既有范式,不标 slow) |
Skill 纪律:T1T3 行为变更执行 `test-driven-development`(先红后绿证据落在本会话工具输出);每次提交执行 `commit`;T4 前执行 `requesting-code-review``verification-before-completion`;异常先 `systematic-debugging`
**保真校验**: 本计划不涉及 `reference/` 参考实现迁移(对冲编排为 D13 自研语义,蓝本即本库 1.3.6 的准入/结算/取消机制),保真校验不适用。
## 2. 文件职责与不变接缝
| 动作 | 精确路径 | 职责 |
| --- | --- | --- |
| 修改 | `src/polygateway/ports.py` | `Transport.complete`(:51-60)加 `first_token_event` 必填 kw;顶部加 `import asyncio`(stdlib,不违 P7) |
| 修改 | `src/polygateway/transports/openai_compat.py` | `complete`(:426-436)加参透传;`_complete_stream`(:552 起)首 token 处(:573-575)置位;`_complete_once`(:646 起)接收但**永不置位**(docstring 明写) |
| 修改 | `src/polygateway/middleware/retry.py` | `_attempt`(:270-353)加 `first_token_event`/`generation_sink` 私有 kw + transport 级计时;`__call__`(:224-267)单轮尝试 → 任务组编排;`_past_hedge_window`/`_attempt_hedged`/`_combine_failures` 新方法;CancelledError 分支(:327-334)`hedge_cancelled` 标签;`__init__``hedge_after_s` |
| 修改 | `src/polygateway/middleware/admission.py` | `pick`(:171-213)加 keyword-only `exclude: frozenset[str] \| None = None` |
| 修改 | `src/polygateway/types.py` | `CallStats`(:296-318)增三字段(全带默认值,追加在 `total_latency_ms` 后);`_CallContext`(:321-360)docstring 修订 + 三个计数 + `record_generation`/`register_hedge`;`snapshot`(:348-353)填三字段 |
| 修改 | `src/polygateway/config.py` | `_RESERVED_SEGMENTS`(:57)加 `"HEDGE"`;`GatewaySettings` 字段(:190 后)加 `hedge_after_s`/`hedge_max_extra`;`__post_init__`(:201 后)加 `_validate_hedge()`;新增 `_load_hedge`(:695 `_load_call_deadline` 之后)与模块级 `check_hedge_assembly` 守卫;`from_env`(:396 同列)透传 |
| 修改 | `src/polygateway/client.py` | `__init__`(:239 后)加两 keyword-only 参数,入口即校(复用 `check_hedge_assembly`);RetryMW 构造(:254-273)传 `hedge_after_s`;`from_settings`(:511 同列)透传 |
| 修改 | `src/polygateway/embedding.py` | `_attempt`(:351 起,transport 调用 :373)两侧计时 + 成功分支 `record_generation(accumulate=True)` |
| 修改 | `src/polygateway/ocr.py` | `_attempt`(:376 起,`_invoke` 调用 :397)两侧计时 + 成功分支 `record_generation(accumulate=False)` |
| 新建 | `tests/unit/test_hedge.py` | 对冲编排全部用例(批次 G) |
| 修改 | `tests/unit/test_openai_compat.py` | 批次 A(端口加参);`_complete` helper(:82-90)同步签名 |
| 修改 | `tests/unit/test_retry.py` `test_types.py` `test_embedding.py` `test_ocr_client.py` `test_client.py` `test_config.py` | 批次 BF、H;`test_retry.py:82` FakeTransport 签名同步 |
| 修改 | `tests/unit/test_backpressure.py:213` `tests/unit/test_ports.py:75` `tests/unit/test_client.py:1741` `tests/integration/test_redis_cross_connection.py:78` `tests/e2e/conftest.py:284-303` | fake/包装 transport 签名同步(e2e 包装**转发** `first_token_event`) |
| 修改 | `tests/unit/test_live_evidence.py:270-279`(`_complete` helper)+`:1258`(直调 `ObservedTransport(...).complete(...)`);`tests/unit/test_usage_source_domain.py:135`(直调真实 `OpenAICompatTransport.complete`) | **调用方**同步(独立审 B1): helper 加 `first_token_event=None` 转发、两直调传 `None`;不传则必填 kw 报 `TypeError`,unit 门必红 |
| 修改 | `CHANGELOG.md``README.md``.env.example` | 新键、三字段、对外承诺措辞(§4 T4) |
| 新建 | `research-wiki/findings/2026-09-10-24-hedged-requests-validation.md` | 红绿、命令、豁免索引,≤300 行 |
**不改**:`errors.py`(零新异常)/`deadline.py`/`telemetry/schema.py`(零新列)/`middleware/{ratelimit,breaker,structured,cache,telemetry}.py`/`backends/**`(含全部 Lua)/`tests/contracts/**`;`embedding.py`/`ocr.py` 除计时点外一字不动;`StallClock`/`backoff_delay`/`settle_and_release` 逐字不动。若必须突破本清单,先说明最小原因交父会话核定。
## 3. 跨任务接口(可执行定义,禁止占位)
### 3.1 T1:`Transport.complete` 加首 token 事件(H2)
`ports.py:51-60` 签名改为(顺序追加在 `reasoning_effort` 后,**必填、不设默认值**,与端口既有约定同款):
```python
async def complete(
self, *, messages: list[dict[str, Any]], source: SourceConfig, stream: bool,
overlay: dict[str, Any], call_id: str, reasoning_effort: Effort | None,
first_token_event: asyncio.Event | None,
) -> TransportResult: ...
```
docstring 补两句:`None` = 调用方不观测首 token(未启用对冲);非流式实现**永不置位**(物理上无中途信号,事件自然退化为纯时间阈值)。`OpenAICompatTransport.complete`(:426-436)加同款必填 kw 并透传两条路径;`_complete_stream` 在 :573-575 `if ttft_ms is None:` 块内加:
```python
if first_token_event is not None:
first_token_event.set()
```
`_complete_once`(:646)接收该参数但永不置位,docstring 明写"非流式无中途信号"。`retry.py:291-300` 调用处 T1 先传字面 `first_token_event=None`(必填参数不传即全库 TypeError;T3 换成真事件)。
假 transport 同步纪律(`test_retry.py:71-73` 既有注释的同款):签名加 `first_token_event`,**不给默认值**;除 `test_hedge.py` 外所有 fake 忽略该参数即可。e2e 包装(`tests/e2e/conftest.py:284-303`)必须**转发**给被包 transport。
### 3.2 T2:`CallStats` 三字段与 `_CallContext` 计数(H3+H8)
`types.py` `CallStats`(:296-318)在 `total_latency_ms` 后追加:
```python
hedges: int = 0
"""本次逻辑调用实际并发发出的对冲路数(触发但准入失败静默不计);1.3.6 及以前恒 0。"""
generation_ms: int = 0
"""裸生成时间: 赢家/成功那次 transport 调用的墙钟时长(口径见设计 §4.5 H8)。"""
hedge_won: bool = False
"""赢家是否为对冲路;无对冲恒 False。"""
```
`_CallContext`(:321-360):docstring "每调用一个实例的**单任务**对象" 修订为 "每逻辑调用一个实例,**可多任务并发登记**(对冲);全部方法无 await,事件循环内任务安全";`__slots__``__init__``_generation_ms: int`/`_hedges: int`/`_hedge_won: bool`;新增两个同步方法:
```python
def record_generation(self, elapsed_ms: int, *, accumulate: bool) -> None:
"""chat/OCR 覆盖(结构化重问最后一轮为准);embedding 分批累加。"""
self._generation_ms = self._generation_ms + elapsed_ms if accumulate else elapsed_ms
def register_hedge(self, *, hedge_won: bool) -> None:
"""对冲路实际发出即计数;赢家裁定后一次性登记。"""
self._hedges += 1
self._hedge_won = hedge_won
```
`snapshot`(:348-353)按字段名填 `hedges=self._hedges, generation_ms=self._generation_ms, hedge_won=self._hedge_won`
### 3.3 T2:三条链路 `_attempt` 的 transport 级计时点
统一形态:计时**只包 transport 调用本身**,用该链路既有注入钟 `self._now`(生产 = `time.monotonic`);起点紧贴调用前、终点在返回后首句,**中间无 await**(取消落进来时 transport 未返回,本就不计)。
| 链路 | 计时点 | 记录点 |
| --- | --- | --- |
| chat(`retry.py:291-300`) | `gen_started = self._now()` 紧贴 `await self._transport.complete(...)` 前;返回后首句 `generation_sink.append(int((self._now() - gen_started) * 1000))` | **不在 `_attempt` 内记录**——对冲赢家归属由编排裁定。`_attempt` 加私有 kw `generation_sink: list[int]`;`__call__`(:248-256)每轮建 sink,`outcome``LLMResponse``call_context` 非 None 时 `record_generation(sink[0], accumulate=False)`(与 `register_attempt` 同款 None 守卫) |
| embedding(`embedding.py:373`) | 同款两侧包 `await self._transport.embed(...)` | 成功分支(`:386 settlement_known = True` 之后)`context.record_generation(gen_ms, accumulate=True)`——分批累加 |
| ocr(`ocr.py:397`) | 同款两侧包 `await self._invoke(...)` | 成功分支 `context.record_generation(gen_ms, accumulate=False)` |
缓存命中/空输入不产生 transport 调用 → `generation_ms` 恒 0(0 是实测,不违 `types.py` "None 表未知" 惯例——本字段语义是时长不是用量)。
### 3.4 T3:对冲编排(retry.py,设计 §4.6 的唯一实现形态)
`RetryMW.__init__` 加 keyword-only `hedge_after_s: float | None = None`(存 `self._hedge_after_s`;`hedge_max_extra` **不下传**——v1 编排固定单路,H5;配置面值域与 v1 生效口径由 §3.6 守卫负责)。模块级:
```python
_HEDGE_LOSER_ATTR = "_polygateway_hedge_loser"
"""编排在 cancel() 之前给输家任务置位的标记;_attempt 读它选遥测标签。"""
```
`__call__`(:244-256)循环体内:`self._hedge_after_s is None`**逐字旧路径**(`_attempt``first_token_event=None`,单任务,默认关闭回归门据此成立);否则 `outcome = await self._attempt_hedged(request, picked, reasons, attempt_fails)`,其后的 `_is_rate_limited`/refund/`fails` 计数机制一字不动。
`_attempt_hedged` 编排(Phase 注释组织;`_attempt` 相应加 `first_token_event` kw 取代 T1 的字面 None):
```python
# 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)
)
# Phase 2 触发窗: 只认"阈值到 + 首 token 未至 + 原路在途"(loop 相对时长,不读注入 now)
if not await self._past_hedge_window(primary, first_token):
return await primary # 原路已了结/首 token 已至: 等价于未配置对冲
# Phase 3 异源准入(完整 pick 路径, 无旁路): 拿不到候选 = 静默等原路
hedge_picked, _ = await self._admission.pick(
reasons, attempt_fails, exclude=frozenset({source.name})
)
if hedge_picked is None:
return await primary
```
Phase 4-5(赢家裁定与收口)规则:
```python
sink_h: list[int] = []
hedge = asyncio.create_task(
self._attempt(request, *hedge_picked, reasons, attempt_fails,
first_token_event=None, generation_sink=sink_h) # v1 单路: 对冲路不再触发梯次
)
done, pending = await asyncio.wait({primary, hedge}, return_when=asyncio.FIRST_COMPLETED)
```
- **裁定**:done 中有成功(LLMResponse)即赢家;两路同时成功(竞速)→ **原路优先**(`hedge_won=False`,保守不弃原路成果);done 全是失败且 pending 非空 → 等 pending 了结后再裁定。
- **收口**:赢家产生后,对 pending 中的输家先 `setattr(task, _HEDGE_LOSER_ATTR, True)``task.cancel()`,然后 `await asyncio.gather(*pending, return_exceptions=True)`——输家 finally 的结算/遥测跑完才返回(快照含输家,`attempts==2`;`client.py:442` 快照在返回后,天然在收口之后)。两路同时完成的竞速落选者**不置标记**(它没被取消,attempt 行是正常成功/失败行)。
- **登记**:对冲路实际发出(Phase 3 之后)即计一次;赢家裁定后 `context.record_generation(赢家 sink[0], accumulate=False)` + `context.register_hedge(hedge_won=winner is hedge)`;**两败轮次(无赢家)同样照登** `register_hedge(hedge_won=False)`——设计 §4.5 已批准口径是"实际并发发出即计,触发但准入失败静默不计";`call_context is None` 时跳过(同 `register_attempt` 守卫)。
- **两败汇合**(H6,只计一次预算;deterministic):
```python
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
```
- **取消穿透**:Phase 2-5 全程包 `except BaseException`(含 `CancelledError` 与准入冒泡的 `GovernanceBackendError`)→ 两任务(存在者)`cancel()` + `gather(return_exceptions=True)` 尽力收口后 `raise` 原异常;收口 await 允许被再取消,不 shield。
`_past_hedge_window` 实现红线(waiter 任务必须收口,且**不得吞外部取消**):
```python
async def _past_hedge_window(self, primary: asyncio.Task, first_token: asyncio.Event) -> bool:
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()
```
输家标签:`_attempt` 的 CancelledError 分支(:327-334)把 `error="cancelled"` 换成按标记选择——`label = "hedge_cancelled" if getattr(asyncio.current_task(), _HEDGE_LOSER_ATTR, False) else "cancelled"`。竞速误贴(外部取消与对冲取消同时到达)记账方向一致(est 保留),属设计 §4.5 已批准的可接受残留。
### 3.5 T3:`admission.pick` 私有排除参数(设计 §4.2)
`pick`(:171-173)签名加 keyword-only `exclude: frozenset[str] | None = None`;候选循环**首部**加:
```python
if exclude and cand.name in exclude:
continue # 被排除不是源的拒绝: 不计 gate_rejections、不写 reasons
```
理由:写进 `reasons`/`gate_rejections` 会污染 `on_no_runnable`(:224)的分派判据与 `per_source_reasons` 对账。对冲调用方拿到 `None` 的处置是静默等原路(§3.4 Phase 3),**严禁**对它调 `on_no_runnable`(那会按 quota/circuit 策略抛错或睡觉,语义全错)。三条既有调用方(`retry.py:244``embedding.py:332``ocr.py:348` 所在循环)不传该参数,行为逐字不变。
### 3.6 T3:配置两键 + 两守卫 + client 透传(H4)
`config.py` 改动(单一定义点纪律,值域/交叉守卫只写一份):
| 项 | 精确定义 |
| --- | --- |
| 保留段 | :57 `_RESERVED_SEGMENTS``"HEDGE"`(防 provider 段撞名);两键均 3 段,`:405``len(parts) != 4` 判据天然跳过 `_load_sources` |
| loader | 新增 `_load_hedge(scope, env)`(:695 `_load_call_deadline` 之后):`AFTER_S``_first` + `_cast(..., "float", ...)` + `ensure_call_deadline`(origin 传实际命中键名,同 `_load_call_deadline` 纪律);`MAX_EXTRA``_first` + `_cast(..., "int", ...)`,未设 = 1;返回 `{"hedge_after_s": ..., "hedge_max_extra": ...}` |
| 字段 | `GatewaySettings` :190 后追加 `hedge_after_s: float \| None = None``hedge_max_extra: int = 1` |
| 守卫 | 新增模块级 `check_hedge_assembly(*, hedge_after_s, hedge_max_extra, sources, call_deadline_s, origin)`,返回归一化后的 `hedge_after_s`;`GatewaySettings.__post_init__`(:201 后)加 `_validate_hedge()` 调它并 `object.__setattr__` 写回归一化值(同 `_validate_call_deadline` 形态);`GatewayClient.__init__` 调同一份(client.py:22 已 import config,合法) |
| from_env | :396 同列加 `**_load_hedge(scope_u, env)` |
`check_hedge_assembly` 守卫全表(设计 §5;`hedge_after_s is None` 时值域归一化后直接返回,交叉守卫不查):
| 守卫 | 判定 |
| --- | --- |
| 值域 | `ensure_call_deadline(hedge_after_s, origin)`(None 或有限正数,复用 `deadline.py` 同款校验) |
| `hedge_after_s ≥ min(源 timeout_s)` | `ValueError`(对冲永不可能触发,配置即错误) |
| `hedge_after_s ≥ call_deadline_s`(两者皆设) | `ValueError`(期限先于对冲触发) |
| `hedge_after_s ≥ min(已设 ttft_timeout_s)` | 装配期 **warning**(流式档被 TTFT 看门狗先行切断;非流式仍有效,不升 ValueError) |
| 单源 scope 设了阈值 | 装配期 **warning**,允许(运行期拿不到候选自然静默) |
| `hedge_max_extra` | 非 int/bool 或不在 [1,3] → `ValueError`;**>1 → warning**"v1 仅单路对冲生效,梯次追加为 H5 预留"(值域按设计 §5 表放到 3,运行期 H5 只允许 1,warning 保 fail-loud 不静默) |
`client.py`:`__init__` :239 后加 keyword-only `hedge_after_s: float | None = None, hedge_max_extra: int = 1`,在 :245-247 期限校验同列调 `check_hedge_assembly(hedge_after_s=..., hedge_max_extra=..., sources=sources, call_deadline_s=self._call_deadline_s, origin="GatewayClient(...)")`;RetryMW 构造(:254-273)传 `hedge_after_s=` 归一化值(**max_extra 不下传**,§3.4);`from_settings` :511 同列传 `settings.hedge_after_s`/`settings.hedge_max_extra``EmbeddingClient`/`OcrClient` 不加对冲参数;它们的 settings 嵌 `GatewaySettings` 故守卫照常跑(文档明写对冲键只对 chat 生效)。
## 4. 任务与提交点(4 个原子提交)
### T0:设计增补并入与本计划(本任务,无代码)
产出:设计文档 §4.5/§7/§8/§9 增补(已完成)+ 本计划。不提交代码。
### T1 → 提交 1 `feat: add a required first-token event to the transport port`
1. **先红**:批次 A(`tests/unit/test_openai_compat.py` 四用例),确认失败为 `TypeError`(签名无此 kw)而非断言值不符。
2. 按 §3.1 改 `ports.py``openai_compat.py``retry.py:291-300` 传 None;同步六处 fake/包装(§2 表)+ `test_live_evidence.py`/`test_usage_source_domain.py` 三处调用点(§2 表末行);`_complete` helper(:82-90)加 `first_token_event=None` 默认转发(测试设施,与生产端口的"必填无默认"约定不冲突——生产端口不变)。
3. **后绿**:批次 A 通过;`pytest tests/unit -q` 全绿且不改一行既有断言。
4. 暂存:`src/polygateway/ports.py``src/polygateway/transports/openai_compat.py``src/polygateway/middleware/retry.py`、六个测试文件。
### T2 → 提交 2 `feat: expose bare generation time and hedge flags in CallStats`
1. **先红**:批次 B(`test_types.py` 三用例,字段不存在 → `TypeError`/`AttributeError`)+ C-F(各链路计时断言,字段恒 0 → 断言失败)。
2. 按 §3.2 改 `types.py`;按 §3.3 改三条 `_attempt``retry.py::__call__` sink 接线;**对冲计数本提交保持 0/False**(T3 才登记)。
3. **后绿**:批次 BF 通过;`pytest tests/unit tests/contracts -q` 全绿不改既有断言。
4. 暂存:`src/polygateway/types.py``middleware/retry.py``embedding.py``ocr.py`、五个测试文件。
### T3 → 提交 3 `feat: add opt-in cross-source hedged requests for chat`
1. **先红**:批次 G(`test_hedge.py`,对冲未实现 → 挂起用例超时或 `hedges==0` 断言失败)+ H(`test_config.py`,键未识 → `ValueError`/`None` 断言失败)。
2. 按 §3.5 改 `admission.py` → §3.6 改 `config.py`/`client.py` → §3.4 改 `retry.py` 编排。
3. **后绿**:批次 G/H 通过;**默认关闭回归门**:`pytest tests/unit tests/contracts -q` 全绿且不改一行既有断言(批次 I);`make lint` 通过。
4. 暂存:`src/polygateway/middleware/{retry,admission}.py``src/polygateway/{config,client}.py``tests/unit/test_hedge.py``tests/unit/test_config.py`
### T4 → 提交 4 `docs: document hedged requests and bare generation time`
1. `CHANGELOG.md` 未发布段:对冲三句强制措辞——**默认关闭,开启即用配额换延迟**(挂起窗口内 in-flight 翻倍);**输家可能已被上游计费**(est 保留只是闸内保守记账,非上游计量);**对冲只对 chat 生效,embedding/OCR 仅获得 `generation_ms` 计时**。另记 `CallStats` 三字段口径(`generation_ms``total_latency_ms` 差值 = 波动开销)与 `hedge_cancelled` 标签的遥测 join 用法。
2. `README.md`:能力表加"长尾对冲(可选)"一行(三句措辞同上);配置键清单加两键与值域/守卫;`CallStats` 说明处加三字段。
3. `.env.example`:`LLM__CALL_DEADLINE_S` 注释行后加 `# LLM__HEDGE__AFTER_S=``# LLM__HEDGE__MAX_EXTRA=1`(缺省关闭,说明触发语义与成本含义)。
4. `research-wiki/findings/2026-09-10-24-hedged-requests-validation.md`:红绿证据、命令与退出码、豁免索引(含 H5 的 v1 单路口径与竞速误贴残留)。
5. wiki 注册本计划与 findings(add_entity/add_edge/rebuild_index);独立验证(全新上下文 verifier)与整分支审查在本提交前完成;版本 bump/发布**不在本计划内**。
## 5. 测试矩阵 → 任务映射
**设施复用核对(动手前必读)**:`tests/unit/test_retry.py:120-127``FakeSleep` 只记录不推进时钟——退避推进须用例自带 `async def sleep(s): clock.advance(s)` 闭包;`tests/unit/test_embedding.py:217` 已有 `_ClockAdvancingEmbedTransport`(尝试内推进时钟),批次 D 直接复用;`tests/unit/test_config.py:31-33` 已有 loguru WARNING 捕获 fixture,warning 断言用它(caplog 抓不到 loguru);`tests/unit/test_client.py:134-139` 已有 `InMemoryCache` 命中回路,批次 F 复用;对冲编排用例(`test_hedge.py`)用**真实 loop 钟**(不注入 FakeClock),阈值 0.05s、断言容差 4–10×;取消窗口用 `entered` Event 范式(`test_retry.py:79-89` 既有),禁 sleep 撞窗口。
| 批次 | 断言(→ 任务) | 落点(精确测试名) |
| --- | --- | --- |
| A 端口加参 | 流式首 token 置位事件;非流式永不置位;`None` 不观测行为不变;漏传 → `TypeError`(钉住必填)(→T1) | `test_openai_compat.py::test_stream_sets_first_token_event``test_non_stream_never_sets_first_token_event``test_none_first_token_event_keeps_behavior``test_first_token_event_is_required_keyword` |
| B 三字段 | 三字段默认值(0/0/False);仅旧三参数构造 `CallStats(...)` 不炸;`record_generation` 覆盖/累加语义;`register_hedge` 计数;`snapshot` 带出三字段(→T2) | `test_types.py::test_callstats_hedge_fields_default``test_callcontext_record_generation_overwrite_and_accumulate``test_callcontext_register_hedge_counts``test_snapshot_includes_hedge_fields` |
| C chat 计时 | 脚本 [Transient, ok]:退避推进时钟 5s、成功次 transport 推进 0.2s → `generation_ms == 200``total_latency_ms ≥ 5200`(证明排除 backoff);无对冲时 `hedges == 0`/`hedge_won is False`(→T2) | `test_retry.py::test_generation_ms_excludes_backoff_and_admission``test_generation_ms_zero_hedge_flags_without_hedging`(配 `_GenClockTransport` 薄包装:委托 FakeTransport 并在返回前 `clock.advance(delta)`) |
| C2 重问覆盖 | 结构化首轮坏 JSON(transport 推进 1s)、重问轮好 JSON(推进 0.2s)→ `generation_ms == 200`(最后一轮覆盖,非累加)(→T2) | `test_client.py::test_generation_ms_structured_last_round_wins`(复用 `_client(structured_strategy=...)` 与 :129-132 范式;若 `_client` 未暴露 `now` 注入,按其既有模式补 keyword 参数——测试设施非公共面) |
| D embedding 计时 | 两批各推进 0.3s → `generation_ms == 600`(批次和)(→T2) | `test_embedding.py::test_generation_ms_sums_batch_transports`(复用 `_ClockAdvancingEmbedTransport`) |
| E ocr 计时 | 单次 transport 推进 0.4s → `generation_ms == 400`(→T2) | `test_ocr_client.py::test_generation_ms_single_transport_call`(同款薄包装,推进 `test_ocr_client.py:344` 那份本地 FakeClock——历史坑:不是 contracts 那份) |
| F 缓存命中 | 第二次同 key 调用 `generation_ms == 0``attempts == 0``cache_hit is True`(→T2) | `test_client.py::test_cache_hit_generation_ms_zero`(复用 :134-139 回路) |
| G 对冲编排 | 见下表(→T3) | `tests/unit/test_hedge.py`(新建) |
| H 配置守卫 | 两键 env 解析(3 段键不被当源字段、`HEDGE` 在保留段);非法值四路(env/直接构造/`dataclasses.replace`/client 直传)→ `ValueError`;`after_s ≥ min(timeout_s)` → ValueError;`after_s ≥ min(ttft)` → warning;单源 → warning;`after_s ≥ call_deadline_s` → ValueError;`max_extra` 0/"x" → ValueError、2/3 → warning 且生效 1(→T3) | `test_config.py::test_hedge_keys_from_env_skip_source_loader``test_hedge_after_s_domain_four_paths``test_hedge_guard_below_min_timeout_raises``test_hedge_guard_ttft_warns``test_hedge_guard_single_source_warns``test_hedge_guard_deadline_conflict_raises``test_hedge_max_extra_v1_cap`;client 直传入口校验 `test_client.py::test_client_hedge_params_entry_validation` |
| I 默认关闭回归 | 不配对冲键时 `tests/unit` + `tests/contracts` 全绿,**不改一行既有断言**(→T3 门) | 全套件 |
批次 G(`test_hedge.py`)用例全表——设施:两源 scope(`s1`/`s2`),event-driven 假 transport(每源一对 `entered`/`release` Event + 可脚本化"先置 first_token 再挂起"),真实 loop 钟,`hedge_after_s=0.05`:
| 测试名 | 断言(设计 §8 矩阵编号) |
| --- | --- |
| `test_non_stream_triggers_hedge_and_fast_leg_wins` | s1 挂起、s2 即时成功:总时长 < 10× 阈值(①);`hedges==1``hedge_won is True`;`generation_ms < total_latency_ms` 且 ≥ s2 实际 transport 耗时下界(⑩:不含触发前等待) |
| `test_stream_triggers_only_when_first_token_absent` | 两例:首 token 未至 → 触发;假 transport 先 `first_token_event.set()` 再挂起 → **不触发**,`attempts==1`(①) |
| `test_hedge_goes_to_other_source` | 对冲请求落在 s2(transport.calls 断言);`logical_call_id` 两行一致(②⑤) |
| `test_hedge_silent_when_no_candidate` | s2 permit 预占满 → 不对冲:`hedges==0``attempts==1`、原请求放行后正常成功(③) |
| `test_hedge_silent_when_single_source` | 单源 scope:运行期自然静默,行为与不配阈值逐字相同(②) |
| `test_winner_settles_actual_loser_keeps_est` | memory limiter:赢家源 `tpm_used == 真实 usage`,输家源 `tpm_used == est`(S3 格);调用结束后两源 `inflight == 0`(④) |
| `test_loser_row_labelled_hedge_cancelled` | 假 emitter:输家 attempt 行 `error=="hedge_cancelled"`,赢家行无 error;两行 `logical_call_id` 相同;无 `terminal_failure` 行(④⑤) |
| `test_loser_does_not_feed_breaker` | memory gate:挂起源 `failure_count` 不变、健康喂数无 `ok=False`;赢家照常 `record_success`(④,§3 关键判断) |
| `test_attempts_two_and_no_task_leak` | `call_stats.attempts == 2`;返回后 `asyncio.all_tasks()` 无本调用残留任务(⑤) |
| `test_external_cancel_cancels_both_legs` | 两路均挂起,`entered` 双置位后 `task.cancel()`:`CancelledError` 上抛;两行 attempt 均 `"cancelled"`(标记只在赢家产生后置,外部取消无 `hedge_cancelled`);两 permit 释放(⑦) |
| `test_deadline_cuts_hedged_tree` | client 级 `call_deadline_s=0.2` + 两路挂起 → `CallDeadlineExceeded`;两 permit 释放(⑧) |
| `test_primary_late_success_wins_back` | s1 挂 0.3s 后成功、s2 对冲路挂起:对冲已触发但原路先完成 → `hedge_won is False``generation_ms` 为原路时长、s2 行 `hedge_cancelled`(⑩ 取快者) |
| `test_both_fail_counts_budget_once` | 两路 Transient:`max_attempts=2` 时恰进第二轮(两败只计一次);最终 `retry_exhausted` 在第二轮两败后(H6) |
| `test_both_429_refund_no_budget` | 两路 429:不耗预算(`max_attempts=1` 不抛 `retry_exhausted`),stall 账退还——小 `stall_window_s` 下终局 `reason=="stalled"` 而非 `"retry_exhausted"` |
| `test_mixed_429_and_failure_counts_budget` | 一路 429 一路 Transient → 计一次预算、不退还 stall 账(§3.4 `_combine_failures`) |
命令(全部 `conda run -n PolyGateway`,禁接管道):`pytest tests/unit/test_openai_compat.py -q``pytest tests/unit -q``pytest tests/unit/test_hedge.py -q``pytest tests/unit tests/contracts -q``make lint`。真实 Redis/网关 slow 用例本计划不新增、不跑,由发布清单第 4 步按 diff 交集选子集(本 diff 触及 retry/限流结算路径,Redis 时间语义变体届时在交集内)。
## 6. 阻塞矩阵与交接
| 触发条件 | 处置 |
| --- | --- |
| 需要新增本计划外的公共键/端口方法/遥测列/异常类 | **停下上报**(设计 §9 边界之外即未批准) |
| `asyncio.current_task().cancelling()` 在目标 Python 版本语义不符 | 3.12 语义同 136 探针已验证的取消计数;若实测不符,改用"取消标志位置于 `_attempt_hedged` 局部"方案并记入 findings,不得吞取消 |
| 两路同时成功的竞速在测试中无法确定性构造 | 用双 Event 栅栏(两 transport 都等同一放行事件)构造;仍不可得则记入 findings 豁免索引,不得删"原路优先"断言 |
| 批次 G 计时断言在 CI 机器抖动 | 只断言下界与相对比较(`generation_ms < total_latency_ms`、总时长 < 10× 阈值),不断言精确值;精确值断言只在注入钟批次(C–F) |
| 想顺手让对冲路再触发梯次对冲 | **不做**(H5:v1 单路;`hedge` 任务恒传 `first_token_event=None`) |
| 想把输家记进熔断/健康分 | **不记**(§3 关键判断:挂起 ≠ 源死亡);运维面靠 `hedge_cancelled` 遥测行统计 |
| `hedge_max_extra > 1` 应 warning 还是 ValueError 存疑 | 本计划取 warning(§5 值域 [1,3] 与 H5 "v1 只允许 1" 的并存解);若人类审定应 ValueError,改 `check_hedge_assembly` 一处 + 批次 H 一条断言 |
| 发现 embedding/OCR 也想加对冲参数 | **不加**(非目标 A,H7);登记为后续 issue |
交接物:4 个提交、1 份 findings、CHANGELOG 未发布段。版本号 bump、tag、构建、上传 registry 与 wiki 同步**不在本计划内**,按 CLAUDE.md §4.4.1 另行执行。
## 7. 自审
| 检查 | 结论 |
| --- | --- |
| 路径/行号/签名是否可执行无 TBD | 是——接入点均现读:`ports.py:51-60``openai_compat.py:426/552/573-575/646``retry.py:244/248-256/270/291-300/327-334``admission.py:171-213/224``types.py:296-318/321-360``config.py:57/190/201/396/405/695``client.py:239/245-247/254-273/511``embedding.py:373/386``ocr.py:397`、六个 fake transport 精确行号 |
| 是否复用而非重造 | 是——取消结算 S3 格、准入全链路、`settle_and_release``asyncio.wait` 范式、`ensure_call_deadline` 值域校验、`_ClockAdvancingEmbedTransport`/loguru 捕获 fixture/InMemoryCache 回路全部复用;新增仅 1 测试文件 + 2 配置键 + 3 字段 |
| 先失败后通过证据点 | 是——T1 批次 A(TypeError)、T2 批次 BF(字段缺失/恒 0)、T3 批次 G/H(未实现/键未识)均先红 |
| 取消与降级铁律 | 取消穿透路径显式收口不吞没;准入失败静默(设计 §4.3 批准);后端不可用照常冒泡;无 shield/后台任务 |
| 反 gold-plating | 四分类/熔断语义/429 分账/Lua/deadline.py/遥测列/embedding-OCR 对冲/分位数/同源/per-call 参数一律不碰;`hedge_max_extra` 不下传 RetryMW(v1 无消费者) |
| 跨任务签名一致 | `first_token_event`(T1 端口 → T3 接线)、`generation_sink`/`record_generation`(T2 定义,T3 编排消费)、`check_hedge_assembly`(config 定义,client 消费)三处接缝均在 §3 写出实际代码 |
| 残余诚实标注 | 输家 est 保留 ≠ 上游真实计费计量;非流式误对冲慢生物理不可分;竞速误贴标签可接受(设计 §4.5);`hedge_max_extra` v1 生效口径取 warning(§6 阻塞矩阵已列复核点) |