跳到正文

构建 ChatGPT 应用的 15 条经验教训

本文目录文章开头

在 Alpic,我们相信下一代产品和服务将围绕 AI-first 体验构建,即用户与模型协作,而非遵循传统的、预定义的 UI 工作流。

当 OpenAI 发布 Apps SDK 时,我们立即开始使用它进行构建。在三个月的时间里,我们开发了二十多个 ChatGPT 应用,既供内部使用,也面向我们在 B2B 和 B2C 领域的客户,涵盖旅游、零售和 SaaS等行业。

我们很早就发现,构建 ChatGPT 应用与构建传统的 Web 或移动应用有着根本的不同。在 Web 上运行良好的模式(即时数据获取、UI 驱动的状态、显式用户配置等)在 agentic 环境中往往会失效,甚至对体验造成损害。

本文提炼了我们在构建真实世界 ChatGPT 应用过程中学到的15 条最重要的经验教训,随后介绍了我们如何将这些经验融入一个面向社区的开源框架 Skybridge,以及一个 Codex Skill,帮助开发者更快地构思、构建、测试和发布应用。

三体问题

在传统的 Web 应用中,情况很简单:只有用户和 UI。而在 ChatGPT 应用中,第三个主体进入了系统:模型。

为 ChatGPT 构建应用最难的部分之一,就是管理这三者之间的信息流动。如果用户在你的小部件中点击“选择”按钮,UI 会在视觉上更新,但作为对话大脑的模型却毫不知情,除非你显式地将该上下文呈现出来。如果用户随后问,“给我更多关于这个产品的细节,”模型完全不知道用户实际在看什么。

我们称之为上下文不对称,即每个主体只掌握系统的部分信息,没有任何一个拥有全貌。构建优秀的 ChatGPT 应用不在于让所有内容保持同步,而在于决定什么信息应该被共享、何时共享,以及谁需要看到它。解决这个问题,是笨拙的应用与无缝 agentic 体验之间的区别。

1. 并非所有上下文都应该共享

我们最初的直觉是“在任何地方共享一切”。事实证明,这是我们最早犯下的错误之一。

在实践中,ChatGPT 应用的不同部分往往需要有意对同一状态持有不同的视图。为什么?

  • 出于性能考虑:UI 小部件通常需要远超模型所需的数据:例如,在一个旅游预订应用中,这可能包括图片、价格变体、预加载的选项。将所有这些发送给模型会增加 token 消耗、延迟和认知噪音。
  • 出于逻辑考虑:有些信息必须在设计上保持不对称。在我们最早的应用之一——一个名为《Murder in the Valleys》的推理游戏中,模型需要知道谁是凶手才能正确地进行角色扮演,而 UI 和用户则不能知道。而在一个类似《Time's Up》的游戏中,情况正好相反:UI 向用户展示秘密词语,而模型必须保持不知情。

这条经验教训不是“永远同步一切”,而是:显式地决定谁需要知道什么。我们通过不同的 tool output 字段将这一原则形式化:

字段 用途 可见方
structuredContent 供小部件和模型使用的类型化数据 小部件和模型均可见(通过 toolOutput 和 callTool 函数)
_meta 响应元数据 仅小部件可见,对模型隐藏

例如,在 Time's Up 游戏中,我们只通过 _meta 字段将秘密词语传递给小部件,让模型根据用户的提示来猜测词语。

2. 懒加载不适用于 AI 应用

来自 Web 开发的我们,默认采用懒加载方式:用户点击时才获取数据;按需加载详情;优化以实现最小的初始负载。

在 ChatGPT 中,范式是相反的:工具调用意味着延迟,由于安全沙箱和模型推理,通常需要几秒钟。

在实践中,我们学会了积极预加载:在初始工具响应中发送尽可能多的数据,并通过 window.openai.toolOutput 为小部件填充数据。这几乎总是带来更快、更灵敏的体验。

当然,如果小部件可以安全地从公共 API 端点获取数据,且不需要与模型共享信息,那么始终可以在小部件内使用经典的 XHR 调用,但大多数时候你希望模型能够自主调用工具,以保持对话式的体验。

3. 模型需要可见性

