這一課完成後:你能定義 JSON Schema、要求模型回 structured data、把結果 parse 成 JavaScript object,再做 application-level validation;也能處理 incomplete / refusal / provider error,而不是假設「API 回 200 就一定有可用 JSON」。

先看最脆弱的做法:用字串切模型答案

假設你要求模型:

請回覆:
Title: ...
Summary: ...
Difficulty: ...

然後程式:

const lines = text.split("\n");
const title = lines[0].replace("Title:", "");

只要模型多一行、改標點、把欄位順序換掉,程式就可能壞。

Human-readable text
→ 猜格式
→ fragile parser

如果下游是程式,不應該要求程式去猜自然語言格式。

JSON 只是第一步,Schema 才是契約

單純要求「請回 JSON」比純文字好,但仍然可能得到:

{
  "title": "API",
  "difficulty": "kind of easy",
  "extra_field": "???"
}

真正需要的是先定義你允許什麼:

{
  "type": "object",
  "properties": {
    "title": { "type": "string" },
    "summary": { "type": "string" },
    "difficulty": {
      "type": "string",
      "enum": ["beginner", "intermediate", "advanced"]
    },
    "key_points": {
      "type": "array",
      "items": { "type": "string" }
    }
  },
  "required": [
    "title",
    "summary",
    "difficulty",
    "key_points"
  ],
  "additionalProperties": false
}

這就是資料 contract。

JSON Schema 在描述「資料可以長什麼樣」

先掌握幾個最常用的:

type → object / string / number / boolean / array
properties → object 有哪些欄位
required → 哪些欄位不能缺
enum → 只能是哪些固定值
items → array 裡的元素型別
additionalProperties → 能不能出現沒定義的欄位

不需要第一天就學完整 JSON Schema 規格。先把產品真正需要的資料結構寫清楚。

Structured Outputs 和「叫模型輸出 JSON」不同

目前 OpenAI Responses API 的 text.format 可以設定 json_schema。官方把這稱為 Structured Outputs,目的是讓支援的模型輸出符合你提供的 schema。

OpenAI Responses API — Structured Outputs

舊的 JSON mode 主要保證是有效 JSON;官方目前建議在支援的模型上優先使用 json_schema,因為它還多了 schema constraint。

JSON mode → valid JSON
Structured Outputs → valid structure that follows supplied schema

把 schema 放進 model request

沿用前面的 model client:

const conceptSchema = {
  type: "object",
  properties: {
    title: { type: "string" },
    summary: { type: "string" },
    difficulty: {
      type: "string",
      enum: ["beginner", "intermediate", "advanced"]
    },
    key_points: {
      type: "array",
      items: { type: "string" }
    }
  },
  required: [
    "title",
    "summary",
    "difficulty",
    "key_points"
  ],
  additionalProperties: false
};

Request:

const response = await client.responses.create({
  model: env.OPENAI_MODEL,
  instructions: `
Analyze the user's concept for a learning product.
Use Traditional Chinese for title, summary and key points.
Do not invent specific facts when the input is unclear.
`,
  input: userInput,
  text: {
    format: {
      type: "json_schema",
      name: "concept_explanation",
      strict: true,
      schema: conceptSchema
    }
  }
});

這裡的 schema 是 API contract,不需要再把同一份 JSON 格式完整複製進 prompt。

取得結果後,先 parse,不要直接相信字串

SDK 的 output_text 仍然是文字表示,因此你可以:

const data = JSON.parse(response.output_text);

現在 data 才是 JavaScript object。

但產品程式仍然要把 provider response 視為 external dependency,而不是「因為模型說是 JSON 就完全不用檢查」。

Schema validation 和 Business validation 是兩層

Schema 可以驗證:

difficulty 是否在 enum
key_points 是否為 array
title 是否為 string
必要欄位是否存在

但它不一定知道你的產品規則,例如:

summary 最多只能顯示 280 字。

key_points 最多只取 5 個。

這個 user 是否有權限把結果寫進某個 project。

資料裡的某個 id 是否真的存在於 database。

所以:

Model schema → 結構正確
Application validation → 產品語意與權限正確

即使 provider 有 Structured Outputs,仍要保留 validation boundary

原因很簡單:你的 backend 不應該把任何外部服務回傳值直接當內部可信資料。

最小 validator 可以先手寫:

function isConceptData(value) {
  return Boolean(
    value &&
    typeof value === "object" &&
    typeof value.title === "string" &&
    typeof value.summary === "string" &&
    ["beginner", "intermediate", "advanced"]
      .includes(value.difficulty) &&
    Array.isArray(value.key_points) &&
    value.key_points.every(
      (item) => typeof item === "string"
    )
  );
}

專案變大後可以換成成熟 schema validator;重點是 validation boundary 本身不能消失。

不要只處理 JSON.parse error

Model request 還可能:

provider request 失敗。

response status 是 incomplete。

模型拒絕某個 request。

輸出因 token limit 沒完成。

你的 schema 本身寫錯或模型不支援該格式。

所以 structured data pipeline 應該是:

Call model
↓ check response state
Extract output
↓ parse JSON
Validate schema / business rules
↓
Use application data

