昨天我們用 prompt 要求 AI 回傳 JSON。它大部分時候會照做,但偶爾會出這種包:
好的,以下是抽取的結果:
```json
{"title": "繳電費", "priority": "高"}
```
希望對你有幫助!
然後你的 json.loads() 就炸了。
一百次裡出錯三次,聽起來還好。但如果 Agent 一次任務要呼叫五次模型,那每二十次任務就有一次會掛掉——這個可靠度不能接受。
今天要解決這個問題。
先講清楚這件事為什麼重要。
文字輸出 vs 結構化輸出:
# 文字輸出 → 只能給人看
"好的,我幫你記下明天下午三點跟客戶開會"
# 結構化輸出 → 程式可以直接用
{
"action": "create_event",
"title": "跟客戶開會",
"date": "2026-10-04",
"time": "15:00"
}
第二種可以接到下一步:
data = get_structured_output(user_input)
if data["action"] == "create_event":
calendar.add(data["title"], data["date"], data["time"])
elif data["action"] == "add_todo":
todos.add(data["title"])
**AI 的輸出變成了程式的控制流。**這就是「聊天機器人」和「Agent」的分界線——一個只會講話,一個能觸發動作。
而且 Day 21 的 Tool Use,本質上就是結構化輸出的一種特殊應用。今天打好基礎,明天會很順。
最低成本的做法,就是昨天那樣要求 JSON,然後假設它可能會加東加西,寫一個容錯的解析器:
"""json_parser.py — 從模型回應中強韌地抽取 JSON。"""
import json
import re
# 匹配 ```json ... ``` 或 ``` ... ```
FENCE_PATTERN = re.compile(r"```(?:json)?\s*(.*?)```", re.DOTALL)
def extract_json(text):
"""從模型回應中抽取 JSON。
處理三種常見情況:
1. 純 JSON
2. 包在 markdown 程式碼框裡
3. 前後有多餘的說明文字
Returns:
dict / list,失敗時回傳 None
"""
if not text:
return None
text = text.strip()
# 情況 1:直接就是 JSON
try:
return json.loads(text)
except json.JSONDecodeError:
pass
# 情況 2:包在程式碼框裡
match = FENCE_PATTERN.search(text)
if match:
try:
return json.loads(match.group(1).strip())
except json.JSONDecodeError:
pass
# 情況 3:找出第一個 { 到最後一個 }(或 [ 到 ])
for open_char, close_char in (("{", "}"), ("[", "]")):
start = text.find(open_char)
end = text.rfind(close_char)
if start != -1 and end > start:
try:
return json.loads(text[start:end + 1])
except json.JSONDecodeError:
continue
return None
測試一下:
cases = [
'{"title": "繳電費"}',
'```json\n{"title": "繳電費"}\n```',
'好的,以下是結果:\n\n{"title": "繳電費"}\n\n希望有幫助!',
'這不是 JSON',
]
for c in cases:
print(extract_json(c))
# {'title': '繳電費'}
# {'title': '繳電費'}
# {'title': '繳電費'}
# None
這個方法成本最低、適用所有模型,但它是治標不治本——你還是得處理 None 的情況,而且模型可能給你合法的 JSON 但欄位不對。
拿到 JSON 之後,還要確認它符合我們要的結構。這件事交給 pydantic:
pip install pydantic
from typing import Literal, Optional
from pydantic import BaseModel, Field, ValidationError
class TodoExtraction(BaseModel):
"""從使用者輸入抽取出的待辦事項。"""
title: str = Field(description="待辦事項的簡短標題")
priority: Literal["高", "中", "低"] = Field(description="優先度")
due: Optional[str] = Field(default=None, description="截止日 YYYY-MM-DD,沒有則為 null")
# 驗證
try:
todo = TodoExtraction.model_validate({
"title": "繳電費",
"priority": "高",
"due": "2026-10-07",
})
print(todo.title, todo.priority)
except ValidationError as e:
print("格式不符:", e)
Pydantic 幫我們做三件事:
1. 檢查必填欄位
TodoExtraction.model_validate({"title": "繳電費"})
# ValidationError: priority Field required
2. 檢查型別與值域
TodoExtraction.model_validate({"title": "x", "priority": "超高"})
# ValidationError: Input should be '高', '中' or '低'
Literal["高", "中", "低"] 就是 Day 15 講的 enum——把可能的值鎖死。
3. 自動產生 JSON Schema
這點最棒:
print(TodoExtraction.model_json_schema())
{
"properties": {
"title": {"description": "待辦事項的簡短標題", "type": "string"},
"priority": {"description": "優先度", "enum": ["高", "中", "低"], "type": "string"},
"due": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null,
"description": "截止日 YYYY-MM-DD,沒有則為 null"}
},
"required": ["title", "priority"],
"title": "TodoExtraction",
"type": "object"
}
**我們定義一次 Python class,就同時得到「驗證器」和「給 AI 看的規格」。**不用手寫兩份,不會不同步。
前兩個方法都是「事後補救」。最好的做法是從源頭就保證格式正確。
Anthropic 的 API 提供 output_config,可以指定輸出必須符合某個 JSON Schema:
response = client.messages.create(
model="claude-opus-5",
max_tokens=1024,
messages=[{"role": "user", "content": "提醒我禮拜三要繳電費"}],
output_config={
"format": {
"type": "json_schema",
"schema": {
"type": "object",
"properties": {
"title": {"type": "string"},
"priority": {"type": "string", "enum": ["高", "中", "低"]},
"due": {"type": ["string", "null"]},
},
"required": ["title", "priority", "due"],
"additionalProperties": False,
},
}
},
)
import json
text = next(b.text for b in response.content if b.type == "text")
data = json.loads(text) # 保證解析得動
用了 output_config 之後,模型只能產生符合 schema 的輸出——不會有前言、不會有程式碼框、不會少欄位。
messages.parse()Python SDK 提供一個直接吃 Pydantic model 的方法:
from pydantic import BaseModel
from typing import Literal, Optional
class TodoExtraction(BaseModel):
title: str
priority: Literal["高", "中", "低"]
due: Optional[str]
response = client.messages.parse(
model="claude-opus-5",
max_tokens=1024,
messages=[{"role": "user", "content": "提醒我禮拜三要繳電費"}],
output_format=TodoExtraction,
)
todo = response.parsed_output # 已經是驗證過的 TodoExtraction 物件
print(todo.title) # 繳電費
print(todo.priority) # 高
**一行定義,一行呼叫,拿到的直接是型別正確的 Python 物件。**這是目前最乾淨的做法。
additionalProperties: False 是必要的(在手寫 schema 時)來做一個 Agent 會用到的實際元件——判斷使用者想做什麼:
"""intent.py — 使用者意圖分類。"""
import logging
from typing import Literal, Optional
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
class Intent(BaseModel):
"""使用者的意圖分類結果。"""
action: Literal[
"query_weather", # 查天氣
"add_todo", # 新增待辦
"list_todos", # 查看待辦
"daily_brief", # 今日簡報
"chat", # 純聊天
"unknown", # 看不懂
] = Field(description="使用者想做的事")
confidence: float = Field(
ge=0.0, le=1.0,
description="判斷的把握程度,0 到 1 之間",
)
city: Optional[str] = Field(
default=None,
description="若是查天氣,使用者提到的縣市;沒提到則為 null",
)
todo_title: Optional[str] = Field(
default=None,
description="若是新增待辦,該事項的標題;否則為 null",
)
reasoning: str = Field(description="一句話說明你為什麼這樣判斷")
INTENT_SYSTEM = """你是一個意圖分類器。
分析使用者的輸入,判斷他想做什麼。
判斷原則:
- 只要提到天氣、下雨、氣溫、帶傘、穿什麼,就是 query_weather
- 出現「提醒我」「記一下」「我要做」,就是 add_todo
- 問「我有什麼事」「待辦清單」,就是 list_todos
- 問「今天如何」「幫我規劃今天」,就是 daily_brief
- 單純寒暄、閒聊,就是 chat
- 完全看不懂或超出能力範圍,就是 unknown
如果同時符合多種,選擇最主要的那一個,並在 confidence 中反映你的不確定。"""
def classify_intent(client, user_input) -> Intent:
"""判斷使用者的意圖。"""
response = client.messages.parse(
model="claude-opus-5",
max_tokens=1024,
system=INTENT_SYSTEM,
messages=[{"role": "user", "content": user_input}],
output_format=Intent,
)
intent = response.parsed_output
logger.info(
"意圖判斷:%s(信心 %.2f)— %s",
intent.action, intent.confidence, intent.reasoning,
)
return intent
用起來:
import anthropic
import config
client = anthropic.Anthropic(api_key=config.ANTHROPIC_API_KEY)
tests = [
"明天要不要帶傘?",
"提醒我禮拜五要交報告",
"我今天有什麼事要做",
"嗨你好",
"幫我把冰箱修好",
]
for text in tests:
intent = classify_intent(client, text)
print(f"{text:20s} → {intent.action:15s}({intent.confidence:.2f})")
輸出:
明天要不要帶傘? → query_weather (0.95)
提醒我禮拜五要交報告 → add_todo (0.98)
我今天有什麼事要做 → list_todos (0.90)
嗨你好 → chat (0.99)
幫我把冰箱修好 → unknown (0.85)
**這個 intent.action 可以直接拿去寫 if-else。**AI 的判斷變成了程式的分支條件。
confidence 這個欄位的用途信心值讓我們可以設計降級策略:
intent = classify_intent(client, user_input)
if intent.confidence < 0.6:
# 不確定就問清楚,不要瞎猜
return f"我不太確定你的意思,你是想{describe(intent.action)}嗎?"
handler = HANDLERS[intent.action]
return handler(intent)
⚠️ 但要提醒:模型自評的信心值不是真正的機率,只是它「覺得」自己有多確定。拿來當粗略的門檻可以,不要當成嚴謹的統計量。
reasoning 這個欄位的用途它有兩個作用:
第二點要注意欄位順序——如果你把 reasoning 放在 action 前面,模型會先產生理由再產生答案,效果更好。JSON 的欄位是有順序的,這個小細節值得留意。
# ❌ 太複雜,模型容易出錯
class Response(BaseModel):
analysis: AnalysisDetail # 巢狀
recommendations: List[Recommendation] # 巢狀 + 陣列
metadata: Dict[str, Any] # 自由格式
# ✅ 拆成多次呼叫
class Analysis(BaseModel): ...
class Recommendation(BaseModel): ...
# ❌ 模型可能回傳「非常高」「極高」「HIGH」
priority: str
# ✅ 只能是這三個
priority: Literal["高", "中", "低"]
due: Optional[str] = Field(
default=None,
description="截止日期,格式 YYYY-MM-DD。使用者沒提到時間就填 null,不要自己猜。",
)
這個 description 會進到 schema 裡,模型看得到。這是欄位層級的 prompt。
action: Literal["query_weather", "add_todo", "unknown"]
如果沒有 unknown,模型被迫在其他選項裡硬選一個。給它一個誠實的出口。
Schema 會佔 token,而且模型在受限的情況下生成通常比較慢。純聊天的部分就不要硬套。
反過來說,這些情況用文字更好:
我的原則是:
要餵給程式的,用結構化;要給人看的,用文字。
而 Agent 通常兩種都要——中間用結構化做決策,最後用文字回應使用者。
今天的內容其實已經摸到明天的門了。
回頭看一下意圖分類器:
class Intent(BaseModel):
action: Literal["query_weather", "add_todo", ...] # 要做什麼
city: Optional[str] # 參數
todo_title: Optional[str] # 參數
這不就是:**AI 決定「呼叫哪個函式」和「傳什麼參數」**嗎?
這正是 Tool Use 的本質。差別在於:
| 今天的做法 | 明天的 Tool Use | |
|---|---|---|
| 工具定義 | 我們手寫在 schema 裡 | 從 Day 15 的 to_schema() 自動產生 |
| 呼叫次數 | 一次分類,一個動作 | 模型可以連續呼叫多個工具 |
| 結果回饋 | 沒有——分類完就結束 | 結果會回傳給模型,它再決定下一步 |
| 控制權 | 在我們的 if-else | 在模型 |
最後兩項是關鍵。當「工具執行的結果」可以回到模型手上,讓它決定下一步時,迴圈就形成了。
而那個迴圈,就是 Agent。
output_config / messages.parse() 最可靠——從源頭保證格式Literal 鎖死值域、每個欄位寫 descriptionunknown 之類的出口,不要逼模型硬選reasoning 放在答案欄位之前,讓模型先想再答**明天是整個系列的轉捩點:Tool Use。**我們要把 Day 15 做的工具交到 AI 手上,讓它自己決定什麼時候用。