单源 scope 下熔断开路等于整体停服,而关掉它只能靠把两个阈值参数当开关用 #14

Closed
opened 2026-08-19 20:02:49 +08:00 by iomgaa · 1 comment
Owner
  • 提交方:dissect(agent 自我进化受控实验框架,单源第三方中转部署)
  • 核查基准:仓库 HEAD 2af445c(1.2.1)源码,非线上装的 1.0.6
  • 现场版本:1.0.6(requirements.txt 钉的是 polygateway>=1.0.6,<1.1
  • 现场证据:一条 6 轮的真实验臂,runs/_gateway_telemetry.db 逐次遥测 + 业务侧记账库

0. 先说清楚这个 issue 是什么

我们先按 1.2.1 源码把两次故障各自复核了一遍,其中一次已经被 issue #8 修掉了
这一条不再诉求任何东西,写在这里是为了让"还剩什么"这句话可信:

故障 1.0.6 的表现 1.2.1 上还会不会发生
一次 300s 超时把整条调用判成 stalledmax_attempts=8 一次没用上 4 条调用被打死 不会。issue #8StallClockmiddleware/retry.py:92-131)把真实尝试的耗时从 stall 账里扣掉了,回归测试 test_single_timeout_does_not_exhaust_stall_budget 正是这个场景
熔断开路把 30 次调用瞬间打死 21 次评估 + 9 次生成,latency_ms 7–74ms,cost_usd=0 会,且一字不差backends/memory/breaker.py 在 1.0.6 与 1.2.1 之间逐字节相同(md5 两边均为 630ed36ddeb87e08a9bac58260056046

本 issue 只诉求第二行。

1. 问题的准确形态

1.1 单源时熔断的语义已经变了,而库没有承认这一点

熔断的设计前提是"这个源坏了,把流量导到别的源"。我们的 scope 只有一个源
(第三方中转,配不出第二个)。这个前提在我们这里不成立,于是同一段代码做的事变成了:
"这个源坏了,所以整个 scope 停止服务"。

具体路径(1.2.1 源码,行号按 HEAD):

  1. middleware/retry.py:319-332 —— _pick_runnable 逐个候选源过闸。源在冷却备忘里
    SourceCooldownMemo)或熔断门返回 allowed=False 时,gate_rejections += 1
    一个网络包都不发
  2. middleware/retry.py:370-379 —— _on_no_runnable 看到 gate_rejections == len(self._sources)
    当场抛 CircuitOpenError单源时这个等式恒等于"唯一那个源被拒了"
    多源时它才有"全都坏了"的含义。

结果:开路期间进来的每一次调用都在几毫秒内死掉,max_attempts 一格没动,
账上是零成本零信息的失败。我们实测的 30 次里,latency_ms 最小 7ms。

1.2 触发条件比想象的容易得多

现场配置:MAX_CONCURRENCY=2TIMEOUT_S=300FAIL_THRESHOLD=20COOLDOWN_S=60
MIN_CALLS=20FAIL_RATE=0.6WINDOW_S=60

失败率通道(backends/memory/breaker.py:92-97)的判据是"窗口样本 ≥ min_calls 且失败率 ≥ fail_rate"。
中转回 503 时失败得非常快(实测 0.6–1.2s/次),所以样本攒得比正常流量快一个量级:

11:07:18–11:07:54  中转连回 19 次 503(另有 1 次流活性超时)
11:07:55           熔断开路 —— 窗口内 22 次尝试 / 19 次失败 = 86.4%
11:07:55–11:08:15  30 次调用被瞬间打死,实验臂当轮报废

中转抖 36 秒,就够把一条跑了 3 小时 18 分钟的实验臂打断。
按库缺省的 min_calls=10 只需要一半时间。

1.3 半开探针让并发调用方的处境更糟(这一条是读代码读出来的,本次事故里没触发)

开路冷却到期后进入 HALF_OPEN,只放一个探针进去(backends/memory/breaker.py:145-156)。
其余并发调用方拿到的 allowed=Falseretry_after_s 是探针租约的剩余时长
probe_ttl_s 的派生规则是 max(2 × 最慢源 timeout_s, cooldown_s, timeout_s + 5)
config.py:367-376)。我们的 TIMEOUT_S=300probe_ttl_s = 600

于是单源 scope 下会出现这种组合:冷却期只有 60 秒,但半开期间被拒的调用被告知"等 600 秒"
业务侧照 retry_after_s 延期重投的话,600 秒往往已经超出它愿意等的上限,只能放弃。

2. 为什么这是库该解决的,而不是下游该绕开的

2.1 现有的三条绕法我们都查过,没有一条是干净的

绕法 查到的结论
PGW_BREAKER_BACKEND 挑一个空实现 没有config.py:55_BREAKER_BACKENDS = frozenset({"memory", "redis"}),没有 noop
FAIL_THRESHOLD 设成极大值 只关掉连败通道。失败率通道(min_calls/fail_rate/window_s)与它完全独立,我们这次正是被失败率通道熔的
FAIL_THRESHOLD + MIN_CALLS 双双设成极大值 两条通道确实都关得掉(_rate_channel_openattempts < min_calls 时直接 return False),但这是把两个阈值参数当开关用,语义靠猜;而且 fail_threshold 会被 config.py:366max(配置值, 源级并发×2) 悄悄改写,"我配的是多少"和"实际是多少"本来就不是一回事

