CodeGym /课程 /ChatGPT Apps /容错性:步骤回滚、重试、错误控制

容错性:步骤回滚、重试、错误控制

ChatGPT Apps
第 11 级 , 课程 3
可用

1. 为什么在 ChatGPT App 中,错误是常态而不是事故

在上一讲中,我们讨论了如何把任务拆成步骤并在 ChatGPT App 中构建多步工作流。现在在这套方案上加入现实世界:错误、超时,以及用户中断。

在传统 Web 中,逻辑常常围绕“幸福路径”,错误被视作罕见且“事故级”的东西——红色的 500 页面之类。而在 ChatGPT App 中情形不同:你在由 LLM、外部 API、MCP、widget 以及可能随时关闭标签页的用户组成的分布式系统里工作。错误与中断是日常。

有几个特点会让事情更复杂:

  • 首先,LLM 具有非确定性。即使同样的 prompt,它也可能做出略有不同的决策:调用不同的工具、改变参数,甚至觉得最好“再确认一下”。
  • 其次,网络与基础设施约束。来自 ChatGPT 的 tool‑call 有超时(通常几十秒),你的 Next.js/Vercel 后端也有。如果外部 API 变慢,一切都可能半途而废。
  • 第三,还有 UX 因素:用户分心、关闭聊天,一天后才回来,而你不可能一直在数据库里持有一笔长事务。

由此得到本讲的核心观点:

容错工作流 = 我们预设任何一步都可能失败,并明确说明那时该做什么。

错误不仅是给用户看的消息,还是给模型的信号,它可以据此调整策略、建议回滚、尝试其他工具,或体面地结束场景。

2. 工作流中的错误版图:都有哪些

要想正确处理故障,首先要学会区分它们。在基于 ChatGPT Apps 的 LLM 应用里,典型会遇到几类错误。

技术错误。 这就是分布式系统的老三样:网络超时、来自你方或外部 API 的 5xx、MCP 服务器崩溃、工具 handler 里的 bug。比如,在 GiftGenius 中,你的 MCP 工具 search_products 访问目录服务,而对方返回 503 Service Unavailable。这就是自动重试(retry)的候选。

逻辑(模型)错误。 包括模型拒绝(判定请求违反策略)、幻觉,或工具响应里损坏的 JSON。模型可能为 tool‑call 生成了不正确的参数,你的 JSON 校验未能通过。这通常是输入数据错误,而非基础设施问题。

业务错误。 与业务语义相关:商品售罄、用户预算对所选筛选条件而言过小、优惠码无效、预订已过期。在 GiftGenius 中,这类似“在 500 个候选中没有一个符合既定约束”。此处重试很少有用:要么调整参数,要么向用户解释约束不现实。

UX 中断。 用户主动中断流程:关闭 ChatGPT,在 widget 里点“返回”,取消操作,把上一步的回答改了。这也应被视作正常流程,而非错误。重要的是要学会在这种情况下恢复与回滚状态,我们稍后会讨论。

逻辑与技术错误的交界处有个特殊棘手案例——代理的无限循环:模型接收错误,想着“嗯,我再试一次”,再次报错,如此往复直到上下文或预算耗尽。防护此类行为是错误设计的重要部分。

3. 基本策略:retry、fail‑fast、rollback、让用户参与

任何错误都可以看作一个分叉点:我们要么重试这一步,要么回滚,要么让用户参与。重要的是,这些策略可以组合。

对于技术性和暂时性故障(网络抖动、API 返回 503),合理做法是有限重试并带回退(backoff)。 对于逻辑与业务错误(“校验器不接受预算”“商品已售罄”)重试无意义,应快速失败(fail‑fast)并请用户修改输入或参数。

对于已经对外部世界产生了改变的操作(创建订单、预订),需要回滚(rollback)——或者在 UI/上下文中“回退一步”,或者执行真正的补偿性动作(取消订单、退款)。

还有一些场景天然需要用户参与:比如支付系统因“发卡行拒绝”而失败,无法由你自动修复。模型应当清晰解释发生了什么,并给出选项:换卡、降低金额,或放弃购买。

为了让工作流可靠,强烈建议为每个步骤明确写出:可能出现哪些错误类型,以及你在每种情况下的处理——自动重试、回滚、请求用户输入,还是仅记录日志并结束分支。

4. 重试与回退(backoff):何时以及如何做

先从开发者最自然的反应开始:“那我们再试一次吧。”想法没错,但细节决定成败。

哪些错误值得重试

一条来自集成实践的好经验:网络错误和 5xx 可带间隔重试,而 4xx 大概率不该重试。

