聊天模型想說話。你的流水線想要一筆記錄。在 「這是一段關於這張工單、很友善的文字」和 {"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 圍欄、沒有道歉。它不會做的,是強制套用你的 結構。你還是得在提示裡描述那些欄位,而模型照樣可能漏掉一個鍵、自己 發明一個,或在你想要布林值的地方塞一個字串。
# 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 陣列(不會漏掉鍵)。
# 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 模式都漏掉的情況 — 而修復回合會解掉其中 大半,因為對於一個你直接指出來的錯誤,模型很擅長更正。
# 沒驗證過的輸出絕不信任。把模型當成不受信任的
# 用戶端: 解析 -> 對你的 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: true、additionalProperties: false,以及 一份完整的 required 陣列;當模型是在選擇一個動作 而非產出一筆固定記錄時,出手用工具呼叫。不論你選哪一個,都要把 schema 設計得扁平、enum 多多,然後永遠走解析-驗證-修復 — 因為 strict 的支援 程度因模型家族而異,而驗證這一層,正是一個只會 demo 的結構化輸出功能 與一個能撐過真實流量的功能之間的差別。同一套程式碼、同一份合約、 真正的模型 — 橫跨 Claude 與 Gemini,藏在同一個 base URL 之後。