CodeGym /课程 /ChatGPT Apps /工具说明:JSON Schema、类型化、annotations

工具说明:JSON Schema、类型化、annotations

ChatGPT Apps
第 4 级 , 课程 1
可用

1. 工具即契约:我们究竟在描述什么

当你在 MCP 服务器中注册一个工具时,你会用一个小对象来描述它。针对 TypeScript SDK 的简化结构如下:

server.registerTool(
  "suggest_gifts",
  {
    title: "Suggest gifts",
    description: "根据收礼人画像推荐礼物。",
    inputSchema: {
      type: "object",
      // 我们现在就从这里深入
    },
  },
  async ({ input }) => {
    // 你的代码
  }
);

模型并不知道处理器 async{ input }=> { ... } 里面是什么。对它而言只有三件事:

  1. name/title —— 工具叫什么。
  2. description —— 何时适合使用它。
  3. inputSchema —— 需要传哪些参数以及格式。

本讲我们所做的一切都与第 3 点相关(还有少量关于 _meta/annotations 的元数据,稍后会谈)。

务必理解:在 ChatGPT App 语境中,JSON Schema 不是乏味的校验器,而是模型提示词的一部分。 模型确实会读取字段的 description,理解 enum,注意到 minItemsformat 等等。

也就是说,你不仅是在保护后端免受错误数据的影响,你也在告诉 AI 模型如何正确地调用你的函数。

2. 工具 suggest_gifts 的基础 JSON Schema

从简单的开始。假设我们有这样一个场景:

用户写道:
“给我25岁的哥哥挑选礼物,预算 50–70 美元,喜欢电子游戏和桌游。”

工具 suggest_gifts 应该接收大致如下参数:

  • 收礼人的年龄;
  • 关系类型(兄弟、同事、伴侣等);
  • 最低与最高预算;
  • 兴趣列表。

我们“直接”用 JSON Schema 来描述,不用 Zod,纯对象:

const suggestGiftsInputSchema = {
  type: "object",
  properties: {
    age: {
      type: "integer",
      minimum: 0,
      maximum: 120,
      description: "收礼人的年龄(岁)。",
    },
    relationship: {
      type: "string",
      enum: ["friend", "partner", "sibling", "colleague", "parent"],
      description:
        "与收礼人的关系类型:friend、partner、sibling(兄弟/姐妹)、colleague、parent。",
    },
    minBudget: {
      type: "number",
      minimum: 0,
      description: "以用户货币计的最低预算。",
    },
    maxBudget: {
      type: "number",
      minimum: 0,
      description: "以用户货币计的最高预算。",
    },
    interests: {
      type: "array",
      items: {
        type: "string",
        description:
          "兴趣的简短名称,例如:videogames、boardgames、books。",
      },
      minItems: 1,
      description: "收礼人的兴趣列表。",
    },
  },
  required: ["relationship", "maxBudget"],
};

这里有几个需要立刻说明的要点。

首先,是字段的 description。在普通 API 中你也许可以不写——前端开发者读了 Swagger 也能理解。 但这里“客户端”是模型,它试图从名称与描述中提炼语义。 你越清晰地说明“年龄(单位:岁)”、“预算(以用户的货币计)”、“enum 为固定取值”,你在运行时看到的奇怪参数就会越少。

其次,enum 是管理模型最强有力的工具之一。 如果你允许模型在 relationship 中写任意字符串,你会收到诸如 “bro”、“girlfriend”、“bestie”、“teammate” 乃至更有创意的内容。 而如果你指定了 enum,模型很大概率只会从这些值中选择。 这能直接减少参数中的“幻觉”。

第三,不是所有字段都必须设为 required。 例如 age 可以是可选的:如果用户没有提供,模型就不会“凭空”编造一个“约莫年龄”(前提是你在描述里这样引导)。 这就是艺术的开始:在灵活与严格之间取得平衡。

现在在注册工具时使用这份模式:

server.registerTool(
  "suggest_gifts",
  {
    title: "Suggest gifts",
    description:
      "根据预算、关系类型和收礼人的兴趣推荐礼物创意。",
    inputSchema: suggestGiftsInputSchema,
  },
  async ({ input }) => {
    // 此处的 input 已大致符合该模式
    // ...
  }
);