也就是说,如果你收到了 503504 或干脆没等到外部 API 响应,那么加上一点延迟后重试是有意义的;但如果服务器返回 400 Bad Request422 Unprocessable Entity,更可能是数据问题,用同样参数重试不会改变结果。

TypeScript 版 callWithRetry 小工具

我们来为 MCP 或后端层写个小工具,方便在工具里复用:

type RetryOptions = {
  maxRetries: number;
  baseDelayMs: number;
};

async function callWithRetry<T>(
  fn: () => Promise<T>,
  { maxRetries, baseDelayMs }: RetryOptions
): Promise<T> {
  let attempt = 0;

  // 我们不需要无限循环
  while (true) {
    try {
      return await fn();
    } catch (err: any) {
      attempt++;
      const status = err?.status ?? err?.response?.status;

      // 对 4xx 不进行重试
      const isClientError = typeof status === "number" && status >= 400 && status < 500;
      if (attempt > maxRetries || isClientError) {
        throw err;
      }

      const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), 10_000);
      // 加一点延迟与随机抖动,避免“羊群效应”冲击 API
      const jitter = Math.random() * 200;

      await new Promise((r) => setTimeout(r, delay + jitter));
    }
  }
}

该函数:

  • 在限定次数内重试调用 fn
  • 使用带少量随机抖动(jitter)的指数回退,避免重试同时发生的“羊群效应”;
  • 遇到 4xx 即停止重试。

它很适合用在访问商品目录或内部推荐 API 的 MCP 工具内部。

在什么位置做重试

常见错误是试图对所有请求一股脑重试,包括你无法控制的层。在 ChatGPT 生态中你可以在几个位置实施重试:

  • 在你自己的后端/MCP 内部(就像我们在 callWithRetry 中做的那样);
  • 在后台 worker/队列里(后续模块会深入聊 job 队列与 DLQ);
  • 有时在 widget 本身,对“刷新列表”这类无副作用的轻操作。

重要的是不要重复实现同一逻辑:如果你的 job worker 已经做了 3 次带 backoff 的重试,就没必要在 widget 层再叠加 5 次。而且千万别写 while(true) { try ... }——那是自己给自己发起 DDoS 的捷径。

5. 步骤幂等性:防止重复

重试带来第二个问题:如何避免把同一动作执行两次。在 LLM 世界中尤为突出:模型可能不小心多次调用同一个工具,ChatGPT 可能在超时后重放 tool‑call,用户可能点了“Regenerate”,随后 UI 或代理又各自发起一次。

幂等性的思路很简单:若在相同输入下重复执行某一步,不会产生额外的副作用,则该步是幂等的。请求 product feed ——可以;重新计算推荐——可以;但重复扣款或基于相同数据创建第二个订单——绝对不可以。

ChatGPT App 中的 idempotency key

经典模式:对每个有副作用的逻辑步骤生成 idempotency_key(通常为 UUID),通过模型传给 MCP 工具,并在那里存储“key → 结果”的对应关系。如果同一个 key 的调用再次到来,工具不重复动作,而是返回之前保存的结果。

在 GiftGenius 中有一步叫 create_order。设想用户点击“支付”,模型调用了工具,支付成功,但回程中某处响应丢失。模型或平台决定重试调用,如果我们没有幂等性,就会得到重复订单或重复扣款。

TypeScript 写一个简单的幂等工具处理器

来做一个极简的 MCP 工具 create_order 的 handler,带 idempotency key。为简单起见用内存 Map,实际项目会用数据库或缓存。

type CreateOrderInput = {
  userId: string;
  items: Array<{ sku: string; qty: number }>;
  idempotencyKey: string;
};

type CreateOrderResult = { orderId: string; status: "created" };

const idempotencyStore = new Map<
  string,
  { paramsHash: string; result: CreateOrderResult }
>();

export async function createOrderTool(input: CreateOrderInput): Promise<CreateOrderResult> {
  const { idempotencyKey, ...rest } = input;
  const paramsHash = JSON.stringify(rest);

  const existing = idempotencyStore.get(idempotencyKey);
  if (existing) {
    // 如果 key 已存在,确保参数一致
    if (existing.paramsHash !== paramsHash) {
      throw new Error("Idempotency key reuse with different params");
    }
    return existing.result;
  }

  // 此处执行真实的下单与扣款
  const result: CreateOrderResult = {
    orderId: "order_" + Math.random().toString(36).slice(2),
    status: "created",
  };

  idempotencyStore.set(idempotencyKey, { paramsHash, result });
  return result;
}

