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 等等。

也就是說,你不只是保護 backend 免於不良資料,更是在向 AI 模型解釋如何正確呼叫你的函式。

2. 工具 suggest_gifts 的基本 JSON Schema

先從簡單的開始。假設有以下情境:

使用者輸入:
「幫 25 歲的弟弟挑個禮物,預算 50–70 美元,喜歡電玩與桌遊」。

工具 suggest_gifts 需要接收大致如下的參數:

  • 收禮者年齡;
  • 關係類型(兄弟、同事、伴侶等);
  • 最低與最高預算;
  • 興趣清單。

先不用 Zod,而是直接用「純物件」把它寫成 JSON Schema:

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 可以不必填:若使用者沒提供,模型就不會「憑空」捏造一個「大概年齡」(前提是你在說明中這樣表述)。 這就是藝術所在:在彈性與嚴格之間取得平衡。

現在把這份 schema 用在註冊工具裡:

server.registerTool(
  "suggest_gifts",
  {
    title: "Suggest gifts",
    description:
      "根據預算、關係類型與收禮者的興趣,提出禮物點子。",
    inputSchema: suggestGiftsInputSchema,
  },
  async ({ input }) => {
    // 這裡的 input 已大致符合 schema
    // ...
  }
);

這種「手寫」物件很適合快速實驗。但隨著應用成長,它會和你的 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 不是「以散文描述一個人」,而是一組「簡短的標籤」。

這些聽起來有點囉嗦,但實務上,有沒有寫清楚說明,差別就是:穩定的應用,與每天都在抽獎看模型今天會傳什麼。

Insight

MCP 工具有嚴格的大小限制——而這往往是導致「神秘」崩潰、怪錯誤,以及助理突然看不見你的工具的主因。