这种“手写”的对象很适合快速试验。但随着应用增长,它会变成一个容易与 TypeScript 类型“脱节”的独立世界。 稍后我们会回到这个问题,看看如何用 Zod 和从类型生成 JSON Schema 的方式来解决。

3. JSON Schema 也是提示词:如何写 description 让模型不犯错

形式上 JSON Schema 是做校验的;但在 LLM 的世界里,它也是结构化的提示词。这里有一些实践规则:

  1. 字段 description 必须回答“这里该放什么以及以什么格式放”。
    “日期”这种描述没有帮助。“ISO 8601 日期,格式 YYYY-MM-DD,例如 "2025-02-14"”——就非常有用。
  2. 涉及金额的字段——请明确单位。
    最好明确写“金额以用户的货币计”或“金额以美元计”。否则模型可能会认真地写 50,而你会猜不出是 50 日元还是 50 欧元。
  3. 字符串“类别”几乎总是用 enum 更好。
    如果字段是“类别”字符串,最好做成 enum,并在工具的 description 里解释每个值。 例如对 relationship,可以在工具描述中写: “relationship:以下之一:friend(朋友)、partner(恋爱伴侣)、sibling(兄弟或姐妹)、colleague(同事)、parent(父母)。不要编造其他值。”
  4. 数组字段最好设置 minItems 并解释这是什么列表。
    如果字段是数组,最好指定 minItems,并简要说明这个列表的含义。 例如,interests 不是“用散文描述一个人”,而是“一组简短的标签”。

这些听起来有些唠叨,但实践中“有描述”和“无描述”的差别,往往就是“稳定应用”和“模型今天又要出什么幺蛾子”的差别。

洞见

MCP 工具有严格的大小限制——这往往是导致“神秘”崩溃、奇怪错误,以及助理突然看不见你的 tools 的罪魁祸首。

关键规则很简单:工具应完整装进约 4 KB 的 JSON。 不仅仅是 description 文本,而是整个结构:

  • 工具描述,
  • 参数模式(inputSchema),
  • 嵌套对象与 enum
  • _meta 与 annotations。

如果你的工具不断膨胀,平台会变得不可预测:会出现诸如 "Tool description is too long""Schema validation failed""Manifest exceeds size limits" 之类的错误, 有时 ChatGPT 甚至会停止加载该工具,或“忘记”它的存在。

建议:将 description 控制在 10002000 字符之内,整个工具控制在“安全”的 ~4 KB 以内。 如果描述变得太长,这几乎总是在提示:这个工具一次做了太多事。 把工具拆得更窄、更清晰——模型会更可靠地理解边界,也更少在输入数据上犯错。

4. TypeScript 和 Zod:一个真相来源,告别双写

手写 JSON 模式对 TypeScript 开发者来说很痛苦。你得同时维护两套“世界”:

  • TS 代码中的类型;
  • 给模型用的 JSON Schema。

应用越大,它们越容易背离。今天你改了 TypeScript 类型的字段,明天忘了更新模式——下周就在线上踩坑。

在 TS 世界里的事实标准是:使用 Zod,并做 Zod -> JSON Schema 的转换。

安装依赖(如果还没有):

npm install zod zod-to-json-schema

用 Zod 为 suggest_gifts 描述输入模式:

import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";

const SuggestGiftsInputZod = z.object({
  age: z
    .number()
    .int()
    .min(0)
    .max(120)
    .describe("收礼人的年龄(岁)。"),
  relationship: z
    .enum(["friend", "partner", "sibling", "colleague", "parent"])
    .describe(
      "关系类型:friend(朋友)、partner(伴侣)、sibling(兄弟/姐妹)、colleague(同事)、parent(父母)。"
    ),
  minBudget: z
    .number()
    .min(0)
    .optional()
    .describe("以用户货币计的最低预算。"),
  maxBudget: z
    .number()
    .min(0)
    .describe("以用户货币计的最高预算。"),
  interests: z
    .array(
      z
        .string()
        .min(1)
        .describe(
          "兴趣的简短标签,例如:videogames、boardgames、books。"
        )
    )
    .min(1)
    .describe("收礼人的兴趣列表。"),
});

现在你拥有:

  1. 运行时校验:SuggestGiftsInputZod.parse(input)
  2. TypeScript 类型:type SuggestGiftsInput = z.infer<typeof SuggestGiftsInputZod>;
  3. 给模型用的 JSON Schema:zodToJsonSchema(SuggestGiftsInputZod)

在注册工具时使用它们:

type SuggestGiftsInput = z.infer<typeof SuggestGiftsInputZod>;

const suggestGiftsInputSchemaJson = zodToJsonSchema(
  SuggestGiftsInputZod,
  "SuggestGiftsInput"
);

server.registerTool(
  "suggest_gifts",
  {
    title: "Suggest gifts",
    description:
      "根据预算、关系类型和收礼人的兴趣推荐礼物创意。",
    inputSchema: suggestGiftsInputSchemaJson,
  },
  async ({ input }) => {
    // 这里可以再用 Zod 进行校验:
    const args = SuggestGiftsInputZod.parse(input) as SuggestGiftsInput;

    // 接下来使用类型化的 args
  }
);

这种方式正好实现了single source of truth——单一事实来源: 你只需描述一次模式,TypeScript 类型与 JSON Schema 会自动生成。

在真实项目里,你还会为此补上测试,以校验 zodToJsonSchema 生成的结构是否符合预期,但这是测试模块的话题了。

洞见:ChatGPT 不擅长处理可选参数

线上最痛的实践之一:一旦你在工具模式里大量使用 optional 字段,tool-call 的质量会明显下滑。 模型理论上“懂得”可选参数是什么,但实际上经常干脆就不发——即便业务逻辑很需要它们。

Response API 把这个问题“优雅地”解决了:直接去掉 optional 字段——工具的所有参数都必须声明为 required。 但问题并未消失:那种“我把一半字段标可选,让模型自己决定填不填”的想法,会撞上现实:它通常什么都不发。

5. “模式”到此为止,“界面设计”从这里开始

前面我们一直在谈 inputSchema——也就是模型为启动工具需要生成的参数。 但工具被调用后,生活还没结束:结果还要在 UI 里渲染出来。

这里把两层分开很有用:

  • 工具的模式描述模型需要生成的输入参数。 它始终是 JSON,存在于 MCP / tool-call 的空间。
  • UI 组件(小部件)读取 toolOutput.structuredContent,并据此构建界面。 structuredContent 的格式也要你来设计,但它已不再是给模型用的 JSON Schema (当然你也可以自用地把它形式化)。

有时开发者想用一个 JSON 对象“射两只鸟”——既做模型输入,又做 UI 数据格式。往往不会有好结果。 更方便的拆分方式是:

  • inputSchema —— 模型需要什么才能启动工具;
  • structuredContent —— UI 需要什么来渲染结果。

例如,inputSchema 对于 suggest_gifts 并不包含任何礼物的 id。 而 structuredContent 恰恰包含带有 idtitleprice、购买链接等的卡片列表。

6. 注解与 _meta:影响 UX 与安全性

除了参数模式与响应结构,还有一层——平台如何对待工具并将其展示给用户。 这由元数据与注解来负责。

除了标准字段 titledescriptioninputSchema,工具还可能有额外的元数据与 annotations。 在 Apps SDK 与 MCP 中,有些东西放在 _meta(例如 securitySchemes), 还有些则是 OpenAI 特定的提示字段,比如 readOnlyHintdestructiveHint

重点是:这些注解不会改变 JSON Schema,但会影响 ChatGPT 如何向用户展示工具、以及如何对待它的调用。

示例:readOnlyHintdestructiveHint

假设你有两个工具:

  • list_gifts —— 仅获取礼物列表(安全);
  • create_order —— 创建订单(潜在危险:涉及金钱、地址等)。

你可以把它们这样标注(伪代码):

server.registerTool(
  "list_gifts",
  {
    title: "List gift suggestions",
    description: "根据给定的筛选条件获取可用礼物列表。",
    inputSchema: listGiftsInputSchema,
    _meta: {
      readOnlyHint: true,
    },
  },
  async ({ input }) => { /* ... */ }
);

server.registerTool(
  "create_order",
  {
    title: "Create gift order",
    description:
      "以用户的名义为指定礼物创建订单。仅在得到明确确认后使用。",
    inputSchema: createOrderInputSchema,
    _meta: {
      destructiveHint: true,
    },
  },
  async ({ input }) => { /* ... */ }
);