而且这条绕法关不掉 force_openrecord_failureforce_open=TrueSourceDeadError
来自 401/403 与 insufficient_quota 的 429)时直接 _open,一击即熔,
不看任何阈值(backends/memory/breaker.py:230-233)。经中转部署时 401/403 也可能来自中转自己。

2.2 errors.py 那句"业务侧 catch 本类做延期重投",本身就是把一件属于库的职责推给了调用方

我们曾经照这句话在下游实现了一层延期重投(catch GatewayUnavailableError、按 retry_after_s
等满再发一次),随后把它整个删掉了,理由是架构层面的:

重试、退避、延期、换源属于网关的职责。调用方再写一层,就是同一件事在两个地方各做一份。
两边的口径将来必然漂移——库调了退避曲线、下游那层不知道;下游改了等待上限、库的遥测算不进去——
而漂移之后,"这次调用到底等了多久、试了几次"没有任何单一事实源答得出来。
对一个要把调用次数与墙钟写进论文的下游来说,这不是洁癖,是数据可信度问题。

即便调用方肯写这一层,它也做不好,原因全在库这边:

  • 开路期间被打死的调用没有发出任何请求,调用方无从判断"这是网关自我保护,还是中转真的死了"。
  • retry_after_s 在半开期给的是探针租约(可达 600s,见 §1.3),而它是从 TIMEOUT_S 派生的,
    与"这个源多久能恢复"没有任何因果关系——照它等,等的是一个物理上无意义的数。
  • 冷却按 cooldown_s × 2^(重开次数-1) 指数递增(_cooldown_eff),单源下"重开"几乎必然连续发生,
    等待时间迅速逼近 max_cooldown_s(缺省 300s)。调用方要么等满,要么在库尚未恢复时放弃。

所以我们对这条契约的诉求是:把它收回库内。 无论采纳下面哪个方案,
建议同时修订 errors.py 那句 docstring——它现在鼓励每一个下游各自实现一份重试逻辑。

2.3 补记:那一层删掉之后,代价已经量化出来了

上面说的删除我们已经做完了,随之而来的后果是可测的,写在这里让"库该不该扛住"这个判断有个数:

熔断打在门控批上,代价是整条实验臂当场停、要人来续跑。 门控批要求一批题全部评得出分才能
与参照分比大小(少几道题去比大小等于悄悄换了比较口径),所以没评上分的尝试会被作废、
这一轮失败退出。我们的全量级门控批是 57 道题,也就是说中转抖 36 秒(实测:双 30 秒桶内
22 次尝试 / 19 次 HTTP 503 = 86.4%,够到 fail_rate 阈值)就足以让一条已经跑了几小时的臂停下来

做题批的代价则是数据变薄:死于熔断的 rollout 不再被送去评分(否则它会拿到一个 0 分,
与"真的做错"在下游完全同形,进而压低门控分、进反思材料池、被判定器判成失败),
于是那一轮的反思材料条数如实变少。这是对的处置,但它意味着每一次熔断都在直接改变实验的输入

这两条都不是"下游没处理好"——它们正是在处理好之后剩下的代价。
调用方能做的到此为止,再往下就只有库能决定"开路期间是当场判死还是排队等"。

核心不对称:多源 scope 里熔断的代价是"少一个源",单源 scope 里熔断的代价是"整个 scope 停服"。
代价差了一个数量级,而库对这两种情形用的是同一套默认值、且不允许调用方表达自己属于哪一种。

2.3 顺带说明:这不是 issue #8 的残留,但它俩是同一个病根的两侧

issue #8 修的是"生产性时间被算进 stall 账"。修完之后仍然成立的是:
被限流闸挡在门外、一次尝试都没发出的调用,其 max_attempts 依然一格都用不上
——因为它烧的是 stall 账(tests/unit/test_backpressure.py::TestStallQuadrants::test_both_windows_exceeded_raises_stalled
钉的正是这个行为,per_source_reasons == {"s1": "rate_limited"})。

这是设计选择、不是 bug,我们不诉求改它;提一句是因为它和本 issue 合起来解释了
同一次中转故障为什么会从两个方向同时打穿单源 scope。

3. 可能的方案与各自代价

按我们的偏好排序,但选哪条由维护方定。

方案 A:给熔断一个显式的关闭档

新增 PGW_BREAKER_BACKEND=noop(或 {SCOPE}__BREAKER__ENABLED=false),
装配一个恒 allowed=True、写回全部 no-op 的门。

  • 好处:语义直白,一行配置,实现量极小(一个 NullGate 类),
    不动任何既有代码路径,GateDecision 契约原样。
  • 代价:关掉之后 401/403/配额耗尽也不再一击即熔,会持续拿坏密钥去撞墙、白烧配额与 RPM。
    这个代价必须写进文档,否则会有人在多源场景下也关掉它。
  • 风险:给了一个容易被滥用的开关。可以用命名(noop 而不是 off)与文档警示缓解。

