精華筆記

· @aihub.tw

Claude API 開發實戰

結構化輸出:保證拿到合法 JSON

結構化輸出:保證拿到合法 JSON

你花了半天把 Claude 對接進自己的產品,程式跑起來了,但三天後客戶回報說某筆資料壞掉了。你追查一輪,發現問題在這裡:Claude 這次回了一個 JSON,但外面多包了一層 markdown code fence——你的 json.loads() 直接爆掉,資料就靜靜地丟失了,沒有任何警告。

這不是 Claude 的 bug,這是「用提示詞要求 JSON 輸出」的先天缺陷。模型不是規則引擎,它輸出的是「看起來像 JSON 的文字」,不是「保證合法的 JSON」。現在有了更好的解法——2025 年 11 月 Anthropic 正式推出 Structured Outputs,讓你傳入一份 JSON Schema,Claude 在 token 層級就被鎖死在這個格式裡輸出,從根本上杜絕格式不合法的問題。

這堂課適合誰 適合:已經會基本 Claude API 呼叫、想讓回應格式穩定可靠的後端開發者或自學者(本課程屬工程師專區)。需要基礎:Python 或 JavaScript 基礎(會定義 class/型別、解析 JSON 即可)。前置課:第 1 課(呼叫 API)、第 3 課(System Prompt 與多輪對話)。

這堂學什麼

  • 為什麼「在 prompt 裡說請輸出 JSON」不可靠,以及它會在哪些情況下失敗
  • Structured Outputs 的底層原理:constrained decoding 是什麼
  • JSON Schema 核心語法速查:type、properties、required、additionalProperties
  • output_config.format 呼叫 Structured Outputs(Python 與 JavaScript 雙語)
  • Pydantic / Zod 型別整合:讓回應直接是你定義的 Python class 或 TypeScript 型別
  • 實戰:文本抽取結構化資料 API——從一段新聞稿抽出公司名、數字、日期

觀念一:舊法哪裡脆

在 Structured Outputs 推出之前,要讓 Claude 輸出 JSON,幾乎人人都用這個做法:在 system prompt 裡加一句「請一律以 JSON 格式回覆」或「請輸出格式如下:{...}」。這個方法在大多數情況下能跑,但它在以下幾個場景下特別容易出問題:

舊法 vs Structured Outputs 對照圖

情境一:回覆加了前言。 Claude 有時候會在 JSON 之前加上「當然,以下是您要求的 JSON:」,然後才是實際內容。你的 json.loads() 直接炸。

情境二:被 markdown 包裹。 模型把 JSON 放進 ```json ... ``` 的 code fence 裡,因為它覺得「這樣比較好看」。你必須先把前後的 fence 剝掉才能解析。

情境三:近乎合法但就是不合法。 尾端多一個逗號、字串裡有非轉義的引號、數字欄位傳了字串——這些在語意上沒問題,但 JSON 解析器不管語意,只管語法,一個字元不對就丟 exception。

情境四:欄位結構跑掉。 你要的是 { "price": 199, "currency": "TWD" },Claude 有時候回 { "金額": "199元台幣" }——語意相同,但你的程式完全對不上。

這些問題的根源是一樣的:模型輸出是機率性的「文字」,你在 prompt 裡說要 JSON 只是給了個提示,不是給了個保證。真正的保證需要在 token 生成層面下手。

觀念二:constrained decoding——從根源保證格式

Structured Outputs 的底層機制叫做 constrained decoding。簡單說:在模型生成每個 token 時,系統會比對你傳入的 JSON Schema,把所有「如果接上去就會違反 schema」的 token 直接從候選清單裡刪掉。

Constrained Decoding 流程圖

換句話說,模型不是「輸出完再被修正」,而是「從一開始就只能輸出符合你 schema 的 token 序列」。這就是為什麼 Structured Outputs 能夠 100% 保證輸出是合法 JSON,並且欄位結構完全符合你定義的 schema——這不是靠模型的「努力」,而是靠數學上的強制約束。

Structured Outputs 在 2025 年 11 月 14 日推出公開測試,現在已是 GA(正式可用)狀態,支援 Claude Haiku 4.5、Sonnet 4.6、Opus 4.8 以及更新的模型。

觀念三:JSON Schema 速查