语义如下: readOnlyHint 向 ChatGPT 表示该工具不会修改任何东西且安全;模型与 UI 可以更自由地调用它。 destructiveHint 表示该工具会做不可逆或关键操作,因此更频繁地需要用户确认,模型也会更谨慎。

在你的 Gift 应用里,suggest_gifts 显然是只读的;而与下单、扣款、修改用户数据相关的工具最好标为具有潜在破坏性(destructive)。

openWorldHint 及类似字段

有时你想提示模型:工具工作于“开放世界”,即其结果并非穷尽。 例如,search_products 永远不可能返回世界上全部商品,而只会返回相关结果。

这样的注解能帮助模型不要下结论“如果在 search_products 中找不到该商品,就说明它不存在”。 这是一种细腻的 UX 处理,但在生产应用中差异非常明显。

围绕 UI 展示的 _meta

当你的工具返回结果时,你可以在 _meta 中进一步指定影响小部件的设置。 比如:使用哪个 HTML 模板作为输出模板、是否需要边框、调用过程中显示什么文案等。

例如,在官方示例中,服务器会将小部件的 HTML 作为 MCP 资源单独注册,然后通过 _meta["openai/outputTemplate"] 引用它。

server.registerTool(
  "suggest_gifts",
  {
    title: "Suggest gifts",
    description: "推荐礼物创意。",
    inputSchema: suggestGiftsInputSchemaJson,
    _meta: {
      "openai/outputTemplate": "ui://widget/gifts.html", // 这是 MCP 资源的 id:server.registerResource(...)
      "openai/toolInvocation/invoking": "正在挑选礼物…",		// 调用过程中显示
      "openai/toolInvocation/invoked": "找到了礼物选项",   // 调用完成后显示
    },
  },
  async ({ input }) => {
    // ...
    return {
      content: [],
      structuredContent: { items: gifts },
    };
  }
);

这样,你在一个地方同时描述了:

  • 模型所需的输入数据形式(inputSchema);
  • 工具在 UI 中的呈现与行为(_meta)。

7. 模式设计:该向模型要什么,不该要什么

一个常见陷阱是把所有工作都丢给模型。 例如,你在 inputSchema 中放了 giftId,并在 description 写:“来自我们数据库的礼物 UUID”。 模型当然会认真生成类似 "0f21b5f0-5a3a-4d1b-8f0b-9f1a6e3c1234" 的 UUID, 问题在于你那边很可能并不存在这个礼物。

一个好规则:不要让模型生成技术标识符及与你内部世界绑定的数据

更好的方式是做成多步流程:

  1. suggest_gifts 返回包含 idtitleprice 等的礼物列表;
  2. UI/模型让用户从建议中选择一个;
  3. create_order 接受来自已有集合的 giftId

从模式角度看,这意味着:

  • 面向“外部”(用户)的工具,其 inputSchema 只描述用户能合理输入的内容:搜索参数、筛选条件、准则;
  • 操作内部实体的工具,其 inputSchema 依赖已知的 id,而不是要求模型“编造”它们。

对于你的 Gift 应用,这意味着在 suggest_gifts 里不要让模型“想出 SKU 代码”,而只提供查询参数。 SKU 本身在后端侧处理,UI 再显示给用户。

说明:SKU 是商品的库存单位代码(国际通用的唯一标识)。示例 "GFT-CHC-500-BS"

8. 小型实践:把所有内容串起来

让我们把上面谈到的内容放到一起:Zod 模式、JSON Schema 生成、带 _meta 的工具注册以及在业务逻辑中的使用。 为 Gift 应用做一个小而完整的示例。

先写 Zod 模式与类型:

import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";

const SuggestGiftsInputZod = z.object({
  relationship: z
    .enum(["friend", "partner", "sibling", "colleague", "parent"])
    .describe("与收礼人的关系类型。"),
  maxBudget: z
    .number()
    .min(0)
    .describe("以用户货币计的最高预算。"),
  interests: z
    .array(
      z
        .string()
        .min(1)
        .describe("简短的兴趣标签,例如:videogames。")
    )
    .min(1)
    .describe("收礼人的兴趣列表。"),
});

type SuggestGiftsInput = z.infer<typeof SuggestGiftsInputZod>;