当用户与小部件交互(例如,在列表中选择特定产品)然后在聊天中提问时,会出现一个微妙但关键的问题。如果模型不知道用户指的是 UI 的哪一部分,它将无法正确回答。

为此我们使用了 window.openai.setWidgetState(state),它允许你存储特定的状态数据,这些数据会在下一次用户与模型交互时添加到模型的上下文中。

随着应用复杂度的增长,我们发现我们在很多地方添加了 setWidgetState,以便模型跟踪导航。因此我们决定引入一种声明式的方式来描述 UI 上下文。我们不是在每次交互时以命令式方式更新模型,而是直接在组件上附加一个 data-llm 属性:

<div
  data-llm={
    selectedTab === "details"
      ? "User is viewing product details"
      : "User is viewing reviews"
  }
>

为了在幕后实现这一点,我们构建了一个 Vite 插件,它会抓取这些属性并自动更新 widgetState。从模型的角度来看,它只是在正确的时机接收相关的 UI 上下文,开发者无需手动同步每次交互。

你可以在我们创建的 开源框架 中找到这个 Vite 插件(以及我们在本文中分享的许多其他技巧),我们创建它是为了与社区分享我们的经验。

4. 不同的交互需要不同的 API

ChatGPT Apps 涉及小部件、服务器和模型之间的多条交互路径。这些路径不可互换:每条路径的存在都是为了支持不同类型的交互。

构建 ChatGPT Apps 的关键经验之一是使这些通信路径显式化,并明确哪种机制负责体验的哪一部分。

绘制这条路径大致如下:

Diagram of the different interactions between the widget, the server, and the model

这些经验奠定了 ChatGPT App 的基础:上下文如何共享、模型如何获得可见性,以及不同的交互如何在系统中传播。下一节将在此基础上,重点讨论对 UI 设计的影响。

为 AI 重新发明 UI

ChatGPT Apps 是一个全新的环境,因此我们很快学会了抛开对 UI 的固有观念,充分利用新能力。本节介绍了我们在创建高效应用时需要学习(以及摒弃)的界面设计假设。

5. UI 必须适应多种显示模式及其限制

ChatGPT Apps 并不局限于单一布局。根据调用方式和时机的不同,同一个组件可以以三种不同的显示模式呈现。

应用可以内联显示在对话中,以画中画(PiP)形式悬浮在对话之上,或者在需要更多空间时全屏显示。虽然画中画和全屏模式能实现更丰富的界面,但它们也引入了组件无法控制的 UI 覆盖层。考虑设备特定的安全区域(例如移动端常驻的关闭按钮)对于避免内容被裁剪和优化交互至关重要。

随着时间的推移,我们总结出了关于显示模式及其使用时机的规律:

外观 使用时机
内联 默认显示模式。组件保留在对话历史中。 用于快速交互
全屏 组件占据整个屏幕,聊天栏位于底部。 如果组件复杂且需要大量空间(例如地图)
画中画 与内联模式尺寸相同,但组件悬浮在对话之上 如果组件在生成后的对话跟进中仍然相关

6. UI 一致性在嵌入式环境中至关重要

早期我们遇到的一个不确定因素是 ChatGPT App 应该拥有多大的视觉自由度。作为面向用户的新界面,它需要在我们自己的应用内部以及与周围的 ChatGPT 生态系统之间都保持熟悉感和一致性。与独立产品不同,组件存在于现有界面之中,视觉上的不一致会立刻显现出来。

幸运的是,OpenAI Apps SDK UI Kit 为我们提供了一个清晰的基准。

它基于 Tailwind CSS 构建,提供了与 ChatGPT 设计系统一致的现成组件、图标和设计令牌。使用它使我们能够快速推进,同时确保我们的组件在视觉上与周围界面保持原生且一致,即使在构建自定义组件时也是如此(例如我们的 Mapbox 集成)。

7. 以语言为先的筛选

传统仪表盘建立在充满复选框和范围滑块的侧边栏之上。在 agentic UI 中,这往往是一种倒退。当用户可以直接用自然语言表达意图时,例如“200 美元以下的欧洲阳光目的地”,强迫他们通过多个 UI 控件会增加摩擦。他们应该能够直接说出来。

