生活分享

模型之間交接資料:JSON Schema 與結構化輸出

把交接寫成 JSON Schema,上游模型的輸出先驗證再送下游,是讓多模型流程穩定下來的做法。本篇對照 OpenAI、Anthropic、Google 三家結構化輸出參數放在請求的哪個欄位,再用 jsonschema 套件示範驗證、失敗重試,以及下游只讀取驗證過欄位的完整流程,附兩段可以直接改寫的 Python 程式。

閱讀時間約 9 分鐘

原創插圖:JSON 文件在兩個模型圖示之間傳遞,中間畫著一道驗證關卡的幾何插圖
圖片:Mokaair (© Mokaair)
本篇目錄
  1. 為什麼要先講好契約
  2. OpenAI、Anthropic、Google 把這個設定放在哪裡
  3. 先定義 Schema,拿到上游模型的輸出
  4. 用 jsonschema 驗證,失敗就帶著錯誤重試
  5. 下游只吃驗證過的欄位

多模型工作流很容易卡住的地方,不是模型答得好不好,而是前一個模型的輸出換到下一個模型手上就對不上格式。這篇要處理的問題是:怎麼把『模型之間交接的資料』寫成一份雙方都遵守的契約,讓下游程式不用猜格式、也不必每次都手動修正。結論是把這份契約寫成 JSON Schema,讓上游模型的輸出先過 jsonschema 驗證,驗證失敗就帶著錯誤訊息重試,只有通過驗證的欄位才准許往下游送。

讀完你會有一份可以直接改用的 Schema 範例、一段呼叫上游模型並驗證輸出的程式,以及失敗重試的邏輯;下游函式只接受驗證過的欄位,不吃原始文字。動手跟著做需要 Python 3.11 以上、裝好 jsonschema 套件,以及一組可用的 OPENAI_API_KEY;不需要同時申請三家的金鑰,本篇的程式只呼叫一家,另外兩家的欄位名稱在文字裡對照說明。

為什麼要先講好契約

模型之間如果只靠一段自然語言文字交接資料,下一個模型必須重新『猜』上一個模型想表達的欄位是什麼,例如日期是不是用同一種格式、有沒有漏欄位、陣列裡是不是真的都是字串。這種猜測在提示詞寫得夠清楚時多半沒事,但只要輸入內容稍微特殊、模型換了版本,或者中間多了一次轉手,格式就可能悄悄跑掉,而且不會立刻報錯,往往是下游程式解析到一半才出問題。

這篇說的『交接契約』,指的是一份雙方都認得的 JSON Schema:上游模型被要求依照它輸出,下游程式也只按照它讀取欄位,兩邊不用另外靠文件或口頭約定對格式。但 schema 只能保證形狀對,也就是型別、必填欄位、列舉值這些邊界條件符合;它沒辦法保證內容是對的,例如模型有沒有正確理解旅客的原始留言。格式驗證跟業務驗證是兩件事,後面會示範怎麼讓下游只吃通過格式驗證的欄位,但這不代表這些欄位的內容已經被確認過。

OpenAI、Anthropic、Google 把這個設定放在哪裡

OpenAI 的文件把這個功能叫做 Structured Outputs。在 Chat Completions API 裡,設定放在 response_format 欄位下,型別是 json_schema,裡面再包一個 schema 欄位與一個 strict 旗標;同一個功能改用 Responses API 呼叫時,位置換成 text.format,Python SDK 也提供 text_format 這個參數,可以直接傳 Pydantic 類別進去,不用自己組 JSON Schema。OpenAI 的文件寫,這項功能是從 GPT-4o 起的較新模型支援,新專案建議直接用 gpt-6-astra。

Anthropic 的位置不一樣,設定放在 output_config.format 底下,一樣有 type 是 json_schema 與一個 schema 欄位;Anthropic 的文件寫,舊的 output_format 參數已經搬到 output_config.format,beta 標頭不再是必要的,舊標頭與舊欄位在過渡期還會被接受;同一段也註明 Python SDK v1.0 起在 client.beta.messages.create() 與 count_tokens() 上不接受舊的 output_format 寫法,會丟 TypeError。Anthropic 另外有一個容易搞混的 strict 旗標,放在工具定義裡面,管的是工具呼叫的參數符不符合 schema,跟這裡講的『整段回覆是不是合法 JSON』是兩件事。支援的模型上,Anthropic 的相容性表列的是系列名而不是 API id:Fable 5 與 5.1、Mythos 5、5.1 與 Preview、Opus 4.5、4.6、4.7、4.8 與 5、Sonnet 4.5、4.6 與 5、Haiku 4.5;頁面範例實際帶進 output_config 的 id 是 claude-opus-5。

