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 大概率不该重试。
也就是说,如果你收到了 503、504 或干脆没等到外部 API 响应,那么加上一点延迟后重试是有意义的;但如果服务器返回 400 Bad Request 或 422 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;
}
}
这里:
- createOrderInDb、reserveItems、chargeCard 是 forward 步骤;
- safeCancelReservation 与 safeCancelOrder 是补偿步骤,它们自身也应当是幂等的(若尝试取消已取消的资源,也不会出问题)。
注意,出错时我们不吞掉错误,而是继续抛出。模型(通过 ToolOutput)应得到清晰的错误信息,然后以自然语言向用户解释并提出下一步建议。
7. 回滚与状态同步:如何避免不同步
有一种容易被低估的“错误”:UI、后端与模型之间的状态不同步。
典型场景:
- 用户按步骤 1 → 2 → 3 进行。
- 在步骤 3 出了点问题,用户在 widget 中点击“返回”。
- widget 诚实地把自己的本地状态退回到步骤 2。
- 但模型“记得”我们曾在步骤 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_type、field、expected 或 allowed_values 等字段对模型很有帮助;
- 若可能,给出最小的正确输入示例——这通常能显著提升模型的恢复准确率。
理想的错误反馈包含两个事实:哪里出了错,以及如何修复。
9. 工作流错误的日志与指标
即使错误的 UX 做得很好,仅靠给用户的消息也不足以知道系统哪里真正出问题。你需要结构化日志与分步骤的指标。
对每个工作流步骤进行日志采集的最小有用字段:
- user_id 或至少 session_id;
- workflow_id 与 step_id;
- 步骤状态(success、failed、retry、rolled_back);
- error_code(若有);
- idempotency_key 与 correlation_id(若该步骤与外部调用有关)。
在 MCP 与 Agents 中有 _meta 字段;把 idempotency_key 与 correlation_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_order、get_payment_methods 等);
- 有 WorkflowContext 存放已选礼物、预算、userId 与当前步骤。
本讲将补充
针对 checkout 步骤,我们将引入:
- 工具 create_order 的 idempotency_key;
- 在支付服务的暂时性错误上做 retry;
- 补偿,用于部分成功的情况;
- 正确的错误 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_id 与 workflow_id 记录日志,然后:
- 尝试后台重试(将来在队列与事件模块详述);
- 或明确将步骤标记为 failed,触发补偿动作,并向用户解释发生了什么。
11. 设计容错工作流的常见错误
错误 №1:“把一切都重试到成功为止”。
对任何步骤无脑重试到胜利,是自找麻烦。网络与 5xx 错误可以带 backoff 和次数上限地重试。但 4xx、业务错误与模型的逻辑失败需要用数据去修,或与用户沟通。否则你会得到不稳定的行为、奇怪的账单与被污染的日志。
错误 №2:涉及钱和订单的地方缺少幂等性。
如果类似 create_order 或 charge_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_id、step_id、error_code、idempotency_key 与 correlation_id。
错误 №7:没有告警策略。
要么什么都告警(包括“在你超窄的筛选条件下没有合适礼物”),要么什么都不告警(包括真正的 MCP 宕机)。请区分关键系统性故障(服务宕机、大量超时、webhook 丢失)与预期的业务事件。前者进监控与 on‑call,后者进分析即可。
GO TO FULL VERSION