方案 B:让"开路时怎么办"可配置,而不是只能 fail-fast

仿照已有的 {SCOPE}__QUOTA_FULL=wait|fail_fast,新增 {SCOPE}__CIRCUIT_OPEN=fail_fast|wait
wait_on_no_runnable 不抛 CircuitOpenError,而是走既有的 poll 轮询分支,
由 stall 预算兜底(冷却轮询本来就已经被归类为非生产性等待,账目现成)。

  • 好处:熔断的保护作用完整保留(开路期间确实不发请求、不烧配额),
    改变的只是"调用方是当场死还是排队等"。这恰好是单源与多源的真正分界:
    多源时 fail-fast 是对的(换源更快),单源时没有源可换,等待才是对的。
    quota_full 同构,学习成本接近零。
  • 代价:单次调用的最坏墙钟被拉长到 stall 预算那一档;行为变更需要 CHANGELOG 的
    "请先读这一条"待遇。半开探针在途时的等待同样需要覆盖,否则 §1.3 那条还在。
  • 风险wait 档下若冷却指数递增到 max_cooldown_s=300,调用会等满 300s 才判死,
    与直接 fail-fast 相比只是把成本从"失败率"挪到"墙钟"。需要在文档里写清这不是免费的。

方案 C:只修半开探针在单源下的 retry_after_s

把 HALF_OPEN 拒绝时的 retry_after_s 从"探针租约剩余"改成一个与恢复时间有因果关系的值
(例如 min(probe_expires - now, cooldown_s))。

  • 好处:改动面最小,只修 §1.3 那一条,不动任何语义。
  • 代价治标。§1.1、§1.2 一点没动,我们这次事故的 30 次死亡与它无关。
  • 我们把它列出来是因为它可以与 A / B 任一条并行做,不冲突。

方案 D:什么都不改,写进文档

在 README 明确写"单源 scope 下熔断等于整体停服,请把 FAIL_THRESHOLDMIN_CALLS
同时设为极大值来实质关闭它,并注意 force_open 不受此影响"。

  • 好处:零代码。
  • 代价:把一个"配置项组合出来的副作用"固化成公开契约,将来动阈值逻辑就会破坏它;
    且它解决不了 force_open。我们认为这条比 A 差,但如果维护方判断熔断的默认值不该被削弱,
    它至少让下游不必靠读源码才能活下来。

4. 我们这边的复现材料

  • 逐次遥测:runs/_gateway_telemetry.db(sqlite,llm_calls 表,
    created_at between '2026-08-19 11:06:20' and '2026-08-19 11:08:16' 即完整现场)
  • 业务侧记账:runs/evo-center-b-m3-s0-t0_6/metrics.db
  • 配置:单源 minimaxMAX_CONCURRENCY=2RPM=45TIMEOUT_S=300
    MAX_ATTEMPTS=8BACKOFF_BASE_S=1BACKOFF_MAX_S=120
    FAIL_THRESHOLD=20COOLDOWN_S=60MIN_CALLS=20FAIL_RATE=0.6WINDOW_S=60

需要哪一段原始数据我们随时可以导出。

- **提交方**:dissect(agent 自我进化受控实验框架,单源第三方中转部署) - **核查基准**:仓库 HEAD `2af445c`(1.2.1)源码,非线上装的 1.0.6 - **现场版本**:1.0.6(`requirements.txt` 钉的是 `polygateway>=1.0.6,<1.1`) - **现场证据**:一条 6 轮的真实验臂,`runs/_gateway_telemetry.db` 逐次遥测 + 业务侧记账库 ## 0. 先说清楚这个 issue **不**是什么 我们先按 1.2.1 源码把两次故障各自复核了一遍,其中一次**已经被 issue #8 修掉了**, 这一条不再诉求任何东西,写在这里是为了让"还剩什么"这句话可信: | 故障 | 1.0.6 的表现 | 1.2.1 上还会不会发生 | |---|---|---| | 一次 300s 超时把整条调用判成 `stalled`,`max_attempts=8` 一次没用上 | 4 条调用被打死 | **不会**。issue #8 的 `StallClock`(`middleware/retry.py:92-131`)把真实尝试的耗时从 stall 账里扣掉了,回归测试 `test_single_timeout_does_not_exhaust_stall_budget` 正是这个场景 | | 熔断开路把 30 次调用瞬间打死 | 21 次评估 + 9 次生成,`latency_ms` 7–74ms,`cost_usd=0` | **会,且一字不差**。`backends/memory/breaker.py` 在 1.0.6 与 1.2.1 之间**逐字节相同**(md5 两边均为 `630ed36ddeb87e08a9bac58260056046`) | 本 issue 只诉求第二行。 ## 1. 问题的准确形态 ### 1.1 单源时熔断的语义已经变了,而库没有承认这一点 熔断的设计前提是"这个源坏了,把流量导到别的源"。**我们的 scope 只有一个源** (第三方中转,配不出第二个)。这个前提在我们这里不成立,于是同一段代码做的事变成了: "这个源坏了,所以整个 scope 停止服务"。 具体路径(1.2.1 源码,行号按 HEAD): 1. `middleware/retry.py:319-332` —— `_pick_runnable` 逐个候选源过闸。源在冷却备忘里 (`SourceCooldownMemo`)或熔断门返回 `allowed=False` 时,`gate_rejections += 1`, **一个网络包都不发**。 2. `middleware/retry.py:370-379` —— `_on_no_runnable` 看到 `gate_rejections == len(self._sources)` 当场抛 `CircuitOpenError`。**单源时这个等式恒等于"唯一那个源被拒了"**, 多源时它才有"全都坏了"的含义。 结果:开路期间进来的**每一次**调用都在几毫秒内死掉,`max_attempts` 一格没动, 账上是零成本零信息的失败。我们实测的 30 次里,`latency_ms` 最小 7ms。 ### 1.2 触发条件比想象的容易得多 现场配置:`MAX_CONCURRENCY=2`、`TIMEOUT_S=300`、`FAIL_THRESHOLD=20`、`COOLDOWN_S=60`、 `MIN_CALLS=20`、`FAIL_RATE=0.6`、`WINDOW_S=60`。 失败率通道(`backends/memory/breaker.py:92-97`)的判据是"窗口样本 ≥ min_calls 且失败率 ≥ fail_rate"。 中转回 503 时**失败得非常快**(实测 0.6–1.2s/次),所以样本攒得比正常流量快一个量级: ``` 11:07:18–11:07:54 中转连回 19 次 503(另有 1 次流活性超时) 11:07:55 熔断开路 —— 窗口内 22 次尝试 / 19 次失败 = 86.4% 11:07:55–11:08:15 30 次调用被瞬间打死,实验臂当轮报废 ``` **中转抖 36 秒,就够把一条跑了 3 小时 18 分钟的实验臂打断。** 按库缺省的 `min_calls=10` 只需要一半时间。 ### 1.3 半开探针让并发调用方的处境更糟(这一条是读代码读出来的,本次事故里没触发) 开路冷却到期后进入 HALF_OPEN,只放一个探针进去(`backends/memory/breaker.py:145-156`)。 **其余并发调用方拿到的 `allowed=False`,`retry_after_s` 是探针租约的剩余时长**, 而 `probe_ttl_s` 的派生规则是 `max(2 × 最慢源 timeout_s, cooldown_s, timeout_s + 5)` (`config.py:367-376`)。我们的 `TIMEOUT_S=300` ⇒ `probe_ttl_s = 600`。 于是单源 scope 下会出现这种组合:**冷却期只有 60 秒,但半开期间被拒的调用被告知"等 600 秒"**。 业务侧照 `retry_after_s` 延期重投的话,600 秒往往已经超出它愿意等的上限,只能放弃。 ## 2. 为什么这是库该解决的,而不是下游该绕开的 ### 2.1 现有的三条绕法我们都查过,没有一条是干净的 | 绕法 | 查到的结论 | |---|---| | `PGW_BREAKER_BACKEND` 挑一个空实现 | **没有**。`config.py:55` 的 `_BREAKER_BACKENDS = frozenset({"memory", "redis"})`,没有 `noop` 档 | | `FAIL_THRESHOLD` 设成极大值 | 只关掉**连败通道**。失败率通道(`min_calls`/`fail_rate`/`window_s`)与它完全独立,我们这次正是被失败率通道熔的 | | `FAIL_THRESHOLD` + `MIN_CALLS` 双双设成极大值 | 两条通道确实都关得掉(`_rate_channel_open` 在 `attempts < min_calls` 时直接 `return False`),但这是**把两个阈值参数当开关用**,语义靠猜;而且 `fail_threshold` 会被 `config.py:366` 的 `max(配置值, 源级并发×2)` 悄悄改写,"我配的是多少"和"实际是多少"本来就不是一回事 | 而且**这条绕法关不掉 `force_open`**:`record_failure` 在 `force_open=True`(`SourceDeadError`, 来自 401/403 与 `insufficient_quota` 的 429)时直接 `_open`,一击即熔, 不看任何阈值(`backends/memory/breaker.py:230-233`)。经中转部署时 401/403 也可能来自中转自己。 ### 2.2 `errors.py` 那句"业务侧 catch 本类做延期重投",本身就是把一件属于库的职责推给了调用方 我们**曾经照这句话在下游实现了一层延期重投**(catch `GatewayUnavailableError`、按 `retry_after_s` 等满再发一次),随后把它整个删掉了,理由是架构层面的: **重试、退避、延期、换源属于网关的职责。调用方再写一层,就是同一件事在两个地方各做一份。** 两边的口径将来必然漂移——库调了退避曲线、下游那层不知道;下游改了等待上限、库的遥测算不进去—— 而漂移之后,"这次调用到底等了多久、试了几次"没有任何单一事实源答得出来。 对一个要把调用次数与墙钟写进论文的下游来说,这不是洁癖,是数据可信度问题。 即便调用方肯写这一层,它也做不好,原因全在库这边: - 开路期间被打死的调用**没有发出任何请求**,调用方无从判断"这是网关自我保护,还是中转真的死了"。 - `retry_after_s` 在半开期给的是探针租约(可达 600s,见 §1.3),而它是从 `TIMEOUT_S` 派生的, 与"这个源多久能恢复"没有任何因果关系——照它等,等的是一个物理上无意义的数。 - 冷却按 `cooldown_s × 2^(重开次数-1)` 指数递增(`_cooldown_eff`),单源下"重开"几乎必然连续发生, 等待时间迅速逼近 `max_cooldown_s`(缺省 300s)。调用方要么等满,要么在库尚未恢复时放弃。 **所以我们对这条契约的诉求是:把它收回库内。** 无论采纳下面哪个方案, 建议同时修订 `errors.py` 那句 docstring——它现在鼓励每一个下游各自实现一份重试逻辑。 ### 2.3 补记:那一层删掉之后,代价已经量化出来了 上面说的删除我们已经做完了,随之而来的后果是可测的,写在这里让"库该不该扛住"这个判断有个数: **熔断打在门控批上,代价是整条实验臂当场停、要人来续跑。** 门控批要求一批题全部评得出分才能 与参照分比大小(少几道题去比大小等于悄悄换了比较口径),所以没评上分的尝试会被作废、 这一轮失败退出。我们的全量级门控批是 57 道题,也就是说**中转抖 36 秒**(实测:双 30 秒桶内 22 次尝试 / 19 次 HTTP 503 = 86.4%,够到 `fail_rate` 阈值)**就足以让一条已经跑了几小时的臂停下来**。 **做题批的代价则是数据变薄**:死于熔断的 rollout 不再被送去评分(否则它会拿到一个 0 分, 与"真的做错"在下游完全同形,进而压低门控分、进反思材料池、被判定器判成失败), 于是那一轮的反思材料条数如实变少。这是对的处置,但它意味着**每一次熔断都在直接改变实验的输入**。 这两条都不是"下游没处理好"——它们正是**在处理好之后**剩下的代价。 调用方能做的到此为止,再往下就只有库能决定"开路期间是当场判死还是排队等"。 **核心不对称**:多源 scope 里熔断的代价是"少一个源",单源 scope 里熔断的代价是"整个 scope 停服"。 代价差了一个数量级,而库对这两种情形用的是同一套默认值、且不允许调用方表达自己属于哪一种。 ### 2.3 顺带说明:这不是 issue #8 的残留,但它俩是同一个病根的两侧 issue #8 修的是"生产性时间被算进 stall 账"。修完之后仍然成立的是: **被限流闸挡在门外、一次尝试都没发出的调用,其 `max_attempts` 依然一格都用不上** ——因为它烧的是 stall 账(`tests/unit/test_backpressure.py::TestStallQuadrants::test_both_windows_exceeded_raises_stalled` 钉的正是这个行为,`per_source_reasons == {"s1": "rate_limited"}`)。 这是设计选择、不是 bug,我们不诉求改它;提一句是因为它和本 issue 合起来解释了 同一次中转故障为什么会从两个方向同时打穿单源 scope。 ## 3. 可能的方案与各自代价 按我们的偏好排序,但选哪条由维护方定。 ### 方案 A:给熔断一个显式的关闭档 新增 `PGW_BREAKER_BACKEND=noop`(或 `{SCOPE}__BREAKER__ENABLED=false`), 装配一个恒 `allowed=True`、写回全部 no-op 的门。 - **好处**:语义直白,一行配置,实现量极小(一个 `NullGate` 类), 不动任何既有代码路径,`GateDecision` 契约原样。 - **代价**:关掉之后 401/403/配额耗尽也不再一击即熔,会持续拿坏密钥去撞墙、白烧配额与 RPM。 **这个代价必须写进文档**,否则会有人在多源场景下也关掉它。 - **风险**:给了一个容易被滥用的开关。可以用命名(`noop` 而不是 `off`)与文档警示缓解。 ### 方案 B:让"开路时怎么办"可配置,而不是只能 fail-fast 仿照已有的 `{SCOPE}__QUOTA_FULL=wait|fail_fast`,新增 `{SCOPE}__CIRCUIT_OPEN=fail_fast|wait`。 取 `wait` 时 `_on_no_runnable` 不抛 `CircuitOpenError`,而是走既有的 poll 轮询分支, 由 stall 预算兜底(冷却轮询本来就已经被归类为非生产性等待,账目现成)。 - **好处**:熔断的**保护作用完整保留**(开路期间确实不发请求、不烧配额), 改变的只是"调用方是当场死还是排队等"。这恰好是单源与多源的真正分界: 多源时 fail-fast 是对的(换源更快),单源时没有源可换,等待才是对的。 与 `quota_full` 同构,学习成本接近零。 - **代价**:单次调用的最坏墙钟被拉长到 stall 预算那一档;行为变更需要 CHANGELOG 的 "请先读这一条"待遇。半开探针在途时的等待同样需要覆盖,否则 §1.3 那条还在。 - **风险**:`wait` 档下若冷却指数递增到 `max_cooldown_s=300`,调用会等满 300s 才判死, 与直接 fail-fast 相比只是把成本从"失败率"挪到"墙钟"。需要在文档里写清这不是免费的。 ### 方案 C:只修半开探针在单源下的 `retry_after_s` 把 HALF_OPEN 拒绝时的 `retry_after_s` 从"探针租约剩余"改成一个与恢复时间有因果关系的值 (例如 `min(probe_expires - now, cooldown_s)`)。 - **好处**:改动面最小,只修 §1.3 那一条,不动任何语义。 - **代价**:**治标**。§1.1、§1.2 一点没动,我们这次事故的 30 次死亡与它无关。 - 我们把它列出来是因为它可以与 A / B 任一条并行做,不冲突。 ### 方案 D:什么都不改,写进文档 在 README 明确写"单源 scope 下熔断等于整体停服,请把 `FAIL_THRESHOLD` 与 `MIN_CALLS` 同时设为极大值来实质关闭它,并注意 `force_open` 不受此影响"。 - **好处**:零代码。 - **代价**:把一个"配置项组合出来的副作用"固化成公开契约,将来动阈值逻辑就会破坏它; 且它解决不了 `force_open`。我们认为这条比 A 差,但如果维护方判断熔断的默认值不该被削弱, 它至少让下游不必靠读源码才能活下来。 ## 4. 我们这边的复现材料 - 逐次遥测:`runs/_gateway_telemetry.db`(sqlite,`llm_calls` 表, `created_at between '2026-08-19 11:06:20' and '2026-08-19 11:08:16'` 即完整现场) - 业务侧记账:`runs/evo-center-b-m3-s0-t0_6/metrics.db` - 配置:单源 `minimax`,`MAX_CONCURRENCY=2`、`RPM=45`、`TIMEOUT_S=300`、 `MAX_ATTEMPTS=8`、`BACKOFF_BASE_S=1`、`BACKOFF_MAX_S=120`、 `FAIL_THRESHOLD=20`、`COOLDOWN_S=60`、`MIN_CALLS=20`、`FAIL_RATE=0.6`、`WINDOW_S=60` 需要哪一段原始数据我们随时可以导出。
Author
Owner