Google 的欄位又是另一種放法。這一頁目前示範的呼叫方式只有 Interactions API 一種:呼叫 client.interactions.create 時,把 response_format 設成一個物件,type 固定是 text,mime_type 設成 application/json,schema 欄位放實際的 JSON Schema;REST 範例打的端點是 v1beta 路徑底下的 /interactions。Google 的文件也提醒,結構化輸出如果要跟 Google 搜尋、程式碼執行這類內建工具一起用,目前還是 Preview,只開放給 Gemini 3 系列模型,本篇不會用到這個組合。

如果原始資料來自旅客上傳的檔案,本站《API 檔案與 JSON:結構化輸出及驗證》示範的是同一個 Google 功能怎麼處理 PDF 輸入,並且額外強調『JSON 合法』跟『內容真的來自檔案』是兩回事,跟本篇格式驗證不等於內容正確的提醒互相呼應。

三家結構化輸出參數比較(2026 年 9 月查證)
供應商參數放在哪裡型別與巢狀放法現況支援模型(節錄)
OpenAIChat Completions 的 response_format;Responses API 在 text.formattype 為 json_schema,schema 與 strict 包在裡面頁面沒有標 beta 或預覽;文件寫從 GPT-4o 起的較新模型支援,新專案建議用 gpt-6-astragpt-6-astra;文件寫『較新的大型語言模型,從 GPT-4o 起』,與 JSON mode 的對照表另外列出相容的 gpt-4o 快照版本與之後的模型
Anthropicoutput_config.formattype 為 json_schema,schema 包在裡面;另有獨立的 strict 旗標管工具呼叫文件寫 output_format 已移到 output_config.format、beta 標頭不再必要;舊標頭與舊欄位過渡期仍接受文件列的是系列名:Fable 5、5.1;Mythos 5、5.1、Preview;Opus 4.5、4.6、4.7、4.8、5;Sonnet 4.5、4.6、5;Haiku 4.5
GoogleInteractions API 的 response_formattype 固定 text,mime_type 設 application/json,schema 包在裡面REST 端點仍在 v1beta;搭配內建工具時是 Preview,只給 Gemini 3 系列頁面範例用 gemini-3.8-flash

先定義 Schema,拿到上游模型的輸出

接下來用一個貼近旅遊網站的例子:旅客寫信要求改班機,程式要把這段話轉成四個固定欄位,再讓後續流程照著處理。範例只接上 OpenAI 一家,把 Anthropic 或 Google 換進來主要是 HTTP 呼叫那幾行不同,驗證與重試的邏輯不用改;但三家文件都寫自己只支援 JSON Schema 的一個子集,換家前要照各自的限制確認 schema。金鑰一律從環境變數讀,程式不會把它印出來或寫進檔案。

schema_and_upstream.py(需要標準庫 urllib) · python
import json
import os
import urllib.request

# 交接契約:下游只認得下列四個欄位,其餘一律視為未驗證資料
CHANGE_SCHEMA = {
  "type": "object",
  "properties": {
    "booking_id": {"type": "string", "description": "訂位代號"},
    "change_type": {
      "type": "string",
      "enum": ["reschedule", "cancel", "seat_upgrade"],
    },
    "new_date": {"type": ["string", "null"], "format": "date"},
    "reason": {"type": "string", "description": "旅客提出的原因"},
  },
  "required": ["booking_id", "change_type", "new_date", "reason"],
  "additionalProperties": False,
}

API_KEY = os.environ["OPENAI_API_KEY"]
ENDPOINT = "https://api.openai.com/v1/chat/completions"


def call_model(prompt: str, schema: dict, model: str) -> str:
  """呼叫上游或下游模型,要求輸出符合 schema 的 JSON 字串。"""
  body = {
    "model": model,
    "messages": [
      {"role": "system", "content": "只輸出符合 schema 的 JSON,不要多寫其他文字。"},
      {"role": "user", "content": prompt},
    ],
    "response_format": {
      "type": "json_schema",
      "json_schema": {"name": "change_request", "schema": schema, "strict": True},
    },
  }
  request = urllib.request.Request(
    ENDPOINT,
    data=json.dumps(body).encode("utf-8"),
    headers={
      "Authorization": f"Bearer {API_KEY}",
      "Content-Type": "application/json",
    },
    method="POST",
  )
  with urllib.request.urlopen(request, timeout=30) as response:
    payload = json.load(response)
  return payload["choices"][0]["message"]["content"]

