CodeGym /课程 /ChatGPT Apps /错误、幂等性与“安全”工具的设计

错误、幂等性与“安全”工具的设计

ChatGPT Apps
第 4 级 , 课程 4
可用

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-* 属性以及 structuredContentDOM 契约的变更,不是“快速修补”,而是一次完整的前端迁移。如果今天渲染 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: falseretryable: false——模型不应带相同参数重复调用。像“未找到”这类业务情形更常作为 ok: true,空结果并附解释。外部服务的基础设施故障应该是 ok: falseretryable: 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_cardsubmit_payment);
  • 发送邮件和通知(send_emailsend_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 再次调用该工具,用户不会遭遇第二次支付,而是直接看到同一个结账会话。

拆分 preparecommit

对于特别敏感的操作(支付、不可逆更改),常用两阶段方案:一个工具负责准备(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_productssuggest_gifts)本身是安全的,即便模型频繁调用也没关系。而 create_ordercharge_payment 则需要更谨慎。

在此类工具的描述中,应明确它们的作用与可调用的上下文。例如:

{
  "name": "create_checkout_session",
  "description": "为单个礼物创建新的支付会话。仅在用户明确确认其选择后再调用。",
  "parameters": { /* ... */ }
}

这并非百分之百的保护(LLM 仍然可能犯错),但至少你向它明确传达了风险信号。

Human-in-the-loop 与确认

对于真正“危险”的操作,构建带确认的流程很有用。例如,模型:

  1. 先调用一个工具,准备购买数据,并以便于 UI 展示的形式返回(礼物名称、价格、配送地址)。
  2. 平台向用户展示带有“确认购买”按钮的小部件。
  3. 仅在点击按钮后,调用提交工具,执行真实支付。

这样即使模型“自作主张”,也无法在没有用户参与的情况下悄悄下单。

在描述与注解中表达风险语义

某些平台版本提供了类似 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 结束,继续对话若无其事。更好的做法是设计独立的 errorempty 状态,显示清晰信息,并尽可能给出下一步建议(改参数、稍后再试)。

错误 №7:忽视最小权限原则。
即便你做了幂等性与良好的错误处理,但若描述了类似 execute_sql_anywhere 这样的工具,风险仍然巨大。LLM 可能在错误的上下文或参数下调用它。每个工具都应尽量聚焦且只做一件明白事——尤其当涉及金钱或用户个人数据时。

1
调查/小测验
App 工具与 <code><span class="text-user">callTool</span></code>第 4 级,课程 4
不可用
App 工具与 callTool
App 工具与 callTool:UI ↔ 后端的连接
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION