Skip to content

s17 - 错误恢复

s15 的执行器是"一死全死"——一个节点报错,后面全部跳过。s16 加了权限控制,让你决定哪些操作需要确认。但还有一个问题没解决:错误发生了,然后呢? 这一章要做的事情,是让工作流在出错时能自己爬起来。

这一章要解决什么问题

真实世界的 API 不可靠。LLM 服务会限流(429),网络会超时,服务器会宕机(5xx)。如果你的工作流跑到一半遇到这些错误就直接死掉,那它只能在理想环境下工作——而理想环境不存在。

s15 的执行器看到错误就设 hasError = true,后面全部 skip。这太粗暴了。

有些错误值得重试。 网络超时,等一秒再试可能就通了。限流,等几秒再试也通了。这些叫"暂时性错误"(transient),重试是有意义的。

有些错误不值得重试。 参数传错了(400),认证失败(401),资源不存在(404)。重试一万次也不会变对。这些叫"永久性错误"(permanent)。

有些错误需要回退方案。 read_file 找不到文件,也许可以用 search_files 搜一下同名文件。主路径走不通,试试旁路。

有些错误需要回滚。 工作流执行了一半,部分节点已经修改了上下文。如果后面的节点失败了,上下文里留着半成品的数据。下次执行时,这些脏数据会干扰结果。需要把上下文恢复到执行前的状态。

这一章实现四层错误恢复:

  1. 分类:判断错误是暂时性的还是永久性的
  2. 重试:暂时性错误自动重试,指数退避
  3. 回退:主工具失败时尝试替代方案
  4. 回滚:彻底失败时恢复到执行前的状态

错误分类

第一步是判断错误的性质。这决定了后续所有策略。

typescript
export function classifyError(err: unknown): ErrorClassification {
  const message = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();

  // 提取 HTTP 状态码
  const statusMatch = message.match(/\b([45]\d{2})\b/);
  const statusCode = statusMatch ? parseInt(statusMatch[1], 10) : undefined;

  // 4xx(除了 429)是永久性错误
  if (statusCode && statusCode >= 400 && statusCode < 500 && statusCode !== 429) {
    return "permanent";
  }

  // 429、5xx、网络错误是暂时性的
  const transientPatterns = [
    "timeout", "429", "rate limit", "502", "503", "504",
    "network error", "fetch failed",
  ];
  for (const pattern of transientPatterns) {
    if (message.includes(pattern)) return "transient";
  }

  return "unknown";
}

分类逻辑基于 HTTP 状态码和错误消息关键词。429(限流)虽然是 4xx,但它是暂时性的——等一下就好了。5xx 是服务端问题,重试通常能解决。

unknown 是"看不出来"的情况。对 unknown 的处理策略是:最多重试一次。宁可多试一次,也不要因为误判而错过恢复的机会。

指数退避重试

确定了错误可以重试之后,下一个问题是:等多久再试?

如果立即重试,可能正好赶上服务还没恢复,白试。如果固定等 1 秒,多个请求可能同时重试(惊群效应),反而加重服务端压力。

答案是指数退避(exponential backoff):

delay = min(baseDelay * 2^attempt + jitter, maxDelay)
  • 第 1 次重试等 1 秒
  • 第 2 次等 2 秒
  • 第 3 次等 4 秒
  • 最多等 30 秒

jitter 是一个 0~30% 的随机扰动。它让不同请求的重试时间错开,避免同时打到服务端。

typescript
export function calculateDelay(attempt: number, config: ErrorRecoveryConfig): number {
  const exponential = config.baseDelay * Math.pow(2, attempt);
  const jitter = exponential * (0.3 * Math.random());
  return Math.min(exponential + jitter, config.maxDelay);
}

把重试逻辑封装成一个通用函数:

typescript
export async function retryWithBackoff<T>(
  fn: () => Promise<T>,
  config: ErrorRecoveryConfig,
  onRetry?: (error: NodeError, attempt: number, delay: number) => void,
): Promise<{ result: T; attempts: number; retryHistory: ... }> {
  const retryHistory = [];
  let attempt = 0;

  while (true) {
    try {
      const result = await fn();
      return { result, attempts: attempt, retryHistory };
    } catch (err) {
      const classification = classifyError(err);
      retryHistory.push({ attempt, error: { ... } });

      if (!shouldRetry(classification, attempt, config)) {
        throw err;  // 不可重试,直接抛出
      }

      const delay = calculateDelay(attempt, config);
      onRetry?.(error, attempt, delay);
      await sleep(delay);
      attempt++;
    }
  }
}

注意 onRetry 回调。它在每次重试前被调用,用来更新 UI——让用户看到"正在重试第 2 次,原因是 429 限流"。执行日志里的 retrying 状态就是从这里来的。

回退策略

重试解决的是"同一个操作再试一次"。但有时候,同一个操作试多少次都没用,需要换个做法。

回退策略只对 tool 节点生效。逻辑是:

typescript
async function executeNodeWithFallback(node, input, config) {
  try {
    return await runNode(node, input);
  } catch (primaryError) {
    // 只有 tool 节点有回退方案
    if (node.type === "tool") {
      const fallbackResult = await runFallbackTool(node.data, input, primaryError);
      if (fallbackResult !== null) {
        return fallbackResult;  // 回退成功
      }
    }
    throw primaryError;  // 没有回退方案,抛出原始错误
  }
}

具体的回退规则:

主工具回退方案逻辑
read_filesearch_files文件读不到?搜一下同名文件
http_request缓存请求失败?看看有没有缓存版本
其他直接失败

回退方案返回 null 表示"没有可用的回退",调用方会抛出原始错误。

这个设计的关键是:回退是透明的。调用方不需要知道底层有没有回退,它只关心最终结果。

状态快照与回滚

前三层(分类、重试、回退)都是在尝试"让当前操作成功"。但如果所有尝试都失败了,你需要处理失败带来的副作用。

工作流执行过程中,每个节点的输出会被写入 context。如果执行到一半失败了,context 里留着前几个节点的输出。下次执行时,这些脏数据会影响结果。

解决方案:在执行前拍快照,失败时恢复。

typescript
export function takeSnapshot(context, workflowNodes): WorkflowSnapshot {
  // 深拷贝 context,确保快照不受后续修改影响
  const contextCopy = deepClone(context);

  return {
    timestamp: Date.now(),
    context: contextCopy,
    completedNodeIds: Object.keys(contextCopy),
    nodeOutputs: { ... },
  };
}

回滚时,把 context 恢复到快照的状态:

typescript
export function restoreSnapshot(snapshot, nodeLogs) {
  const context = deepClone(snapshot.context);

  // 找出在快照之后开始执行的节点(需要重置状态)
  const nodesToReset = nodeLogs
    .filter(log => log.startedAt > snapshot.timestamp)
    .map(log => log.nodeId);

  return { context, nodesToReset };
}

快照用栈管理(SnapshotStack),支持多级回滚。如果工作流有多个检查点,可以回滚到最近的一个,而不是最早的那个。

执行器集成

把上面三层机制集成到执行器里。核心变化在节点执行的 try-catch 块:

typescript
for (const node of sorted) {
  if (hasError) {
    logger.skipNode(node.id, node.type);
    continue;
  }

  logger.startNode(node.id, node.type, nodeInput);

  try {
    // 带重试 + 回退的执行
    const { result, attempts, retryHistory } = await retryWithBackoff(
      () => executeNodeWithFallback(node, nodeInput, config),
      config,
      (error, attempt, delay) => {
        logger.markRetrying(node.id, error, attempt, delay);
      },
    );

    context[node.id] = result;
    logger.finishNode(node.id, result, attempts, retryHistory);
  } catch (err) {
    // 自动重试和回退都失败了,交给用户决定
    if (onNodeError) {
      const action = await onNodeError(node.id, error, 0);

      switch (action) {
        case "skip":
          // 跳过这个节点,继续执行
          context[node.id] = null;
          continue;  // 不设 hasError,后续节点继续
        case "retry":
          // 用户手动重试
          break;
        case "abort":
          // 终止 + 回滚
          hasError = true;
          if (config.autoRollback) {
            const snapshot = snapshotStack.pop();
            if (snapshot) {
              const { context: restored, nodesToReset } = restoreSnapshot(snapshot, ...);
              Object.assign(context, restored);
              rolledBack = true;
            }
          }
          break;
      }
    }
  }
}

