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

24 KiB
Raw Blame History

长尾对冲请求(issue #24)设计

  • 状态: 已批准(2026-09-10 人类批准 §9 全部批准项 H1–H7,含增补 H8:CallStatsgeneration_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-353admission.py:46-60 对冲输家走取消路径,结算语义现成:额外成本上限 = 一份 est 预扣滞留
取消的 attempt 行记 error="cancelled" 后穿透 retry.py:332-334 输家遥测只需换一个区分字符串,零新列(§4.5)
_CallContext 承诺"每调用一个实例的单任务对象,计数无需锁" types.py:321-337;register_attempt :339-345claim_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(必填,不设默认值,与端口既有约定同款);OpenAICompatTransportopenai_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_enterGovernanceBackendError,照常冒泡 铁律"后端不可用 → 报错而非放行",对冲分支不新增降级面
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_mstotal_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(completefirst_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 == 0attempts == 0)。

9. 集中人类批准项

状态: H1H7 与增补 H8 全部已于 2026-09-10 获人类批准(H1 取方案 A;H3 取零新列档;H5 取"v1 只允许 1")。

# 决策 推荐 备选代价
H1 方案选型 A(并发对冲) B 省 2/3 实现量,但须新造"挂起失败"语义且污染或不污染熔断二选一;C 不解决 issue 现场
H2 Transport.completefirst_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 不碰