本节摘要:本节处理验证判定为「失败」之后的事。核心主张:重试之前先分类——不同类别的失败,正确反应完全不同。三类主分类:瞬态网络(超时、连接重置,重试有效且应该带指数退避)、选择器漂移(页面改版、元素移位,同参重试无效,正确反应是换感知——重新 observe 定位再执行)、指令歧义(模型选错目标或指令含糊,重试反而放大损害,正确反应是改写指令或升级人工)。在此之上搭三级回退链:L0 同参重试、L1 observe 重定位后重试、L2 人工工单,每级有进入条件与出口。超时预算把「还能救多久」变成显式数字:任务总预算分解到步骤,预算耗尽即走人工,不许无限救援。死循环治理作为循环类失败专案:重复动作检测与步数上限。示例为 Python 与 JS 双语,含一个可复用的回退链执行器骨架。
第 3.2 节立过重试纪律:上限加退避、失败降级检视。本节把「检视」展开成分类学,因为不分类的重试是灾难放大器:
| 类别 | 典型信号 | 同参重试有效吗 | 正确反应 |
|---|---|---|---|
| 瞬态网络 | 超时、连接重置、偶发 5xx | 有效(下次就好) | 指数退避后重试 |
| 选择器漂移 | 元素找不到、定位到了错元素 | 无效(页面已经变了) | 重新 observe 定位再执行 |
| 指令歧义 | 点了相邻同名按钮、填错字段 | 有害(重复错误动作) | 改写指令或升级人工 |
| 权限与业务拦截 | 401、403、业务校验提示 | 无效 | 检查登录态与任务前提 |
| 模型误判 | 断言复核发现判定错 | 不适用 | 换断言口径,第 6.1 节复核队列 |
三行主分类加两行补充,构成失败分类学的第一版。分类的价值在「反应分流」:瞬态走 L0,漂移走 L1,歧义直接 L2——把这张表读成回退链的路由表即可。权限与业务拦截单列是因为它们常被误诊为漂移:元素明明在、就是点不动,这时候该查的是第 7.1 节的登录态与任务前提,而不是反复重定位。
回退链的形态:
失败发生 ▼ L0 同参重试(仅瞬态类,指数退避,上限 3 次)──成功──▶ 继续任务 │ 重试耗尽 / 判为漂移 ▼ L1 observe 重定位(重看页面再选目标)──成功──▶ 刷新缓存(第 3.3 节的自愈机制) │ 重定位仍失败 / 判为歧义 / 预算耗尽 ▼ L2 人工工单(附 6.1 节三件套证据)──▶ 人处理完,结论回写知识库
两级设计细节值得点破。其一,L1 的本质是换感知:漂移意味着「上一次对页面的理解过期了」,重试旧理解没有意义,必须重新 observe 生成新的候选再执行——这正是第 3.3 节缓存失效自愈机制(命中失败回退 act 并刷新)的推广形态。其二,L2 不是失败的垃圾桶而是流程的一等公民:工单要带第 6.1 节的三件套证据,人处理完的结论(「这个站点改版了,入口换位置了」)应该回写进指令库或任务定义——救援的产出反哺系统,回退链才越用越短。
没有时间约束的回退链会变成资源黑洞:一个任务占着浏览器实例反复救援,队列后面排着的任务全部延迟。超时预算的做法:
| 层级 | 预算(示意值) | 用途 | 耗尽动作 |
|---|---|---|---|
| 任务总预算 | 5 分钟 | 整个任务的生命周期上限 | 转人工工单,释放实例 |
| 步骤预算 | 30 秒 | 单个动作含救援的上限 | 进入 L1 或 L2 |
| L0 重试预算 | 3 次或 45 秒 | 同参重试总量 | 降级到 L1 |
| 全局并发救援上限 | 实例数的两成 | 防救援风暴 | 新失败直接进 L2 |
预算数字本身不重要(按任务定),重要的是预算显式存在且可配置。「重试三次」写死在代码里和写在配置里,是两种工程物种。预算还有一个隐含收益:它逼你在任务设计期就想清楚「这个任务值得救多久」——5 分钟救不回来的任务,多半 50 分钟也救不回来。
Python 版(分类器加回退链):
# retry_policy.py —— 失败分类与三级回退链(写法示意,以官方文档为准) import asyncio import time TRANSIENT = "transient" # 瞬态网络 DRIFT = "drift" # 选择器漂移 AMBIGUOUS = "ambiguous" # 指令歧义 def classify(error: Exception) -> str: text = str(error).lower() if "timeout" in text or "net::" in text or "reset" in text: return TRANSIENT if "strict mode violation" in text or "waiting for" in text: return DRIFT return AMBIGUOUS async def with_retry(step, attempts: int = 3, base_delay: float = 1.0) -> None: """L0 同参重试:仅瞬态类,指数退避""" for i in range(attempts): try: await step() return except Exception as exc: # noqa: BLE001 if classify(exc) != TRANSIENT: raise await asyncio.sleep(base_delay * 2 ** i) raise RuntimeError("L0 重试预算耗尽") async def run_step(step, relocate, budget_s: float = 30.0) -> None: """回退链编排:L0 重试 → L1 重定位 → L2 人工""" deadline = time.monotonic() + budget_s try: await with_retry(step) return except Exception as exc: if time.monotonic() > deadline: print("预算耗尽,直接转人工:", exc) raise try: await relocate() # 换感知:重新 observe 页面,产出新定位 await step() return except Exception as exc: print("L1 失败,生成人工工单(附三件套证据):", exc) raise async def demo() -> None: async def flaky() -> None: raise TimeoutError("waiting for response timed out") await run_step(flaky, relocate=lambda: None) asyncio.run(demo())
JS 版:
// retry_policy.mjs —— 失败分类与三级回退链(写法示意,以官方文档为准) /** 失败分类 @param {string} message 错误文本 @returns {"transient"|"drift"|"ambiguous"} 类别 */ function classify(message) { const text = message.toLowerCase(); if (text.includes("timeout") || text.includes("net::") || text.includes("reset")) { return "transient"; } if (text.includes("strict mode violation") || text.includes("waiting for")) { return "drift"; } return "ambiguous"; } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /** * L0 同参重试:仅瞬态类,指数退避 * @param {() => Promise<void>} step 步骤函数 * @param {number} [attempts=3] 上限 @param {number} [baseMs=1000] 基础退避 */ async function withRetry(step, attempts = 3, baseMs = 1000) { for (let i = 0; i < attempts; i++) { try { await step(); return; } catch (err) { if (classify(err.message) !== "transient") throw err; await sleep(baseMs * 2 ** i); } } throw new Error("L0 重试预算耗尽"); } /** * 回退链编排:L0 重试 → L1 重定位 → L2 人工 * @param {() => Promise<void>} step 步骤 @param {() => Promise<void>} relocate 重定位 * @param {number} [budgetMs=30000] 步骤预算 */ async function runStep(step, relocate, budgetMs = 30000) { const deadline = Date.now() + budgetMs; try { await withRetry(step); return; } catch (err) { if (Date.now() > deadline) { console.error("预算耗尽,直接转人工:", err.message); throw err; } } try { await relocate(); // 换感知:重新 observe 页面,产出新定位 await step(); } catch (err) { console.error("L1 失败,生成人工工单(附三件套证据):", err.message); throw err; } } let n = 0; await runStep( async () => { n += 1; if (n < 3) throw new Error("waiting for response timed out"); }, async () => {} ); console.log("回退链示例跑通");
两个骨架都把分类器写成独立函数——因为分类规则会随第 11.2 节的错误 taxonomy 持续扩充,它应该是系统里最常被修改的代码之一,值得独立成单元。
死循环是失败恢复的反面形态:任务没有报错,但也没有进展——模型在两个动作间来回摆(点了添加又点了删除),或在同一页面反复 observe。第 2.1 节给过步数上限,这里是完整工具箱:
| 手段 | 机理 | 参数建议(示意) |
|---|---|---|
| 步数上限 | 硬顶,防资源黑洞 | 任务的合理步数乘三 |
| 重复动作检测 | 相邻 N 步动作序列重复即告警 | 窗口 4 步,重复 2 轮 |
| 状态相似度检测 | 连续 observe 结果几乎不变 | 快照哈希对比 |
| 进度断言 | 关键里程碑处插第 6.1 节断言 | 不可逆动作前必插 |
产品化的关键一条:检测到疑似死循环不要直接杀任务,先注入一条提示(「你似乎在重复操作,请重新阅读任务目标」)给模型一次自救机会,仍无进展再终止转人工——这比直接终止的拯救率高得多,成本只是一次模型调用。死循环的完整处置记录应进第 8.3 节的审计日志:它是最有价值的复盘素材。
| 坑 | 现象 | 排查与处理 |
|---|---|---|
| 不分类盲目重试 | 漂移被重试三次才升级 | 分类器前置,漂移直接跳 L1 |
| 救援无预算 | 少数任务占满实例池 | 三层预算显式配置(任务、步骤、重试) |
| 工单不带证据 | 人工接手后从零排查 | L2 强制附带三件套 |
| 死循环一刀切 | 可自救任务被误杀 | 先注入提示再终止 |
| 误诊权限失败为漂移 | 反复重定位仍 401 | 401/403 单列,先查登录态(第 7.1 节) |
验证与恢复都住在软件层,但它们的地基是硬件层的:浏览器实例从哪来、登录态存在哪、凭证怎么注入。下一章进入会话与基建——先把地基打好,第 6 章的恢复链才有稳定的会话可以救援。