这里我们:

  • 要求在工具输入中提供 idempotencyKey
  • 与之一起保存参数的哈希(此处简化为 JSON.stringify);
  • 若用相同 key 但不同数据重试——视为错误;
  • 若用相同 key 且相同数据重试——直接返回先前结果。

在真实项目中应当:

  • 把 key 存在带 TTL 的数据库里(避免表无限增长);
  • 记录 idempotency_key 并把它放入 MCP 消息的 _meta,以便通过 Inspector 和看板追踪。

6. 步骤回滚与 Saga 模式

幂等性能防重复,但不能解决另一个问题:如果场景中途某一步失败了怎么办

在电商(e‑commerce)中这是经典难题:你已经创建了订单并在仓库预留了商品,但在支付阶段出了问题。你不能就此“忘掉”它——需要以某种方式回退之前的状态。

逻辑回滚 vs 技术回滚

在 ChatGPT 工作流里有两个层次的回滚。

逻辑回滚——回到场景的上一步并修正上下文。比如在“支付”步骤出错,你决定回退到“选择支付方式”,甚至“选择礼物”。此时要注意:

  • 更新后端的 WorkflowContext(当前步骤、已选参数);
  • 通过 tool‑call/ToolOutput 告诉模型步骤已变更,让它“忘掉”旧分支并调整后续行为;
  • 更新 widget 的 UI,使步骤与按钮与新状态一致。

技术回滚——业务层面的补偿:撤销已创建的实体,抵消外部副作用。比如:取消订单、解除库存预留、发起退款。这就是 Saga 模式:为每个“危险”步骤预先设计补偿动作

GiftGenius 的 forward/compensate 示意

对于简化版的 GiftGenius 结账流程,可以画出这样的序列:

flowchart TD
  A[步骤 1: create_order] --> B[步骤 2: reserve_items]
  B --> C[步骤 3: charge_card]

  C -->|成功| D[状态: completed]

  C -->|错误| E[补偿: cancel_reservation]
  E --> F[补偿: cancel_order]
  F --> G[状态: failed + 给用户的消息]

每个改变外部世界的动作(创建订单、预留库存、支付)都对应一个补偿动作(取消订单、解除预留、退款)。它们不一定严格对称,也不总能一一对应,但总体原则如此。

带补偿的迷你代码示例

来看一段执行这些步骤的简短代码:

async function completeCheckout(ctx: { userId: string }) {
  const order = await createOrderInDb(ctx.userId);

  try {
    await reserveItems(order.id);
    await chargeCard(order.id);
    return { orderId: order.id, status: "paid" as const };
  } catch (err) {
    // 补偿动作
    await safeCancelReservation(order.id);
    await safeCancelOrder(order.id);
    throw err;
  }
}

这里:

  • createOrderInDbreserveItemschargeCard 是 forward 步骤;
  • safeCancelReservationsafeCancelOrder 是补偿步骤,它们自身也应当是幂等的(若尝试取消已取消的资源,也不会出问题)。

注意,出错时我们不吞掉错误,而是继续抛出。模型(通过 ToolOutput)应得到清晰的错误信息,然后以自然语言向用户解释并提出下一步建议。

7. 回滚与状态同步:如何避免不同步

有一种容易被低估的“错误”:UI、后端与模型之间的状态不同步

典型场景:

  1. 用户按步骤 1 → 2 → 3 进行。
  2. 在步骤 3 出了点问题,用户在 widget 中点击“返回”。
  3. widget 诚实地把自己的本地状态退回到步骤 2。
  4. 但模型“记得”我们曾在步骤 3 并尝试过支付。下一条消息里它仍谈论支付,而用户看到的是选择礼物的界面。

为了避免这种情况,建议引入显式的回退事件。由 widget 发送到 MCP/模型——可作为工具调用或 ToolOutput。

例如,做一个简单的工具 user_navigated_to_step,用于记录当前步骤及其状态:

type NavigateInput = {
  workflowId: string;
  stepId: string;
};

export async function userNavigatedToStep(input: NavigateInput) {
  await workflowRepo.setCurrentStep(input.workflowId, input.stepId);
  return {
    message: `User moved to step ${input.stepId}`,
  };
}

widget 在点击“返回”时调用该工具;模型在工具调用历史里能看到它的结果,从而理解接下来要从新的步骤继续对话。

在 UI 侧,大致是这样的处理器:

async function handleBackClick() {
  const { workflowId, prevStepId } = widgetState;

  await window.openai.tools.call("user_navigated_to_step", {
    workflowId,
    stepId: prevStepId,
  });

  setWidgetState((s) => ({ ...s, currentStepId: prevStepId }));
}