因此,我们决定在大多数应用中采用“无筛选”的方式。我们没有提供带有筛选和排序选项的侧边栏,而是为工具参数向模型提供取值列表(LOV)。

这使得模型可以直接将用户的消息作为输入,防止它“猜测”有哪些可用选项。换句话说,这允许它将自然语言直接映射到我们后端的 API 要求。如果用户说“阳光”,模型就知道调用工具时使用 weather=“sunny”。

8. 文件可以解锁更丰富的交互

在我们构建更复杂的应用过程中,得出的一个经验是:文件不应被视为次要输入。在 ChatGPT Apps 中,文件可以解锁新的交互方式。体验不必从表单或筛选器开始,而是可以从用户已有的东西开始。

例如,在一个电商应用中,用户可以在聊天中上传一张商品照片,让模型识别它,然后直接在小部件中继续进行商品匹配或发现。

这之所以成为可能,是因为让文件在系统的两侧都能流通。在模型侧,工具可以通过 openai/fileParams 直接消费聊天中上传的文件,使模型能够对图像或其他用户提供的资产进行推理。在 UI 侧,小部件也可以使用 window.openai.uploadFile 和 window.openai.getFileDownloadUrl 直接处理文件,从而可以在 UI 流程中请求上传,或生成可供用户下载和重复使用的文件。

投入生产

接下来,随着应用走出本地开发阶段,围绕安全性、配置和工具链会涉及另一套不同的考量。这正是第三组经验所涵盖的内容。

9. CSP 是新的 CORS

出于安全原因,OpenAI 在双层嵌套的 iframe 中渲染 Apps。内容安全策略(CSP)是 iframe 隔离的原生机制,此设置会严格强制执行它们,通常表现为经典的“本地能跑,生产就挂”综合症。

与传统 Web 开发中宽松策略可能侥幸通过不同,Apps SDK 要求你做到精准。

在应用清单中,这意味着要仔细声明每种交互类型允许哪些域名:

字段 用途 示例 常见错误
connectDomains API 和 XHR 请求 https://api.weather.com 忘记区分预发布 API 和生产 API。
resourceDomains 图片、字体、脚本 https://cdn.jsdelivr.net 使用像 delivr.net 这样的通用 CDN 却未将其加入白名单
frameDomains 嵌入 iframe https://www.youtube.com 嵌入 YouTube 视频或 Mapbox 实例却未将其加入白名单。
redirectDomains 无警告打开的外部链接 https://app.alpic.ai 忘记结账或 OAuth 回调域名。

尽早将 CSP 配置作为头等大事来对待,为我们节省了后期大量的生产环境调试工作。

10. 小小的 widget 标志影响巨大

除了 CSP 之外,一小部分 widget 级别的设置决定了控制权如何在 widget、模型和宿主环境之间共享。这些标志很容易被忽视,但它们定义了导航、工具访问和发布的关键边界。

宿主与导航边界

  • widgetDomain 是提交所必需的。它定义了全屏模式下“在 <App> 中打开”按钮指向的默认位置,并参与源白名单,因为 widget 是在 <widgetDomain>.web-sandbox.oaiusercontent.com 下渲染的。我们使用 setOpenInAppUrl 根据上下文将用户路由到相应的路径。

模型与工具边界

  • 工具注解必须遵循发布指南。readOnly、destructiveHint 和 openWorldHint 等标志是必需的,并在提交时进行验证。
  • 工具可见性很重要:不应被模型调用的工具必须明确标记为私有。

Widget 执行边界

  • widgetAccessible 控制 widget 是否可以使用 callTool 自行调用工具。

这些设置单独来看都很小,但它们共同决定了应用发布后的行为是否正确。

为快速迭代而优化

Apps SDK 正在快速发展,我们很兴奋能与之共同构建。为了支持顺畅高效的开发流程,我们决定开发自己的开源框架并与社区分享。以下是我们在初期遇到的一些开发者体验问题的经验教训。

11. 快速迭代需要热重载

我们首先着手解决的问题之一是迭代速度。长 TTL 资源缓存与使用 JSON-RPC 转发资源的组合,使得标准的热模块重载(如 Vite 或 Next.js 中的)开箱即用地与 ChatGPT Apps 不兼容。