使用 Structured Outputs 需要你自己定義一份 JSON Schema,告訴 Claude 輸出的資料長什麼樣。大部分場景你只需要五個關鍵字:

JSON Schema 結構解剖圖

{
  "type": "object",
  "properties": {
    "company_name": { "type": "string" },
    "revenue": { "type": "number" },
    "is_profitable": { "type": "boolean" },
    "founded_year": { "type": "integer" },
    "tags": {
      "type": "array",
      "items": { "type": "string" }
    }
  },
  "required": ["company_name", "revenue", "is_profitable"],
  "additionalProperties": false
}

幾個重點說清楚:

  • type 支援:stringnumber(含小數)、integer(整數)、booleanarrayobjectnull
  • required 是一個陣列,列出你一定要拿到的欄位;沒列進去的欄位 Claude 可以省略
  • additionalProperties: false強制必填,少了它 API 會報錯。意思是「不接受 schema 以外的欄位」
  • 你也可以用 enum 限制值的範圍,例如 { "type": "string", "enum": ["pending", "approved", "rejected"] }

特別注意幾個不支援的語法,寫了會讓 API 回 400 錯誤:

  • minimummaximumminLengthmaxLength 這類數值/字串約束
  • 遞迴 schema(一個物件的欄位指向自己)
  • additionalProperties: true(不能允許任意欄位)

手把手實戰

以下用「從新聞稿抽取公司財務資訊」為主軸,從最基本的呼叫方式逐步帶到實戰。

最基本的 Structured Outputs 呼叫

重點在於加入 output_config 參數,其他地方跟一般 messages.create() 完全一樣:

Python:

import anthropic
import json

client = anthropic.Anthropic()  # 自動讀取 ANTHROPIC_API_KEY

# 定義你要的輸出結構
schema = {
    "type": "object",
    "properties": {
        "company_name": {"type": "string"},
        "revenue_million_usd": {"type": "number"},
        "yoy_growth_pct": {"type": "number"},
        "is_profitable": {"type": "boolean"},
        "fiscal_quarter": {"type": "string"}
    },
    "required": [
        "company_name", "revenue_million_usd",
        "yoy_growth_pct", "is_profitable", "fiscal_quarter"
    ],
    "additionalProperties": False  # Python 用 False(大寫)
}

news_text = """
台積電今日公布 2026 年第一季財報,合併營收達 8,350 億新台幣(約 258 億美元),
較去年同期成長 41.6%。單季淨利為 3,615 億新台幣,創歷史新高。
"""

response = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=512,
    system="你是一位財務資料抽取 AI。從用戶提供的新聞稿中,抽取指定的結構化財務資訊。",
    messages=[
        {"role": "user", "content": f"請從以下新聞稿抽取財務資訊:\n\n{news_text}"}
    ],
    output_config={
        "format": {
            "type": "json_schema",
            "schema": schema
        }
    }
)

# 回應在 response.content[0].text,是一個 JSON 字串,直接解析即可
data = json.loads(response.content[0].text)
print(data)
# {"company_name": "台積電", "revenue_million_usd": 25800.0, "yoy_growth_pct": 41.6, "is_profitable": true, "fiscal_quarter": "2026 Q1"}

JavaScript/TypeScript:

import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic();

const schema = {
  type: "object" as const,
  properties: {
    company_name: { type: "string" as const },
    revenue_million_usd: { type: "number" as const },
    yoy_growth_pct: { type: "number" as const },
    is_profitable: { type: "boolean" as const },
    fiscal_quarter: { type: "string" as const }
  },
  required: ["company_name", "revenue_million_usd", "yoy_growth_pct", "is_profitable", "fiscal_quarter"],
  additionalProperties: false
};

const newsText = `
台積電今日公布 2026 年第一季財報,合併營收達 8,350 億新台幣(約 258 億美元),
較去年同期成長 41.6%。單季淨利為 3,615 億新台幣,創歷史新高。
`;

const response = await client.messages.create({
  model: "claude-sonnet-4-6",
  max_tokens: 512,
  system: "你是一位財務資料抽取 AI。從用戶提供的新聞稿中,抽取指定的結構化財務資訊。",
  messages: [
    { role: "user", content: `請從以下新聞稿抽取財務資訊:\n\n${newsText}` }
  ],
  output_config: {
    format: {
      type: "json_schema",
      schema
    }
  }
});