把 parse 和 model call 分開

不要把所有事情塞在 route 裡:

function parseConceptOutput(text) {
  let value;

  try {
    value = JSON.parse(text);
  } catch {
    throw new Error("MODEL_OUTPUT_NOT_JSON");
  }

  if (!isConceptData(value)) {
    throw new Error("MODEL_OUTPUT_INVALID");
  }

  return value;
}

Model adapter:

async function generateConceptData(env, input) {
  const response = await createConceptResponse(env, input);
  return parseConceptOutput(response.output_text);
}

Route 只需要處理產品流程,不需要知道每個 provider output 的細節。

Retry 不是「錯了就無限再問一次」

可以重試的例子:

暫時性 provider 5xx。

短暫 network error。

你明確判斷為可恢復的 output failure。

不該盲目重試:

使用者輸入本來就不合法。

Auth / permission 失敗。

模型明確 refusal。

Schema 定義本身有 bug。

Retry 要有上限,否則一個錯誤 request 可以變成三倍、十倍成本。

Fallback 要先定義「失敗時產品怎麼活著」

如果 structured extraction 失敗,你可以:

顯示可理解 error
或要求使用者補資訊
或回到較簡單功能
或排入人工 review

不要悄悄把 invalid output 寫進正式 database,只因為「至少有東西」。

不要讓模型替你產生真正的 Database ID

例如你要分類 task,模型可以回:

{
  "category": "school"
}

但不要要求模型幻想:

{
  "category_id": 9472
}

如果 9472 必須對應真實 row,應由程式查 database、驗證或透過 tool 執行。

Model → semantic choice
Program → authoritative ID / permission / side effect

Schema 不要設計成「什麼都 optional」

如果所有欄位都可有可無:

{}

也可能合法,那 schema 幾乎沒提供 contract。

真正需要的欄位就列入 required;允許沒有資料時,可以設計明確狀態,例如:

{
  "status": "insufficient_information",
  "reason": "The input does not identify a concept."
}

「不知道」也可以是一種正式資料狀態,而不是靠缺欄位猜。

Enum 很適合 UI 與 workflow

例如:

"difficulty": {
  "type": "string",
  "enum": [
    "beginner",
    "intermediate",
    "advanced"
  ]
}

Frontend 可以直接根據固定值渲染,不必處理:

easy
Easy
簡單
超簡單
beginner-ish

Enum 是把自然語言自由度收斂成程式可以依賴的狀態。

Schema 也要版本化

今天的 response:

{
  "title": "...",
  "summary": "..."
}

下週多一個:

"key_points": [...]

這已經是 API contract 變更。

最小做法:

const OUTPUT_SCHEMA_VERSION = "concept-v1";

Log / database 可以記錄 schema version,讓之後知道某筆 AI data 是用哪個 contract 產生的。

Structured Output 很適合做「抽取」而不是只有回答

例如把一段學生專案描述轉成:

{
  "project_name": "...",
  "goal": "...",
  "skills": ["..."],
  "evidence": ["..."],
  "missing_information": ["..."]
}

這種輸出可以進 UI、DB、搜尋索引或下一個 workflow。

但每個欄位仍然是模型根據 context 產生的判斷;如果某欄位需要真實世界驗證,就要另外查 authoritative source。

小挑戰:把 Explain 升級成 Concept Analyzer

需求 1:建立固定 JSON Schema,至少有 title、summary、difficulty、key_points。

需求 2:difficulty 使用 enum。

需求 3:拒絕 schema 以外的額外欄位。

需求 4:使用 Structured Outputs,而不是只在 prompt 寫「請回 JSON」。

需求 5:Backend parse 後再次做 application validation。

需求 6:invalid / incomplete / provider error 都有自己的處理路徑。

需求 7:Retry 最多固定次數,不可以無限 retry。

需求 8:Frontend 直接使用 object fields render,不再 split 文字。

需求 9:加入 OUTPUT_SCHEMA_VERSION。

測試 Structured Output 時,故意挑難的 input

至少測:

正常概念:「什麼是 API?」

很模糊:「那個東西是什麼?」

極短 input。

很長但合法的 input。

包含 prompt injection 文字的 input。

要求模型多加 schema 沒有的欄位。

要求 difficulty 回一個 enum 外的值。

你要驗證的是 contract 有沒有守住,而不是只看正常案例漂亮不漂亮。

最後收斂:模型輸出先是外部資料,再是產品資料

User / context
↓
Model + JSON Schema
↓
Structured model output
↓ parse
↓ validate
↓ business rules / authorization
Trusted application data

Structured Output 的目的不是讓模型「看起來更像 API」,而是把不穩定的自然語言邊界收斂成程式可以明確處理的 contract。

下一課會把資料來源往外擴:RAG。當答案需要自己的 PDF、文章、知識庫或資料文件時,不是把整份文件硬塞進 context,而是先檢索最相關的片段,再讓模型回答。

完成條件:你能用 JSON Schema 定義 AI output contract,使用 Structured Outputs 取得符合結構的結果,再經 parse 與 application validation 後才交給 frontend / database;並能處理 retry、fallback、schema version 與 provider failure。