注意 onNodeError 回调。当自动重试耗尽后,执行器不会直接死掉,而是把错误交给用户。用户通过 ErrorBanner 选择:重试、跳过、还是终止。

skip 是一个有意思的选择:它跳过当前节点但继续执行后续节点。这在某些场景下很有用——比如一个工具节点失败了,但后面的输出节点不需要它的结果也能工作。

错误分类的用户体验

ErrorBanner 是用户看到错误时的第一反应。它的设计遵循一个原则:用人话说错误,用按钮给选择。

typescript
function formatErrorMessage(error: NodeError): string {
  if (msg.includes("429")) return "请求太频繁,API 限流了。稍等几秒再试应该就好了。";
  if (msg.includes("timeout")) return "请求超时了。可能是网络问题,也可能是模型思考太久。";
  if (msg.includes("401")) return "认证失败。请检查 API Key 是否正确配置。";
  // ...
}

按钮按危险程度排列:

  1. 重试(安全):再来一次,什么都不改
  2. 跳过(中等):跳过这个节点,继续往下走
  3. 终止(危险):停止一切,回滚到执行前

暂时性错误显示自动重试的倒计时。永久性错误不显示重试按钮——因为重试没有意义。

Canvas 错误可视化

画布里的节点现在会根据执行状态改变外观:

  • 正常:默认灰色边框
  • 执行中:黄色脉冲
  • 成功:绿色边框
  • 失败:红色边框 + 抖动动画 + 红色发光背景
  • 重试中:橙色边框 + 脉冲 + 重试次数标签
  • 回滚重置:状态恢复到 pending

错误节点出发的连线也会变红,带发光效果。一眼就能看出哪条路径出了问题。

日志里的重试信息

执行日志现在记录每次重试的详情:

NodeLog
├── retryCount: 2           // 总共重试了 2 次
├── retryHistory: [
│   ├── { attempt: 0, error: "429 Too Many Requests", classification: "transient" },
│   └── { attempt: 1, error: "429 Too Many Requests", classification: "transient" }
│ ]
├── status: "success"       // 最终成功了
└── duration: 5200ms        // 包含重试等待的总时间

时间线上,重试区间用橙色半透明条标记。你可以看到每次重试花了多长时间,以及重试之间等了多久。

用户控制

工具栏提供了两个错误恢复配置项:

  • 重试次数:下拉选择 0/1/3/5。设为 0 禁用自动重试。
  • 回滚:勾选框。启用后,执行失败时自动回滚到初始状态。

这两个配置让调试变得容易。如果一个节点反复失败,你可以把重试次数设为 0,直接看到原始错误,而不是等三次重试都失败。

教学边界

这一章只做一件事:让工作流在出错时能自己恢复。

不涉及:

  • 断点续传(执行中断后从断点恢复,而不是从头开始)
  • 降级策略(主模型不可用时自动切换备用模型)
  • 熔断器(连续失败 N 次后自动停止调用,避免浪费资源)
  • 错误告警(发送邮件/Slack 通知)
  • 错误统计和趋势分析

这些都在后面的章节里。

Phase 4 进展

s17 是 Phase 4(加固生产级)的第二步:

章节你加了什么系统有了什么能力
s16权限控制关键操作需要确认
s17错误恢复暂时性错误自动重试,失败可回滚
s18待定...

从"能跑"到"能容错",这是走向生产环境的关键一步。

试着改改

1. 熔断器

如果同一个节点连续失败 3 次,自动跳过后续所有调用,直接返回缓存结果。这比每次等超时再重试要快得多。

2. 降级策略

给 LLM 节点加一个备用模型配置。主模型调用失败时,自动切换到备用模型。比如 DeepSeek 不可用时降级到 GPT-3.5。

3. 自定义回退规则

把回退规则从硬编码改成可配置。在节点的 data 里加一个 fallback 字段,让用户自己定义回退方案。

4. 错误率统计

在历史面板加一个错误率统计:哪些节点最容易出错?平均重试几次才成功?这些数据能帮你优化工作流设计。

一句话记住

真实世界的 API 不可靠。重试解决暂时性问题,回退解决路径问题,回滚解决状态问题。三层机制加在一起,工作流才能在混乱的环境里活下来。