上面的 call_model 只負責把提示詞跟 schema 送出去,回傳的是模型輸出的原始文字,這段文字有沒有真的符合 schema,程式目前還沒有檢查。因為 schema 已經把 additionalProperties 設成 false、required 又把四個欄位都列進去,符合 strict 模式的要求,理論上模型應該只會輸出這四個欄位;但『理論上』不是保證:OpenAI 的文件自己列了兩種例外,一是模型基於安全理由拒答,回覆會多出一個 refusal 欄位、不一定照 schema,二是碰到 max tokens 上限、回覆被截斷而不完整,這就是驗證要接手的地方。

用 jsonschema 驗證,失敗就帶著錯誤重試

jsonschema 套件的最上層函式是 validate(),一次呼叫做兩件事:先確認 schema 本身合法,再拿 instance 去比對,其中一步不通過就丟出例外,schema 本身有問題丟的是 jsonschema.exceptions.SchemaError,instance 不符合 schema 丟的是 jsonschema.exceptions.ValidationError。官方文件寫,如果你已經確定 schema 本身沒問題、又要拿同一份 schema 驗很多筆資料,多半會偏好直接用 Draft202012Validator 這類版本化的驗證器物件,先建立一次再重複呼叫,不用每次都重新檢查 schema 本身;它的 iter_errors() 方法會把所有不符合的地方一次列完,不是抓到第一個錯誤就停,每個錯誤物件都有一個 message 屬性,是給人看的說明文字,很適合直接夾回提示詞裡讓模型自己修正。要注意的是,同一頁也寫,JSON Schema 的 format 關鍵字預設不做驗證,要另外把 format_checker 掛到驗證器上才會生效,所以下面範例的 new_date 就算標了 format 是 date,預設也不會真的去驗那串日期。

  1. 把 schema 和提示詞一起送給上游模型
  2. 用 Draft202012Validator 檢查回傳的 JSON
  3. 有錯誤就把 message 夾回提示詞,重新送一次
  4. 重試達到上限(本篇範例設成三次)還沒過,就丟出例外並保留最後一次輸出
validate_and_handoff.py(需要 jsonschema) · python
import json

from jsonschema import Draft202012Validator

from schema_and_upstream import CHANGE_SCHEMA, call_model  # 延續上一段程式

MAX_ATTEMPTS = 3
UPSTREAM_MODEL = "gpt-6-astra"
# 這個範例上下游都接 OpenAI;換成別家只要改 call_model 裡的端點與標頭
DOWNSTREAM_MODEL = "gpt-6-astra"


def parse_and_validate(prompt: str) -> dict:
  """驗證失敗就把錯誤訊息夾回提示詞,最多重試 MAX_ATTEMPTS 次。"""
  validator = Draft202012Validator(CHANGE_SCHEMA)
  attempt_prompt = prompt
  for attempt in range(1, MAX_ATTEMPTS + 1):
    raw = call_model(attempt_prompt, CHANGE_SCHEMA, UPSTREAM_MODEL)
    try:
      data = json.loads(raw)
    except json.JSONDecodeError as error:
      attempt_prompt = f"{prompt}\n上一次輸出不是合法 JSON:{error},請重新輸出。"
      continue
    errors = sorted(validator.iter_errors(data), key=str)
    if not errors:
      # 只把 schema 定義過的欄位交給下游,其餘一律丟棄
      return {key: data[key] for key in CHANGE_SCHEMA["properties"]}
    messages = ";".join(error.message for error in errors)
    attempt_prompt = f"{prompt}\n上一次輸出不符合欄位規則:{messages},請修正後重新輸出。"
  raise ValueError(f"重試 {MAX_ATTEMPTS} 次後仍未通過驗證,最後一次輸出:{raw}")


def forward_to_downstream(validated: dict) -> str:
  """下游模型只讀 validated 這個字典,看不到任何未驗證過的原始文字。"""
  reply_schema = {
    "type": "object",
    "properties": {"reply": {"type": "string"}},
    "required": ["reply"],
    "additionalProperties": False,
  }
  summary = (
    f"訂位 {validated['booking_id']} 要求 {validated['change_type']},"
    f"新日期 {validated['new_date']},原因:{validated['reason']}"
  )
  prompt = f"用一句客氣的繁體中文回覆旅客,內容只能根據:{summary}"
  raw = call_model(prompt, reply_schema, DOWNSTREAM_MODEL)
  return json.loads(raw)["reply"]

下游只吃驗證過的欄位

