OpenProgram · 设计文档
范围:一次 runtime.exec() 从发起到失败/超时的完整路径——重试、退避、连接/流超时、错误分类、外层 watchdog。
目的:先把现状写清楚再优化。优化项在文末(§8)单列并标注实现状态与对应 commit。配套:docs/design/llm-fault-tolerance.md(跨框架对比)、error-retry.md。
一句话结论。 provider 层的连接/流超时基础设施是成熟的——mid-stream stall(连上后不再吐 token)已被 SSE idle/data-stall/total 预算主动限住。真正会导致「卡很久没有新行」的是
三层重试循环相乘 +
deadline 不穿透 +
外层 watchdog 被 except 吞掉。三者叠加,一次持续的 ConnectError 能跑数百秒到上千秒。
4e64ac95 — deadline 穿透 + ExecInterrupt + 不跨层重试 + OPENPROGRAM_EXEC_TIMEOUT_S74bfd06 — SampleTimeoutError(ExecInterrupt) + --exec-timeout-s + 按 LLMError.reason 分类 + auth 中止 + reraise_if_fatal系统里有三种语义不同的「超时」,分属不同层、由不同代码负责。把它们混在一起是大多数困惑的来源。
一次 HTTP 请求 / 一条 SSE 流最多等多久;chunk 之间最大间隔。由 httpx 超时 + TCP keepalive + codex SSE 解析预算共同保证。
runtime.exec(timeout_s=)整个 exec(含全部 retry sleep)的 wall-clock 上限。当前是「attempt 之间的软 deadline」,不向下穿透到内层 stream 重试,且默认 None=无界。
一条 benchmark 样本 / 一个 agent turn 的总预算(OCR + 多轮 crop + gate + review + 重试)。只有调用方知道。ScreenSpot 现用 signal.alarm,会被框架吞掉。
从 benchmark 一路到网络 I/O。每层右下角标注它自己持有的超时/重试控制。注意有 三层独立的重试循环(locator、exec、stream_retry),彼此不共享 wall-clock。
signal.alarm(sample_timeout_s) 软中断;locator 内部还有自己的多轮重试。benchmark 侧(不在本仓)for attempt in range(max_retries) 包 except Exception;deadline 只在 attempt 之间检查,不打断正在跑的 _call。runtime.py:750 / :780 / :754_call_via_providers → _run_async()。runtime.py:771 / :981 / :1007asyncio.run(coro) 跑在当前线程(CLI/benchmark 即主线程);已有 loop(web/worker)→ offload 到 线程池 worker 线程。runtime.py:1248–1261for attempt in range(max_attempts) 包 except Exception;ConnectError 判 retryable=True。纯按次数,完全不知道任何 deadline。stream_retry.py:230 / :238 / :51await asyncio.wait_for(line_iter.__anext__(), timeout=wait),wait = min(idle_left, stall_left, total−now)。mid-stream stall 在这里被主动限住。openai_codex.py:407 / :447 / :449 / :424这些都已经在生效,默认值全部可经 env 覆盖。集中定义在 providers/utils/timeouts.py 与 http_client.py。
| 防御 | 默认值 | env 覆盖 | 生效点 | 状态 |
|---|---|---|---|---|
| connect 超时 | 30s | OPENPROGRAM_HTTPX_CONNECT_TIMEOUT_S | timeouts.py:39 | on |
| write / pool 超时 | 30s | …_WRITE_/_POOL_… | timeouts.py:40–41 | on |
| TCP keepalive 探死连接 | ~60s (30+10×3) | OPENPROGRAM_TCP_KEEPALIVE | http_client.py:48–79 | on |
| SSE idle(无任何字节) | 1800s | OPENPROGRAM_SSE_IDLE_TIMEOUT_S | timeouts.py:45 / codex:449 | on |
| SSE data-stall(无真实 data) | 900s | OPENPROGRAM_SSE_DATA_STALL_TIMEOUT_S | timeouts.py:47 / codex:447 | on |
| SSE 单流 total 上限 | 7200s | OPENPROGRAM_SSE_TOTAL_TIMEOUT_S | timeouts.py:49 / codex:424 | on |
| httpx read backstop | 1860s (idle+60) | OPENPROGRAM_HTTPX_READ_TIMEOUT_S | timeouts.py:52–60 | on |
| 大 prompt 预算缩放 | ×1.25–2.0 | 按 context_tokens 分档 | timeouts.py:65–89 | on |
| force-IPv4 / proxy 逃生 | off / 自动 | OPENPROGRAM_FORCE_IPV4, HTTP(S)_PROXY | http_client.py:92–101 | on |
_parse_sse_stream 用 asyncio.wait_for 主动限在 idle/data-stall/total 三个预算内——不是裸奔。所以「provider 缺 read-timeout」的判断对 codex 不成立。一次 exec() 内部的重试逻辑。关键:deadline 检查只发生在两次 _call 之间(虚线框),无法打断正在执行的 _call。
# runtime.py:746–818(精简) _deadline = start + timeout_s # timeout_s=None ⇒ _deadline=None ⇒ 整段失效 for attempt in range(max_retries): # 默认 6(_default_max_retries, :58) ┌── 软 deadline 检查(仅 attempt 之间)─────────────┐ if _deadline and now() >= _deadline: # :754 raise LLMError(reason=TIMEOUT, retryable=False) └──────────────────────────────────────────────────┘ try: reply = self._call(...) # :771 同步阻塞,无 timeout 入参;卡这里时上面的检查到不了 break except (TypeError, NotImplementedError): raise # 编程错误,不重试 except Exception as e: # :780 ← 这里吞掉一切,包括外来的 TimeoutError permanent = _is_permanent_error(e) # :782 只匹配图片/auth marker if permanent or attempt == max_retries-1: raise _build_llm_error(...) # → LLMError(reason, retryable, cause, …) sleep_s = _retry_sleep_seconds(attempt) # 1.5·2^n,±25% 抖动;attempt5 ≈ 48s(:81) if _deadline and now()+sleep_s >= _deadline: # :801 退避会越界就提前判 TIMEOUT raise LLMError(reason=TIMEOUT, retryable=False) time.sleep(sleep_s) # :818
_is_permanent_error 只认两类 runtime.py:104–120marker 列表只有:not a valid image / invalid image / image data is not / login expired / login failed / re-auth / unauthorized / invalid api key。任何超时类异常都不命中 → permanent=False → 会被重试。这正是外层 SampleTimeoutError 被当 transient 吃掉的直接原因。
持续 ConnectError(死路由 / VPN 黑洞 / proxy 不通,日志即 openai-codex stream retry … ConnectError)时,三层循环逐层相乘,没有任何一层用 wall-clock 封顶:
| 循环 | 位置 | 次数 | 单次代价 | 退避 | deadline 感知 |
|---|---|---|---|---|---|
| #3 内层 stream | stream_retry.py:230 | max_attempts=3 | connect ≤30s | 1→2s(封顶30s) | 无 |
| #2 中层 exec | runtime.py:750 | max_retries=6 | = 一整个内层循环 | 1.5→48s | 软 / 需传 timeout_s |
| #1 外层 locator | benchmark 侧 | 多轮 | = 一整个中层循环 | — | 无 |
ConnectError 在内层已经被判 retryable=True 并重试满 3 次、抛出 ProviderStreamError(retryable=True);交到 exec 后,_is_permanent_error 不命中 → exec 又用全新的 6× 预算重试同一个传输错误。同一个传输错误被两层各自重试,这才是把 ~90s 的问题放大成 600s+ 的根本。4e64ac95。 exec 把单一 deadline 穿透进 stream_retry(每 attempt 前 + 退避前都检查),且 stream_retry 耗尽预算时给异常打 transport_exhausted 标志,exec 见到就不再用新预算重试 —— 18 次(3×6)塌回内层 3 次。_parse_sse_stream 的 SSE 等待也 clamp 到 min(budget, 剩余 deadline),单条流不再越过预算。ScreenSpot 用 signal.alarm 想在 sample 总预算到点时硬停,但它抛的异常正好撞进上面两层的 except Exception。
raise SampleTimeoutError(继承 TimeoutError → Exception)。except Exception(:238)或 exec 的 except Exception(:780),被当成「一次普通调用失败」。_is_permanent_error 不命中 → permanent=False → 继续 retry。sample 语义(「整条样本已超时、必须停」)丢失。signal.alarm 是一次性的,已消耗;外层 finally: alarm(0) 要等 run_one() 返回才执行——但它正卡着。此后没有任何硬保护,继续跑。_run_async 把 asyncio.run offload 到线程池 worker 线程(runtime.py:1260)。Python 只向主线程投递信号,所以 signal.alarm 在这种语境里根本落不到正在跑的 _call 上——软 watchdog 完全无效。4e64ac95 + harness 74bfd06。 SampleTimeoutError 改继承 ExecInterrupt(BaseException),两层 except Exception 都吞不住它 —— 它穿过 OpenProgram 重试层、由 run_one() 在 sample 边界接住记 sample_timeout(步骤 4/5 不再发生)。worker 线程语境里信号本就落不到,改由 --exec-timeout-s → OPENPROGRAM_EXEC_TIMEOUT_S 的 call-level deadline 兜底(不依赖信号),且 _run_async 现在把 deadline contextvar 带进 worker 线程。exec 放弃时抛的是结构化 LLMError(errors.py:62),已经带够了上层精确计分所需的字段——上层不该一律记成 wrong_format。
ErrorReason 枚举 errors.py:31TRANSPORT · RATE_LIMIT · PROVIDER_INTERNAL · AUTHENTICATION · AUTHORIZATION · INVALID_REQUEST · CONTEXT_LENGTH · CONTENT_POLICY · TIMEOUT · UNKNOWN
LLMError 字段 errors.py:62–85reason · retryable · http_status · retry_after_s · attempts · elapsed_s · had_image · provider · model · last_error_type · cause
classify_error 判定顺序 errors.py:191–258| 优先级 | 依据 | 判定 | retryable |
|---|---|---|---|
| 1 | HTTP 429 | RATE_LIMIT | 是 |
| 1 | HTTP 500/502/503/504 | PROVIDER_INTERNAL | 是 |
| 1 | HTTP 401 / 403 | AUTHENTICATION / AUTHORIZATION | 否 |
| 1 | HTTP 400(+context/policy marker) | INVALID_REQUEST / CONTEXT_LENGTH / CONTENT_POLICY | 否 |
| 2 | 异常类型名 ∈ 传输集合 ConnectError / ReadTimeout / …(stream_retry.py:72) | TRANSPORT | 是 |
| 3 | 消息子串兜底(auth / invalid / context / policy / rate-limit / 传输名) | 对应 reason | 视类 |
| — | 都不命中 | UNKNOWN | 否 |
注意:override_reason=TIMEOUT 的 deadline 命中一律 retryable=False(runtime.py:170–176);其余「预算耗尽但类型本可重试」保持 retryable=True,含义是「换个新预算可再试」,不是「立刻重试」。
两条线并行、职责不同:框架侧 #1–#4 让任何调用方(channels worker、web turn、benchmark)都不被嵌套重试拖死;harness 侧 #5 负责 sample-level 计分与可靠 watchdog。子进程不替代框架修复,框架修复也不免除 runner 的 watchdog。#1–#4 已在框架 4e64ac95 落地,#5 在 harness 74bfd06 落地(子进程隔离暂缓);#6 暂缓。
| # | 修复点 | 为什么 | 价值 | 状态 |
|---|---|---|---|---|
| 1 | 把单一 deadline 从 exec 穿透到 _call → codex _run → stream_retry(SSE wait 取 min(budget, 剩余)) | 现在 exec(timeout_s) 只管中层、且要调用方传;内层 stream_retry + SSE 预算都不知道它。穿透后才是真 end-to-end,溢出 ≤ 一次 connect。 | 最高 | 已实施 4e64ac95新 providers/utils/deadline.py(ContextVar);exec/async_exec set+reset;stream_retry & codex 读取 |
| 2 | 传输层错误不跨层重复重试 | stream_retry 已耗尽 3 次的 ProviderStreamError 到 exec 又被重试 6 次 = 18 次。exec 应识别「provider 自己的传输预算已耗尽」,几乎不再重试。 | 高 | 已实施 4e64ac95stream_retry 打 transport_exhausted 标志;exec 见到即不再重试(reason 仍诚实 TRANSPORT/retryable) |
| 3 | 引入 ExecInterrupt(BaseException) 硬中断契约 | exec 与 stream_retry 都有 except Exception;BaseException 同时穿透两层,外部 watchdog 一抛即退。 | 高 | 已实施 4e64ac95errors.ExecInterrupt;两层显式 except ExecInterrupt: raise |
| 4 | exec(timeout_s) 给默认值 / 入口必传 | 现默认 None=无界。dispatcher / worker / benchmark 任一未 arm 的调用方都裸奔。 | 中 | 已实施 4e64ac95OPENPROGRAM_EXEC_TIMEOUT_S 进程级默认;默认 0=无界(opt-in,不改既有行为) |
| 5 | ScreenSpot:可靠 watchdog + 按 LLMError.reason 计分(+ 子进程隔离) | sample-level 总预算只有 runner 知道。auth 直接停 run、timeout 单列、传输失败单列,不污染 wrong_format。 | harness 侧 | 部分 74bfd06SampleTimeoutError(ExecInterrupt)、--exec-timeout-s、reason 分类 + provider_auth、auth 中止 run、reraise_if_fatal 已做;子进程硬隔离暂缓(watchdog 已可靠 + 有 call-level deadline,优先级降低) |
| 6 | 同步 _call 线程 future 硬上限(兜底) | 仅防「单次 _call 真无限挂」。SSE 预算已限住 stall,故降级为 belt-and-suspenders。 | 兜底 | 暂缓 副作用见下;#1 已让 stall/connect 有界,单独 _call 无限挂的窗口已很小 |
_call 线程仍在后台跑,直到 socket 自己报错才回收;其间它可能仍去 append model-call node / 写 store/DAG,造成状态污染。所以 #6 只能当兜底,且必须依赖 #1 的 read-timeout 让泄漏窗口有界。理想路径是推动 provider 暴露 async,exec 主走 asyncio.wait_for(能真正取消到 await 点,无泄漏线程),同步线程方案仅作无 async 路径时的退路。agentic_programming/runtime.py(exec/retry/bridge)、
providers/utils/timeouts.py、providers/utils/http_client.py、
providers/utils/stream_retry.py、providers/utils/errors.py、
providers/openai_codex/openai_codex.py。
行号为撰写时快照,优化落地后需同步更新。配套对比见 llm-fault-tolerance.md。