const suggestGiftsInputSchemaJson = zodToJsonSchema(
  SuggestGiftsInputZod,
  "SuggestGiftsInput"
);

接着——注册带有 _meta 的工具用于 UI:

server.registerTool(
  "suggest_gifts",
  {
    title: "Suggest gifts",
    description:
      "当需要根据预算、关系与兴趣来推荐礼物创意时使用。",
    inputSchema: suggestGiftsInputSchemaJson,
    _meta: {
      "openai/outputTemplate": "ui://widget/gifts.html",
      "openai/toolInvocation/invoking": "正在挑选礼物…",
      "openai/toolInvocation/invoked": "找到了礼物选项",
      readOnlyHint: true,
    },
  },
  async ({ input }) => {
    const args = SuggestGiftsInputZod.parse(input) as SuggestGiftsInput;

    const gifts = await findGifts(args); // 你的业务逻辑

    return {
      content: [],
      structuredContent: {
        items: gifts,
      },
    };
  }
);

旁边会有一个类型化的业务函数:

async function findGifts(input: SuggestGiftsInput) {
  // 这里可以使用 input.relationship、input.maxBudget、input.interests
  // 并返回一个 Gift 类型的对象数组
  return [
    {
      id: "gift-1",
      title: "电子游戏主题的桌游",
      price: 45,
      currency: "USD",
    },
  ];
}

在小部件一侧,你随后会读取 window.openai.toolOutput.structuredContent.items 并渲染卡片,不过这个会在后面的课程中详细介绍。

9. 描述工具时的常见错误

错误 1:字段描述过于笼统或无意义。
如果你写 description: "日期"description: "筛选参数",模型几乎得不到任何有用信息。 这就像文档里写“该方法做了一些重要的事”一样。 请使用能回答“这里该放什么”“格式是什么”的描述。 例如:“ISO 8601 日期,格式 YYYY-MM-DD,如 "2025-02-14"”或“金额以用户的货币计,示例:49.99”。

错误 2:该用 enum 的地方没有用。
开发者常常懒得把字符串改成 enum,而是保留 type: "string"。 结果就是模型编造自己的取值、后端懵逼、UI 崩坏。 如果有固定集合(如 relationship、状态类型、排序方式)——几乎总该做成 enum 并列出可能的取值。 这能显著提升 tool-call 的可预测性。

错误 3:模式与类型有两个真相来源。
经典错误:你在 TypeScript 把 maxBudget 改成了 priceMax,却忘了更新 JSON Schema。 模型继续发 maxBudget,代码却期待 priceMax,然后一切都挂了。 这类错误常在生产才被发现。 因此最好一开始就使用 Zod 或类似工具,从一份声明同时生成类型与 JSON Schema。

错误 4:让模型生成内部标识符。
诸如 userIdgiftIdorderId 等字段,如果你把它们描述为“我们系统中的用户 UUID”,模型不可避免地会填入杜撰的值。 即便你为 UUID 加了 pattern,模型也只会生成“看起来正确”的 UUID,却毫无对应关系。 这类字段最好在后端按上下文(鉴权、前一个 tool-call)填充,而不是让模型来做。

错误 5:巨大无比、企图包打天下的“上帝模式”。
有时会想整一个 do_everything 的工具,里面对象超大,一半字段 nullable,一半 optional。 模型会在其中迷失。 请把功能拆成多个更窄、更易懂的工具: 一个负责找礼物,另一个负责获取具体礼物详情,第三个负责创建订单。

错误 6:忽略 _meta 与 annotations。
很多开发者只用 namedescriptioninputSchema, 却漏掉了 _meta 中的字段,如 openai/outputTemplate,以及像 destructiveHint 这样的提示。 结果就是那些“默默”做危险动作的工具,在 UI 中既没有提示也没有确认。 这会降低用户信任,并带来意外操作的风险。 使用 annotations 来明确标记只读与危险工具,同时设置友好的执行状态文案。

错误 7:服务端缺少输入校验。
即使 JSON Schema 与 Zod 看起来描述充分,仅依赖模型依然有风险。 模型有时会给出部分有效的数据,或者你自己改了模式却忘记了业务约束。 在处理器外包一层 try { parse } catch { ... } 并返回友好错误,能给模型一次修正参数的机会,也能避免一次失败的 tool-call 拖垮整个服务。

评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION