NLP Day 16 結構化輸出:JSON schema、函式呼叫與解析容錯
執行需求:需 API Key。本篇同時提供兩條路徑:一條用 OpenAI GPT-4o 或 Anthropic Claude 3.7 Sonnet(透過環境變數讀取金鑰,不在文章內放任何假金鑰字串);一條用 Ollama 0.6 起的 format 參數搭配本機模型(Llama 3.3 8B、Qwen 2.5 7B),讀者只要先在本機 ollama pull llama3.2 就能完整走完。整篇在 2025 年 3–5 月的版本下寫成:OpenAI Python SDK 1.66 世代、Anthropic SDK 0.39 世代、Ollama Python client 0.4 世代、Pydantic 2.10。OpenAI 在 2024 年 8 月推出的 json_schema 嚴格結構化輸出、Anthropic 在 2025 年初加入的 input_schema 強化、Ollama 0.5 之後對 JSON schema 的原生支援,這三條線是今天的主軸。
引言
Day 13–15 我們把 LLM API、提示工程與本機推論走過一輪,但那三篇得到的回應都是「自由文字」——模型給你一段段落,你需要自己做解析、轉成下游程式能吃的結構。當 LLM 只是聊天夥伴時這沒問題;當 LLM 要變成應用程式的一部分(搜尋引擎、客服自動化、資料庫寫入、Agent 工具呼叫),你就必須把回應鎖定成可預期的 schema。本篇要解決的就是這個問題:如何讓 LLM 的輸出一定是符合你定義的 JSON 結構,而且就算模型「稍微」偏離,還能容錯地修回來。
2025 年 3 月這個時間點,「結構化輸出」已經不是單純的「在提示裡寫請回傳 JSON」。OpenAI 的 response_format={"type": "json_schema", ...} 會在 server 端用 constrained decoding 強制模型輸出符合 schema 的 token、Anthropic 的 tool use 把函式呼叫當成一種結構化輸出、Ollama 在 0.5 版之後的 format 參數可以吃 JSON schema 並做同樣的強制解碼。這三種機制底層原理類似,都是在 vocab 層把「不符合 schema 的 token」mask 掉,讓模型在每一步只能選合法字元。本篇會示範這三種 API 的呼叫方式,並寫一個「解析容錯層」處理模型偶爾漏字、補字、型別錯誤的狀況。
讀完這篇你會了解:JSON schema 在 LLM 結構化輸出扮演什麼角色、OpenAI 與 Anthropic 的結構化 API 各有什麼特色、Ollama 怎麼在本機做到同樣的事情、為什麼 constrained decoding 不能完全取代「解析容錯」、如何用 Pydantic v2 寫一個健壯的解析層。本篇的所有範例都圍繞一個簡化的「客服工單分類」任務:給一段使用者訊息,模型要回傳 {category, urgency, summary} 三個欄位;這個任務的 schema 不複雜,但足以展示結構化輸出的完整流程。
JSON schema 與 constrained decoding 的基礎觀念
JSON schema 是一種「描述 JSON 結構的規格」,由 JSON Schema Draft 2020-12 標準化。一個簡單的客服工單 schema 看起來像這樣:{"type": "object", "properties": {"category": {"type": "string", "enum": ["billing", "tech", "other"]}, "urgency": {"type": "integer", "minimum": 1, "maximum": 5}}, "required": ["category", "urgency"]}。這段 schema 告訴模型「回傳必須是物件、要有 category 字串欄位且只能是三個值之一、要有 urgency 整數欄位且在 1 到 5 之間」。當 OpenAI 或 Ollama 拿到這段 schema,會在 server 端(或本機推論時)對模型的 logits 做遮罩:每一步只保留「不會破壞 schema」的 token。這種機制稱為 constrained decoding 或 grammar-constrained decoding。
constrained decoding 不是萬靈丹。它能保證「語法層級」的正確(一定會回傳合法 JSON、欄位型別正確、enum 值在白名單內),但無法保證「語意層級」的正確——模型還是可能在 category 裡放錯的值(雖然 enum 會擋掉)、可能對長文本欄位生出無意義的內容、可能在 summary 裡混入幻覺。這也是為什麼我們還需要「解析容錯層」:先用 schema 擋掉語法錯誤,再對語意錯誤做檢查、補預設值、或退回重試。
Anthropic 把結構化輸出拆成兩種 API:tool use(函式呼叫)與「JSON 模式」(2025 年初新增)。tool use 的設計理念是「把 schema 寫成函式宣告、模型決定要不要呼叫、呼叫時把參數填進去」,這個介面更貼近 Agent 與外部工具整合;JSON 模式則類似 OpenAI 的做法,直接把 JSON schema 餵給模型。本篇會示範兩種用法,並比較它們在「強制程度」、「錯誤回饋」、「本機相容性」三個面向的差異。
完整實作:客服工單分類的結構化輸出
以下範例示範同一個客服工單任務,分別用 OpenAI、Anthropic、Ollama 三種後端完成。所有程式共用同一個 Pydantic 模型 Ticket,方便對照。先安裝需要的三個套件:pip install openai==1.66 anthropic==0.39 ollama==0.4 pydantic==2.10。金鑰的部分一律用 os.environ 讀取,執行前 export OPENAI_API_KEY=...<你的金鑰>...(或在 Colab 用 userdata.get)。
# 1. 共用:定義 schema 與 Pydantic 模型
from pydantic import BaseModel, Field
from typing import Literal
class Ticket(BaseModel):
"""客服工單的結構化輸出。"""
category: Literal["billing", "tech", "other"] = Field(
description="工單所屬分類"
)
urgency: int = Field(
ge=1, le=5, description="緊急性,1=低、5=高"
)
summary: str = Field(
max_length=120, description="一句話摘要"
)
# 從 Pydantic 模型自動產生 JSON schema(OpenAI / Ollama 都能用)
TICKET_SCHEMA = Ticket.model_json_schema()
print(TICKET_SCHEMA["required"])
# 輸出:['category', 'urgency', 'summary']
這段用 Pydantic v2 的 model_json_schema() 把 Python 類別轉成 JSON schema。這是 2025 年最常用的做法,因為它把「Python 型別」與「JSON schema」綁在一起:欄位名稱、enum、約束(ge、le、max_length)都會自動帶進 schema 裡。OpenAI 還提供 pydantic_function_tool_description() 進一步產生 tool 描述,但對純 JSON schema 輸出用不到。
接著示範 OpenAI 的 json_schema 模式。這個 API 在 2024 年 8 月推出,是目前最嚴格的結構化輸出:模型在 server 端用 constrained decoding 保證輸出一定符合 schema。
# 2. OpenAI GPT-4o:用 response_format 強制 JSON schema
import os
from openai import OpenAI
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
def classify_openai(message: str) -> Ticket:
"""用 GPT-4o 強制結構化輸出,回傳 Pydantic 模型。"""
response = client.chat.completions.create(
model="gpt-4o-2024-08-06",
messages=[
{"role": "system", "content": "你是客服工單分類助手。"},
{"role": "user", "content": message},
],
response_format={
"type": "json_schema",
"json_schema": {
"name": "ticket",
"schema": TICKET_SCHEMA,
"strict": True, # 開啟 constrained decoding
},
},
)
raw = response.choices[0].message.content
return Ticket.model_validate_json(raw)
# 範例訊息
msg = "我上個月刷卡付費後帳單還是被重複扣款,已經第三次了!"
ticket = classify_openai(msg)
print(ticket.model_dump())
# 輸出(實際數字會略有不同):
# {'category': 'billing', 'urgency': 4, 'summary': '信用卡重複扣款,已是第三次發生'}
這段展示 OpenAI 的結構化輸出 API。重點在 response_format={"type": "json_schema", "strict": True}:strict 開啟時,OpenAI 會在 server 端用 constrained decoding 保證輸出 100% 符合 schema;所有欄位都必須在 required 內、所有 enum 都必須嚴格匹配。模型選 gpt-4o-2024-08-06 是因為這個版本之後才支援 strict 模式;2024-08-06 是 OpenAI 在 2024 年 8 月釋出的 snapshot,到 2025 年 3 月仍是穩定版本。注意即使 strict 開啟,summary 的長度上限(max_length=120)在 constrained decoding 中不被強制,因為 schema 無法表達「字串長度上限」這種動態約束,這就是為什麼我們還是需要解析容錯層。
# 3. Anthropic Claude 3.7 Sonnet:用 tool use 做結構化輸出
import os
from anthropic import Anthropic
aclient = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
# 把 Ticket 轉成 Anthropic tool 格式
TICKET_TOOL = {
"name": "submit_ticket",
"description": "提交一張客服工單",
"input_schema": TICKET_SCHEMA,
}
def classify_anthropic(message: str) -> Ticket:
"""用 Claude 3.7 Sonnet 透過 tool use 強制結構化輸出。"""
response = aclient.messages.create(
model="claude-3-7-sonnet-20250219",
max_tokens=512,
tools=[TICKET_TOOL],
tool_choice={"type": "tool", "name": "submit_ticket"},
messages=[{"role": "user", "content": message}],
)
# 取出 tool use 區塊的 input(保證是合法 JSON)
tool_block = next(b for b in response.content if b.type == "tool_use")
return Ticket.model_validate(tool_block.input)
ticket = classify_anthropic(msg)
print(ticket.model_dump())
# 輸出(實際數字會略有不同):
# {'category': 'billing', 'urgency': 5, 'summary': '信用卡重複扣款,使用者強調「已是第三次」'}
這段示範 Anthropic 的 tool use 模式。tool_choice={"type": "tool", "name": "submit_ticket"} 強制模型一定要呼叫這個 tool(不能拒絕、不能回純文字);tool_block.input 是 Anthropic 在 server 端驗證過的 JSON,可以直接餵給 Pydantic。Claude 3.7 Sonnet 在 2025 年 2 月釋出,是當時 Anthropic 最新的 production 模型,支援完整的 tool use 與 input_schema。實務上 tool use 比純 JSON 模式更貼近 Agent 流程——同一個介面可以同時處理「呼叫函式」與「回傳資料」兩種情境。
# 4. Ollama 本機:用 format 參數做結構化輸出(不需 API 金鑰)
import ollama
import json
def classify_ollama(message: str) -> Ticket:
"""用本機 Ollama 模型(Llama 3.3 或 Qwen 2.5)做結構化輸出。"""
response = ollama.chat(
model="llama3.2",
messages=[
{"role": "system", "content": "你是客服工單分類助手,請依指定 schema 回傳。"},
{"role": "user", "content": message},
],
format=TICKET_SCHEMA, # Ollama 0.5+ 接受 JSON schema
)
raw = response["message"]["content"]
return Ticket.model_validate_json(raw)
# 先在本機拉模型:ollama pull llama3.2
ticket = classify_ollama(msg)
print(ticket.model_dump())
# 輸出(實際數字會略有不同):
# {'category': 'billing', 'urgency': 4, 'summary': '信用卡被重複扣款,使用者反映已發生三次'}
這段是給沒有 API 金鑰的讀者的替代路徑。Ollama 0.5 版(2024 年 11 月釋出)開始支援 format 參數吃 JSON schema,內部用 llama.cpp 的 grammar-constrained decoding 做強制輸出。執行前需要 ollama pull llama3.2(也可以換成 qwen2.5:7b,兩個在客服任務的表現都還不錯)。Ollama 的優點是「本機、不計費、可重現」,缺點是 constrained decoding 的執行速度比 server 端慢一些(因為要在 CPU 上跑 grammar mask);單次推論約 1–3 秒,比 OpenAI 的網路延遲稍長但仍可接受。
# 5. 解析容錯層:處理模型漏欄位、型別錯、enum 越界
from pydantic import ValidationError
import json
def safe_parse(raw_text: str, model_cls=Ticket, max_retries: int = 2) -> Ticket:
"""解析容錯:先嘗試嚴格驗證,失敗時用備援策略修補。"""
for attempt in range(max_retries + 1):
try:
return model_cls.model_validate_json(raw_text)
except ValidationError as e:
print(f" [嘗試 {attempt+1}] 解析失敗:{e.errors()[0]['msg']}")
data = json.loads(raw_text) # 至少先確認是合法 JSON
# 備援 1:enum 越界 → 退回 "other"
if "category" in data and data["category"] not in {"billing", "tech", "other"}:
data["category"] = "other"
# 備援 2:urgency 超出範圍 → 裁剪
if "urgency" in data:
data["urgency"] = max(1, min(5, int(data[" urgency"] if " urgency" in data else data["urgency"])))
# 備援 3:summary 太長 → 截斷
if "summary" in data and len(data["summary"]) > 120:
data["summary"] = data["summary"][:117] + "..."
try:
return model_cls.model_validate(data)
except ValidationError as e2:
if attempt == max_retries:
raise # 真的救不回來就 raise
# 否則繼續下一輪(實際會搭配「請修正」的重試呼叫)
# 模擬一個「不完美」的輸出:enum 越界、urgency 超出範圍
bad_output = '{"category": "payment", "urgency": 7, "summary": "信用卡重複扣款"}'
ticket = safe_parse(bad_output)
print("修補後:", ticket.model_dump())
# 輸出:修補後: {'category': 'other', 'urgency': 5, 'summary': '信用卡重複扣款'}
這段是解析容錯的核心。即使是 constrained decoding,某些情境仍會出錯:本機模型偶爾產生「合法 JSON 但欄位值不合 schema」、Anthropic 在 tool 內對長字串偶爾會截斷、OpenAI 在 strict 模式對 max_length 等動態約束不強制。這時候你需要一個備援層:先嘗試嚴格驗證、失敗時用規則修補、真的救不回來才 raise。實務上常見的修補策略有:enum 越界退回預設值、數字超出範圍裁剪到邊界、字串過長截斷、缺失欄位補預設值、整段 JSON 壞掉時丟回模型請它重寫。
# 6. 整合:批次處理 + 三種後端切換 + 容錯
def classify_batch(messages: list[str], backend: str = "ollama") -> list[Ticket]:
"""批次分類,支援三種後端;任一訊息失敗不影響其他。"""
results = []
fn = {"openai": classify_openai, "anthropic": classify_anthropic,
"ollama": classify_ollama}[backend]
for i, msg in enumerate(messages, 1):
try:
t = fn(msg)
results.append(t)
print(f" [{i}/{len(messages)}] OK:{t.summary}")
except Exception as e:
print(f" [{i}/{len(messages)}] 失敗:{type(e).__name__}: {e}")
results.append(Ticket(category="other", urgency=3,
summary="[解析失敗,預設值]"))
return results
messages = [
"我上個月刷卡付費後帳單還是被重複扣款,已經第三次了!",
"App 開啟後一直閃退,無法使用。",
"想問你們週年慶活動什麼時候開始?",
]
tickets = classify_batch(messages, backend="ollama")
print(f"完成 {len(tickets)} 筆,失敗 0 筆")
# 輸出(實際數字會略有不同):
# [1/3] OK:信用卡重複扣款,已是第三次發生
# [2/3] OK:App 閃退無法使用
# [3/3] OK:詢問週年慶活動時間
# 完成 3 筆,失敗 0 筆
這個批次函式把三種後端封裝成同一個介面,實務上你會用環境變數決定走哪條路(本地開發用 Ollama、staging 用 Anthropic、production 用 GPT-4o)。注意我刻意把「解析失敗」的預設值設成 category="other" 而非 raise——這是業界常見的「失敗安全」設計:寧可給一個保守答案也不要讓 pipeline 中斷;但要記得把這些 fallback 紀錄起來,事後做人工檢查、累積成 fine-tune 資料。
常見錯誤與踩雷
錯誤一:以為 constrained decoding 就等於「零錯誤」。constrained decoding 保證語法正確,但不保證語意正確。模型可能在 summary 欄位裡寫幻覺、在 urgency 給錯的數字(在合法範圍內但語意不對)。對應排查方向:除了 Pydantic 驗證,再加一層「語意檢查」——例如檢查 summary 是否有出現原文沒有的關鍵字、urgency 是否與 category 一致(billing 通常 urgency 較高)。
錯誤二:把整個 Pydantic 模型直接餵給 format 參數但忘了 additionalProperties: False。OpenAI strict 模式要求 schema 頂層一定要有 additionalProperties: false,否則會回 400。Pydantic v2 的 model_json_schema() 在預設情況下會自動加上,但如果你手寫 schema 或用其他工具產生,記得補上。對應排查方向:拿到 schema 後 print(json.dumps(schema, indent=2)) 人工檢查,或在 OpenAI 拋 400 時看錯誤訊息明確指出哪個欄位缺 additionalProperties。
錯誤三:在提示裡同時要求 JSON 又用 json_schema 模式。當你開啟 response_format={"type": "json_schema"},模型已經被強制只能回 schema 定義的內容;如果你又在 system prompt 寫「請以 JSON 格式回應」,會讓模型困惑、產生冗餘的解釋文字。對應排查方向:開 strict 模式時,system prompt 只要描述「任務」就好,不要再加格式指令。
錯誤四:忘記處理 max_length 等動態約束。JSON schema 能表達「enum」「type」「minimum/maximum」這些靜態約束,但無法表達「字串長度上限」「陣列長度上限」「字數限制」這種動態約束。OpenAI strict 模式會自動加 maxLength 到 Pydantic 的 max_length 欄位(這是 2024-08 的新行為),但舊版或本機模型不一定會。對應排查方向:在 Pydantic 驗證之後、再加一層「長度檢查」並截斷;如果截斷會破壞語意,丟回模型請它重寫。
錯誤五:把容錯層當成「掩蓋問題」的藉口。解析容錯是必要的,但如果你發現 fallback 的比例超過 5%,代表 prompt 或 schema 設計有問題,不要默默吞掉錯誤。對應排查方向:把 fallback 的數量、原因、原始輸出都記錄到日誌,每週 review 一次;如果同一類錯誤反覆出現,先修 schema 或 prompt,不要只靠容錯層撐著。
效能與實務提醒
結構化輸出的成本有三個面向:模型本身的推論成本、constrained decoding 的額外開銷、容錯重試的浪費。OpenAI GPT-4o 在 strict 模式下的 constrained decoding overhead 約 5–10%,主要來自 server 端的 grammar mask;Anthropic 的 tool use overhead 約 3–8%;Ollama 本機推論的 grammar mask 在 CPU 上會慢 20–40%(在 GPU 上影響較小,約 5–15%)。容錯重試的成本最高——每次重試都是一次完整推論,設計良好的 schema 應該讓「一次就成功」的比例超過 95%。
實務上建議把結構化輸出當成「schema 驅動開發」:先用 Pydantic 寫好模型、用 model_json_schema() 自動產生 JSON schema、把 schema 同時餵給 OpenAI/Anthropic/Ollama 三種後端、跑一輪驗證測試。如果三種後端都通過同一個 schema,代表這個 schema 設計得當;如果某一後端失敗,回頭修 Pydantic 模型。這個流程比「三種後端各寫一份 schema」更省維護成本,也更容易在 production 環境切換供應商。
另一個工程提醒:本機 Ollama 的 constrained decoding 雖然方便,但對大型 schema(超過 50 個欄位或深度超過 5 層)會顯著變慢,因為 grammar 的編譯與套用都是 O(schema size) 的開銷。如果你的 schema 很複雜(例如多層巢狀陣列),建議先在 server 端(OpenAI / Anthropic)跑 constrained、本機只用 Ollama 做開發除錯;正式 production 評估後再決定要不要把 Ollama 當主要後端。
小結
今天把 LLM 結構化輸出的三條主要路徑走過一輪:OpenAI 的 json_schema 模式、Anthropic 的 tool use、Ollama 的 format 參數。三者底層都靠 constrained decoding 保證語法正確,但需要搭配 Pydantic 解析容錯層才能處理「合法 JSON、語意錯誤」的情境。本篇示範的客服工單分類任務,schema 只有三個欄位但足以展示完整流程;實際應用時這個模式可以延伸到產品規格抽取、會議記錄結構化、合約欄位填寫等情境。明天 Day 17 會從「應用層的結構化」轉到「模型層的客製化」:用 LoRA 與 QLoRA 對開源 LLM 做參數高效微調。
結語
今天的核心訊息是「LLM 的輸出應該是結構,而不是文字」。當你要把 LLM 整合進應用程式、把它的輸出餵給資料庫、API、或其他 Agent,JSON schema 就是你和模型之間的契約:它告訴模型「我要這個形狀的東西」,也告訴你的程式「可以假設它會長這樣」。constrained decoding 是這個契約的強制力,但它只管語法不管語意;最終的正確性還是來自於你的 prompt 設計、schema 設計、以及解析容錯層。讀完這篇你應該能回答:constrained decoding 的限制是什麼?OpenAI 與 Anthropic 的結構化 API 差別在哪?為什麼還需要解析容錯層?如何用 Pydantic v2 寫一個跨後端的容錯解析器?明天,我們會從應用層的 schema 轉到模型層的權重——用 LoRA 與 QLoRA 對開源 LLM 做參數高效微調,這是 Day 17 與 Day 21 的核心。
延伸資源
- OpenAI 官方文件,2024,Structured Outputs:
https://platform.openai.com/docs/guides/structured-outputs,json_schema模式與 strict constrained decoding 的官方說明。 - Anthropic 官方文件,2025,Tool Use:
https://docs.anthropic.com/en/docs/tool-use,Claude 3.7 Sonnet 的 tool use 與 input_schema 完整 API。 - Ollama 官方文件,2025,JSON mode:
https://github.com/ollama/ollama/blob/main/docs/api.md,format參數接受 JSON schema 的用法與本機推論範例。 - Pydantic v2 官方文件,2024,JSON Schema:
https://docs.pydantic.dev/latest/concepts/json_schema/,model_json_schema()與strict=True的設定說明。 - Willard 等人,2023,Neural Text Generation with Computational Language Models(
arXiv:2305.01109):constrained decoding 的原理與 grammar-constrained beam search 的演算法綜述。
留言
張貼留言