const data = JSON.parse(response.content[0].text);
console.log(data);

注意兩個細節:第一,回應拿到的是 response.content[0].text(字串),你還是要自己 json.loads() / JSON.parse() 一次——Structured Outputs 保證它一定合法,所以這步不會失敗;第二,schema 裡的 additionalProperties 在 Python 要寫 False(大寫),在 JSON 和 JS 裡寫 false

Pydantic 型別整合(Python 推薦做法)

上面的方式每次都要手寫 schema dict,而且拿回來的是 dict,你後面存取欄位要用 data["company_name"] 而不是 data.company_name。用 Pydantic 可以解決這兩個問題:

from pydantic import BaseModel, Field
from anthropic import Anthropic

client = Anthropic()

class FinancialInfo(BaseModel):
    company_name: str = Field(description="公司名稱")
    revenue_million_usd: float = Field(description="季度營收,單位百萬美元")
    yoy_growth_pct: float = Field(description="年增率,百分比數字,不含 % 符號")
    is_profitable: bool = Field(description="本季是否獲利")
    fiscal_quarter: str = Field(description="財報季度,例如 2026 Q1")

news_text = """
台積電今日公布 2026 年第一季財報,合併營收達 8,350 億新台幣(約 258 億美元),
較去年同期成長 41.6%。單季淨利為 3,615 億新台幣,創歷史新高。
"""

# 用 .parse() 方法,傳入 Pydantic model class
response = client.messages.parse(
    model="claude-sonnet-4-6",
    max_tokens=512,
    system="你是一位財務資料抽取 AI。從用戶提供的新聞稿中,抽取指定的結構化財務資訊。",
    messages=[
        {"role": "user", "content": f"請從以下新聞稿抽取財務資訊:\n\n{news_text}"}
    ],
    output_format=FinancialInfo  # 直接傳 class,SDK 自動轉成 schema
)

# response.parsed_output 就是一個 FinancialInfo 實例,有完整型別提示
info = response.parsed_output
print(info.company_name)        # "台積電"
print(info.revenue_million_usd) # 25800.0
print(info.is_profitable)       # True
print(type(info))               # <class '__main__.FinancialInfo'>

兩個關鍵差異:第一,用 .parse() 而不是 .create();第二,用 output_format= 參數傳入 Pydantic class。SDK 會自動把 Pydantic model 轉成 JSON Schema 傳給 API,回應也會自動用 Pydantic 驗證並建構成物件。

Field(description="...") 是可選的,但強烈建議加——這段 description 會進入 schema 成為 API 的一部分,讓 Claude 更精確理解每個欄位的語意。

TypeScript Zod 整合

JavaScript 端對應的做法是用 Zod:

import Anthropic from "@anthropic-ai/sdk";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import { z } from "zod";

const client = new Anthropic();

const FinancialInfoSchema = z.object({
  company_name: z.string().describe("公司名稱"),
  revenue_million_usd: z.number().describe("季度營收,單位百萬美元"),
  yoy_growth_pct: z.number().describe("年增率,百分比數字,不含 % 符號"),
  is_profitable: z.boolean().describe("本季是否獲利"),
  fiscal_quarter: z.string().describe("財報季度,例如 2026 Q1")
});

type FinancialInfo = z.infer<typeof FinancialInfoSchema>;

const newsText = `
台積電今日公布 2026 年第一季財報,合併營收達 8,350 億新台幣(約 258 億美元),
較去年同期成長 41.6%。單季淨利為 3,615 億新台幣,創歷史新高。
`;

const response = await client.messages.parse({
  model: "claude-sonnet-4-6",
  max_tokens: 512,
  system: "你是一位財務資料抽取 AI。從用戶提供的新聞稿中,抽取指定的結構化財務資訊。",
  messages: [
    { role: "user", content: `請從以下新聞稿抽取財務資訊:\n\n${newsText}` }
  ],
  output_config: { format: zodOutputFormat(FinancialInfoSchema) }
});

// response.parsed_output 是完整型別的物件
const info: FinancialInfo = response.parsed_output;
console.log(info.company_name);         // "台積電"
console.log(info.revenue_million_usd);  // 25800

注意 JS 版用的是 output_config: { format: zodOutputFormat(...) } 而不是 output_format=——兩種語言的 API 略有不同,要看清楚。

實戰:處理巢狀結構與陣列欄位

真實場景往往需要更複雜的結構。假設你要從同一篇新聞稿同時抽取多個提到的公司,或是抽取帶有子物件的層次資料:

from pydantic import BaseModel, Field
from typing import Optional
from anthropic import Anthropic

client = Anthropic()

class Competitor(BaseModel):
    name: str = Field(description="競爭對手名稱")
    mentioned_revenue_billion_usd: Optional[float] = Field(
        default=None,
        description="如有提及營收,單位為十億美元;若未提及則為 null"
    )

class NewsAnalysis(BaseModel):
    primary_company: str = Field(description="新聞主角公司名稱")
    quarter: str = Field(description="財報季度")
    revenue_billion_usd: float = Field(description="主角公司營收,單位十億美元")
    yoy_growth_pct: float = Field(description="年增率,純數字")
    key_highlights: list[str] = Field(description="3 到 5 條重點摘要,每條一句話")
    competitors_mentioned: list[Competitor] = Field(
        default_factory=list,
        description="新聞中有被提及的競爭對手列表,若無則為空陣列"
    )

long_news = """
台積電今日公布 2026 年第一季財報,合併營收達 258 億美元,年增 41.6%。
單季淨利創歷史新高,毛利率維持在 58% 以上。
法說會上,台積電表示 AI 相關需求持續超出預期,先進製程訂單能見度已達年底。
三星電子同期財報出現虧損,英特爾晶圓代工部門營收約 45 億美元。
台積電預估次季營收將達 280-290 億美元,成長動能不減。
"""

response = client.messages.parse(
    model="claude-sonnet-4-6",
    max_tokens=1024,
    system="你是一位財務新聞分析 AI。從新聞稿中抽取結構化分析資料。",
    messages=[
        {"role": "user", "content": f"請分析以下新聞稿:\n\n{long_news}"}
    ],
    output_format=NewsAnalysis
)

result = response.parsed_output
print(result.primary_company)
print(result.key_highlights)           # ['AI 需求超預期', '毛利率 58% 以上', ...]
print(result.competitors_mentioned)    # [Competitor(name='三星電子', ...), ...]

# 存進資料庫或序列化
import json
print(result.model_dump_json(indent=2))

幾個在複雜 schema 裡的技巧:

  • Optional[float] + default=None 讓欄位可以是 null,搭配 description 告訴 Claude 什麼時候填 null
  • list[str]list[Competitor] 定義陣列欄位
  • default_factory=list 讓 Claude 在沒有相關資訊時回傳空陣列而不是省略欄位
  • Pydantic 的 .model_dump_json() 可以直接序列化整個結果

常見坑

坑 1:忘記加 additionalProperties: false,API 回 400

Error: 400 {"type":"error","error":{"type":"invalid_request_error",
"message":"output_config.format.schema: additionalProperties must be false"}}

只要 schema 裡有 type: "object" 的物件(包含巢狀物件),每個物件層級都要加 "additionalProperties": false。這是 Structured Outputs 的強制要求。如果用 Pydantic,SDK 會自動處理這個,所以 Pydantic 方式比手寫 schema 更不容易踩到這坑。

坑 2:用了不支援的 schema 約束

Error: 400 {"type":"error","error":{"type":"invalid_request_error",
"message":"output_config.format.schema: minimum/maximum/minLength/maxLength not supported"}}

Structured Outputs 不支援數值或字串的長度/範圍約束。如果你的 schema 裡有 "minimum": 0"maximum": 100"minLength": 1,直接刪掉——你改用 description 告訴 Claude 期望的範圍就好,例如 "description": "必須是 0 到 100 之間的整數"。另外 additionalProperties: true 也不支援,只能是 false

坑 3:stop_reason 是 "refusal" 但沒有攔截

response = client.messages.create(...)

# 以為這樣一定拿到 JSON,直接解析
data = json.loads(response.content[0].text)  # 可能炸