已在 1.2.4 修复并发布。感谢这份 issue——它的复核质量让讨论可以直接从"修什么"开始,而不是先花几轮确认现象。

你们要做的

{SCOPE}__CIRCUIT_OPEN=wait

一行。另外 requirements.txt 里钉的是 polygateway>=1.0.6,<1.1,需要放开到 >=1.2.4,<2 才拿得到。

采纳了 B + C,但两者不是并列关系

方案 B({SCOPE}__CIRCUIT_OPEN=fail_fast|wait)是主干,与 QUOTA_FULL 逐项同形。但 C 不是你们说的"可以并行做的治标"——它是 B 的前提wait 档要按 retry_after_s 睡,而那个值在半开时是探针租约剩余(你们算得没错,TIMEOUT_S=300 时是 600s)。不先修它,wait 档就会照着一个物理上无意义的数去睡,冷却 60 秒就结束了却要多躺 540 秒——那是拿一个新事故换旧事故。

A 和 D 都没采纳。A(noop 后端)关掉的是保护:401/403/配额耗尽的一击即熔会一并失效,库会拿着坏密钥持续撞墙;而你们要的是"别当场判死",不是"别熔断"。它还给库开了"治理组件可以整个关掉"的先例,限流迟早也会有人要关。D 会把"两个阈值同设极大值"这个配置副作用固化成公开契约,将来一动阈值逻辑就破坏它,而且如你们所说它关不掉 force_open

§1.3 比你们判断的更重,而且不改配置也受益

你们把半开那条标为"读代码读出来的,本次事故里没触发",诉求只落在"下游被告知等 600 秒"。实际后果在库内,比对外报错的数字重得多:

那个 600 被喂进了源冷却备忘(retry.py:354),而 SourceCooldownMemo.set_until 取更晚者、不可回退。于是——源开路 → 冷却到期 → 调用①拿到探针 → 并发的调用②被拒并给该源记下 600 秒本地冷却 → 调用①的探针成功、门恢复 CLOSED本进程此后仍然跳过这个健康的源将近 10 分钟。单源下每次调用照旧抛 CircuitOpenError

实测(InMemoryGate + 注入时钟,cooldown_s=60probe_ttl_s=600):

B 决定: allowed=False state=half_open retry_after_s=600.0   <- 冷却只有 60s
探针成功后门 state: closed
门已 CLOSED,memo.active('s1') = True
再过 120 秒(远超 60s 冷却) memo.active = True  剩余 480.0 秒

这条与单源无关——多源部署里任何一个源每开路一次,就会被该进程从可用池里除名将近 2 × TIMEOUT_S,只是别的源接住了流量所以从来没人报过。你们那 30 次瞬死里有多少来自这一条无法反推,但机制确凿。