关键点:后端/代理才是当前步骤的唯一可信来源(source of truth),模型通过工具看到它。这样即使稍后恢复会话,也能正确同步上下文。

8. 错误的 UX:用户看到什么,模型看到什么

我们已经学会了技术上扛住错误(重试、回滚、幂等、状态同步)。还要确保这对用户和模型都“看起来”合理。

即便重试和回滚做得完美,如果错误的 UX 还是“上古 Java Servlet 风”:红色文本、堆栈跟踪和神秘的 “Unexpected error”,也救不了体验。

在 ChatGPT App 中,错误消息有两类受众:

  • 用户,他需要理解发生了什么、以及接下来能做什么;
  • 模型,它需要足够结构化的信息来决定:是否重试、是否修改参数、是否给出替代方案或结束场景。

好的实践:

  • 在 MCP/工具层返回带代码、类型、retryable 标志与简短技术文本的结构化错误;
  • 把这份结构(比如放在 result.structuredContent 中)喂给模型,而不是一大坨堆栈;
  • 在 UI 中给用户展示人类可读、简短的消息。

工具返回的错误结构迷你示例:

type ToolError = {
  code: string;          // e.g. "PAYMENT_TIMEOUT"
  message: string;       // 简短的技术描述
  retryable: boolean;    // 是否可重试
};

throw {
  isError: true,
  error: <ToolError>{
    code: "PAYMENT_TIMEOUT",
    message: "Payment provider did not respond in time",
    retryable: true,
  },
};

模型看到 retryable: true,可以尝试其他工具,或建议用户重试。

在 widget 侧,你只需把这些代码映射为用户可理解的文本:

function ErrorBanner({ code }: { code: string }) {
  const text =
    code === "PAYMENT_TIMEOUT"
      ? "支付服务未能及时响应。请在一分钟后重试。"
      : "出了点问题。请重试。";

  return <div className="error-banner">{text}</div>;
}

还有一个重要点:不要向用户展示异常堆栈、令牌、密钥。既不美观,也不安全。技术细节请在你们侧记录日志,给用户的信息应简短且安全。

Insight

在像 ChatGPT 这样的 LLM 系统里,工具调用不规范更像常态而非例外。模型经常会生成无法通过校验的参数:类型混淆、字段缺失、值不正确、结构损坏。这并非传统工程意义上的错误——它是随机性模型的本质一部分,整个错误接口都需要为此做适配。

关键思想:错误消息并非“坏了”的铃声,而是下一次尝试的修复说明。它的首要受众就是模型本身。如果消息结构化并包含准确指示,模型完全可以自动纠正参数并把调用重做正确。这正是 Tool‑Reflection 等技术的基础:恰当的反馈能让代理在无人干预下改进下一步动作。

建议错误格式遵循以下要求:

  • 消息应指明具体字段未通过校验——不要停留在 “Invalid parameters” 这样的笼统层面;
  • 明确描述期待的格式或允许的取值,以便模型选择合适值;
  • 消息应当简短、正式且结构化:诸如 error_typefieldexpectedallowed_values 等字段对模型很有帮助;
  • 若可能,给出最小的正确输入示例——这通常能显著提升模型的恢复准确率。

理想的错误反馈包含两个事实:哪里出了错,以及如何修复

9. 工作流错误的日志与指标

即使错误的 UX 做得很好,仅靠给用户的消息也不足以知道系统哪里真正出问题。你需要结构化日志与分步骤的指标。

对每个工作流步骤进行日志采集的最小有用字段:

  • user_id 或至少 session_id;
  • workflow_idstep_id
  • 步骤状态(successfailedretryrolled_back);
  • error_code(若有);
  • idempotency_keycorrelation_id(若该步骤与外部调用有关)。

在 MCP 与 Agents 中有 _meta 字段;把 idempotency_keycorrelation_id 放进去,便于在日志与 Inspector 中查看。

一个最简单的 Node.js/TypeScript 日志示例(可以用 console,也可以用 winston/pino):

function logStepFailure(params: {
  userId?: string;
  workflowId: string;
  stepId: string;
  errorCode: string;
  idempotencyKey?: string;
}) {
  console.error(
    JSON.stringify({
      level: "error",
      event: "workflow_step_failed",
      ...params,
      timestamp: new Date().toISOString(),
    })
  );
}

这类日志易于解析、做看板,并用于统计:

  • 步骤之间的转化率;
  • 最常见的错误类型;
  • 以重试结束的步骤占比 vs 最终失败的占比。