在花费大量时间了解 Vite 的内部机制后,我们构建了一个 Vite 插件,可在 ChatGPT 内部直接实现 widget 的实时重载。该插件拦截对 MCP 服务器的资源请求,并将实时更新注入 ChatGPT 的 iframe 中。在 IDE 中看到的更改立即反映在 ChatGPT 中,极大地缩短了我们的反馈循环。

12. 并非所有测试都应在 ChatGPT 中进行

在 ChatGPT 上测试是黄金标准,但对于最初的迭代,本地模拟器可以帮助你更快地推进,尤其是当你处理需要在 ChatGPT 中刷新自定义 MCP 服务器连接的工具定义时。

为了加快早期迭代,我们构建了一个轻量级本地模拟器,模拟 ChatGPT 宿主环境,并配备调试工具和应用专用日志。这使我们能够在毫秒级内迭代 React 状态和布局,将真实的 ChatGPT 测试保留用于验证模型交互和边缘情况。

13. 移动端测试需要显式支持

移动端测试带来了一个独立的挑战:虽然将本地服务器通过隧道暴露出来对于在 ChatGPT 中进行测试是必要的,但 Vite 默认使用 localhost,导致相同的 URL 无法从其他设备访问。

我们通过扩展 Vite 插件以支持隧道端口上的域名转发来解决这一问题,这使得 iOS 和 Android 设备上的测试得以进行,并使移动端验证成为我们常规工作流程的一部分。

14. 熟悉的抽象(如 React hooks)可加速前端工作

Apps SDK 暴露了强大的功能,但主要通过底层 JavaScript API。作为长期的 React 用户,我们希望更接近我们已经掌握的概念。

因此我们引入了一些对 React 友好的抽象——如 useCallTool、useWidgetState 和 useLocale 这样的 hooks,以及基于 Zustand 构建的更高级状态管理(如 createStore)来处理复杂数据流。重新引入熟悉的前端模式减少了样板代码,使 widget 开发更贴近现代 Web 工作流程。

将经验转化为 Codex Skill

15. 将经验转化为可复用的工具

随着这些模式在多个应用中出现,我们清楚地意识到反复重新发现它们正在拖慢我们的速度。为了让 ChatGPT App 开发更快、更可预测,我们决定将这些经验直接编码到我们的工具中,而且不仅是为了我们自己,也是为了社区。

这催生了两项互补的工作:

  1. Skybridge 框架:一个开源 React 框架,将本文描述的许多模式打包成可重用的构建块,包括我们的 hooks(useCallTool、useToolInfo)、开发工具(HMR 和本地模拟器)以及 data-llm 属性。
  2. chatgpt-apps-builder Codex Skill:在框架之上,我们构建了一个专门的 Codex Skill 来支持完整的应用生命周期:
    • 构思:集思广益,思考如何让应用具备“agentic”特性,而不仅仅是网页移植。
    • 代码生成:同时编写 React 前端和 MCP 服务器后端,并预先配置好所有正确的 UX 和 UI 模式。
    • 本地测试:启动开发服务器,并将本地应用连接到 ChatGPT,通过热重载进行实时迭代。
    • QA 和发布:针对 OpenAI 的提交指南运行结构化检查,包括 CSP 验证、安全区域考虑和生产环境测试。
    • 应用部署:协助完成发布和迭代应用所需的最后步骤。

要安装和使用该 Skill,只需使用以下命令:

npx skills add alpic-ai/skybridge

您的浏览器不支持视频标签。

结论

构建 ChatGPT Apps 需要重新思考上下文如何流动、界面如何表现,以及用户和模型如何协作。本文中的许多经验教训都来自熟悉的 Web 模式与 agentic 系统现实之间的差距。

通过分享这些经验教训,并将它们编码到我们的开源框架和 Codex skill 中,我们希望帮助团队减少重新发现相同问题的时间,更多地探索这种新交互模式所带来的可能性。最具吸引力的 ChatGPT Apps 不会是现有产品的简单移植,而是围绕这种全新的 AI 优先体验精心设计的体验。

阅读显示设置

调整正文排版

当前浏览器记住选择,应用于文章正文。手机上正文宽度以屏幕可用空间为限。

图表完整性提示

普通文章遇到图表保存问题时显示提示。