34 KiB
type, node_id, title, date
| type | node_id | title | date |
|---|---|---|---|
| plan | plan:2026-09-10-24-hedged-requests | issue #24 长尾对冲请求与裸生成时间实施计划 | 2026-09-10 |
issue #24 长尾对冲请求与裸生成时间实施计划
设计:
research-wiki/designs/2026-09-10-24-hedged-requests-design.md,人类于 2026-09-10 正式批准(§9 H1–H7 及增补 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 钟(4–10× 余量)、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 钟,断言容差 4–10×(tests/unit/test_streaming.py 既有范式,不标 slow) |
Skill 纪律:T1–T3 行为变更执行 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 |
批次 B–F、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 后,必填、不设默认值,与端口既有约定同款):
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: 块内加:
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 后追加:
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;新增两个同步方法:
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 守卫负责)。模块级:
_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):
# 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(赢家裁定与收口)规则:
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):
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 任务必须收口,且不得吞外部取消):
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;候选循环首部加:
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
- 先红:批次 A(
tests/unit/test_openai_compat.py四用例),确认失败为TypeError(签名无此 kw)而非断言值不符。 - 按 §3.1 改
ports.py、openai_compat.py、retry.py:291-300传 None;同步六处 fake/包装(§2 表)+test_live_evidence.py/test_usage_source_domain.py三处调用点(§2 表末行);_completehelper(:82-90)加first_token_event=None默认转发(测试设施,与生产端口的"必填无默认"约定不冲突——生产端口不变)。 - 后绿:批次 A 通过;
pytest tests/unit -q全绿且不改一行既有断言。 - 暂存:
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
- 先红:批次 B(
test_types.py三用例,字段不存在 →TypeError/AttributeError)+ C-F(各链路计时断言,字段恒 0 → 断言失败)。 - 按 §3.2 改
types.py;按 §3.3 改三条_attempt与retry.py::__call__sink 接线;对冲计数本提交保持 0/False(T3 才登记)。 - 后绿:批次 B–F 通过;
pytest tests/unit tests/contracts -q全绿不改既有断言。 - 暂存:
src/polygateway/types.py、middleware/retry.py、embedding.py、ocr.py、五个测试文件。
T3 → 提交 3 feat: add opt-in cross-source hedged requests for chat
- 先红:批次 G(
test_hedge.py,对冲未实现 → 挂起用例超时或hedges==0断言失败)+ H(test_config.py,键未识 →ValueError/None断言失败)。 - 按 §3.5 改
admission.py→ §3.6 改config.py/client.py→ §3.4 改retry.py编排。 - 后绿:批次 G/H 通过;默认关闭回归门:
pytest tests/unit tests/contracts -q全绿且不改一行既有断言(批次 I);make lint通过。 - 暂存:
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
CHANGELOG.md未发布段:对冲三句强制措辞——默认关闭,开启即用配额换延迟(挂起窗口内 in-flight 翻倍);输家可能已被上游计费(est 保留只是闸内保守记账,非上游计量);对冲只对 chat 生效,embedding/OCR 仅获得generation_ms计时。另记CallStats三字段口径(generation_ms与total_latency_ms差值 = 波动开销)与hedge_cancelled标签的遥测 join 用法。README.md:能力表加"长尾对冲(可选)"一行(三句措辞同上);配置键清单加两键与值域/守卫;CallStats说明处加三字段。.env.example:LLM__CALL_DEADLINE_S注释行后加# LLM__HEDGE__AFTER_S=与# LLM__HEDGE__MAX_EXTRA=1(缺省关闭,说明触发语义与成本含义)。research-wiki/findings/2026-09-10-24-hedged-requests-validation.md:红绿证据、命令与退出码、豁免索引(含 H5 的 v1 单路口径与竞速误贴残留)。- 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 批次 B–F(字段缺失/恒 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 阻塞矩阵已列复核点) |