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

190 lines
24 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.
# 长尾对冲请求(issue #24)设计
- 状态: **已批准**(2026-09-10 人类批准 §9 全部批准项 H1–H7,含增补 H8:`CallStats``generation_ms`/`hedge_won` 裸生成时间字段);本文件只做设计与权衡,不含实现
- 基线: main `166b286` / 1.3.6(已发布,含 `call_deadline_s` 与取消结算修复)
- 输入: issue #24 原文(非流式挂起后正常 200:20 次里 5 次超 60s、中位 15.1s、真实负载 13% 调用吃掉 71% 模型总时间、慢调用输出中位 186 token——在等不在生成、90–96s 窄峰疑似源侧固定机制)
- 关联: `designs/2026-09-09-136-call-budgets-design.md`(期限与取消结算,本设计直接站在其 S3 格上);ARCH §6.4 取消语义、§7.2 重试、§7.3 限流;`designs/2026-08-06-issue8-stall-budget-design.md`
## 1. 目标与非目标
| 项 | 内容 |
| --- | --- |
| 目标 1 | 非流式请求挂起超过阈值时,并发向**另一个等价源**再发一次,先回者赢,输家取消——把 p99 从"挂起时长"压到"阈值 + 健康源耗时" |
| 目标 2 | 流式请求以 **TTFT 未至**为触发判据(不误杀慢生成);非流式无 TTFT 可观测,用总时长阈值 |
| 目标 3 | 默认关闭;开启后的一切行为(配额、熔断、遥测、结算)可观测、可对账 |
| 非目标 A | embedding / OCR 不做对冲(无 TTFT 概念、issue 未涉、无配置面) |
| 非目标 B | 不做滚动分位数触发(`AFTER_PERCENTILE`);不做同源对冲;不加遥测新列(默认档) |
| 非目标 C | 不改 `call_deadline_s`/`deadline.py`/限流 Lua/429 分账;不改既有四分类 |
## 2. 现状核实(现读 1.3.6 源码,不引用旧报告)
| 事实 | 证据 | 对本设计的意义 |
| --- | --- | --- |
| 非流式路径是单 JSON 响应,**仅 total 超时**,无中途进度信号 | `transports/openai_compat.py:646-654`(`_complete_once` docstring 原文)、`:684 ttft_ms=None` | 非流式的对冲触发**物理上只有总时长阈值**一种;"挂起 vs 慢生成"在非流式不可分,只能靠阈值取值与成本上限控制误对冲 |
| 流式首 token 观测点已存在 | `openai_compat.py:569-575`(`ttft_ms` 首次赋值处) | TTFT 事件信号只需在该点 `event.set()`,探测成本近零 |
| `Transport.complete` 是公共端口签名,库内唯一实现 | `ports.py:51-60` | 加首 token 事件参数 = **公共端口签名变更**,必须进批准项(H2) |
| 每次尝试 = 选源 → 熔断门 → 限流 permit → transport,全在 `_attempt` 内 | `middleware/retry.py:276-353`;准入编排 `middleware/admission.py:171-213`(`pick`) | 对冲 = **并发跑第二次 `_attempt`**,配额/熔断/结算/pacer 全部复用,无需发明第二套准入 |
| 取消路径结算: `settlement_known=False` 时保留预扣 est | `retry.py:327-331`(1.3.6 S3 格);finally 结算 `:352-353``admission.py:46-60` | 对冲输家走取消路径,**结算语义现成**:额外成本上限 = 一份 est 预扣滞留 |
| 取消的 attempt 行记 `error="cancelled"` 后穿透 | `retry.py:332-334` | 输家遥测只需换一个区分字符串,零新列(§4.5) |
| `_CallContext` 承诺"每调用一个实例的**单任务**对象,计数无需锁" | `types.py:321-337`;`register_attempt` `:339-345``claim_terminal` `:354-360` 均为无 await 同步方法 | 对冲引入第二个并发任务,该 docstring 承诺须修订;同步方法在事件循环内天然任务安全(无 await 间隙),机制零改动 |
| 成功响应在**返回前**冻结 `call_stats` 快照 | `client.py:442`(`dataclasses.replace(response, call_stats=context.snapshot())`) | 输家取消收口必须**先于**快照,否则 `attempts` 漏计输家(§4.5) |
| 期限包整棵树,缺省 None 不进上下文 | `deadline.py:64-86`;接入点 `client.py:419-421`;配置 `config.py:190``:695-715` | deadline 与 hedge 正交组合,`deadline.py` 零改动(§6) |
| 429 免重试预算且耗时退 stall 账 | `retry.py:158-164``:250-257` | 对冲轮内某任务 429 的免预算语义沿用 attempt 级既有机制(§4.6) |
| 配置键两段/三段式天然跳过 `_load_sources`;保留段防撞名 | `config.py:401-407`(len==4 判定)、`:57`(`_RESERVED_SEGMENTS`) | 新键 `{SCOPE}__HEDGE__AFTER_S` 为 3 段,天然不被当源字段;`HEDGE` 须加进保留段(§5) |
## 3. 备选方案与权衡
| 维度 | **方案 A:并发对冲(推荐)** | 方案 B:取消式投机重试 | 方案 C:仅流式对冲,非流式只靠 deadline |
| --- | --- | --- | --- |
| 做法 | 阈值到 → 并发向异源发第二次 `_attempt`,`asyncio.wait(FIRST_COMPLETED)`,赢家返回、输家 `cancel()` 并 await 收口 | 阈值到 → 取消在途 attempt,按可重试失败走既有换源重试循环 | 只对 `stream=True` 做 TTFT 对冲;非流式维持 1.3.6 现状(期限切长尾) |
| 挂起请求的信号处理 | 输家只是"被取消",**不喂熔断/健康分**——挂起的请求最终正常 200,记 failure 是错误信号(源没坏,是这一跳排队) | 必须新造一类"挂起失败":复用 Transient 会把未死源喂进熔断失败计数(`retry.py:340/346-348`),污染熔断与健康分;新造免预算类别 = 又一类四分类外特例 | 同 A(但只覆盖流式) |
| 重试预算 | 对冲不消耗 `max_attempts`——它是"一次尝试的加速形态" | 消耗预算(3 次挂起即 `AllSourcesExhausted`),除非新造免预算类 | 同 A |
| 尾部赢面 | 原请求"后发先至"时仍可用其成果;尾部的尾部 = min(两路) | 原请求成果恒被丢弃;延迟恒 = 阈值 + 重试耗时 | 流式同 A;非流式尾部 = 期限(更晚失败,不是更快成功) |
| 成本 | 对冲窗口内两路并发,输家可能被上游计费 + 一份 est 预扣滞留 | 取消更早(阈值即取消),已计费浪费**更少** | 最低(覆盖面也最小) |
| 实现量 | 大:对冲编排 + 端口加参 + 并发收口 + 遥测区分 | 约为 A 的 1/3:阈值计时器 + 取消 + 失败归类 | 中:同 A 但免非流式分支 |
| 解决 issue 现场 | 是(issue 复现即非流式) | 是 | **否**——issue 的现场就是非流式,等于没解决 |
**关键判断**: B 的性价比看似更高(issue 数据显示挂起峰在 90s+,原请求几乎不可能后发先至),但它要回答一个 A 不用回答的问题——"挂起中的源该不该记失败"。记,则熔断/健康分被一次排队事件污染(双峰窄峰指向源侧固定机制,不是源死亡);不记,则要在四分类外新造语义。A 让输家落进 1.3.6 已有的取消路径,**零新分类语义**,且对冲拿不到配额时自然静默(饱和期不添乱)。C 不解决原问题,仅列为范围收缩的退路。
**推荐 A**;B 作为"预算敏感且接受熔断语义代价"的降级备选保留在批准项(H1)中由人类定夺。
## 4. 方案 A 的具体形态
### 4.1 触发条件(设计问题 1)
| 调用形态 | 触发判据 | 机制 |
| --- | --- | --- |
| 流式 | 已过 `hedge_after_s` **且首 token 事件未置位** | `Transport.complete` 加 keyword-only 参数 `first_token_event: asyncio.Event \| None`(必填,不设默认值,与端口既有约定同款);`OpenAICompatTransport``openai_compat.py:573-575` 首 token 处 `set()`。阈值计时器 = 等待该事件,超时即触发 |
| 非流式 | 已过 `hedge_after_s`(纯总时长阈值) | `_complete_once` 物理上无中途信号(`openai_compat.py:646-654`),事件**永不置位**直到完成——同一套"等事件超时"机制自然退化为时间阈值,**零分支** |
| 分位数触发 | **v1 不做** | 滚动分位数需要 per-source 状态窗口,跨进程部署还得进 Redis;issue 的双峰形态(主峰 010s vs 挂起峰 90s+)用绝对阈值区分度已足够。保留为未来扩展 |
- "非流式不对冲只做 deadline"已被方案 C 覆盖并否决(不解决 issue 现场);但**配置层面允许只对流式生效**——`stream=False` 的调用方若不接受误对冲成本,可不配阈值。
- 误对冲的代价有界:最多 `hedge_max_extra` 次额外请求/逻辑调用,输家记账见 §4.4。
- 时钟纪律同 deadline(136 设计 §5.1):只用**相对时长 + 事件循环钟**,不读注入 `now`——测试伪造注入钟跳变不得触发对冲,验收矩阵钉住。
### 4.2 对冲目标 = 异源(设计问题 2)
| 决策 | 取法 | 理由 |
| --- | --- | --- |
| 同源 or 异源 | **异源,且仅异源** | issue 观测的 90–96s 固定窗口窄峰指向源侧机制;同源对冲 = 给同一队列再排一个号,徒增成本 |
| 等价源定义 | 同 scope 内 `SourceAdmission.pick` 正常排序选出的下一个候选——等价性由 **scope 语义**承诺(同 scope 源本就可互换,同 model 集合是常态),对冲层不发明新的等价概念 | 复用既有选源排序、冷却备忘、调用内降权(`admission.py:63-127`),不新建"等价类"配置维度 |
| 排除当前源 | `pick` 加**私有**排除参数(如 `exclude: frozenset[str]`);对冲任务以在途源名为排除集 | 改动收敛在 middleware 内部,不碰公共端口 |
| 无候选可用 | 单源 scope / 其余源全冷却、开路、配额满 → **放弃本次对冲**,继续等原请求 | 对冲是优化不是权利;单源 scope 配了阈值 = 装配期 warning、运行期自然静默(§5) |
| 同源对冲开关 | 不做(YAGNI) | 配置面少一个维度;真出现"源内分片排队"形态再立 issue |
### 4.3 准入不独立:对冲走完整准入(设计问题 3)
对冲请求**照常走** QuotaGate + BreakerGate + pacer + 冷却备忘(`admission.py:171-213` 的完整 `pick` 路径),**不给旁路**:
| 情形 | 行为 | 对齐 |
| --- | --- | --- |
| 配额满 / 被熔断 / pacer 超限 | 放弃本次对冲,原请求继续等(不抛错、不排队硬等) | 对冲若绕闸,源挂起风暴时并发翻倍打进正在排队的网关——正是限流铁律要防的击穿;拿不到配额时自然静默,饱和期不添乱 |
| 限流/熔断后端不可用 | `try_acquire`/`try_enter``GovernanceBackendError`,照常冒泡 | 铁律"后端不可用 → 报错而非放行",对冲分支不新增降级面 |
| permit 持有 | 赢家输家各持各的 permit,各自 `finally` 结算释放(`retry.py:352-353`) | 与两个独立并发调用完全同构,限流契约零改动 |
### 4.4 成本与取消记账(设计问题 4)
| 角色 | 结算 | 依据 |
| --- | --- | --- |
| 赢家(先成功) | 正常成功路径:`settle(实际 usage)`;`usage_source="unavailable"` 时按 est | `retry.py:300-308` 既有分支,零改动 |
| 输家(被取消) | 落 1.3.6 取消 S3 格:transport 在途、结算未定 → `settle(est)`(**保留预扣,不退款**) | `retry.py:327-331`;上游可能已对输家计费,est 保留是保守下限——与期限到期同口径,文档明写"对冲掉的那次可能已计费" |
| 输家(取消前已真失败) | 走既有失败分支结算(dead=0/瞬时=est) | `retry.py:346-347`,取消落点决定取值,S5 机制已覆盖 |
| 对冲轮内 429 | attempt 级免预算 + stall 退还照既有机制 | `retry.py:158-164` |
**计费对账口径**:一次逻辑调用对冲一次的最大额外成本 = 一份 est 预扣滞留(窗口过期自动释放)+ 输家已被上游计费的不可观测部分。下游要能算出"对冲浪费多少钱"——靠 §4.5 的遥测区分,而不是新记账通道。
### 4.5 并发安全与遥测区分(设计问题 5)
| 关注点 | 设计 |
| --- | --- |
| `_CallContext` 共享 | 两个对冲任务共享同一个 context(同一逻辑调用)。`register_attempt`/`claim_terminal`/`snapshot` 均为**无 await 同步方法**(`types.py:339-360`),事件循环内任务并发调用天然安全,机制零改动;但 `types.py:321-337` docstring 的"单任务对象"承诺须修订为"单逻辑调用、可多任务并发登记"。增补 H8 后 context 再持 `_hedges`/`_generation_ms`/`_hedge_won` 三个计数与 `register_hedge`/`record_generation` 两个同步方法,任务安全性与 `register_attempt` 同款 |
| 逻辑调用 ID | 不变:两任务共享 `logical_call_id`;各 attempt 独立 `call_id`(uuid4,`retry.py:279`) |
| 快照时点 | 赢家产生 → 输家 `cancel()`**await 收口完毕**(输家 finally 的结算/遥测跑完)→ 才允许 `client.py:442` 的快照返回。`attempts` 因此恒含输家(=2),`total_latency_ms` 含输家清理耗时——与 deadline"返回时刻 = 期限 + 清理耗时"同口径 |
| 任务泄漏 | 编排用 `asyncio.wait(FIRST_COMPLETED)` + 显式收口;取消优先铁律不变:外部取消到达时两任务都被取消并穿透,不 shield、不留后台任务(ARCH §6.4) |
| 遥测行区分(默认档,零新列零 DDL) | 输家 attempt 行 `error="hedge_cancelled"`(与既有 `"cancelled"` 同通道,`retry.py:327-334` 同款字符串);赢家 attempt 行照常;`CallStats` **只增**三字段(`types.py:296-318` 既有快照对象,经 `LLMResponse.call_stats` 既有通道带出,`types.py:425`;三字段全带默认值,1.3.6 及以前构造的 `CallStats(...)` 位置调用不炸):`hedges: int = 0`(本次调用**实际并发发出**的对冲路数;触发但准入失败静默不计)、`generation_ms: int = 0`(裸生成时间,口径见下行)、`hedge_won: bool = False`(赢家是否对冲路) |
| `generation_ms` 口径(增补 H8) | **赢家那次 transport 调用的墙钟时长**(HTTP 发出到响应收完):chat/对冲 = 赢家那次;无对冲 = 成功那次 attempt;结构化重问 = 最后一轮(覆盖语义,每轮成功覆写);embedding = 各批 transport 时长之和(累加语义);OCR = 单次;缓存命中 = 0(未产生 transport 调用,0 是实测而非"未知")。**排除** admission 排队/backoff/对冲触发前等待/清理遥测;计时点收敛在三条链路 `_attempt` 的 transport 调用两侧,用该链路既有注入钟(与 `total_latency_ms` 同钟,差值才有意义);对冲编排裁定赢家后才写入 context,输家(含两路同时完成的竞速落选者)的值一律丢弃 |
| 对前端有用的对冲参数(增补 H8 取舍) | **纳入** `hedges` + `hedge_won` + `generation_ms`(经 CallStats 既有通道带出,零遥测新列);**不纳入**每路 attempt 分别耗时/输家身份——那是运维诊断面,遥测 DB attempt 行已有 `hedge_cancelled` 标签与同 `logical_call_id` 可 join 还原,不重复进公开响应。`generation_ms``total_latency_ms` 的**差值即波动开销**(等待/退避/准入/对冲触发前耗损),前端可直接展示"在等不在生成" |
| `hedge_cancelled` 标签机制 | 编排在 `cancel()` **之前**给输家任务置位标记(如 `task._polygateway_hedge_loser = True`);`_attempt` 的 CancelledError 分支读标记选 `"hedge_cancelled"`/`"cancelled"`。外部取消与对冲取消竞速时可能误贴——两任务同消、记账方向一致(est 保留),标签误贴不造成结算或熔断错误,属可接受并明写 |
| 遥测行区分(备选调) | attempt 表加 `hedge_role` 列(`NULL/'primary'/'hedge'`,PG/SQLite 各一次 DDL)——遥测列变更代价有 issue #12/#13/#15 教训,v1 不推荐;列进批准项(H3)由人类定夺 |
| 熔断/健康信号 | 输家取消**不喂**失败、赢家照常记成功——挂起不是源死亡证据(§3 关键判断) |
### 4.6 编排形态与失败汇合
- 对冲轮 = 一次"超级尝试":首个成功即本轮结果;**两任务都失败**才进既有重试循环,且 `fails += 1` 只计一次(对冲是加速形态,不是两次独立尝试;429 的免预算/refund 仍在 attempt 级生效)。
- 原 attempt 先失败、对冲在途 → 直接等对冲结果,不重试;对冲先失败、原 attempt 在途 → 继续等原 attempt(等价于未触发对冲)。
- `retry` 循环骨架(`retry.py:228-263`)、退避、stall 判定全部不变;变化收敛在"单轮尝试的内部从单任务变任务组"。
## 5. 配置面(设计问题 6;默认必须关闭)
| 键 | 值域 | 缺省 | 说明 |
| --- | --- | --- | --- |
| `{SCOPE}__HEDGE__AFTER_S` | 有限正数秒,复用 `ensure_call_deadline` 同款值域校验(`deadline.py:20-42`) | **未设 = 关闭** | 3 段键天然跳过 `_load_sources`(`config.py:401-407`);`HEDGE` 加进 `_RESERVED_SEGMENTS`(`config.py:57`)防 provider 段撞名 |
| `{SCOPE}__HEDGE__MAX_EXTRA` | int ∈ [1,3] | 1 | 每次逻辑调用最多并发对冲几路;>1 仅对冲再挂起时梯次追加 |
装配路径与期限同款(136 设计 §4.3 形态):`GatewaySettings` 末尾追加两字段 + `__post_init__` 新守卫(`config.py:190-201` 同列);`GatewayClient.__init__` keyword-only 参数**入口即校**(`client.py:239-241` 同列);`from_settings` 透传,`from_env` 无签名变化。
| 守卫 | 判定 | 理由 |
| --- | --- | --- |
| `hedge_after_s ≥ min(源 timeout_s)` | `ValueError` | 对冲永不可能触发,配置即错误(与 `_validate_probe` 同款装配期炸掉哲学,`config.py:346-355`) |
| `hedge_after_s ≥ min(ttft_timeout_s)`(仅设有该键的源) | 装配期 **warning** | 流式档挂起已被 TTFT 看门狗先行切断(`openai_compat.py:561-566`),对冲形同虚设;非流式仍有效,故不升 ValueError(与 `stall_window_s ≥ max(ttft_timeout_s)` 同型交叉守卫先例,`config.py:336-344`) |
| 单源 scope 设了阈值 | 装配期 **warning**,允许 | 源集合可运行期之外的配置演进;运行期拿不到候选自然静默(§4.2) |
| `hedge_after_s ≥ call_deadline_s`(两者皆设) | `ValueError` | 期限先于对冲触发,对冲形同虚设(§6) |
| chat() per-call 覆盖参数 | **不提供** | 对冲阈值是源/渠道特性,不是任务特性(期限有 per-call 是因为任务耐心不同);需要不同阈值就装配两个 client |
## 6. 与 `call_deadline_s` 的关系(设计问题 7)
| 维度 | hedge | deadline |
| --- | --- | --- |
| 语义 | **提前换路**:提高 deadline 内拿到结果的概率 | **最终保险**:超过耐心即终止(治理等待) |
| 层级 | RetryMW 单轮尝试内部 | 公开边界包整棵树(`client.py:419-421`),对冲编排在树内,`deadline.py` 零改动 |
| 独立配置 | 可只配 hedge(无期限) | 可只配 deadline(1.3.6 现状) |
| 组合 | `hedge_after_s < call_deadline_s`(装配守卫强制);典型:`timeout_s=300, hedge_after_s=8, call_deadline_s=120` | 输家取消的清理耗时不受期限管辖,沿用"返回时刻 = 期限 + 清理耗时"措辞(136 设计 §5.3);**per-call 覆盖** `chat(call_deadline_s=X)` 使 X < hedge_after_s 时,该次调用对冲不触发(deadline 先切整棵树),属合法语义不告警——装配守卫只管默认值,per-call 是调用方的当次选择 |
两者回答不同问题:deadline 让长尾**更早失败**,hedge 让调用**更快成功**——文档不得混写(136 设计 §11 已立此措辞纪律)。
## 7. 变更点清单(反 gold-plating;实施前置零)
| 类别 | 内容 |
| --- | --- |
| 改动 | `middleware/retry.py`(单轮尝试 → 任务组编排 + 对冲计时 + 输家 `hedge_cancelled` 遥测 + `_attempt` transport 级计时点);`middleware/admission.py`(`pick` 加私有排除参数);`ports.py` + `transports/openai_compat.py`(`complete``first_token_event` 必填 kw,流式首 token 处置位);`config.py`(两键 + loader + 两守卫 + 保留段);`types.py`(`CallStats` 增三字段 `hedges`/`generation_ms`/`hedge_won` + `_CallContext` docstring 修订与计数方法);`client.py`(构造参数 + 透传);`embedding.py`/`ocr.py`(`_attempt` 加 transport 级计时点——只计时不对冲,非目标 A 不变) |
| 新增文件 | 无(编排收敛在 retry.py;若超 150 行可拆 `middleware/hedge.py`,实施期定) |
| 直接复用 | 取消结算 S3 格、`settle_and_release` 单一出口、准入全链路、`asyncio.timeout` 范式、假 transport/FakeClock 测试设施、限流契约套件(Lua 不改) |
| 明确不做 | 不改四分类/熔断语义/429 分账/限流 Lua/`deadline.py`;不加遥测列(默认档);不做 embedding/OCR/分位数/同源对冲/per-call 参数;不引入 shield/后台任务 |
## 8. 测试策略(设计问题 8:事件驱动,不 sleep 撞窗口)
| 原则 | 做法 |
| --- | --- |
| 事件驱动假 transport | 两个 `asyncio.Event`(`first_token`/`complete`)精确控制 TTFT 与完成时刻;触发判定 = "事件未置位且计时器到期",从不真睡出长尾 |
| 真实 loop 钟 + 余量 | 对冲阈值取 0.05s 级、断言容差 4–10×(`tests/unit/test_streaming.py` 既有范式,136 设计 §10 已验证稳定,不标 slow) |
| 先失败后通过 | 同一挂起场景:无对冲 → 总时长 = 挂起时长(红);开启 → 总时长 ≈ 阈值 + 快源耗时(绿) |
| 注入钟纪律 | 伪造注入 `now` 跳变 10^6 秒不得触发对冲(对冲计时只用 loop 相对时长) |
验收矩阵(离线、不触网):① 触发两形态(流式 TTFT 未至触发/已至不触发;非流式纯时间触发);② 异源排除(断言第二请求落在另一源;无候选静默);③ 准入失败静默(配额满 → 不对冲,原请求照等);④ 赢输记账(赢家 settle 实际 usage、输家 settle est、`tpm_used` 断言);⑤ 并发安全(`attempts==2`、终态行恰 1 条、`logical_call_id` 一致、无任务泄漏告警);⑥ 默认关闭回归(现有全套件不改一行断言全绿);⑦ 外部取消穿透(两任务同消、`CancelledError` 上抛);⑧ 与 deadline 组合(期限切断含对冲的整棵树);⑨ 配置守卫四路(env/直接构造/replace/client 直传);⑩ 裸生成时间断言(增补 H8):**赢家计时不含等待**(对冲赢家的 `generation_ms` ≈ 赢家路 transport 时长,不含触发前等待/admission/backoff);**对冲赢家取快者**(对冲路赢 → `hedge_won=True` 且为对冲路时长;原路后发先至 → `hedge_won=False` 且为原路时长);**embedding 为批次和**(N 批各自 transport 时长累加);**缓存命中为 0**(第二次同 key 调用 `generation_ms == 0``attempts == 0`)。
## 9. 集中人类批准项
**状态: H1H7 与增补 H8 全部已于 2026-09-10 获人类批准**(H1 取方案 A;H3 取零新列档;H5 取"v1 只允许 1")。
| # | 决策 | 推荐 | 备选代价 |
| --- | --- | --- | --- |
| H1 | 方案选型 | **A(并发对冲)** | B 省 2/3 实现量,但须新造"挂起失败"语义且污染或不污染熔断二选一;C 不解决 issue 现场 |
| H2 | `Transport.complete``first_token_event` 必填 kw(公共端口签名变更) | 批准 | 不加则流式只能用纯时间阈值,误对冲慢生成(issue 明示的反面) |
| H3 | 遥测区分档位 | 零新列(`hedge_cancelled` 字符串 + `CallStats` 三字段,含 H8) | `hedge_role` 列更规整但要 PG/SQLite 双 DDL + 迁移纪律 |
| H4 | 配置键名/值域/守卫(§5 全表,含交叉守卫 ValueError) | 按 §5 | 交叉守卫降为 warning 则错配静默 |
| H5 | `hedge_max_extra > 1` 的梯次对冲 | v1 只允许 1(键存在但上限 1 也接受) | 直接放开到 3 省一次版本,但多路对冲洗掉信号 |
| H6 | 两败计一次重试预算 | 批准 | 计两次会让对冲调用更快耗尽预算,语义说不过去 |
| H7 | embedding/OCR/分位数/同源对冲/per-call 参数全部不进本版 | 批准 | 任一纳入都是公共面扩大,需单独论证 |
| H8(增补) | `CallStats` 再增 `generation_ms`(裸生成时间,口径见 §4.5)与 `hedge_won` 两字段;retry/embedding/ocr 三条链路 `_attempt` 加 transport 级计时点 | 批准(2026-09-10,随 H1H7 同日) | 不加则前端拿不到"在等不在生成"的量化口径,issue #24 的现场观测(13% 调用吃掉 71% 模型总时间)无法在产品面复现;每路 attempt 分别耗时与输家身份走遥测 DB join 还原,不进公开响应 |
## 10. 残余风险(诚实标注)
| 项 | 状态 |
| --- | --- |
| 输家取消能否止住上游计费 | 无一手证据(与 136 设计 §12 同款):est 保留只是闸内保守记账,**不是**上游真实计费的计量;文档只写"可能已计费",不写"对冲浪费上限 = est" |
| 非流式误对冲慢生成 | 物理不可分(无中途信号);只能靠阈值取值(建议 > 源 p50 数倍)与 `hedge_max_extra` 上限控制;分位数触发是未来缓解 |
| 对冲流量放大 | 开启后挂起窗口内 in-flight 翻倍;准入全走闸意味着饱和期自然静默,但**配置者须理解**对冲 = 用配额换延迟 |
| "挂起不喂熔断"的反向代价 | 一个持续挂起的源不会因对冲输家而被熔断标记;源级淘汰仍靠既有失败/超时路径——这是有意选择(§3),但运维上"挂起率"只能靠 `hedge_cancelled` 遥测行统计 |
| 两任务共享 `_CallContext` 的承诺修订 | docstring 级变更;若未来给 context 加带 await 的方法,须重审任务安全 |
| 多路对冲(H5 若放开) | 信号冲刷与成本上界均未论证,v1 不碰 |