1. ChatGPT App 中的错误与幂等性
在传统 Web 中,许多人至今还停留在“用户点一下按钮 → 一次 HTTP 请求 → 一个响应”的范式里。在 LLM 的世界,这早已不是常态。模型可能决定多次调用你的工具,用户点击 Regenerate 后可能重新生成答案,模型可能追问,或在过程中遇到网络错误。最终,同一个工具完全可能被调用两到三次,且参数非常相似。
与此同时,任何错误都会突然拥有两个“消费者”。一方面是模型,它需要机器可读的清晰说明出错原因,以便修正参数并重试。另一方面是用户侧 UI(小部件和聊天本身),需要展示人能看懂的消息并给出下一步建议,而不是“Error: 500 (see logs)”。
还有一个重要点:经典架构很少假设有人会频繁点击“重新生成”,从而增加重复请求(重试)的数量。在 ChatGPT 中,这种场景是常态。此外平台本身也可能在临时网络问题时发起重试。因此在这个生态里,幂等性不是“可选项”,而是基础要求,尤其是那些“真正改变状态”的工具——创建订单、扣款、发送邮件等。
本讲正是关于如何避免一次失败的工具调用(tool call)毁掉用户心情,也避免把你的生产环境带沟里。
洞见
ChatGPT 并不是“传入参数给你的函数”,而更像是在猜测一组符合你模式的参数。它根据 JSON Schema、对话上下文统计性地挑选值——而且相当容易出错。诸如“类型不对”“遗漏必填字段”“参数矛盾”等错误,是 tool-call 的日常,而不是意外。从公开资料与遥测看,对于复杂模式,这类失误很容易占到约 30% 的调用。
对模型而言这不是问题:它会把你的响应视为“参数不好”的信号,接着再试,也许连续试两三次,略微调整输入。对你而言,则意味着另一件事:每个工具都需要按“几乎肯定会被用几次、参数非常相似”的方式来设计。
这就是幂等性如此重要的原因。ChatGPT 会一次又一次地尝试猜测用哪些参数调用你的函数。每次调用重试 2–3 次是常态。
2. 小部件的安全配置:text/html+skybridge 与 _meta
在进入纯服务器侧(错误、重试、幂等性)之前,先解决一个 Apps SDK 中与 UI 安全相关的特性:如何让你的小部件在聊天中安全渲染,而不是像“来自互联网的恐怖页面”。
registerResource 与 MIME 类型 text/html+skybridge
从 ChatGPT 的视角看,你的小部件是一个特殊的 HTML 资源,它会进入 ChatGPT 客户端的沙箱,而不是直接进到用户浏览器。为了让平台知道这是小部件而不是普通 HTML,要使用 MIME 类型 text/html+skybridge。
在 MCP/服务器层,你用类似(伪 TS)的方式注册资源:
// 在 MCP 服务器配置中的某处
registerResource({
name: "giftgenius-widget",
path: "/widget",
mimeType: "text/html+skybridge", // 重要!
});
这个 mimeType 是发给 ChatGPT 客户端的信号:“这不仅是 HTML,而是一个用于内嵌小部件的组件模板,需要在隔离环境中运行”。如果标成普通的 text/html,平台可能只会展示原始 HTML,甚至拒绝渲染。
_meta 与安全控制:CSP、域与边框
接下来是随工具或资源响应一起传递的元数据——_meta。通过它你可以控制小部件能加载哪些外部资源、如何表现,以及模型该如何描述它。
典型结构示例:
const toolResult = {
content: "<!-- 小部件的 HTML -->",
_meta: {
"openai/widgetCSP": "default-src 'self'; img-src https://cdn.example.com",
"openai/widgetDomain": "https://chatgpt.com",
"openai/widgetPrefersBorder": true,
"openai/widgetDescription": "GiftGenius 以卡片形式展示礼物推荐。"
}
};
逐一说明关键字段。
- openai/widgetCSP 为小部件设置 Content Security Policy。这是 ChatGPT 内部浏览器的小防火墙:你要明确列出脚本、样式、图片、XHR 等可以加载的来源。平台期望严格策略,不要使用通配符 *;你需要显式标明使用的域名(聊天、你的 API、CDN)。
- openai/widgetDomain 设定小部件运行的 origin。通常是 ChatGPT 的域;你不把它替换为你的网站,只是声明在隔离环境里它应该如何呈现。
- openai/widgetPrefersBorder ——纯视觉标记:是否在小部件周围绘制边框。对 GiftGenius 来说,保留边框能将推荐区块和普通聊天消息在视觉上区分开。
- openai/widgetDescription ——给模型的文本描述。模型可以直接使用这段话来向用户说明当前出现的界面,而不是自行“编造”解释,从而降低怪异或冗余叙述的风险。
实用结论:一次性把 mimeType 和 _meta 配置好,你就能获得安全、隔离的 UI——既不会越权,也能在用户与平台视角下表现可预测。前端侧的安全搞定:小部件在沙箱内,只访问你允许的资源。接下来聚焦服务器侧——错误类型、错误的描述与返回方式,以及如何让工具具备幂等性。
洞见:小部件缓存
ChatGPT 会在应用注册时缓存小部件的 HTML。ChatGPT 的 HTML 小部件不是“活的前端”,而是一个固定的构建产物。在发布应用(Store 或 Dev Mode)时,平台会读取这个 HTML 资源(text/html+skybridge),此后始终使用这个版本。任何修改——哪怕是一行文字或一个卡片的缩进——实际上都意味着一个新版本发布。
结论:对 HTML 结构、插槽、data-* 属性以及 structuredContent → DOM 契约的变更,不是“快速修补”,而是一次完整的前端迁移。如果今天渲染 items[] 列表,明天切到 results[],旧小部件并不会知道:它仍然会收到旧的 JSON,于是工作不正确。
3. 工具运行中的错误类型
现在进入重点:一个工具可能出现哪些错误,从 UX 和后端的角度如何区分。思考上可以分成四层错误。
输入校验错误
最基础的一层——输入参数根本不符合契约。
以我们的教学应用 GiftGenius 以及它的 suggest_gifts 工具(根据兴趣和预算挑选礼物)为例:
- 年龄小于 0 或大于 120;
- 预算为负;
- 缺少必填字段 relationship_type;
- budget_min > budget_max。
不符合 Schema 的 JSON 也在此列。理想情况下,Apps SDK 和 JSON Schema 会在到达你的代码之前就拦下“非常糟糕”的调用,但业务校验(例如 budget_min/budget_max 的关系)仍需要你自己做。
业务逻辑错误
这一层是:输入看起来没问题,但根据领域规则无法给出正常结果。
典型情形:
- 给定兴趣与预算,没找到任何礼物;
- 用户超出了当天的推荐次数限制;
- 模型请求购买的商品已不再销售。
这不是“服务器崩了”,而是正常、可预期的情况,应当用用户与模型都能理解的形式呈现,而不是 500 Internal Server Error。
外部基础设施错误
这一层是“技术地狱”:数据库不可达、外部 API 超时、你的代码内部抛出了未处理的异常。
例如:
- 对礼物目录的请求返回 503 或没有响应;
- MongoDB 突然暂停了;
- 在礼物筛选代码里出现了除以零。
从 UX 的角度,这常常意味着:“服务暂不可用,请稍后再试”,某些时候可以尝试隐式重试。但务必要避免无声失败,也不要把原始堆栈直接扔给用户。
平台/网络错误
最后一层可能发生在你的代码之外:tool-call 没有到达、连接在响应中途断开、流式传输中断。这种情况比你想象得更常见。例如使用免费隧道时,在高峰时段速度会低到让 ChatGPT 的 tool calls 因超时而失败。
你无法完全控制这些,但完全可以把工具与小部件设计成即使有重复调用与中断,也不会把系统拖入混乱。因此我们讨论的是幂等性与细致的错误处理,而不是“try/catch 然后忘掉”。
4. 如何为模型与 UI 同时描述并返回错误
思维方式上的关键变化:你的错误不只是记录在 console.error 中的东西。它是工具契约的一部分,模型和界面都会使用它。
错误结构
通常可遵循一个简单的结构:
type ToolError = {
code: string; // "VALIDATION_ERROR", "NO_RESULTS", "UPSTREAM_TIMEOUT"
message: string; // 面向人类的可读文本,或面向模型的简明信息
retryable: boolean; // 是否有必要再试一次
};
并将工具的返回包装为可辨别联合:
type SuggestGiftsResult =
| { ok: true; gifts: GiftCard[] }
| { ok: false; error: ToolError };
MCP 协议层面还有一个“这是错误”的独立标志,但在内部坚持自有格式很有用,这样 UI 与模型能用一致方式解读发生了什么。
“优雅失败”(fail gracefully)策略
不是每一种不理想情况都要作为“硬错误”返回。有时返回空结果而不报错,并配一点说明,反而更有用。
例如,没找到礼物时,可以返回 ok: true,空数组 gifts: [],以及一个 noResultsReason 字段给 UI 与模型,而不是把它当作 "NO_RESULTS" 错误。这样模型可以继续对话:“在该预算内没有找到。要提高预算或细化兴趣吗?”
而如果外部 API 完全挂了,则更应返回 ok: false,带上 code: "UPSTREAM_UNAVAILABLE" 与 retryable: true,让模型有机会稍后或用其他参数再试。
回顾第 3 节的四层错误。校验错误通常用 ok: false 且 retryable: false——模型不应带相同参数重复调用。像“未找到”这类业务情形更常作为 ok: true,空结果并附解释。外部服务的基础设施故障应该是 ok: false 且 retryable: true,以便模型可以安全重试。平台/网络错误可能发生在你的代码之前或之后,实践中常体现为工具的重复调用——这正是我们需要幂等性与细致处理的原因,下节继续。
不要把内部细节泄露出去
在服务器端很容易图省事,直接把 error.toString() 丢回响应。对 LLM 工具而言,这不是好主意:你会在对话中制造噪音,还可能泄露敏感信息(内部服务的 URL、堆栈、表名)。建议捕获异常并将其转换为精简的错误码与合适的信息。
最小包装示例:
try {
const gifts = await loadGiftsFromCatalog(input);
return { ok: true, gifts };
} catch (err) {
console.error("suggest_gifts failed", err);
return {
ok: false,
error: {
code: "UPSTREAM_ERROR",
message: "Catalog service is unavailable",
retryable: true
}
};
}
模型能看到简洁的信号,UI 有可理解的文本,细节则留在日志里。
在小部件中展示错误
从 React 小部件的角度,任务很简单:检查 ok,如果为 false,就显示友好的消息,并在可能时提供继续的方式。
function GiftResults({ result }: { result: SuggestGiftsResult }) {
if (!result.ok) {
return (
<div>
<p>无法挑选礼物:{result.error.message}</p>
{result.error.retryable && <p>请尝试修改参数或重试请求。</p>}
</div>
);
}
if (result.gifts.length === 0) {
return <p>在这些条件下未找到礼物。请尝试调整预算或兴趣。</p>;
}
return <GiftCardsList gifts={result.gifts} />;
}
这正是“简单而坦诚”的信息比“出了点问题”之类的表述更能显著改善 UX 的例子。
我们已经约定,部分错误可以标记为 retryable: true 并向用户建议“再试一次”。一旦系统里出现了这些重试(UI 的显式重试,或平台侧的隐式重试),随之而来的问题是:如果同一个工具用同样的数据被调用了两次,会发生什么?这就是幂等性的主题。
5. 幂等性:防止“再来一次同样的调用”
到了最有意思的部分。形式化地说,幂等性是指某操作以相同输入重复调用时,不会改变系统状态,且结果保持一致。严格上它既包含无重复副作用,也包含相同响应。在 ChatGPT Apps 的实践中,我们首先关注前者:即便响应可能略有不同,也不要破坏数据或创建新实体。
在 ChatGPT Apps 的语境下,幂等性是对重试、Regenerate 与 LLM 不可预测性的全面防护。
幂等性尤为重要的场景
只读工具通常天生安全:无论多少次用相同参数调用 suggest_gifts,你只是再得到一个礼物列表。即使略有差异,也不会改变系统状态,不会产生副作用。
关键在于那些会修改外部系统状态的工具:
- 创建订单(create_order);
- 执行支付(charge_card、submit_payment);
- 发送邮件和通知(send_email、send_sms);
- 创建具有副作用的实体(例如预订)。
如果此类工具被用几乎相同的参数连续调用两次,你可能会得到重复订单、重复扣款,以及会计上的“惊喜”。
idempotency_key 模式
经典做法:为工具增加一个参数 idempotency_key——该操作的字符串标识。如果带该键的请求已成功处理过,服务器不再执行动作,而是返回缓存结果。
以 GiftGenius 的假想工具 create_checkout_session 为例的扩展 Schema:
const CreateCheckoutSchema = {
type: "object",
properties: {
giftId: {
type: "string",
description: "所选礼物的 ID"
},
idempotency_key: {
type: "string",
description: "用于防止重复的唯一操作键"
}
},
required: ["giftId", "idempotency_key"]
} as const;
服务器端处理器大致如下:
async function createCheckoutSession(input: CreateCheckoutInput) {
const existing = await db.checkoutSessions.findOne({ idempotencyKey: input.idempotency_key });
if (existing) {
return existing; // 返回旧的结果
}
const session = await paymentProvider.createSession({ giftId: input.giftId });
await db.checkoutSessions.insert({ idempotencyKey: input.idempotency_key, session });
return session;
}
如果模型因为某种原因用相同的 idempotency_key 再次调用该工具,用户不会遭遇第二次支付,而是直接看到同一个结账会话。
拆分 prepare 与 commit
对于特别敏感的操作(支付、不可逆更改),常用两阶段方案:一个工具负责准备(prepare_*),一个负责提交(commit_*)。
例如:
- prepare_order——检查库存、计算价格并返回“订单草案”;
- commit_order——根据草案 ID 创建真实订单并发起支付。
这种设计带来多个好处。首先,可以让第一步完全幂等:相同参数重复调用 prepare_order 返回同一份草案。其次,可以只在用户明确确认后才允许调用 commit_order,这对 UX 与安全都更友好。
6. 工具的安全设计
幂等性是必要但不充分的条件。很大程度上,安全取决于你把哪些工具交给模型以及如何设计它们。
最小权限原则
思路很简单:每个工具只做场景所需之事,不多一行。不要搞一个 do_anything_with_user_account 的大杂烩函数,它:
- 可以随意读取、更新、删除;
- 接收一个字符串 operation 和一个任意的 JSON payload 说走就走。
更好的做法是拆分成职责明确的 tools:
- get_user_profile;
- update_user_preferences;
- create_order;
- cancel_order。
对于 GiftGenius 同理:suggest_gifts 只负责给出备选;create_checkout_session 不需要知道如何取消订单或修改用户 e‑mail。
区分“read”与“write”工具
一个好的模式是明确分离只读工具与写操作工具。查询礼物目录(search_products、suggest_gifts)本身是安全的,即便模型频繁调用也没关系。而 create_order 或 charge_payment 则需要更谨慎。
在此类工具的描述中,应明确它们的作用与可调用的上下文。例如:
{
"name": "create_checkout_session",
"description": "为单个礼物创建新的支付会话。仅在用户明确确认其选择后再调用。",
"parameters": { /* ... */ }
}
这并非百分之百的保护(LLM 仍然可能犯错),但至少你向它明确传达了风险信号。
Human-in-the-loop 与确认
对于真正“危险”的操作,构建带确认的流程很有用。例如,模型:
- 先调用一个工具,准备购买数据,并以便于 UI 展示的形式返回(礼物名称、价格、配送地址)。
- 平台向用户展示带有“确认购买”按钮的小部件。
- 仅在点击按钮后,调用提交工具,执行真实支付。
这样即使模型“自作主张”,也无法在没有用户参与的情况下悄悄下单。
在描述与注解中表达风险语义
某些平台版本提供了类似 destructiveHint 的注解,用以提示工具可能执行不可逆操作。即便没有这些字段或尚不稳定,你也可以直接在 description 与参数命名中体现风险语义。
例如,与其这样:
{
"name": "delete_user_data",
"description": "删除用户数据。"
}
不如这样:
{
"name": "request_user_data_deletion",
"description": "将用户账户标记为删除其个人数据(遵循服务政策)。仅在用户明确请求删除时使用。"
}
同时围绕它构建良好的人机确认式 UX。
7. 对 GiftGenius 的一个小实践改造
让我们把这些与 GiftGenius(礼物推荐 App)串起来。假设我们给 GiftGenius 再加一个工具——create_checkout_session,让用户不仅能挑礼物,还能继续结账。
从 JSON Schema 与安全的角度我们这样做。
首先,加入 idempotency_key 并写好描述:
const CreateCheckoutTool = {
name: "create_checkout_session",
description:
"为选中的单个礼物创建支付会话。" +
"仅在用户确认想要购买该礼物后再调用。",
parameters: {
type: "object",
properties: {
gift_id: {
type: "string",
description: "来自 suggest_gifts 结果的礼物标识符。"
},
idempotency_key: {
type: "string",
description: "唯一操作键。在重复调用时使用相同的键。"
}
},
required: ["gift_id", "idempotency_key"]
}
} as const;
其次,在服务器实现幂等的处理器:
async function handleCreateCheckout(input: CreateCheckoutInput) {
const existing = await db.checkout.findOne({ idempotencyKey: input.idempotency_key });
if (existing) {
return { ok: true, checkout: existing };
}
const checkout = await payments.createSession({ giftId: input.gift_id });
await db.checkout.insert({ idempotencyKey: input.idempotency_key, ...checkout });
return { ok: true, checkout };
}
第三,考虑错误处理:
try {
return await handleCreateCheckout(input);
} catch (err) {
console.error("create_checkout_session failed", err);
return {
ok: false,
error: {
code: "PAYMENT_PROVIDER_ERROR",
message: "无法创建支付会话。请稍后再试。",
retryable: true
}
};
}
在小部件中展示清晰的错误状态,并可能提供一个“重试”按钮,由 UI 层发起与模型的新一轮对话。
一步步下来,我们的教学项目就不再是“演示玩具”,而开始具备走向生产的雏形。
8. 处理工具错误与幂等性的常见错误
错误 №1:错误 = 直接 throw 和 500。
如果你的 tool 在任何失败时都仅仅抛异常,最后变成“出了点问题”,模型与 UI 都拿不到信息。模型不清楚是否应带不同参数重试,用户也不清楚下一步怎么做。更好的做法是返回结构化错误:错误码、简短信息和 retryable 标记;详细信息留在服务器日志里。
错误 №2:不区分错误类型。
把校验、业务、基础设施错误混成一锅粥是坏主意。最后“未找到任何结果”看起来跟“数据库挂了”一样。这会破坏 UX,也让模型无法合理反应:它本该建议修改请求,却会以“抱歉,服务坏了”的模式应对。尤其当你把第 3 节中的业务错误与基础设施错误混在一起时,问题更严重。
错误 №3:在重试世界中缺乏幂等性。
把 create_order 设计成永远只会被调用一次,是通往重复订单的直达车——特别当用户频繁点 Regenerate 或连接中途断开时。如果工具有副作用,几乎总是应该加上 idempotency_key 并保存结果,以免重复调用创建新实体。
错误 №4:一个“万能”的巨型工具。
有时开发者会做一个带 action 参数的超级工具,什么都能:查、增、改、删。对 LLM 来说,这几乎注定行为难以预测:模型更难学会何时调用什么工具,错误后果也更严重。正确做法是拆分为小而清晰、尽量只读的工具,并将有副作用的工具独立出来、配上确认流程。
错误 №5:把内部细节泄漏到响应中。
把原始堆栈或完整异常文本丢给模型和 UI,是典型的工程惰性。它既不便于用户,也可能泄露系统内部结构,还无助于模型自我修正。应当捕获异常,将其映射为简洁的错误码与明了的消息,所有细节留在日志与监控系统。
错误 №6:错误处理与小部件 UX 脱节。
常见情况是:服务器端认真返回了错误码,而小部件 UI 却陷入永远的 spinner 或空白。用户看到“什么都没发生”,模型看到 tool-call 结束,继续对话若无其事。更好的做法是设计独立的 error 与 empty 状态,显示清晰信息,并尽可能给出下一步建议(改参数、稍后再试)。
错误 №7:忽视最小权限原则。
即便你做了幂等性与良好的错误处理,但若描述了类似 execute_sql_anywhere 这样的工具,风险仍然巨大。LLM 可能在错误的上下文或参数下调用它。每个工具都应尽量聚焦且只做一件明白事——尤其当涉及金钱或用户个人数据时。
GO TO FULL VERSION