修法是把 retry_after_s 的语义定死为「距离确定可再试的时刻还有多久」:OPEN → 剩余冷却,HALF_OPEN0.0(探针随时可能出结果,不存在确定时刻),准入允许 → 0.0。备忘于是写入一个已过期的时刻,回归"只记开路的确定冷却期"。这部分在缺省 fail_fast 档下同样生效,所以即使你们暂时不改配置,升级本身就修掉了它。

同批还统一了两个后端在六个出口上的口径,其中四处是既有的 memory/redis 分叉(redis 在授予探针时返回探针 TTL、写回被 fencing 拒时返回租约剩余,而 memory 一直返回 0)。契约测试此前只钉了"第二个进入者会被拒绝",从没钉过它拿到的是什么数,这个盲区把分叉掩护到了今天。

wait 档的代价,说清楚

保护完整保留:等待期间一个请求都不发,不烧配额不烧钱。改变的只是调用方当场死还是排队等。

  • 单次调用最坏墙钟的上限是 {SCOPE}__BACKPRESSURE__STALL_WINDOW_S(缺省 300s)。等待按冷却截止睡,不是按 poll_interval 空转(60 秒冷却用 50ms 轮询是 1200 次 Redis 往返 × 每个在途调用)。
  • wait 不豁免重试预算:冷却结束后放行的探针是一次真实尝试,失败照样烧一格 MAX_ATTEMPTS。所以密钥失效(401/403)这类一击即熔的源通常更早以 reason=retry_exhausted 结束,而不是等满窗口的 stalled——哪个先到取决于 MAX_ATTEMPTS 与冷却时长、STALL_WINDOW_S 的相对大小。(这条是我们自己的整分支审查揪出来的:初稿文档写成了"一律等满 300 秒",与代码不符,已更正。)
  • 你们 MAX_ATTEMPTS=8COOLDOWN_S=60STALL_WINDOW_S 缺省 300 的组合下,冷却指数递增(60/120/240…)会先吃光 stall 预算,所以更可能落在 stalled 那一侧。

§2.2 那条契约也改了

GatewayUnavailableError 的 docstring 重写了。原文"业务侧 catch 本类做延期重投"确实读起来像在鼓励每个下游各写一份重试逻辑。现在写明:调用级的重试、退避、换源、等待冷却全部在库内,本异常表示那份预算已经用尽;下游据此再投属于任务级重试,语义不同,由业务自行在库外包。你们删掉那一层是对的。

一处判断分歧,说明一下

我们没有按"单源特判"来修。if len(sources) == 1 会让行为随池大小突变、两条分支要分别测试维护,是比现状更重的债。真正缺的是准入策略矩阵里的一格——限流拒绝有 wait/fail_fast 两档,熔断拒绝只有一档。补上这一格之后,单源和多源走同一套逻辑,库不需要知道自己有几个源。这也意味着多源部署遇到"所有源同时开路"(共同上游挂掉、全网抖动)时同样受益,只是概率低而已。


验证:全套件 980 passed,Redis 时间语义 18 个真实等待变体全绿(19 分 11 秒,不缩放时长),覆盖率 94%。设计与决策论证见 research-wiki/designs/2026-08-19-issue14-admission-wait-policy-design.md

升级后如果 wait 档的等待时长或失败 reason 与预期不符,欢迎带遥测数据再开 issue。