關鍵規則很簡單:整個工具(序列化成 JSON)應控制在約 ~4 KB。 這不只包含 description 的文字,而是整個結構:

  • 工具的描述,
  • 參數的 schema(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‑schema 對 TypeScript 開發者是種痛苦。你得維護兩個平行世界:

  • TS 程式碼中的型別;
  • 給模型用的 JSON Schema。

隨著應用成長,它們會開始走樣。今天你改了 TypeScript 型別,明天忘了更新 Schema——一週後在 prod 才踩雷。

事實上的標準做法是:使用 Zod,並把 Zod -> JSON Schema。

先安裝相依(若尚未安裝):

npm install zod zod-to-json-schema

用 Zod 定義 suggest_gifts 的輸入 schema:

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 是否產生了預期的結構,但這是測試模組的主題。

Insight: ChatGPT 對可選參數處理不佳

在 prod 中最痛的經驗之一:一旦你在工具的 schema 大量使用 optional 欄位,tool‑calls 的品質就會明顯下降。 模型理論上「理解」可選參數,但實務上常常乾脆不傳——即使依商業邏輯你很需要。

Response API 以漂亮的方式規避了這點:它乾脆移除 optional 欄位——所有工具參數都必須宣告為 required。 但問題不會自動消失:那種「我把一半欄位標 optional,讓模型自己決定要不要填」的想法,通常只會換來它什麼都不填。

5. 哪裡是「schema」的邊界,哪裡開始是「介面設計」

前面我們一直在談 inputSchema——也就是模型為了啟動工具需要生成的參數。 但工具被呼叫之後還沒結束:結果還要在 UI 上呈現。

在這裡,分清兩個層次很有幫助:

  • 工具的 schema 描述的是模型需要生成的輸入參數。 這永遠是存在 MCP / tool‑call 空間中的 JSON。
  • UI 元件(小工具)會讀取 toolOutput.structuredContent,並在此基礎上建構介面。 structuredContent 的格式也由你設計,但它不是給模型用的 JSON Schema (當然你可以在團隊內部把它形式化)。

有時開發者想用一個 JSON 物件同時解決兩件事——既當模型的輸入,又當 UI 的資料格式。這通常下場不好。 比較好的做法是分開:

  • inputSchema —— 給模型啟動工具所需;
  • structuredContent —— 給 UI 繪製結果所需。

例如,inputSchemasuggest_gifts 不包含任何禮物的 id。 而 structuredContent 則相反——它包含卡片清單,內有 idtitleprice、購買連結等。

6. Annotations 與 _meta:如何影響 UX 與安全

除了參數 schema 與回應結構,還有另一層——平台如何對待你的工具並把它展示給使用者。 這一層由中繼資料與 annotations 負責。

除了標準欄位 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 顯然是 read‑only,而建立訂單、扣款與修改使用者資料的工具則適合標註為可能 destructive。

openWorldHint 與相似欄位

有些情況你想提示模型,工具運作在「開放世界」,也就是結果不是窮舉的。 例如,search_products 永遠不會回傳世界上所有商品,只會回傳相關的。

這類註記能幫助模型不要下重結論,例如「如果在 search_products 找不到某商品,就代表它不存在」。 這是細膩的 UX 面向,但在正式環境差異很明顯。

_meta 與 UI 呈現

當你的工具回傳結果時,可以在 _meta 中加入進一步會影響小工具外觀的設定。 例如:使用哪個 HTML 樣板作為 output‑template、是否需要邊框、呼叫期間顯示哪段文案等。

例如,在官方範例中,伺服器會把小工具的 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. Schema 設計:該向模型要什麼,不該要什麼

常見陷阱之一——試著把所有工作都丟給模型。 例如,你在 inputSchema 裡放了 giftId,並在 description 寫:「我們資料庫的禮物 UUID」。 模型當然會試著生成類似 "0f21b5f0-5a3a-4d1b-8f0b-9f1a6e3c1234" 的 UUID, 問題是,你的系統裡很可能根本沒有這個禮物。

好原則:不要請模型生成與你內部世界綁定的技術性識別碼與資料

改成多步驟流程比較好:

  1. suggest_gifts 回傳帶有 idtitleprice 等資訊的禮物清單;
  2. UI/模型讓使用者從建議中選擇;
  3. create_order 接收已存在集合中的 giftId

就 schema 而言,這意味著:

  • 面向「外部」(使用者)的工具,其 inputSchema 只描述人類能理性提供的內容:搜尋參數、篩選條件、準則;
  • 操作內部實體的工具,其 inputSchema 依賴已知的 id,而不是要求模型去編造。

對你的 Gift 應用來說,這表示在 suggest_gifts 裡不要叫模型「編一個 SKU」,而只請它提供查詢參數。 SKU 會由 backend 端接上,UI 再顯示給使用者。

備註:SKU 是商品的唯一代碼。範例 "GFT-CHC-500-BS"

8. 小實作:把一切串起來

讓我們把上面提過的:Zod‑schema、JSON Schema 產生、帶有 _meta 的工具註冊,以及在商業邏輯中的使用, 整合成一個最小但完整的 Gift 應用範例。

先是 Zod‑schema 與型別:

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 的工具註冊:

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‑calls 的可預測性。

錯誤 #3:Schema 與型別有兩套真相。
經典案例:在 TypeScript 把 maxBudget 改成 priceMax,卻忘了更新 JSON Schema。 模型還在傳 maxBudget,程式碼期待 priceMax,然後一切崩潰。 這類錯誤常在 prod 才被發現。 因此最好一開始就使用 Zod 或類似工具,從單一宣告同時產生型別與 JSON Schema。

錯誤 #4:請模型生成內部識別碼。
userIdgiftIdorderId 這類欄位,如果你描述成「我們系統中的使用者 UUID」,模型不可避免地會用編造的值填上。 即便你加了 UUID 的 pattern,模型只會開始產生「看起來正確」的 UUID,卻毫無對應。 這種欄位最好根據情境在 backend 端填入(驗證、先前的 tool‑call),而不是請模型去填。

錯誤 #5:巨無霸的「萬能」schema。
有時很想做一個 do_everything 工具,配一個巨大的物件,一半 nullable、一半 optional。 模型會在其中迷失。 把功能拆成數個工具、各自更窄且清楚: 一個負責搜尋禮物,另一個拿特定禮物細節,第三個負責建立訂單。

錯誤 #6:忽略 _meta 與 annotations。
許多開發者只使用 namedescriptioninputSchema, 卻忽略了 _meta 中如 openai/outputTemplate 的欄位,以及像 destructiveHint 之類的提示。 結果是工具會「默默」進行危險動作,UI 沒有適當的提示與確認。 這會降低使用者信任,並帶來意外操作的風險。 請使用 annotations 來明確標記 read‑only 與危險的工具,並設定友善的執行狀態文案。

錯誤 #7:伺服器端缺乏輸入驗證。
即使 JSON Schema 與 Zod 看似都描述完了,只仰賴模型仍然有風險。 有時模型會輸出部分有效的資料,或你自己改了 schema,卻忘了商業限制。 把處理器包在 try { parse } catch { ... } 中並回傳友善錯誤,能讓模型有機會修正參數,也能避免一次不良的 tool‑call 讓整個服務倒下。

留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION