iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0
AI Engineering

從 Prompt 到自主決策:用 Python × Agentic Workflow 實作生活助理系列 第 20 篇

讓 AI 回傳程式能用的東西:結構化輸出

  • 分享至 

  • xImage
  •  

昨天我們用 prompt 要求 AI 回傳 JSON。它大部分時候會照做,但偶爾會出這種包:

好的,以下是抽取的結果:

```json
{"title": "繳電費", "priority": "高"}
```

希望對你有幫助!

然後你的 json.loads() 就炸了。

一百次裡出錯三次,聽起來還好。但如果 Agent 一次任務要呼叫五次模型,那每二十次任務就有一次會掛掉——這個可靠度不能接受。

今天要解決這個問題。


一、為什麼結構化輸出是 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,本質上就是結構化輸出的一種特殊應用。今天打好基礎,明天會很順。


二、方法一:Prompt + 防禦性解析

最低成本的做法,就是昨天那樣要求 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 但欄位不對。


三、方法二:用 Pydantic 驗證

拿到 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 看的規格」。**不用手寫兩份,不會不同步。


四、方法三:API 原生的結構化輸出(最可靠)

前兩個方法都是「事後補救」。最好的做法是從源頭就保證格式正確。

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 時)
  • 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 這個欄位的用途

它有兩個作用:

  1. 除錯:分類錯的時候,你能看到它為什麼這樣想
  2. 可能提升準確度:讓模型先講理由再給答案,等於強迫它思考

第二點要注意欄位順序——如果你把 reasoning 放在 action 前面,模型會先產生理由再產生答案,效果更好。JSON 的欄位是有順序的,這個小細節值得留意。


六、結構化輸出的實務建議

1. Schema 越簡單越好

# ❌ 太複雜,模型容易出錯
class Response(BaseModel):
    analysis: AnalysisDetail        # 巢狀
    recommendations: List[Recommendation]   # 巢狀 + 陣列
    metadata: Dict[str, Any]        # 自由格式

# ✅ 拆成多次呼叫
class Analysis(BaseModel): ...
class Recommendation(BaseModel): ...

2. 用 enum 鎖死可能的值

# ❌ 模型可能回傳「非常高」「極高」「HIGH」
priority: str

# ✅ 只能是這三個
priority: Literal["高", "中", "低"]

3. 給每個欄位寫 description

due: Optional[str] = Field(
    default=None,
    description="截止日期,格式 YYYY-MM-DD。使用者沒提到時間就填 null,不要自己猜。",
)

這個 description 會進到 schema 裡,模型看得到。這是欄位層級的 prompt。

4. 需要「不知道」的選項

action: Literal["query_weather", "add_todo", "unknown"]

如果沒有 unknown,模型被迫在其他選項裡硬選一個。給它一個誠實的出口。

5. 結構化輸出不便宜

Schema 會佔 token,而且模型在受限的情況下生成通常比較慢。純聊天的部分就不要硬套。


七、什麼時候不該用結構化輸出

反過來說,這些情況用文字更好:

  • 最終要給使用者看的回應——自然的語言比 JSON 親切
  • 開放式的創作或解釋
  • 只是要一段摘要

我的原則是:

要餵給程式的,用結構化;要給人看的,用文字。

而 Agent 通常兩種都要——中間用結構化做決策,最後用文字回應使用者。


八、從結構化輸出到 Tool Use

今天的內容其實已經摸到明天的門了。

回頭看一下意圖分類器:

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。


小結

  • 用 prompt 要求 JSON 不夠可靠,需要防禦性解析(處理程式碼框、多餘文字)
  • Pydantic 提供驗證 + 自動產生 JSON Schema,一份定義兩用
  • API 原生的 output_config / messages.parse() 最可靠——從源頭保證格式
  • Schema 要簡單、用 Literal 鎖死值域、每個欄位寫 description
  • 一定要提供 unknown 之類的出口,不要逼模型硬選
  • 把 reasoning 放在答案欄位之前,讓模型先想再答
  • 要餵給程式的用結構化,要給人看的用文字
  • 結構化輸出讓 AI 的判斷變成程式的控制流——這是 Agent 的起點

**明天是整個系列的轉捩點:Tool Use。**我們要把 Day 15 做的工具交到 AI 手上,讓它自己決定什麼時候用。


上一篇
把話說清楚:Prompt 設計的原則與實作
下一篇
讓 AI 動手:Tool Use 的原理與第一次實作
系列文
從 Prompt 到自主決策:用 Python × Agentic Workflow 實作生活助理 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言