diff --git a/research-wiki/designs/2026-08-06-issue8-stall-budget-design.md b/research-wiki/designs/2026-08-06-issue8-stall-budget-design.md index 1407e2e..a905b5d 100644 --- a/research-wiki/designs/2026-08-06-issue8-stall-budget-design.md +++ b/research-wiki/designs/2026-08-06-issue8-stall-budget-design.md @@ -30,7 +30,7 @@ | 时间性质 | 构成 | 应由谁治理 | 耗尽后 | |---|---|---|---| -| **生产性** | 真实发出请求并等待响应(含耗满 `timeout_s` 的超时、TTFT 等待、流式读取) | `max_attempts`(重试预算) | `retry_exhausted` | +| **生产性** | 一次尝试的完整生命周期(发请求、等响应含耗满 `timeout_s` 的超时/TTFT/流式读取,以及该次尝试的记账与遥测收尾) | `max_attempts`(重试预算) | `retry_exhausted` | | **非生产性** | 429 退避、配额 wait 轮询、熔断冷却轮询、AIMD 排队 | **无人治理**(429 不计 `fails`)→ 正是 stall 的职责 | `stalled` | **缺陷即:生产性时间同时向两个预算计费。** 而 stall 预算(默认 300s)远小于重试预算(`3 × 300s`),必然先耗尽,于是重试预算在超时场景下**永远用不上**——issue 观察到的"静默失效"就是这个重叠计费的直接后果。 @@ -57,6 +57,8 @@ | 真实尝试(`_attempt` 内) | 重试预算 `max_attempts` | | 其余一切等待 | stall 预算 `stall_window_s` | +**"生产性"的边界即 `_attempt` 的边界**——包含该次尝试的记账(`record_success`/`mark_progress`)与遥测收尾,而不止于"等响应"。这是有意的:这些收尾是"尝试已有结论"之后的动作,不是"在等待重试机会"的停滞;把它们计入 stall 会让遥测抖动参与判死,与「遥测写失败降级不冒泡」所守的"遥测不得影响主路径判决"同精神。其耗时本也在毫秒量级。 + 这与库内既有原则**同构**:429 不烧重试预算,所以 429 等待烧 stall 预算;真实尝试烧重试预算,所以它不烧 stall 预算。 ### 3.2 为什么取补集,而不是逐处标记 sleep @@ -127,7 +129,9 @@ async with clock.attempting(): # 包裹真实尝试 ### 3.5 429 饱和场景下兜底仍然有效(正确性验证) -修改后必须确认 stall 兜底没有被削弱:429 往返本身是生产性时间,不再计入 stall。但一次 429 往返是**快速失败**(网关立即拒绝,不耗 `timeout_s`),而其后的退避 sleep 是非生产性的且随尝试次数指数增长。故饱和期内非生产性时间占绝对多数,`stalled_s` 仍会在接近 `stall_window_s` 的时间内累满 —— 兜底有效,只是触发时刻比修改前晚了"若干次 429 往返"的量级(秒级),可忽略。 +修改后必须确认 stall 兜底没有被削弱:429 往返本身是生产性时间,不再计入 stall。 + +注意退避时长在纯 429 场景下**不随轮次增长**:429 免预算使 `fails` 恒为 0,`retry.py:243` 的 `max(fails, 1)` 令退避恒定在 `backoff_base_s` 档(或取 `Retry-After` 提示的较大值)。但这不影响结论——每轮的构成是「一次**快速失败**的 429 往返(网关立即拒绝,不耗 `timeout_s`,毫秒至秒级)」+「一段恒定退避 sleep(`backoff_base_s` 量级)」,后者是非生产性且**每轮都在累加**。故饱和期内非生产性时间仍占绝对多数,`stalled_s` 单调逼近 `stall_window_s`,兜底有效;触发时刻仅比修改前晚了"累计 429 往返耗时"的量级,可忽略。 ## 4. 旧版行为审计(stall 子系统逐条)