前面分享了 LLM 回應的形式,streaming 分段是方便人讀懂沒錯,但要讓程式直接處理的話,單單只是一段自然語言文字還是有點困擾。
例如要把使用者輸入的訂單描述,轉成一個可以直接寫進資料庫的物件,結構化資料就顯得格外重要。
傳統上的做法是取得資料後再做處理(post-hoc parsing),但現在透過 LLM 本身的能力與廠商提供的 API,我們也可以在向模型發送請求時,要求模型回傳指定的形式。
最直覺的做法:在 prompt 裡直接要求「請用 JSON 格式回答,欄位是 xxx」。這個做法雖然能動,但 LLM 沒辦法保證每次的輸出都合乎規定,漏欄位、加入多餘內容等都有機率會發生。在這種情況下讓程式直接對回應做 json.loads,會伴隨著模型的不穩定而爆炸!
正規的做法是使用 API 原生支援的機制——Tool Use 跟 Structured Outputs。
Tool Use 原本的設計目的是讓模型能夠呼叫外部工具(查天氣、查資料庫等),但只要我們定義一個「工具」,把它的 input_schema 設計成我們想要的資料格式,模型在決定呼叫這個工具時,就會生成符合該 schema 的參數——這組參數本質上就是我們要的結構化資料。
假設使用者輸入「我要訂兩件白色的 T 恤,寄到台北市信義區松高路 1 號」,我們想擷取出商品、數量、地址。看看 Anthropic 的寫法:
import os
import anthropic
client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
order_tool = {
"name": "extract_order",
"description": "從使用者描述中擷取訂單資訊",
"input_schema": {
"type": "object",
"properties": {
"product": {"type": "string", "description": "商品名稱"},
"quantity": {"type": "number", "description": "數量"},
"address": {"type": "string", "description": "收件地址"},
},
"required": ["product", "quantity", "address"],
},
}
response = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
tools=[order_tool],
tool_choice={"type": "tool", "name": "extract_order"},
messages=[
{"role": "user", "content": "我要訂兩件白色的T恤,寄到台北市信義區松高路1號"}
],
)
tool_use = next(block for block in response.content if block.type == "tool_use")
print(tool_use.input)
# {'product': '白色T恤', 'quantity': 2, 'address': '台北市信義區松高路1號'}
拿到的 tool_use.input 已經是一個現成的物件(dict),不用自己再解析一次。
雖然方法一已經能獲得想要的結構化資料,後來各家廠商還是推出了可以直接控制回應格式的 API,不需要再假借工具名義。看看 Anthropic 的寫法:
import os
import anthropic
client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
order_schema = {
"type": "object",
"properties": {
"product": {"type": "string", "description": "商品名稱"},
"quantity": {"type": "number", "description": "數量"},
"address": {"type": "string", "description": "收件地址"},
},
"required": ["product", "quantity", "address"],
"additionalProperties": False,
}
response = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
output_config={
"format": {
"type": "json_schema",
"schema": order_schema,
}
},
messages=[
{"role": "user", "content": "我要訂兩件白色的T恤,寄到台北市信義區松高路1號"}
],
)
text_block = next(block for block in response.content if block.type == "text")
print(text_block.text)
# {"product": "白色T恤", "quantity": 2, "address": "台北市信義區松高路1號"}
跟 Tool Use 不同的是,text 區塊本身就已經是合法 JSON。透過 API 的 constrained decoding(限制性取樣)在生成階段就強制輸出符合 schema,而不是生成完再驗證重試,因此不再需要處理 JSON 解析錯誤、缺欄位或型別不一致等問題,是官方提供的可靠途徑。
import os
from openai import OpenAI
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
order_schema = {
"name": "extract_order",
"schema": {
"type": "object",
"properties": {
"product": {"type": "string", "description": "商品名稱"},
"quantity": {"type": "number", "description": "數量"},
"address": {"type": "string", "description": "收件地址"},
},
"required": ["product", "quantity", "address"],
"additionalProperties": False,
},
"strict": True,
}
response = client.chat.completions.create(
model="gpt-4.1",
max_tokens=1024,
response_format={"type": "json_schema", "json_schema": order_schema},
messages=[
{"role": "user", "content": "我要訂兩件白色的T恤,寄到台北市信義區松高路1號"}
],
)
print(response.choices[0].message.content)
# {"product": "白色T恤", "quantity": 2, "address": "台北市信義區松高路1號"}
跟 Anthropic 不同點:
output_config.format vs. response_format)。json_schema 底下。json_schema 參數中。兩者都會讓模型回傳一段合法的 JSON 字串,但不像 Tool Use 會自動幫忙解析成物件,要自己用 json.loads 轉換後才能當一般資料使用。
有了結構化輸出,LLM 才能真正被接進程式的資料流裡——它的輸出不再只是給人看的文字,而是可以直接被下一段程式邏輯使用的資料。
其實目前 OpenAI 跟 Anthropic 都有提供更方便的方法,透過搭配 Pydantic model 來定義 schema,並回傳型別驗證過的物件,但今天示範的做法比較能看清楚底層 schema 回傳的原理。在正式專案中如果要使用較嚴謹的方法,也可以到官網上參考相關資訊。
我們已經學會如何將 LLM 的回應做成結構化資料,目前為止範例都很順利,但跑程式的時候不會永遠都是成功的,明天將會分享遇到 API 呼叫失敗的情況!