Structured Outputs 有一個例外:如果 Claude 基於安全考量拒絕回答,stop_reason 會是 "refusal" 而不是 "end_turn",這時 response.content[0].text 不是 JSON,你的解析就會失敗。另一個例外是 stop_reason: "max_tokens":token 被截斷,JSON 不完整,解析也會炸。正確的防禦寫法:

response = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=1024,
    messages=[...],
    output_config={"format": {"type": "json_schema", "schema": schema}}
)

stop_reason = response.stop_reason

if stop_reason == "refusal":
    raise ValueError("模型拒絕回應此請求(安全考量)")
elif stop_reason == "max_tokens":
    raise ValueError("輸出被截斷,請增加 max_tokens 或縮小 schema")
elif stop_reason != "end_turn":
    raise ValueError(f"未預期的 stop_reason: {stop_reason}")

data = json.loads(response.content[0].text)

使用 Pydantic .parse() 時,SDK 會自動在 refusal 時拋出 anthropic.APIStatusError,可以直接 try/except 攔截。

坑 4:用了舊的 beta header 但混用新參數

Error: 400 Cannot use both output_format and output_config.format

Structured Outputs 在 beta 期間的舊參數是 output_format(直接放在 messages.create() 的頂層),現在 GA 後改成 output_config.format。這兩個不能同時用。如果你是從舊教學或文章複製的程式碼,請把 output_format 整個換成 output_config: {"format": ...} 的結構;同時把 betas=["structured-outputs-2025-11-13"] 這行也刪掉——GA 版不需要 beta header。

舊 Beta 寫法 vs GA 寫法對照

坑 5:把文字推理欄位硬塞進 schema

有些人會在 schema 裡加 "reasoning""thinking" 欄位,想讓 Claude 同時輸出分析過程和結論。這沒有問題,但要注意:這些文字欄位會消耗大量 token,max_tokens 要設得夠大;而且如果 schema 設計不好(欄位定義不清),模型有時候會把大篇幅的推理文字塞在一個 string 欄位裡讓欄位爆長。建議把「推理」和「結論」拆開,或者乾脆不要在 Structured Outputs 裡放推理欄位——讓 Claude 在 system prompt 裡先用文字推理,再輸出結構化結論是更乾淨的做法。

這節課的定位:一次就做對的基礎

Structured Outputs 不只是「讓 JSON 不炸」這麼簡單。它讓你可以把 Claude 當成一個可靠的「資訊抽取引擎」——你定義 schema,它幫你從非結構化的文字(新聞稿、用戶 feedback、客服紀錄、合約文件)裡抽出乾淨的結構化資料,直接進資料庫或 API,中間不需要任何人工校正。這種抽取 pipeline 是很多 AI 自動化應用的核心,而你現在已經知道怎麼正確搭建它了。

資訊抽取 Pipeline 流程圖

作業

  1. 基礎版:拿任意一篇中文新聞稿(財經、科技、體育皆可),自己設計一份 3–5 個欄位的 schema,用 Structured Outputs 呼叫 claude-haiku-4-5(最便宜)把資料抽出來。驗證:把回應傳給 json.loads() 確認零錯誤。

  2. 進階版:把 schema 改成含有陣列欄位的版本(例如「提到的人物列表」或「三個重點摘要」),並加入一個 Optional 欄位(有資訊就填、沒有就 null)。確認 Pydantic 的 .parse() 方法能正確處理。

  3. 防禦版:在你的程式碼裡加入 stop_reason 的檢查,確保 refusal 和 max_tokens 的情況都有適當的錯誤處理,不會靜默失敗。

下一課預告

現在你可以讓 Claude 輸出你定義的任何資料結構。但如果你想讓 Claude 不只「回傳資料」,而是主動做事——查資料庫、呼叫第三方 API、執行計算、甚至控制你系統裡的函式——那就需要第 5 課的主題:Tool Use。Tool Use 讓你定義一組「工具」給 Claude,Claude 在對話過程中決定要不要呼叫哪個工具、傳什麼參數,你的程式執行完後把結果回傳給它,它再繼續推理。這是從「AI 問答機器」到「AI 代理程式」的關鍵一躍,也是整門課程技術密度最高的一堂——想好了嗎?

#Claude API#Structured Outputs#JSON Schema#Pydantic#Python#JavaScript

← 回所有文章