1. 全景:通过服务器调用工具的路径
在写代码之前,先固定架构,有助于避免陷入细节。
在 Apps SDK + MCP 的术语里,流程如下:我们有一个 MCP 服务器(在本课程中是 Next.js 的 Route Handler app/mcp/route.ts),它注册工具与资源,并实现这些工具的处理器。
高层示意:
sequenceDiagram
participant User as 用户
participant Chat as ChatGPT(模型)
participant App as ChatGPT App
participant MCP as MCP 服务器 / 后端
participant DB as 目录/外部 API
User->>Chat: "挑选礼物……"
Chat->>App: 决定调用 tool `suggest_gifts`
App->>MCP: JSON-RPC call_tool(名称 + 参数)
MCP->>MCP: 验证、授权
MCP->>DB: 查询目录/过滤
DB-->>MCP: 候选列表
MCP-->>App: structuredContent + content + _meta
App-->>Chat: 将结果交给模型与小部件
Chat-->>User: 解释选择,展示小部件
核心思路:服务器对模型的“魔法”一无所知。它只看到一个普通请求:工具名 + 参数,并且必须返回结构化的响应。而模型根本看不到你的代码,它只看到:
- 有哪些工具及其模式;
- 它自己构造的参数;
- 你返回的 JSON 响应。
因此本讲的目标——扎实实现中间环节:MCP 服务器与 tools 的处理器。
Insight: mcp-tools 数量限制
在 MCP 服务器中,工具数量是一项与内存或上下文 token 一样受限的指标。形式上你可以注册几十甚至上百个 tools,但平台与模型并非线性利用它们:每新增一个工具都会增加路由时的“噪音”。
实务经验给出这些参考:
- 硬上限 对于 ChatGPT ≈ 每个服务器至多 128 个 MCP-tools;
- 推荐工作区间——不超过 50 个工具。再往上质量会明显下降:模型会混淆描述相近的工具,更少记起冷门工具,更容易选错。
Anthropic 的情况类似:上限约 最多 100 个 tools,而他们自己也推荐控制在 50 个以内。
2. Next.js + Apps SDK 模板中服务器逻辑的位置
在模块 2 我们已搭建官方的 ChatGPT App Next.js 模板并粗略浏览了其结构。现在来看看 MCP 服务器在其中的位置,以及它如何与小部件关联。
使用该模板时,MCP 服务器通常在 app/mcp/route.ts(App Router)中实现。ChatGPT 的 JSON‑RPC 调用(如 tools/call、resources/list、handshake 等)就会发到这里。
典型项目结构:
my-chatgpt-app/
├─ app/
│ ├─ mcp/
│ │ └─ route.ts # MCP 服务器 + 工具注册
│ ├─ page.tsx # React 小部件(UI)
│ ├─ layout.tsx # 根布局,Bootstrap SDK
│ └─ globals.css # 全局样式
│
├─ proxy.ts # CORS 等
├─ next.config.ts
├─ package.json
├─ tsconfig.json
└─ .env
在 route.ts 中,我们会:
- 创建 MCP 服务器实例(通过 @modelcontextprotocol/sdk);
- 注册工具(server.registerTool(...));
- 定义 HTTP 处理器,接收来自 ChatGPT 的请求并转发给 MCP 服务器。
接下来我们将基于这个结构,用 TypeScript 编写代码。
3. 最小化 MCP 服务器与工具处理器
先从最简单的开始:创建服务器并添加我们的教学用工具 suggest_gifts,返回一个占位结果。
假设已经安装了 MCP‑SDK:
pnpm add @modelcontextprotocol/sdk
然后创建简易的 app/mcp/route.ts:
// app/mcp/route.ts
import { NextRequest } from "next/server";
import { McpServer } from "@modelcontextprotocol/sdk/server";
const server = new McpServer({ name: "giftgenius-mcp" });
// 注册一个带最小模式的工具
server.registerTool(
"suggest_gifts",
{
title: "礼物推荐",
description: "根据兴趣和预算推荐礼物。",
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "收礼人的简要描述。" },
},
required: ["query"],
},
},
async ({ input }) => {
// 这里放业务逻辑
return {
content: [
{
type: "text",
text: `占位结果:给“${input.query}”的礼物。`,
},
],
structuredContent: {},
};
}
);
// Next.js 的 HTTP 处理器
export async function POST(req: NextRequest) {
const body = await req.text(); // JSON-RPC 字符串
const response = await server.handle(body);
return new Response(response, {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
这已可工作:ChatGPT 能调用 suggest_gifts,服务器返回文本占位。
需要注意,server.registerTool 接收:
- 工具名;
- 元数据与输入的 JSON Schema;
- 处理器——一个异步函数,参数中包含 input。
不过目前还没有校验、规范化的结构化输出,也没有授权。接下来我们补齐这些。
4. 输入校验与分层
为什么仅有 JSON Schema 还不够
没错,平台会根据模式自动校验基础内容:字段类型、必填属性等。但:
- 模型可能传来逻辑上不正确的数据(比如预算 −100,或者包含 1000 个元素的兴趣列表);
- 你有业务约束(预算上限、支持的货币等);
- 有时 ChatGPT 或其他客户端可能行为异常,发来完全意外的内容。
因此在处理器内部仍需要额外的逻辑校验。
代码分层:handler ↔ 业务逻辑
为了避免服务端代码“面条化”,建议将业务逻辑单独存放。比如创建 app/mcp/gifts.ts:
// app/mcp/gifts.ts
export type SuggestGiftsInput = {
age?: number | null;
relationship: "friend" | "partner" | "colleague";
maxBudget: number;
interests: string[];
};
export type GiftItem = {
id: string;
title: string;
price: number;
currency: "USD";
score: number;
tags: string[];
shortDescription: string;
};
// 简单的“礼物库”
const CATALOG: GiftItem[] = [
{
id: "board-game-1",
title: "桌游《宇宙战略》",
price: 39,
currency: "USD",
score: 0.93,
tags: ["board_games", "strategy", "2-4_players"],
shortDescription: "适合桌游爱好者的绝佳礼物。",
},
// ...
];
export function suggestGifts(input: SuggestGiftsInput): GiftItem[] {
if (input.maxBudget <= 0) {
throw new Error("预算必须为正数。");
}
const filtered = CATALOG.filter(
(item) => item.price <= input.maxBudget
);
// 简化处理:按 score 排序,取前 3 个
return filtered.sort((a, b) => b.score - a.score).slice(0, 3);
}
现在在 MCP 工具的处理器里,我们需要:
- 解析 input;
- 映射到类型 SuggestGiftsInput;
- 安全地调用 suggestGifts;
- 将结果打包为 ChatGPT 与我们的 UI 都能理解的格式。
5. 实现处理器:从 input 到 structuredContent
在 route.ts 中重写 registerTool,使用我们的业务逻辑:
// app/mcp/route.ts(片段)
import { suggestGifts, SuggestGiftsInput } from "./gifts";
server.registerTool(
"suggest_gifts",
{
title: "礼物推荐",
description:
"当需要根据兴趣、预算和关系类型挑选礼物时使用。",
inputSchema: {
type: "object",
properties: {
age: {
type: "integer",
minimum: 0,
maximum: 120,
description: "收礼人的年龄(如果已知)。",
},
relationship: {
type: "string",
enum: ["friend", "partner", "colleague"],
description: "与收礼人的关系类型。",
},
maxBudget: {
type: "number",
minimum: 1,
description: "最大预算(美元)。",
},
interests: {
type: "array",
items: { type: "string" },
description: "收礼人的兴趣(例如:board games, hiking)。",
},
},
required: ["relationship", "maxBudget", "interests"],
},
},
async ({ input }) => {
// 基本逻辑校验
if (!Array.isArray(input.interests) || input.interests.length === 0) {
return {
isError: true,
content: [
{
type: "text",
text: "必须至少提供一个收礼人的兴趣。",
},
],
structuredContent: { errorCode: "NO_INTERESTS" },
};
}
const payload: SuggestGiftsInput = {
age: input.age ?? null,
relationship: input.relationship,
maxBudget: input.maxBudget,
interests: input.interests,
};
const items = suggestGifts(payload);
if (items.length === 0) {
return {
content: [
{
type: "text",
text:
"在给定预算内没有找到合适的礼物。请尝试提高预算或调整兴趣。",
},
],
structuredContent: {
items: [],
emptyReason: "NO_MATCHES",
},
};
}
return {
content: [
{
type: "text",
text: `找到 ${items.length} 个合适的礼物选项。`,
},
],
structuredContent: {
items: items.map((item) => ({
id: item.id,
title: item.title,
price: item.price,
currency: item.currency,
shortDescription: item.shortDescription,
tags: item.tags,
})),
},
};
}
);
这里有几个关键点。
第一,我们显式检查 interests 不是空列表。即便 JSON Schema 形式上允许空数组,对我们来说这个请求也毫无意义。与其胡乱返回列表,不如直接给出清晰的错误。
第二,我们返回两组数据:
- content——给模型看的。它是简短文字摘要:“找到 N 个选项”。模型会用它来给用户编写回复。
- structuredContent——既给模型也给 UI。它是带礼物列表的结构化 JSON,我们的小部件可以用卡片把它渲染出来。
常见错误是把整坨 JSON 都塞进 content。没必要这样做:模型会浪费 token,还可能混乱。更好的做法是让 content 简洁,把细节放在 structuredContent 里。
6. 添加 UI 模板与 _meta/openai/outputTemplate
在 Apps SDK 层面,服务器还可以告诉 ChatGPT 用哪个 UI 模板来可视化工具的结果。这通过资源与 _meta["openai/outputTemplate"] 来实现:服务器注册一个 mimeType 为 "text/html+skybridge" 的 HTML 资源,工具在响应中引用它。
在 Next.js 模板中这一点通常被更方便的封装隐藏了,但简化后大致如下:
// 在 MCP 服务器初始化时的某处
server.registerResource("ui://widget/gifts.html", {
name: "礼物推荐小部件",
mimeType: "text/html+skybridge",
// 下面:提供 HTML 的方式(内置模板或文件)
});
而在工具的响应中:
return {
content: [{ type: "text", text: `找到 ${items.length} 个礼物。` }],
structuredContent: { items: /* ... */ },
_meta: {
"openai/outputTemplate": "ui://widget/gifts.html",
},
};
这样 ChatGPT 不仅理解结果的结构,还会加载对应的小部件 HTML/JS,而我们在 iframe 内的 React 组件会读取 window.openai.toolOutput 并渲染礼物列表。
关于 UI 的细节我们会在同模块的 ToolOutput → UI 讲解中展开,因此这里仅强调关联:工具处理器不仅要产出业务数据,还需要指定要绑定的 UI 模板,并设计好放进 structuredContent 的结构。
Insight
ChatGPT 的设计者把小部件定位为“用于展示 JSON 的模板”,因此使用了 outputTemplate 这个命名。最初的设想是:ChatGPT 调用 mcp-tool,mcp-tool 返回 JSON,并且有时会返回一个小部件。如果没有小部件,ChatGPT 自行决定如何展示 JSON。
而如果指定了小部件,ChatGPT 就会展示小部件,把 JSON 作为 toolOutput 传给小部件,由小部件来呈现 JSON。小部件是展示 JSON 的模板。这也正是它在应用上架到 Store 的注册阶段就会被缓存的原因。
你可以按需使用小部件:它内部可以调用 fetch()。但如果理解 ChatGPT 开发者的初衷,你会更容易接受某些限制以及未来可能的变更。
7. 处理器中的授权与访问
目前我们假装世界上全是公共数据。实际中,部分工具需要授权:访问用户账户、订单、支付、文档等。
在 Apps SDK / MCP 的术语里,可以为工具设置 securitySchemes,然后在处理器中检查 token 与上下文。
最简单的示例:
server.registerTool(
"list_user_orders",
{
title: "用户订单列表",
description: "返回已登录用户的最近订单。",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
_meta: {
securitySchemes: [{ type: "oauth2", scopes: ["orders.read"] }],
}
},
async ({ auth }) => {
if (!auth?.accessToken) {
return {
isError: true,
content: [
{
type: "text",
text: "需要登录账户才能查看订单。",
},
],
_meta: {
// 请求 ChatGPT 启动 OAuth UI
"mcp/www_authenticate": [
'Bearer resource_metadata="https://your-mcp.example.com/.well-known/oauth-protected-resource", error="insufficient_scope", error_description="请先完成授权以继续操作。"',
],
},
};
}
// 在此校验 token、issuer、audience、scope...
const orders = await fetchUserOrders(auth.accessToken);
return {
content: [
{
type: "text",
text: `找到 ${orders.length} 条最近的订单。`,
},
],
structuredContent: { orders },
};
}
);
这里要理解:
- ChatGPT 不会“替你猜”校验逻辑。它只转发 token 和上下文,你必须自己完成授权校验。
- 特殊字段 _meta["mcp/www_authenticate"] 会告诉平台:“需要给用户展示登录/刷新 token 的 UI”。否则 ChatGPT 只会看到错误。
关于授权的复杂性我们会在模块 10 单讲,这里先建立基本概念:在处理器中验证 token,别盲目信任模型。
8. 与外部 API 和数据库的交互:分层与实践
“都写在处理器里”的诱惑很大:参数解析、数据库查询、过滤、映射到 structuredContent、日志,再来点哲学——全部塞进一段 150 行的函数。这就像把整个应用写在 pages/index.tsx 里——可以,但很痛苦。
更好的方式是明确分层:
// gifts-repository.ts
import type { GiftItem } from "./gifts";
export async function fetchGiftsFromApi(
maxBudget: number,
interests: string[]
): Promise<GiftItem[]> {
const resp = await fetch("https://example.com/api/gifts", {
method: "POST",
body: JSON.stringify({ maxBudget, interests }),
headers: { "Content-Type": "application/json" },
});
if (!resp.ok) {
throw new Error(`Gift API error: ${resp.status}`);
}
const data = (await resp.json()) as GiftItem[];
return data;
}
// gifts.ts(更新)
import { fetchGiftsFromApi } from "./gifts-repository";
export async function suggestGifts(input: SuggestGiftsInput): Promise<GiftItem[]> {
if (input.maxBudget <= 0) {
throw new Error("预算必须为正数。");
}
const items = await fetchGiftsFromApi(input.maxBudget, input.interests);
return items.sort((a, b) => b.score - a.score).slice(0, 3);
}
// route.ts(处理器片段)
async ({ input }) => {
try {
const payload: SuggestGiftsInput = {
age: input.age ?? null,
relationship: input.relationship,
maxBudget: input.maxBudget,
interests: input.interests,
};
const items = await suggestGifts(payload);
// ...
} catch (err) {
console.error("suggest_gifts failed", err);
return {
isError: true,
content: [
{
type: "text",
text: "挑选礼物时发生错误。请稍后再试。",
},
],
structuredContent: {
errorCode: "INTERNAL_ERROR",
},
};
}
}
这种做法的好处有:
- 可测试性:可以给 suggestGifts 和 fetchGiftsFromApi 写单元测试,而不用启动 MCP 服务器。
- 可读性:处理器保持为 MCP 协议与业务逻辑之间的薄适配层。
- 可复用:若之后在其他场景(如独立的 REST API)也需要礼物推荐,不必从 MCP 中“剥离”逻辑。
9. 日志与基础可观测性
工具的服务器端实现,是建设最小可观测性的绝佳位置。在生产环境你会关心:
- 哪些工具被调用;
- 调用所带的参数(当然不能包含 PII);
- 处理耗时;
- 错误的数量与类型。
当前我们关注 ChatGPT App 的机制,专业日志库可暂缓。一个最简单的处理器外层日志包装可如下:
// simple-logger.ts
export function logToolInvocationStart(tool: string, args: unknown) {
console.log(
JSON.stringify({
level: "info",
event: "tool_invocation_started",
tool,
timestamp: new Date().toISOString(),
// 生产环境切勿记录 PII!
args,
})
);
}
export function logToolInvocationEnd(tool: string, ms: number, success: boolean) {
console.log(
JSON.stringify({
level: "info",
event: "tool_invocation_finished",
tool,
durationMs: ms,
success,
timestamp: new Date().toISOString(),
})
);
}
// route.ts(处理器外层包装)
import { logToolInvocationStart, logToolInvocationEnd } from "./simple-logger";
server.registerTool(
"suggest_gifts",
{ /* ...meta... */ },
async ({ input }) => {
const startedAt = Date.now();
logToolInvocationStart("suggest_gifts", {
relationship: input.relationship,
maxBudget: input.maxBudget,
interestsCount: Array.isArray(input.interests)
? input.interests.length
: 0,
});
try {
// ... 主要逻辑 ...
const duration = Date.now() - startedAt;
logToolInvocationEnd("suggest_gifts", duration, true);
return result;
} catch (err) {
const duration = Date.now() - startedAt;
logToolInvocationEnd("suggest_gifts", duration, false);
throw err;
}
}
);
后续在度量、SLO 与监控的模块中,你可以基于这些日志构建图表与告警。但从现在起就养成良好的日志习惯,会让一切更顺畅。
10. 服务器结果如何进入小部件(以及回流)
在第 6 节我们已经通过 _meta["openai/outputTemplate"] 将工具结果绑定到 UI 模板。现在从另一侧看看——structuredContent 如何进入 React 小部件,UI 又该如何使用。
虽说本讲聚焦服务器端,但务必明白你同时在设计“模型的 API”与“UI 的 API”。服务器返回:
- structuredContent——模型和小部件都能看到的数据(通过 toolOutput);
- content——给模型看的“压缩版”描述;
- _meta——小部件的私有字段:openai/outputTemplate、openai/widgetCSP、openai/widgetDomain 等。
在 React 小部件内部,你可能会这样做:
// app/page.tsx(片段)
type ToolOutput = {
items?: {
id: string;
title: string;
price: number;
currency: string;
shortDescription: string;
tags: string[];
}[];
emptyReason?: string;
};
declare global {
interface Window {
openai?: {
toolOutput?: ToolOutput;
};
}
}
export default function GiftWidget() {
const output = typeof window !== "undefined"
? window.openai?.toolOutput
: undefined;
if (!output) {
return <div>正在等待礼物推荐结果……</div>;
}
if (!output.items || output.items.length === 0) {
return <div>没有合适的礼物。请尝试调整条件。</div>;
}
return (
<ul>
{output.items.map((item) => (
<li key={item.id}>
<strong>{item.title}</strong> — {item.price} {item.currency}
</li>
))}
</ul>
);
}
这也是为什么 structuredContent 的契约需要稳定,且尽量对 UI 友好:字段独立清晰,不要 10 层嵌套的“地狱”。
关于这条链路我们会在模块 4 的另一讲中详细说明,这里先明确:服务器与小部件基于同一份 structuredContent 结构协作。
11. 服务器端错误处理:格式与策略
在第 8–9 节我们已经涉及了处理器内部的错误与日志。现在把它们收敛成统一格式:工具的错误该如何返回,才能同时让模型与 UI 好用。
处理器中的错误不可避免:外部 API 可能故障、输入可能异常、你也可能手滑。关键是不要把它们变成“500 Internal Server Error 毫无说明”,既对模型糟糕,也对用户糟糕。
一个好的工具服务端实现:
- 区分用户/模型的输入校验错误与内部错误;
- 返回明确的 isError 字段与清晰的 errorCode(在 structuredContent 中);
- 在 content 中给用户友好的提示。
示例(假设工具的元数据——title、description、inputSchema 等——已经抽到 meta 变量里,这里不再重复):
function makeErrorResult(message: string, code: string) {
return {
isError: true,
content: [
{
type: "text",
text: message,
},
],
structuredContent: {
errorCode: code,
},
};
}
server.registerTool(
"suggest_gifts",
meta,
async ({ input }) => {
try {
if (input.maxBudget > 10000) {
return makeErrorResult(
"预算过高。请将预算限制在 10000 USD 以内。",
"BUDGET_TOO_HIGH"
);
}
const items = await suggestGifts({
age: input.age ?? null,
relationship: input.relationship,
maxBudget: input.maxBudget,
interests: input.interests,
});
if (!items.length) {
return {
content: [
{
type: "text",
text:
"没有找到该预算下的礼物。请尝试调整兴趣或提高预算。",
},
],
structuredContent: {
items: [],
emptyReason: "NO_MATCHES",
},
};
}
return {/* 正常结果 */};
} catch (err) {
console.error(err);
return makeErrorResult(
"服务器在挑选礼物时发生内部错误。",
"INTERNAL_ERROR"
);
}
}
);
这样的格式既方便模型(可以尝试调整参数),也方便 UI(小部件可基于不同的 errorCode 展示特定信息)。
关于韧性、幂等与安全的工具设计我们会在后续几讲深入,这里先养成习惯:与其默默做出奇怪结果,不如明确返回错误。
在讲末我们还会把这些要点与其他注意事项整理为“工具服务器端实现的常见错误”清单,方便对照。
12. 端到端小例子:从请求到响应
把我们做的内容串起来,基于 GiftGenius 应用演示一遍。
- 用户对 ChatGPT 说:
“给朋友挑个礼物,他喜欢桌游,预算不超过 50 美元。” - 模型知晓工具 suggest_gifts 及其模式,决定调用它并构造 tool_call:
{ "tool": "suggest_gifts", "arguments": { "relationship": "friend", "maxBudget": 50, "interests": ["board games"], "age": null } } - 平台将该 JSON‑RPC 发到我们的 MCP 服务器(POST /app/mcp),Next.js 将请求体传给 server.handle(...)。
- 我们的处理器 suggest_gifts:
- 校验 interests 非空;
- 调用 suggestGifts(payload);
- 获得 GiftItem[](按 score 取前 3 个);
- 打包到 structuredContent.items,并设置 _meta["openai/outputTemplate"] = "ui://widget/gifts.html"。
- ChatGPT 收到响应,将 structuredContent 放入上下文,加载小部件 HTML 资源 gifts.html,并把 toolOutput 传给它。
- 我们的 React 小部件读取 window.openai.toolOutput.items 并渲染礼物列表;模型基于 content 与 structuredContent 给出文字说明为何这些礼物合适。
- 用户在小部件中点击“显示更多”——小部件通过 SDK 再次调用 callTool → 再次进入我们的处理器,但已携带不同参数(比如更高预算)。
这整条链路的基石是:工具的服务器端实现能够:
- 根据约定的 JSON Schema 接收结构化的 input;
- 认真校验数据;
- 调用隔离的业务逻辑;
- 返回稳定的结构化输出;
- 按需指定 UI 模板与元数据。
13. 工具服务器端实现的常见错误
错误一:“一切写在一处”——巨型处理器。
当所有逻辑与外部 API 调用都写在 server.registerTool(..., async () => { ... }) 内,代码很快就会膨胀成难以阅读的巨石。一点小改动就可能牵一发动全身。把业务逻辑抽到独立函数/模块,让处理器成为薄适配层,会更好。
错误二:盲目信任 JSON Schema。
开发者常想:“既然有模式,输入就一定有效。”但模型可能给出奇怪的值,外部客户端更是如此。不能只依赖类型与 JSON Schema——还需要逻辑校验(预算边界、数组长度、允许值等)。
错误三:把所有内容堆进 content,忽视 structuredContent。
有人把巨大的 JSON 以字符串形式塞进 content“以防万一”。这会让模型提示变得嘈杂且耗费 token,UI 端也很痛苦,因为不得不先解码字符串才能使用结构。更好的做法是让 content 简洁,把细节放在 structuredContent。
错误四:结构化输出格式不稳定。
今天 items 是带 id、title、price 的对象数组,明天你突然把 price 改名为 amount,小部件就挂了。或者增加了新的嵌套层级。可以改,但要么对契约做版本化,要么以更小的步子演进模式,否则 UI 与测试会一直被打断。
错误五:缺少有意义的错误处理。
抛出异常并指望平台“自己处理”并非上策。模型看到的是莫名其妙的 JSON‑RPC 错误,用户看到的是红色错误条,而你丢失了问题的上下文。更好的方式是返回明确的 isError、errorCode 与用户可读的信息,同时在服务器端记录详细日志。
错误六:忽视授权并信任模型。
有时开发者认为:“模型很聪明,用户没登录它就不会调用这个工具。”实际上模型并不了解你的 ACL 与配额,它只看到工具描述。所有权限校验都必须在服务器处理器中完成,不论工具如何描述。
错误七:把所有内容都记录到日志,包括 PII。
很容易顺手把完整的 input 记录下来。对于 ChatGPT App,这可能包含 PII(姓名、邮箱、地址等),既违反 OpenAI 政策,也不符合常识。更好的方式是只记录聚合/去标识的信息:关系类型、预算区间、兴趣数量。
错误八:与外部 API 交互时没有超时与重试。
若工具在处理器中对外部 API 执行 fetch 而没有超时与重试,任何该 API 的延迟都会表现为“ChatGPT 卡住”。用户会以为整个应用坏了。服务器端需要设置时间限制,处理超时,并返回有意义的错误。
GO TO FULL VERSION