cd ../back to blog
$Guide//June 4, 2026//7 min read

結構化輸出與 JSON 模式:逼 Claude 和 Gemini 吐出有效的 JSON

用同一套 OpenAI 請求格式,讓 Claude 與 Gemini 輸出可驗證的結構化 JSON:json_object、json_schema 與工具呼叫怎麼選,以及解析-驗證-修復的上線之道。

聊天模型想說話。你的流水線想要一筆記錄。在 「這是一段關於這張工單、很友善的文字」和 {"category": "billing", "priority": "high"} 之間的落差, 正是多數 LLM 整合悄悄出包的地方 — 一道脫韁的 markdown 圍欄、一個多餘的 逗號、一個憑空捏造的鍵,下游的 json.loads 就在凌晨三點 丟出例外。這篇談的,是如何逼 Claude 與 Gemini 吐出有效又有用的 JSON, 用同一套 OpenAI 請求格式、藏在同一個 base_url 之後,以及讓它從 demo 等級升上正式上線等級的那一層 驗證。

這份工作有三件工具:帶 json_object response_format、帶 json_schema response_format,以及原生的工具/函式呼叫。它們並不能互換, 而選錯工具,正是讓結構化輸出功能用起來時靈時不靈最常見的原因。我們會 逐一走過每一個:該在什麼時候出手、怎麼設計 schema,以及如何驗證並修復 回傳的內容。

JSON 模式:保證可解析,但不保證正確

最簡單的槓桿是 response_format={"type": "json_object"}。它會約束模型只輸出語法上有效的 JSON — 沒有開場白、沒有 ```json 圍欄、沒有道歉。它不會做的,是強制套用你的 結構。你還是得在提示裡描述那些欄位,而模型照樣可能漏掉一個鍵、自己 發明一個,或在你想要布林值的地方塞一個字串。

json_object.py
# response_format=json_object: 模型會被約束只輸出語法
# 上有效的 JSON。它「不會」強制套用你的結構 — 你還是得在提示裡
# 描述那些欄位。同一段呼叫,在同一個 base_url 之下對 Claude 與 Gemini 通用。
from openai import OpenAI
import json

client = OpenAI(
    api_key="sk-brievio-...",
    base_url="https://api.brievio.com/v1",
)

resp = client.chat.completions.create(
    model="claude-sonnet-4-6",          # 或 "gemini-2.5-flash" — 程式碼相同
    response_format={"type": "json_object"},
    messages=[
        {
            "role": "system",
            "content": (
                "Extract the support ticket fields. Reply ONLY with a JSON object "
                "with keys: category (one of billing|bug|feature|other), "
                "priority (low|medium|high), summary (string), "
                "needs_human (boolean)."
            ),
        },
        {"role": "user", "content": "I was charged twice this month, please refund."},
    ],
)

data = json.loads(resp.choices[0].message.content)   # 保證可被解析
print(data["category"], data["priority"])            # "billing" "high"

# json_object 保證的是: 它能被解析。它「不」保證你要的鍵存在、
# enum 有效、或型別正確。那是驗證該負責的事。

當結構很簡單、當你把提示掌控得很緊,或當你反正都會做驗證(你會的)時, 這就是對的工具。心智模型是這樣:json_object 替你買到一個 保證,讓 json.loads 不會丟出例外。它買不到那個物件就是你 以為的意思這個保證。請把這個差別當成整場比賽的勝負關鍵。

JSON Schema 模式:約束結構,不只是約束語法

當你想要欄位名稱、型別與 enum 被強制套用 — 而不只是被要求 — 時,就出手 用 json_schema。schema 會隨請求一起送出,而搭配 strict: true(在模型家族支援之處)輸出會被約束去吻合。 讓「strict」真正有意義的那兩個欄位,是 additionalProperties: false(不會冒出意外的鍵)以及一份 完整的 required 陣列(不會漏掉鍵)。

json_schema.py
# response_format=json_schema: schema 會被送進模型,輸出
# 則被約束去符合它。在支援的地方設 strict=True 取得硬性保證。
# 是 additionalProperties=False + required 鍵,才讓「strict」真正有意義。
schema = {
    "name": "support_ticket",
    "strict": True,
    "schema": {
        "type": "object",
        "additionalProperties": False,
        "properties": {
            "category": {"type": "string", "enum": ["billing", "bug", "feature", "other"]},
            "priority": {"type": "string", "enum": ["low", "medium", "high"]},
            "summary": {"type": "string"},
            "needs_human": {"type": "boolean"},
        },
        "required": ["category", "priority", "summary", "needs_human"],
    },
}

resp = client.chat.completions.create(
    model="claude-sonnet-4-6",
    response_format={"type": "json_schema", "json_schema": schema},
    messages=[
        {"role": "system", "content": "Extract the support ticket fields."},
        {"role": "user", "content": "I was charged twice this month, please refund."},
    ],
)

ticket = json.loads(resp.choices[0].message.content)
# 有了 strict json_schema,category 可被證明就是那四個 enum 之一 —
# 在正常路徑上不需要防禦性的 "if category not in (...)"。

這裡有個誠實的但書,而且很重要: strict json_schema 的支援程度因模型家族而異。 有些模型會遵守每一條約束,包含巢狀的 additionalProperties: false;有些則把 schema 當成一個強烈的 提示,而非硬性文法,尤其是在深度巢狀的物件、聯集(anyOf)或 遞迴結構上。Brievio 會把你的 response_format 原封不動直傳 給真正的第一方模型,所以你拿到的是真實模型的真實行為 — 而非打了折扣 的模擬。但這也代表模型的原生極限就是你的極限。實務上的原則是: 送出 schema,然後照樣驗證。絕不要讓「strict」說服你 跳過驗證這一步。

JSON 模式 vs. 工具呼叫:何時用哪一個

工具/函式呼叫同樣會回傳結構化 JSON — 引數會以一段對應到函式名稱的 JSON 字串回來。那麼你該用哪一個?這個區別關乎的是意圖, 不是格式:

  • 當 JSON 本身就是答案時,用 JSON 模式。 你是在抽取欄位、做分類、彙整成一筆記錄,或產生一個設定物件。你想 要回來的,每一次都恰好是同一種結構。response_format 更俐落 — 單一輸出,沒有函式呼叫的繁文縟節,也沒有 tool_choice 的管線要接。
  • 當模型是在選擇一個動作時,用工具呼叫。 它可能會呼叫 get_weather、或 search_db,或 以文字回答 — 而你想讓模型自己決定用哪一個,甚至可能呼叫好幾個。 函式呼叫就是為了分派而生:許多候選結構,由模型來挑。硬把 它擠進單一個 JSON 物件會很彆扭。
  • 灰色地帶:用單一個強制工具呼叫當作結構化輸出。 tool_choice 設成必須呼叫某個特定函式,是在那些早於 json_schema 的模型上取得結構化輸出、行之有年的做法。它 至今仍管用,也是個不錯的後備方案。但如果一個模型支援 json_schema,那條路更直接,也更不用費神去推敲。

如果你的工作負載真的是關於動作與分派,而非一筆固定的記錄,那麼它的 運作機制與跨模型的眉角,都寫在 跨 Claude 與 Gemini 的工具使用。至於所有「給我這個物件」的情境,就守著 response_format

設計一個模型真的命中得了的 schema

schema 就是一份提示。你塑造它的方式,對命中率的影響不亞於選哪個模型。 以下幾條原則,在兩個家族上都見效:

  • 偏好扁平,而非深度巢狀。三層巢狀再加上可選的物件, 正是 strict 模式開始搖擺的地方。如果你能把 address.city 壓平成 city,就壓,之後再於驗證後重塑形狀。
  • 任何封閉集合都用 enum。 "priority": {"enum": ["low","medium","high"]} 遠比一個 要你事後處理的自由 string 可靠。enum 是 schema 裡槓桿 最高的單一特性。
  • 用人會用的方式替欄位命名。 needs_human_review 勝過 nh_flag。模型把 取好名字的欄位填得更準確,因為名字本身就帶著指示。
  • 在語意模糊的欄位上放一段 description 在 schema 裡替每個欄位寫一行,就能解掉多數「模型猜錯」的情況,而 不必重寫提示。
  • 把可選性講明白。如果一個欄位可以不存在,要嘛把它 排除在 required 之外,要嘛把它設計成可為 null 的聯集 — 別指望模型自己發明一個哨兵值。決定好「缺漏」這個情況由誰來扛,是你 還是模型。
  • 能用有界型別時,就別用自由格式的數字。 把 1~5 的整數評分設成 [1,2,3,4,5] 的 enum,表現會勝過在 提示裡寫「一個 1 到 5 之間的數字」。

驗證與修復:能上線的那一層

單一最大的可靠度升級,就是把模型輸出當成一個不受信任的用戶端請求來 對待:解析它、對你真正的 schema 驗證它,並在失敗時把錯誤餵回去重試 一次。一個 Pydantic 模型(或 zod,或你語言裡的 JSON Schema 驗證)能接住那些連 strict 模式都漏掉的情況 — 而修復回合會解掉其中 大半,因為對於一個你直接指出來的錯誤,模型很擅長更正。

validate.py
# 沒驗證過的輸出絕不信任。把模型當成不受信任的
# 用戶端: 解析 -> 對你的 schema 驗證 -> 把錯誤餵回去重試一次。
# 這一層,正是把「通常會動」變成「能上線」的關鍵。
from pydantic import BaseModel, ValidationError
from typing import Literal

class Ticket(BaseModel):
    category: Literal["billing", "bug", "feature", "other"]
    priority: Literal["low", "medium", "high"]
    summary: str
    needs_human: bool

def extract(text: str, model: str, retries: int = 1) -> Ticket:
    messages = [
        {"role": "system", "content": "Extract the support ticket fields as JSON."},
        {"role": "user", "content": text},
    ]
    for attempt in range(retries + 1):
        resp = client.chat.completions.create(
            model=model,
            response_format={"type": "json_object"},
            messages=messages,
        )
        raw = resp.choices[0].message.content
        try:
            return Ticket.model_validate_json(raw)      # 一步完成解析 + 驗證
        except ValidationError as e:
            if attempt == retries:
                raise
            # 修復回合: 把錯在哪裡原原本本地秀給模型看。
            messages += [
                {"role": "assistant", "content": raw},
                {"role": "user", "content": f"That failed validation: {e}. Re-emit valid JSON only."},
            ]
    raise RuntimeError("unreachable")

留意修復回合做了什麼:它把模型自己的爛輸出 確切的驗證錯誤一起秀給它看,然後要求重出一份。一次重試 就能解掉絕大多數的失敗;如果它還是失敗,你會想知道,所以就讓它丟出 例外。別永無止境地迴圈、燒掉權杖 — 把重試次數設上界、記錄原始 酬載,並在硬性失敗時發出警報。一個一直無法通過的驗證失敗,通常代表 schema 要的東西是輸入根本撐不起來的,而不是模型壞了。

兩個正式上線的提醒。第一,把 max_tokens 設得寬裕一點: 在物件中途被截斷的 JSON 就是無效的 JSON,而過緊的權杖上限,是大型 記錄上解析失敗的主要成因之一。第二,在抽取與分類時把 temperature 維持得很低(0 到 0.3) — 你要的是同一份輸入 產出同一筆記錄,而在填一個結構時,創意並不是一種美德。

一套結構,兩個模型家族

上面每一段程式碼,只要改一個字串 — 那個 model 欄位 — 就能跑在 Claude Sonnet 4.6 與 Gemini 2.5 Flash 上。這正是把結構化輸出 繞經 Brievio 的重點所在:OpenAI 形狀的 response_format 合約完全一致,所以你可以在一個抽取任務上 A/B 測試一個更便宜的模型,或在事故期間跨家族做後備,而不必重寫你的 解析或驗證。你送出的請求,就是真正的第一方模型收到的請求 — 這裡寫清楚了哪些會吻合、又該留意什麼 當你倚賴 OpenAI 相容性時。

一套實務工作流:在 Sonnet 上用 strict json_schema 做 原型,確認你的驗證器在一組保留集上能通過,再把同一個 schema 拿到 Flash 上試。如果更便宜的模型能清過你的驗證率,你就在零程式碼變動下 砍掉了成本 — 而且因為 Brievio 回報誠實的權杖數,並把失敗的 4xx/5xx 呼叫以零計費,你的重試與修復回合不會 藏著一個計量上的意外。在 模型頁上比較各個模型,而聊天的完整 請求/回應合約,就在 聊天文件裡。

結論帶走這幾點

當結構很簡單、而且提示由你掌控時,出手用 json_object; 當你想讓結構被強制套用時,出手用 json_schema 搭配 strict: trueadditionalProperties: false,以及 一份完整的 required 陣列;當模型是在選擇一個動作 而非產出一筆固定記錄時,出手用工具呼叫。不論你選哪一個,都要把 schema 設計得扁平、enum 多多,然後永遠走解析-驗證-修復 — 因為 strict 的支援 程度因模型家族而異,而驗證這一層,正是一個只會 demo 的結構化輸出功能 與一個能撐過真實流量的功能之間的差別。同一套程式碼、同一份合約、 真正的模型 — 橫跨 Claude 與 Gemini,藏在同一個 base URL 之後。