不是每个错误都需要在生产环境触发告警。关键性的——MCP 宕机、系统性超时、某一步骤的批量失败——应该进入监控;而“找不到符合你超窄筛选条件的礼物”则是业务事件,而非事故。

10. 进化 GiftGenius:稳健的 checkout 步骤

现在把一切组合在一起:重试、幂等、Saga、状态同步、错误 UX 与日志——落在我们教学应用 GiftGenius 的一个步骤上:下单(checkout)。

我们已有

到此为止,我们已经:

  • 有了多步工作流:信息收集 → 点子生成 → 礼物选择 → checkout;
  • 配置了工具管控(tool gating):在 checkout 步骤只开放一组电商工具(如 create_orderget_payment_methods 等);
  • WorkflowContext 存放已选礼物、预算、userId 与当前步骤。

本讲将补充

针对 checkout 步骤,我们将引入:

  1. 工具 create_orderidempotency_key
  2. 在支付服务的暂时性错误上做 retry
  3. 补偿,用于部分成功的情况;
  4. 正确的错误 UX 用于 widget。

在点击“支付”时于 widget 里生成 idempotency key:

import { v4 as uuid } from "uuid";

async function handlePayClick() {
  const idempotencyKey = uuid();
  setWidgetState((s) => ({ ...s, idempotencyKey }));

  await window.openai.tools.call("create_order", {
    userId: widgetState.userId,
    items: [/* ... */],
    idempotencyKey,
  });
}

在工具 create_order 侧,就是我们上面写的幂等 handler:它保存 key 与结果,重复调用时不会创建新订单。

与支付 API 的交互可以用 callWithRetry 包裹,在网络抖动时尝试几次。同时别忘了在错误里加上 retryable: true,让模型知道可以建议重试。

如果在成功创建订单并扣款后又有环节出错(例如外部 webhook 未及时到达),我们用 correlation_idworkflow_id 记录日志,然后:

  • 尝试后台重试(将来在队列与事件模块详述);
  • 或明确将步骤标记为 failed,触发补偿动作,并向用户解释发生了什么。

11. 设计容错工作流的常见错误

错误 №1:“把一切都重试到成功为止”。
对任何步骤无脑重试到胜利,是自找麻烦。网络与 5xx 错误可以带 backoff 和次数上限地重试。但 4xx、业务错误与模型的逻辑失败需要用数据去修,或与用户沟通。否则你会得到不稳定的行为、奇怪的账单与被污染的日志。

错误 №2:涉及钱和订单的地方缺少幂等性。
如果类似 create_ordercharge_card 的工具不是幂等的,任何一次重复调用(因超时、Regenerate、代理 bug)都可能导致重复。LLM 场景的重复比经典 REST 前端常见得多,所以 idempotency_key 不是“可有可无”,而是支付等关键步骤的必要条件。

错误 №3:没有补偿动作(缺少 Saga)。
创建了订单、预留了商品,但支付失败就只给用户一个“出了点问题”。结果系统里挂着半成品订单、预留和财务“尾巴”。对每个改变外部世界的步骤,都要想清楚下一步失败时你要做什么:撤销、退款、标记为 “expired” 等等。

错误 №4:让代理陷入无限重试循环。
如果不限制尝试次数(比如在 helper 里用 maxRetries,或在代理逻辑里用 max_iterations),且不在不能重试的地方把错误标为 retryable: false,模型可能会自陷循环:“我再试一次……再一次……”。这会烧掉 token、时间和耐心。

错误 №5:回滚时 UI 与模型状态不同步。
开发者常只在 UI 实现“返回”按钮,却忘了与后端和模型同步状态。最终用户看到步骤 2,模型还活在步骤 3,并提出奇怪建议。解决办法——显式事件如 user_navigated_to_step,并在每次跳转时更新 WorkflowContext

错误 №6:把技术信息丢给用户,却不给开发者留日志。
用户看到“Error: ECONNRESET at TcpSocket.onEnd…”,而你拿不到任何关于哪个 workflow_id、哪个 step_id 出错的信息。正确做法:对用户——简短、清晰、带下一步建议;对开发者——结构化日志,包含 workflow_idstep_iderror_codeidempotency_keycorrelation_id

错误 №7:没有告警策略。
要么什么都告警(包括“在你超窄的筛选条件下没有合适礼物”),要么什么都不告警(包括真正的 MCP 宕机)。请区分关键系统性故障(服务宕机、大量超时、webhook 丢失)与预期的业务事件。前者进监控与 on‑call,后者进分析即可。

评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION