Independent verification found the first cut had swapped one bug for a worse one. The budgets were split by "did we send a request", so a 429 attempt counted as productive — but 429 is exempt from the retry budget, so its time burned neither budget. Against a queueing gateway that holds the request for the full timeout before answering 429, a call could hang for 301 attempts / 25.2 hours, measured, versus 301 seconds before the change. The split is now by which budget the time consumes: time that burns max_attempts is excluded from stall, time that does not (429 attempts included) belongs to stall. Measured again: back to one attempt / 301s. Only the chat loop needs this — embedding and ocr count 429 against max_attempts unconditionally, so the gap never existed there. The stall verdict moved into _stalled(), which both call sites had duplicated, to keep __call__ under the complexity gate.
18 KiB
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 记录该先例),不新建模块、不动依赖层次。
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
调用点改动(三处循环同款):
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)
否决理由三条:
- 治标。它把"两个预算重叠计费"这个缺陷固化成一条配置契约,要求配置方绕开它,而不是消除它。
- 约束值不可接受。要让重试预算真正可用,须
stall_window > timeout × max_attempts(本机 900s)。stall 兜底随之迟钝到 900s 才触发,饱和期无限循环的防护近乎失效——修好一个洞,挖开另一个。 - 挡不住残余情形。即便配到 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 配置可能需要相应调小。