# stall 判定改为非生产性等待口径设计(Issue #8)

- **日期**: 2026-08-06
- **来源**: Gitea Issue #8(本机全套件跑 391.67s,1 failed;失败源于单次 300s 超时耗尽 stall 窗口,基于 1.1.0 源码核查)
- **状态**: **已批准(2026-08-06)**,待 `writing-plans`
- **触发档位**: 强制(变更治理行为——判死条件的度量口径,是库对下游的承诺)
- **方案范围**: 人类明确要求单一方案,故本文不列平行备选,仅在 §5 记录被否决路线及否决理由(体例沿用 Issue #7 设计)

## 1. 目标与非目标

| | 内容 |
|---|---|
| **G1** | 消除"单次超时即判 scope 级死亡"——`timeout_s` 与 `stall_window_s` 的隐式耦合彻底解除,重试预算在超时场景下真实可用 |
| **G2** | 使 stall 判定的度量对象与它的职责一致:**它治理的是无人治理的非生产性循环,不是已被重试预算治理的真实尝试** |
| **G3** | 三条治理循环(chat / embedding / ocr)口径一致,计时逻辑收敛为单一共享单元,杜绝第四次复制 |
| **G4** | 配置方不再需要心算 `stall_window > timeout × max_attempts`;`.env.example` 注释与实际语义对齐 |
| **非目标** | 不改 `progress_age_s()` 的 `inf` 语义(见 §3.4);不新增装配期校验(见 §5.2);不新增配置项;不改 `AllSourcesExhausted` 的字段与 `reason` 取值;不给 embedding/ocr 新增主循环判死路径(见 §5.4);不改 429 免预算、AIMD、选源、熔断任何既有行为 |

### 1.1 Issue 前提的三处修正(按 1.1.0 源码核实)

| Issue 原文 | 实际情况 |
|---|---|
| 失效点为 `retry.py:216` 一处 | **三处同构**:`retry.py:216`(主循环)、`retry.py:305` / `embedding.py:247` / `ocr.py:272`(`_on_no_runnable`)。四个判定点共用同一个墙钟 `entered_at`,故 embedding/ocr 在"先超时一次、再遇到无可用源"时同样误判——issue 只覆盖了 chat |
| 建议方向 1:装配期校验 `stall_window_s > max(timeout_s)` | **不采纳**。它把耦合固化成契约而非消除耦合,且约束值须为 `timeout × max_attempts`(本机即 900s),会让 stall 兜底迟钝到近乎失效。详见 §5.1 |
| 建议方向 2:`inf` 不参与判死 | **不采纳**。在新口径下 `inf` 从"有害恒真"变回"正确的保守默认";且它会反转已被测试钉住的既有行为。详见 §3.4 与 §5.3 |

## 2. 根因:两个预算重叠计费

`retry.py:214-215` 的注释自述这处判定是「429 免预算后的兜底,防饱和期无限循环」——它治理的对象是**非生产性循环**。但条件 A `now - entered_at > stall` 度量的是**墙钟总耗时**,无法区分两类性质相反的时间:

| 时间性质 | 构成 | 应由谁治理 | 耗尽后 |
|---|---|---|---|
| **生产性** | 一次尝试的完整生命周期(发请求、等响应含耗满 `timeout_s` 的超时/TTFT/流式读取,以及该次尝试的记账与遥测收尾) | `max_attempts`(重试预算) | `retry_exhausted` |
| **非生产性** | 429 退避、配额 wait 轮询、熔断冷却轮询、AIMD 排队 | **无人治理**(429 不计 `fails`)→ 正是 stall 的职责 | `stalled` |

**缺陷即:生产性时间同时向两个预算计费。** 而 stall 预算(默认 300s)远小于重试预算(`3 × 300s`),必然先耗尽,于是重试预算在超时场景下**永远用不上**——issue 观察到的"静默失效"就是这个重叠计费的直接后果。

`.env` 里 `TIMEOUT_S=300` 与 `_DEFAULT_STALL_WINDOW_S=300.0`(`config.py:60`)相等只是把它暴露得最快;只要 `timeout_s ≥ stall_window_s / 1`,一次超时就够。

### 2.1 两条佐证:`inf` 恒真是遗漏而非设计

| 证据 | 出处 | 含义 |
|---|---|---|
| `_PROGRESS_TTL_S = 3600  # 远大于任何 stall_window,防进度键过期造成假停滞` | `backends/redis/limiter.py:32` | 「无 progress 记录 ≠ 停滞」早已是设计共识,作者用超长 TTL 规避了"键过期"这一路径,但 TTL 再长也救不了"**从来没写过**"——冷启动是同类情形的漏网之鱼 |
| `test_global_stale_but_local_fresh_keeps_waiting` docstring 写「仅全局超窗(从未出餐 age=inf)」 | `tests/unit/test_backpressure.py:120-121` | 现有测试把 `inf` 当作"全局超窗成立"钉住了;`test_both_windows_exceeded_raises_stalled`(:89)更是**全靠 `inf` 恒真**才能触发判死 |

## 3. 选定方案:双预算正交模型

### 3.1 一句话

**stall 计时器只累计非生产性等待时间**:`stalled_s = (now − entered_at) − 真实尝试累计耗时`。

两个预算自此正交,各管一段,无缝覆盖调用的全部时间:

| 花在哪 | 烧哪个预算 |
|---|---|
| 真实尝试(`_attempt` 内),**429 除外** | 重试预算 `max_attempts` |
| 其余一切等待,**含 429 尝试本身** | stall 预算 `stall_window_s` |

> **划分依据是"谁消耗重试预算",不是"是否发出了请求"**(2026-08-06 实施期订正,见 §3.6)。初稿按后者划分,使 429 尝试两个预算都不烧。

**"生产性"的边界即 `_attempt` 的边界**——包含该次尝试的记账(`record_success`/`mark_progress`)与遥测收尾,而不止于"等响应"。这是有意的:这些收尾是"尝试已有结论"之后的动作,不是"在等待重试机会"的停滞;把它们计入 stall 会让遥测抖动参与判死,与「遥测写失败降级不冒泡」所守的"遥测不得影响主路径判决"同精神。其耗时本也在毫秒量级。

这与库内既有原则**同构**:429 不烧重试预算,所以 429 等待烧 stall 预算;真实尝试烧重试预算,所以它不烧 stall 预算。

### 3.2 为什么取补集,而不是逐处标记 sleep

两种实现都能达到 §3.1 的语义,选**取补集**(总时间减去 `_attempt` 耗时):

| 维度 | 取补集(选定) | 逐处标记 sleep(否决) |
|---|---|---|
| 埋点数量 | 每条循环 **1 处**(`_attempt` 调用点) | chat 3 处、embedding/ocr 各 2 处,共 7 处 |
| 演进安全性 | **默认安全**:将来新增任何等待路径自动计入 stall,兜底不会漏 | 默认危险:新增等待路径若忘记标记,即成新的 stall 盲区 |
| 语义可读性 | 「stall 时间 = 总时间 − 花在真实尝试上的时间」,一句话说清 | 需读者遍历全部标记点才能确认覆盖完整 |

`_attempt` 是纯生产性的:permit 获取、熔断准入、AIMD 判定全部在 `_pick_runnable` 内完成,`_attempt` 进入时已持 permit,内部只做"发请求 + 记账"。故补集口径不会把非生产性时间误算为生产性。

### 3.3 共享单元:`StallClock`

计时逻辑提取为 `middleware/retry.py` 的模块级小类,embedding/ocr 复用——沿用 `backoff_delay` 已被两者复用的既有手法(`tests/unit/test_backpressure.py:258` 记录该先例),不新建模块、不动依赖层次。

```python
class StallClock:
    """调用级 stall 计时器: 只累计非生产性等待(设计 §3.1)。

    实例per调用创建, 严禁提升为实例属性——并发调用共享会互相污染。
    """

    def __init__(self, now: Callable[[], float]) -> None:
        self._now = now
        self._entered_at = now()
        self._productive_s = 0.0

    def stalled_s(self) -> float:
        return self._now() - self._entered_at - self._productive_s

    @contextlib.asynccontextmanager
    async def attempting(self):
        started = self._now()
        try:
            yield
        finally:
            # 只做算术, 不吞任何异常——CancelledError 照常穿透(库铁律)
            self._productive_s += self._now() - started
```

调用点改动(三处循环同款):

```python
clock = StallClock(self._now)                       # 替换 entered_at = self._now()
...
if clock.stalled_s() > stall and await self._quota.progress_age_s() > stall:
    raise AllSourcesExhausted(..., reason="stalled", ...)
...
async with clock.attempting():                      # 包裹真实尝试
    outcome = await self._attempt(request, *picked, reasons, attempt_fails)
```

`_on_no_runnable` 的形参由 `entered_at: float` 改为 `clock: StallClock`(三处同改)。

### 3.4 `inf` 语义为何不动(本设计的核心权衡)

新口径下第一象限的含义变为:「**非生产性排队已耗满 `stall_window_s`,且整个 scope 从未出餐**」。此时判死是正当的——真的没有任何证据表明这个 scope 还活着,而调用方已经白等了一整个窗口。`inf` 由此从"有害的恒真"回归为"正确的保守默认"。

反过来,若同时改 `inf` 语义:

- 冷启动窗口内 stall 判定**完全失效**,429 饱和场景下 chat 主循环重新暴露无限循环风险(429 不计 `fails`,无其他兜底);
- 会反转 `test_both_windows_exceeded_raises_stalled` 钉住的行为,并与 CHS 保真蓝本分叉。

**一次改动解决问题,优于两次改动互相牵制。** 这是本设计只动条件 A 的理由。

### 3.5 429 饱和场景下兜底仍然有效(正确性验证)

修改后必须确认 stall 兜底没有被削弱。429 免预算使 `fails` 恒为 0,`retry.py` 的 `max(fails, 1)` 令退避恒定在 `backoff_base_s` 档(或取 `Retry-After` 提示的较大值),不随轮次增长。每轮构成为「一次 429 往返」+「一段恒定退避 sleep」,后者非生产性且每轮累加,`stalled_s` 单调逼近 `stall_window_s`,兜底有效。

**但这个论证在初稿里依赖一个未加保护的假设**:「429 往返是快速失败,毫秒至秒级」。§3.6 处理它不成立的情形。

### 3.6 订正:429 尝试必须退还给 stall 账(2026-08-06 实施期,独立验证发现)

**缺陷**:初稿按"是否发出请求"划分两个预算,于是 429 尝试的耗时算生产性。但 429 **不消耗重试预算**——它于是**两个预算都不烧**,掉进缝隙。§3.1 初稿声称的"无缝覆盖调用的全部时间"因此不成立。

**后果实测**(排队型网关:持满 `timeout_s` 才回 429,`timeout=300 / stall=300 / backoff_base=2 / rng=0`):

| | 尝试次数 | 墙钟 |
|---|---|---|
| 修复前(main) | 1 | 301s |
| 初稿口径 | **301** | **90,601s ≈ 25.2 小时** |
| 订正后 | 1 | 301s |

即初稿把一个 bug 换成了一个更严重的 bug——25 小时的挂起。

**订正**:划分依据改为**"谁消耗重试预算"**。429 免重试预算 → 429 尝试的耗时归 stall 治理,由 `StallClock.attempting()` yield 的句柄 `refund()` 退还。缝隙就此闭合,且这条规则比初稿更本质:两个预算按"由谁治理"划分,而非按"是否发出请求"这个表象。

**影响范围仅 chat**:embedding/ocr 无 429 免预算(无条件 `fails += 1`),429 照常烧重试预算,不存在缝隙,无需改动(与 §5.4 的分析一致)。

## 4. 旧版行为审计(stall 子系统逐条)

| 既有行为 | 处置 | 说明 |
|---|---|---|
| 双条件判死(本地超窗 ∧ 全局无进展超窗) | **保留** | 结构不变,只改条件 A 的度量口径 |
| 条件 A = 调用级累计、循环内不重置(CHS `governance.py:207`) | **保留** | `StallClock` 同样每调用一个实例、循环内不重置 |
| 条件 A 计入真实尝试耗时 | **替换** | 本设计的唯一行为变更 |
| 条件 B `progress_age_s()`,`inf` = 从未进展 | **保留** | 见 §3.4 |
| 本地 monotonic 与后端时钟刻意不混用 | **保留** | `StallClock` 只用注入的 `self._now`,不读后端时钟 |
| poll jitter ∈ [0.5p, 1.0p] 防惊群 | **保留** | 不触碰 |
| `fail_fast` 不进入 stall 判定 | **保留** | 不触碰 |
| 429 免预算(chat 独有) | **保留** | 不触碰;embedding/ocr 无此逻辑,故无对应缺口(§5.4) |
| `AllSourcesExhausted(reason="stalled")` 及其 `retry_after_s` 取值 | **保留** | 错误面零变更,下游 `except` 写法不受影响 |

**有意放弃**: 无。本设计不删除任何既有行为。

## 5. 被否决的路线

### 5.1 装配期校验 `stall_window_s > max(timeout_s)`(Issue 建议方向 1)

否决理由三条:

1. **治标**。它把"两个预算重叠计费"这个缺陷固化成一条配置契约,要求配置方绕开它,而不是消除它。
2. **约束值不可接受**。要让重试预算真正可用,须 `stall_window > timeout × max_attempts`(本机 900s)。stall 兜底随之迟钝到 900s 才触发,饱和期无限循环的防护近乎失效——**修好一个洞,挖开另一个**。
3. **挡不住残余情形**。即便配到 1200s,一次调用若在 429 轮询与超时上累计超过 1200s,条件 B 的 `inf` 仍恒真,双条件仍退化为单条件。坑只是被推远。

新口径下 `stall_window_s` 与 `timeout_s` 不再有任何耦合,**这条校验没有存在的理由**——不加校验、而是消除掉需要校验的耦合。

### 5.2 既有校验 `stall_window_s ≥ max(ttft_timeout_s)` 的处置

`config.py:240-247` 的 `_validate_stall` 被 `ARCHITECTURE.md` §7.3 记为契约补强 G6。新口径下 TTFT 等待属生产性时间,其 docstring 的理由「防把正常慢首包误判为卡死」**已不成立**。

**人类已定夺:保留校验,改写 docstring 说明新口径**。校验本身无害(不会误拒任何合理配置),保留可避免改动 ARCHITECTURE.md 既有契约、把本次改动的影响面控制在最小。docstring 改为说明"该校验在新口径下为保守冗余,TTFT 已不计入 stall"。

### 5.3 `inf` 不参与判死(Issue 建议方向 2)

见 §3.4:新口径下 `inf` 已无害,单独改它会制造冷启动兜底真空并反转既有测试。

### 5.4 给 embedding/ocr 补主循环 stall 判定

设计过程中一度提出(前提是"429 饱和时它们没有防无限循环兜底"),**核实后前提不成立,故否决**:

| 循环路径 | embedding/ocr 的兜底 |
|---|---|
| `picked is None` → `_on_no_runnable` 轮询(不烧 `fails`) | `_on_no_runnable` 内已有 stall 判定(`embedding.py:247` / `ocr.py:272`)✓ |
| 尝试失败 → `fails += 1` | `max_attempts` ✓ |

`retry.py:233-234` 的 429 免预算分支是 chat **独有**的(`embedding.py:191`、`ocr.py:216` 均为无条件 `fails += 1`,两文件亦无 `pacer`),主循环判定正是为它打的补丁。embedding/ocr 两条路径均已封闭,补齐等于凭空新增一条判死路径,使其比 chat 更易判死——纯 gold-plating。

## 6. 非功能维度

| 维度 | 回答 |
|---|---|
| **并发** | `StallClock` **每次调用创建一个实例**,是调用级局部状态,与被替换的 `entered_at` 局部变量同性质。严禁提升为实例属性(并发调用会互相污染计时)——docstring 已写明,单测钉住并发两路调用互不干扰 |
| **取消** | `attempting()` 的 `finally` 只做浮点加法,不含 `await`、不捕获任何异常,`CancelledError` 逐字穿透。既有 `test_cancellation_pierces_wait_loop` 继续有效,并新增一条"取消发生在 `_attempt` 内"的用例 |
| **降级方向** | 不变。stall 判定读取的 `progress_age_s()` 属准入侧,后端故障仍 fail-closed 抛 `GovernanceBackendError`(scope 级),不放行 |
| **幂等与重复** | `stalled_s()` 是纯读,可任意次调用;`attempting()` 可重入多次(每次尝试一次),累加语义天然幂等于"总生产性时间" |
| **持久化与原子性** | 不适用。纯进程内计时,无落盘、无后端写入,不新增任何 Redis 往返 |
| **性能** | 每次尝试新增两次 `self._now()` 调用与一次浮点加法,可忽略 |

## 7. 错误处理与测试策略

**错误分类**: 无变更。判死仍抛 `AllSourcesExhausted(reason="stalled")`,属 scope 级不可用(`GatewayUnavailableError` 家族),下游延期重投语义不变。

### 7.1 回归证据(先失败后通过)

核心用例 `test_single_timeout_does_not_exhaust_stall_budget`:`stall_window_s == timeout_s == 300`,第一次尝试推进 `FakeClock` 超过 300s 后抛 `TransientError`,第二次返回成功。

- **改前**:第二次尝试发出前即被判死,抛 `AllSourcesExhausted(reason="stalled")` → **失败**
- **改后**:重试预算正常生效,返回成功响应 → **通过**

embedding / ocr 各一条同构用例(经"先超时一次、再遇到无可用源"触发 `_on_no_runnable`)。

### 7.2 其余用例

| 用例 | 钉住什么 |
|---|---|
| 四象限现有四条(`TestStallQuadrants`) | 非生产性路径行为逐字不变;`test_both_windows_exceeded` 全程无真实尝试,`stalled_s` 等价于旧墙钟,**应原样通过** |
| `test_productive_time_excluded_from_stall` | 直接断言:仅靠真实尝试耗时无论多久都不触发判死 |
| `test_nonproductive_wait_still_triggers_stall` | 反向:纯轮询等待累满窗口仍正常判死(兜底未被削弱) |
| `test_saturation_429_still_stalls` | §3.5 的正确性验证:429 连续拒绝 + 退避,最终仍判死而非无限循环 |
| `test_cancel_inside_attempt_pierces` | 取消穿透 `attempting()` 的 `finally` |
| `test_concurrent_calls_do_not_share_clock` | 两路并发调用,一路长尝试不影响另一路的 stall 账 |

Redis 后端无需新增用例:本设计不改后端接口与 `progress_age_s()` 语义。

## 8. 交付清单(供 `writing-plans` 展开)

| # | 内容 |
|---|---|
| T1 | `middleware/retry.py` 新增 `StallClock`;主循环与 `_on_no_runnable` 改用之 |
| T2 | `embedding.py` / `ocr.py` 复用 `StallClock`,`_on_no_runnable` 形参改签名 |
| T3 | `config.py:240-247` `_validate_stall` docstring 改写(§5.2) |
| T4 | 测试:§7.1 回归三条 + §7.2 五条;`test_backpressure.py:121` docstring 订正 |
| T5 | `.env.example:41` 注释改写(删除误导性的"须 ≥ 最大源 TTFT",说明新口径);本机 `.env:37` 的临时缓解 `STALL_WINDOW_S=1200` 可回退默认(不入库,仅记录) |
| T6 | `ARCHITECTURE.md` §7.3 背压条目补记新口径与本设计指针;`CHANGELOG.md` 记治理行为变更 |
| T7 | Wiki 同步(`docs-convention.md` §2「治理行为变更」行):`解释-治理行为` + `指南-限流与熔断` |

**副作用提醒**: 修复后单次调用最坏耗时由 `stall_window_s` 抬升至 `max_attempts × timeout_s`(本机 900s)——这是重试预算恢复生效的**正确表现**,但 e2e 冒烟测试的最坏耗时随之变长,`tests/e2e` 的源 `timeout_s` 配置可能需要相应调小。