重試迴圈通過以後,parse_and_validate 回傳的字典只會有 schema 裡 properties 列出的四個鍵,其餘不管模型有沒有多說什麼,都不會被放進這個字典,也就不會被傳到 forward_to_downstream。這樣下游函式收到的一定是形狀固定的資料,不用另外寫防禦性的 if 判斷欄位存不存在;如果之後要用同一段輸出去跑資料庫寫入或發信,也只需要對這四個欄位做業務規則檢查,不用重新解析整段回覆。這裡討論的是『輸出格式』本身的契約,跟本站另一篇《工具呼叫(Tool Calling)是什麼:模型如何請程式做事》講的『模型請程式做事』是不同的機制,兩者常常一起出現,但解決的是不同的問題。

如果只有一個模型、不需要交接給別的模型,本站《Claude Code|把 claude -p 接進有驗證的 JSON 流程》示範的是同一個概念的單模型版本,把 claude -p 輸出的 JSON 接進自己寫的驗證邏輯,再決定要不要往下一步送。多模型的差別只在於,上游輸出經過驗證以後,還要決定要不要先摘要或者換句話說,才送進下一個模型的提示詞,畢竟下游模型看到的還是文字,不是真的 Python 物件。

流程圖:定契約 → 問上游 → 做驗證 → 失敗重試 → 才交下游
模型交接的五個步驟(2026 年 9 月查證的三家參數命名為準) · 圖片:Mokaair (© Mokaair)

常見問題

為什麼不能只跟模型說『請回傳 JSON』就好?

因為模型即使回傳一個空物件或漏了幾個欄位,語法上仍然是合法 JSON,這種要求沒辦法保證有哪些欄位、型別對不對。把要求寫成 JSON Schema,再搭配 OpenAI 的 strict 模式或 jsonschema 的驗證,才能在資料送進下游之前先確認它的形狀。

OpenAI、Anthropic、Google 的結構化輸出參數可以共用同一份 schema 嗎?

大致可以共用同一份 JSON Schema 物件,但放的位置不一樣:OpenAI 放在 response_format 或 text.format,Anthropic 放在 output_config.format,Google 放在 response_format 底下的 schema 欄位。換供應商時主要改的是外層怎麼包;不過三家文件都寫自己只支援 JSON Schema 的一個子集,schema 裡用到的關鍵字仍要照各自的限制確認。

驗證失敗要重試幾次比較合理?

本篇的範例把重試上限設在三次,超過就直接丟出例外,不會無限迴圈下去;實際次數要看提示詞好不好修正、每次呼叫要付多少錢,這種取捨在系列另一篇《成本、品質、延遲:多模型流程怎麼取捨》談得更完整。

驗證通過了,是不是就代表內容一定正確?

不是。JSON Schema 能檢查的是形狀:型別對不對、必填欄位在不在、值是不是列舉裡允許的選項,沒辦法檢查模型是不是正確理解了旅客的原始留言。格式正確跟內容正確是兩件事,下游程式在讀到驗證過的欄位以後,通常還是要另外做一次業務規則檢查。

結構化輸出(Structured Outputs)跟工具呼叫(Tool Calling)是同一件事嗎?

不是同一件事,但常常一起出現。結構化輸出管的是模型『回覆』的格式,工具呼叫管的是模型『請程式做什麼』;Anthropic 的 strict 旗標剛好把兩者放在一起講,因為它讓工具呼叫的參數也符合 schema,但這仍然是兩種各自獨立的機制。

上游模型一直輸出不合法的 JSON,程式要怎麼辦?

把驗證失敗的錯誤訊息夾回提示詞裡,明確告訴模型是哪個欄位、哪一種規則沒過,通常比只說『請重新輸出』更容易修正;如果設定的重試次數用完了還是沒過,程式應該直接丟出例外並記錄原始輸出,不要放行沒驗證過的資料,也不要無限重試下去。

回總目錄

  • 生活分享

    Claude Code、Codex 搭本機模型:兩種接法怎麼選

    Claude Code 與 Codex 搭配本機模型有兩種接法:代理照常連雲端、把大量雜務交給腳本或 MCP 工具去問本機模型,或是把代理的模型整個換成本機模型。這篇用資料能不能出門、上下文開得夠不夠長、工作的類型三個問題幫你選,並對照 Ollama、LM Studio、Anthropic 與 OpenAI 的官方文件,分清楚本機權重、Ollama 的 cloud 標籤與供應商端點是三種不同的東西。

最新旅遊情報攻略

資料來源

生活分享