已在 **1.2.4** 修复并发布。感谢这份 issue——它的复核质量让讨论可以直接从"修什么"开始,而不是先花几轮确认现象。 ## 你们要做的 ``` {SCOPE}__CIRCUIT_OPEN=wait ``` 一行。另外 `requirements.txt` 里钉的是 `polygateway>=1.0.6,<1.1`,需要放开到 `>=1.2.4,<2` 才拿得到。 ## 采纳了 B + C,但两者不是并列关系 方案 B(`{SCOPE}__CIRCUIT_OPEN=fail_fast|wait`)是主干,与 `QUOTA_FULL` 逐项同形。**但 C 不是你们说的"可以并行做的治标"——它是 B 的前提**:`wait` 档要按 `retry_after_s` 睡,而那个值在半开时是探针租约剩余(你们算得没错,`TIMEOUT_S=300` 时是 600s)。不先修它,`wait` 档就会照着一个物理上无意义的数去睡,冷却 60 秒就结束了却要多躺 540 秒——那是拿一个新事故换旧事故。 A 和 D 都没采纳。A(`noop` 后端)关掉的是**保护**:401/403/配额耗尽的一击即熔会一并失效,库会拿着坏密钥持续撞墙;而你们要的是"别当场判死",不是"别熔断"。它还给库开了"治理组件可以整个关掉"的先例,限流迟早也会有人要关。D 会把"两个阈值同设极大值"这个配置副作用固化成公开契约,将来一动阈值逻辑就破坏它,而且如你们所说它关不掉 `force_open`。 ## §1.3 比你们判断的更重,而且**不改配置也受益** 你们把半开那条标为"读代码读出来的,本次事故里没触发",诉求只落在"下游被告知等 600 秒"。实际后果在库内,比对外报错的数字重得多: 那个 600 被喂进了源冷却备忘(`retry.py:354`),而 `SourceCooldownMemo.set_until` **取更晚者、不可回退**。于是——源开路 → 冷却到期 → 调用①拿到探针 → 并发的调用②被拒并给该源记下 600 秒本地冷却 → 调用①的探针成功、门恢复 `CLOSED` → **本进程此后仍然跳过这个健康的源将近 10 分钟**。单源下每次调用照旧抛 `CircuitOpenError`。 实测(`InMemoryGate` + 注入时钟,`cooldown_s=60`、`probe_ttl_s=600`): ``` B 决定: allowed=False state=half_open retry_after_s=600.0 <- 冷却只有 60s 探针成功后门 state: closed 门已 CLOSED,memo.active('s1') = True 再过 120 秒(远超 60s 冷却) memo.active = True 剩余 480.0 秒 ``` 这条**与单源无关**——多源部署里任何一个源每开路一次,就会被该进程从可用池里除名将近 `2 × TIMEOUT_S`,只是别的源接住了流量所以从来没人报过。你们那 30 次瞬死里有多少来自这一条无法反推,但机制确凿。 修法是把 `retry_after_s` 的语义定死为「距离**确定**可再试的时刻还有多久」:`OPEN` → 剩余冷却,`HALF_OPEN` → `0.0`(探针随时可能出结果,不存在确定时刻),准入允许 → `0.0`。备忘于是写入一个已过期的时刻,回归"只记开路的确定冷却期"。**这部分在缺省 `fail_fast` 档下同样生效**,所以即使你们暂时不改配置,升级本身就修掉了它。 同批还统一了两个后端在**六个出口**上的口径,其中四处是既有的 memory/redis 分叉(redis 在授予探针时返回探针 TTL、写回被 fencing 拒时返回租约剩余,而 memory 一直返回 0)。契约测试此前只钉了"第二个进入者会被拒绝",从没钉过它拿到的是什么数,这个盲区把分叉掩护到了今天。 ## `wait` 档的代价,说清楚 保护完整保留:等待期间**一个请求都不发**,不烧配额不烧钱。改变的只是调用方当场死还是排队等。 - 单次调用最坏墙钟的上限是 `{SCOPE}__BACKPRESSURE__STALL_WINDOW_S`(缺省 300s)。等待按冷却截止睡,不是按 `poll_interval` 空转(60 秒冷却用 50ms 轮询是 1200 次 Redis 往返 × 每个在途调用)。 - **`wait` 不豁免重试预算**:冷却结束后放行的探针是一次真实尝试,失败照样烧一格 `MAX_ATTEMPTS`。所以密钥失效(401/403)这类一击即熔的源通常更早以 `reason=retry_exhausted` 结束,而不是等满窗口的 `stalled`——哪个先到取决于 `MAX_ATTEMPTS` 与冷却时长、`STALL_WINDOW_S` 的相对大小。(这条是我们自己的整分支审查揪出来的:初稿文档写成了"一律等满 300 秒",与代码不符,已更正。) - 你们 `MAX_ATTEMPTS=8`、`COOLDOWN_S=60`、`STALL_WINDOW_S` 缺省 300 的组合下,冷却指数递增(60/120/240…)会先吃光 stall 预算,所以更可能落在 `stalled` 那一侧。 ## §2.2 那条契约也改了 `GatewayUnavailableError` 的 docstring 重写了。原文"业务侧 catch 本类做延期重投"确实读起来像在鼓励每个下游各写一份重试逻辑。现在写明:调用级的重试、退避、换源、等待冷却全部在库内,本异常表示那份预算已经用尽;下游据此再投属于**任务级**重试,语义不同,由业务自行在库外包。你们删掉那一层是对的。 ## 一处判断分歧,说明一下 我们没有按"单源特判"来修。`if len(sources) == 1` 会让行为随池大小突变、两条分支要分别测试维护,是比现状更重的债。真正缺的是准入策略矩阵里的一格——限流拒绝有 `wait`/`fail_fast` 两档,熔断拒绝只有一档。补上这一格之后,单源和多源走**同一套逻辑**,库不需要知道自己有几个源。这也意味着多源部署遇到"所有源同时开路"(共同上游挂掉、全网抖动)时同样受益,只是概率低而已。 --- 验证:全套件 980 passed,Redis 时间语义 18 个真实等待变体全绿(19 分 11 秒,不缩放时长),覆盖率 94%。设计与决策论证见 `research-wiki/designs/2026-08-19-issue14-admission-wait-policy-design.md`。 升级后如果 `wait` 档的等待时长或失败 reason 与预期不符,欢迎带遥测数据再开 issue。
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: iomgaa/PolyGateway#14