- Structured Outputs ใช้
output_config.format ระดับ json_schema เพื่อบังคับให้ output ตรงกับ schema เป๊ะๆ (ไม่ต้องเขียน prefill อีกต่อไป และ prefill ก็ถูก deprecated บน Opus/Sonnet 4.6+ แล้ว)
- รองรับเต็มที่บน Claude Opus 4.8, Sonnet 4.6, Haiku 4.5 และ Fable 5 โดยไม่ต้องใส่ beta header
- การ compile schema ครั้งแรกใช้เวลานานกว่าปกติเล็กน้อย แต่จะถูก cache ไว้ 24 ชั่วโมงทำให้ request ถัดๆ ไปเร็วขึ้น
- ใช้คู่กับ
strict: true ใน tool definition เพื่อรับประกัน parameter validation ใน function calling
- มี SDK helper อย่าง
messages.parse() (Python) ที่ทำ schema generation จาก Pydantic ให้อัตโนมัติ
- ไม่สามารถใช้ร่วมกับ citations และยังต้องจัดการกับ
stop_reason: "refusal" และ "max_tokens" ที่อาจทำให้ output ไม่ครบ schema
Structured Outputs คืออะไร และทำไมต้องใช้ปี 2026
ในการออกแบบ pipeline ที่ผมทำมาในช่วงสองปีหลัง ปัญหาที่กินเวลาดีบักมากที่สุดไม่ใช่ตัวโมเดลตอบผิด แต่เป็นการที่โมเดลตอบ "เกือบถูก" เช่น เปิด { มาแล้วใส่ comment เป็น markdown หรือใส่ trailing comma ที่ JSON.parse() ไม่ยอมรับ ก่อนหน้าปี 2025 วิธีที่ workflow architect ส่วนใหญ่ใช้คือเทคนิค assistant prefill ซึ่งเป็นการบังคับให้ตัวโมเดลเริ่มตอบจาก {" เพื่อให้ออกแบบ output structure ได้ แต่วิธีนี้พังในยุคใหม่
ตั้งแต่ Opus 4.6 และ Sonnet 4.6 เป็นต้นมา assistant prefill ที่ใช้บังคับ JSON ถูก deprecated และส่ง 400 error กลับมา ทางออกใหม่คือ Structured Outputs ซึ่งทำงานบน Messages API โดยตรงผ่าน output_config.format โมเดลจะถูกจำกัดอยู่ภายใต้ JSON Schema ที่คุณกำหนด ทำให้ output ที่ออกมาตรง schema 100% สามารถ json.loads() ได้โดยไม่ต้องห่อ try/except
ฟีเจอร์นี้ทำงานร่วมกับฟีเจอร์อื่นในระบบเดียวกัน ได้แก่ prompt caching ของ Claude API สำหรับลดต้นทุน, batch API สำหรับงานปริมาณมาก, extended thinking สำหรับงานที่ต้องใช้เหตุผลซับซ้อน และ streaming สำหรับ UX แบบ real-time แต่จะ ใช้ร่วมกับ citations ไม่ได้ (ส่ง 400 error) ซึ่งเป็นข้อจำกัดที่ผมเจอบ่อยเวลาออกแบบ RAG pipeline จะต้องตัดสินใจว่าเอา structured output หรือ source attribution อย่างใดอย่างหนึ่ง
วิธีใช้ output_config.format กับ JSON Schema
โครงสร้างพื้นฐานของ Structured Outputs ใน Claude API ปี 2026 มีหน้าตาแบบนี้ ผมจะแสดงทั้งแบบ raw schema ก่อน เพื่อให้เห็นว่าระดับล่างทำอะไรกัน
import anthropic
import json
client = anthropic.Anthropic()
# กำหนด JSON Schema ตามที่ต้องการ
contact_schema = {
"type": "object",
"properties": {
"name": {"type": "string"},
"email": {"type": "string"},
"plan": {
"type": "string",
"enum": ["Starter", "Pro", "Enterprise"]
},
"demo_requested": {"type": "boolean"}
},
"required": ["name", "email", "plan", "demo_requested"],
"additionalProperties": False
}
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=2048,
messages=[{
"role": "user",
"content": "ลูกค้าชื่อสมชาย ใจดี อีเมล [email protected] "
"สนใจแพ็กเกจ Enterprise และอยากดู demo"
}],
output_config={
"format": {
"type": "json_schema",
"schema": contact_schema
}
}
)
# ดึง text block แรกออกมา รับประกันว่าเป็น JSON valid
text = next(b.text for b in response.content if b.type == "text")
contact = json.loads(text)
print(contact["name"], contact["plan"])
สิ่งที่ต้องระวังจาก JSON Schema spec ที่ Claude รองรับ คือฟิลด์ additionalProperties: false ต้องมีในทุก object และต้องระบุ required ครบทุก property ที่ใส่ใน properties ถ้าลืม schema จะถูกปฏิเสธตอน compile ครั้งแรก (ซึ่งช้าหน่อย, ผมเจอ latency เพิ่ม 800-1200ms ใน request แรก หลังจากนั้นจะ cache 24 ชั่วโมง)
ข้อจำกัดของ Schema ที่ต้องรู้
Claude รองรับ JSON Schema ส่วนใหญ่ แต่ ไม่รองรับสิ่งเหล่านี้ ซึ่งผมเสียเวลาดีบักไปกับมันหลายชั่วโมงตอนเริ่มใช้ใหม่ๆ:
- Recursive schemas (schema ที่อ้างถึงตัวเอง)
- Numerical constraints เช่น
minimum, maximum, multipleOf
- String constraints เช่น
minLength, maxLength, pattern ที่ซับซ้อน
- Array constraints ที่ซับซ้อนเกินไป
additionalProperties ที่เป็นค่าอื่นนอกจาก false
Python SDK และ TypeScript SDK รุ่นใหม่ฉลาดพอที่จะตัด constraint ที่ไม่รองรับออกจาก schema ก่อนส่งไปยัง API และมา validate ฝั่ง client แทน ทำให้คุณยังเขียน Pydantic model ตามสะดวกได้ (ซึ่งเป็นวิธีที่ผมแนะนำ)
ใช้ Pydantic + messages.parse() ลดโค้ดลงครึ่งหนึ่ง
วิธีที่ผมใช้ใน production จริงคือไม่เขียน JSON Schema ด้วยมือเลย เพราะมัน error-prone และอ่านยาก ผมใช้ Pydantic model แทน แล้วใช้ client.messages.parse() ซึ่งจะ:
- Generate JSON Schema จาก Pydantic model อัตโนมัติ
- เรียก API พร้อม
output_config.format
- Parse response กลับเป็น Pydantic instance ที่ type-safe
from pydantic import BaseModel, Field
from typing import Literal
import anthropic
client = anthropic.Anthropic()
class ContactInfo(BaseModel):
name: str = Field(description="ชื่อ-นามสกุลของลูกค้า")
email: str
plan: Literal["Starter", "Pro", "Enterprise"]
interests: list[str] = Field(
description="หัวข้อที่ลูกค้าสนใจ เช่น API, SDK, security"
)
demo_requested: bool
notes: str | None = None # optional field
response = client.messages.parse(
model="claude-opus-4-8",
max_tokens=2048,
messages=[{
"role": "user",
"content": "Jane Doe ([email protected]) ต้องการแพ็กเกจ Enterprise "
"สนใจเรื่อง API integration และ SDK ภาษา Python "
"ขอนัด demo ในสัปดาห์หน้า"
}],
output_format=ContactInfo,
)
# parsed_output เป็น ContactInfo instance ที่ผ่าน validation แล้ว
contact = response.parsed_output
print(f"ลูกค้า: {contact.name}")
print(f"แพ็กเกจ: {contact.plan}")
print(f"สนใจ: {', '.join(contact.interests)}")
โค้ดสั้นลงประมาณ 40% และที่สำคัญคือได้ static type checking ตั้งแต่ตอนเขียน ถ้าผมพิมพ์ contact.plann ผิด pyright จะแจ้งทันที ไม่ต้องรอจน runtime ถึงจะรู้ ฝั่ง TypeScript ใช้ Zod schema กับ zodOutputFormat() ในรูปแบบเดียวกัน concept เหมือนกันเป๊ะๆ
หลายคนสับสนระหว่าง Structured Outputs (output_config.format) กับ Strict Tool Use (strict: true) เพราะทั้งคู่บังคับ JSON schema เหมือนกัน ความแตกต่างที่สำคัญคือ มันบังคับ schema คนละจุด:
| คุณสมบัติ | Structured Outputs | Strict Tool Use |
| พารามิเตอร์ | output_config.format | strict: true ใน tool definition |
| บังคับ schema ตรงไหน | Response ของโมเดล (text content) | Input ของ tool call |
| เหมาะกับ | Data extraction, classification, JSON response | Function calling, tool/agent workflow |
| ใช้ร่วมกันได้ไหม | ใช่ (บังคับทั้ง response และ tool input) | ใช่ |
| ตำแหน่งใน request body | Top-level | ภายใน object ของ tool ใน array tools |
| ค่าใช้จ่ายเพิ่ม | Compile schema ครั้งแรกช้ากว่าปกติ | เหมือน Structured Outputs |
กฎที่ผมจำไว้คือ ถ้าต้องการให้ โมเดลตอบกลับเป็น structured data ตรงๆ ใช้ Structured Outputs แต่ถ้าต้องการให้ โมเดลเรียก function/tool ของเรา ใช้ Strict Tool Use ในงานจริงผมใช้ทั้งคู่พร้อมกันบ่อย เช่นใน multi-agent workflow ที่ agent ต้องเรียก tool หลายตัวแล้วสุดท้ายตอบกลับเป็น structured summary (ถ้าคุณกำลังออกแบบ agent loop แนะนำให้อ่าน Claude tool use สำหรับ AI agent ประกอบด้วย)
# ตัวอย่าง: ใช้ทั้งคู่ใน request เดียว
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=4096,
messages=[{"role": "user", "content": "วางแผน trip ไปโตเกียวเดือนหน้า"}],
output_config={
"format": {
"type": "json_schema",
"schema": {
"type": "object",
"properties": {
"summary": {"type": "string"},
"next_steps": {
"type": "array",
"items": {"type": "string"}
}
},
"required": ["summary", "next_steps"],
"additionalProperties": False
}
}
},
tools=[{
"name": "search_flights",
"description": "ค้นหาเที่ยวบินตามวันที่และปลายทาง",
"strict": True,
"input_schema": {
"type": "object",
"properties": {
"destination": {"type": "string"},
"date": {"type": "string", "format": "date"}
},
"required": ["destination", "date"],
"additionalProperties": False
}
}]
)
เคสที่ผมเจอบ่อยที่สุดเวลาออกแบบ workflow ให้ลูกค้าในไทย คือการดึงข้อมูลจากใบแจ้งหนี้หรือใบเสร็จที่เป็น PDF เพื่อ insert ลง accounting system งานแบบนี้เหมาะกับ Claude มากเพราะรองรับ PDF ในตัว ไม่ต้อง OCR แยก ผสานกับ Structured Outputs แล้วได้ pipeline ที่ทำงาน end-to-end
import base64
from pydantic import BaseModel, Field
import anthropic
client = anthropic.Anthropic()
class LineItem(BaseModel):
description: str = Field(description="รายละเอียดสินค้าหรือบริการ")
quantity: float
unit_price: float
total: float
class Invoice(BaseModel):
invoice_number: str = Field(description="เลขที่ใบแจ้งหนี้")
issue_date: str = Field(description="วันที่ออกใบแจ้งหนี้ ในรูปแบบ YYYY-MM-DD")
due_date: str | None = None
vendor_name: str = Field(description="ชื่อผู้ขายหรือบริษัทที่ออกเอกสาร")
vendor_tax_id: str | None = Field(
default=None,
description="เลขประจำตัวผู้เสียภาษีของผู้ขาย 13 หลัก"
)
customer_name: str
line_items: list[LineItem]
subtotal: float
vat_amount: float = Field(description="ภาษีมูลค่าเพิ่ม 7%")
total_amount: float
# โหลด PDF เป็น base64
with open("invoice.pdf", "rb") as f:
pdf_data = base64.standard_b64encode(f.read()).decode("utf-8")
response = client.messages.parse(
model="claude-opus-4-8",
max_tokens=4096,
messages=[{
"role": "user",
"content": [
{
"type": "document",
"source": {
"type": "base64",
"media_type": "application/pdf",
"data": pdf_data
}
},
{
"type": "text",
"text": "ดึงข้อมูลจากใบแจ้งหนี้นี้ทั้งหมด "
"วันที่ให้แปลงเป็น YYYY-MM-DD เสมอ "
"ตัวเลขให้ใส่เป็น float ไม่ต้องมี comma คั่น"
}
]
}],
output_format=Invoice,
)
invoice = response.parsed_output
print(f"เลขที่: {invoice.invoice_number}")
print(f"ผู้ขาย: {invoice.vendor_name}")
print(f"ยอดรวม: {invoice.total_amount:,.2f} บาท")
# แต่ละ line item พร้อม insert ลง DB ได้ทันที
for item in invoice.line_items:
print(f" - {item.description}: {item.quantity} x {item.unit_price}")
สิ่งที่ผมชอบใน pattern นี้คือ ทุกอย่างเป็น typed โดย invoice.total_amount เป็น float รับประกันได้ ไม่ต้องเขียน float(data.get("total_amount", 0)) เพื่อกัน case ที่ field หายไปหรือเป็น string ผมเอา invoice object นี้ส่งต่อให้ ORM อย่าง SQLAlchemy ได้ตรงๆ ลด glue code ได้เยอะ
จัดการ refusal, max_tokens และ schema limitations
โค้ดที่ดีต้องเตรียมรับ edge case ทั้งสามนี้ ผมเคยมี pipeline ที่ทำงานสมบูรณ์ใน dev แต่พอเจอ production traffic จริงก็พังเพราะไม่ได้ handle stop_reason ที่ไม่ใช่ end_turn (สอนผมเรื่องหนึ่ง: อย่าเชื่อ happy path)
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=2048,
messages=[...],
output_config={"format": {"type": "json_schema", "schema": schema}}
)
# ตรวจ stop_reason ก่อนเสมอ
match response.stop_reason:
case "end_turn":
# ปกติ, JSON ครบ
text = next(b.text for b in response.content if b.type == "text")
data = json.loads(text)
case "max_tokens":
# output ถูกตัด, JSON ไม่ valid แน่นอน
# ต้อง retry ด้วย max_tokens ที่สูงขึ้น
raise IncompleteResponseError(
"Output ถูกตัดที่ max_tokens, เพิ่ม budget หรือลด schema"
)
case "refusal":
# ตัวโมเดลปฏิเสธ, output อาจว่างเปล่า
category = (
response.stop_details.category
if response.stop_details else None
)
log.warning(f"Claude ปฏิเสธ category={category}")
# ต้องตัดสินใจว่าจะ retry บนโมเดลอื่นหรือไม่
case _:
log.error(f"Unexpected stop_reason: {response.stop_reason}")
สำหรับ Claude Fable 5 ที่ classifier ปฏิเสธ request ได้แม้ prompt ดูปกติ (false positive ในงาน security tooling หรือ life-sciences) ให้เปิดใช้ fallbacks parameter ตั้งแต่แรก ถ้า Fable 5 ปฏิเสธ API จะ retry บน Opus 4.8 ในการเรียกครั้งเดียวกัน billing คิดแบบ credit-style
response = client.beta.messages.create(
model="claude-fable-5",
max_tokens=4096,
betas=["server-side-fallback-2026-06-01"],
fallbacks=[{"model": "claude-opus-4-8"}],
output_config={"format": {"type": "json_schema", "schema": schema}},
messages=[...],
)
ผสานกับ n8n และ workflow orchestration
ในฐานะ workflow architect ผมไม่ค่อยใช้ Claude API แบบ standalone, ส่วนใหญ่จะอยู่ใน n8n หรือ pipeline ที่เรียกผ่าน webhook สำหรับ n8n ปี 2026 มี Anthropic node ตัวใหม่ที่รองรับ output_config โดยตรง ใช้ Set Output Format → JSON Schema แล้ววาง schema ลงไปได้เลย
แต่สำหรับงานที่ต้องการ control เต็มที่ ผมแนะนำให้สร้าง microservice เล็กๆ ด้วย FastAPI ที่รับ webhook จาก n8n แล้วเรียก Claude เพราะ pattern นี้ทำให้ schema versioning, monitoring และ rate limit handling อยู่ในที่เดียว ไม่กระจัดกระจาย
from fastapi import FastAPI
from pydantic import BaseModel
import anthropic
app = FastAPI()
client = anthropic.Anthropic()
class ExtractRequest(BaseModel):
text: str
schema_name: str # ระบุว่าใช้ schema ตัวไหน
class TicketClassification(BaseModel):
category: Literal["billing", "technical", "sales", "other"]
priority: Literal["low", "medium", "high", "urgent"]
sentiment: Literal["positive", "neutral", "negative"]
summary: str
@app.post("/classify-ticket")
async def classify_ticket(req: ExtractRequest):
response = client.messages.parse(
model="claude-haiku-4-5", # เร็วและถูกพอสำหรับ classification
max_tokens=1024,
messages=[{"role": "user", "content": req.text}],
output_format=TicketClassification,
)
return response.parsed_output.model_dump()
ใน n8n ผมก็แค่เรียก HTTP endpoint นี้ ฝั่ง n8n ไม่ต้องรู้เรื่อง schema เลย ไอเดียคือ แยกความซับซ้อนของ AI ออกจาก workflow logic ทำให้ทีมที่ดูแล n8n และทีมที่ดูแล AI ทำงานแยกกันได้
Structured Outputs มี overhead พิเศษคือ schema compilation ครั้งแรก ซึ่งผมวัดได้ประมาณ 800-1500ms เพิ่มจาก request ปกติ หลังจากนั้น schema จะถูก cache 24 ชั่วโมง โดย request ถัดๆ ไปแทบไม่มี overhead เลย
เคล็ดลับเพื่อรักษา cache hit:
- Schema ต้องเหมือนเดิม byte-by-byte ถ้าคุณ generate schema ใหม่จาก Pydantic ทุก request และ field ordering ไม่ deterministic จะ miss cache
- Pydantic v2 generate JSON Schema แบบ deterministic อยู่แล้ว ใช้
model_json_schema() โดยตรงได้
- ถ้า inline schema ลง code ให้ระวัง
json.dumps() ที่ไม่มี sort_keys=True อาจทำให้ key order เปลี่ยนไปคนละ request
ฝั่งราคา Structured Outputs ไม่มีค่าใช้จ่ายเพิ่มต่อ token ใช้ราคาเดียวกับ Messages API ปกติ การเลือกโมเดลให้ถูกงานสำคัญกว่า: ผมใช้ Haiku 4.5 ($1/$5 ต่อ 1M token) สำหรับ classification และ simple extraction, Sonnet 4.6 ($3/$15) สำหรับงานที่ต้องเข้าใจ context ซับซ้อน, Opus 4.8 ($5/$25) เก็บไว้ใช้กับงาน reasoning ที่ Haiku/Sonnet ทำไม่ได้
สำหรับ batch processing ผมแนะนำให้ใช้ Batch API ซึ่งลดราคาลง 50% และรองรับ Structured Outputs เต็มที่ เหมาะกับงานอย่าง backfill ข้อมูลเก่าจากหลักล้านเอกสาร หรือ overnight pipeline ที่ไม่ต้องการ real-time response
เปรียบเทียบกับ OpenAI Structured Outputs
หลายคนถามผมว่า Claude Structured Outputs ต่างจากของ OpenAI ยังไง ในเชิงแนวคิดเหมือนกัน (บังคับ JSON Schema กับ response) แต่มีความแตกต่างที่ practical ดังนี้
- Claude ไม่ต้องใส่
strict: true เพิ่มใน schema, กำหนด additionalProperties: false ก็พอ
- Claude รองรับ schema ที่มี
anyOf, allOf, $ref ได้ครบ ในขณะที่ OpenAI มีข้อจำกัดเรื่อง union type มากกว่า
- Claude มี
messages.parse() ที่ทำ Pydantic integration ในตัว ส่วนของ OpenAI ต้องใช้ response_format + manual parsing
- Claude มี
output_config.effort ที่ควบคุม thinking depth ได้ ทำให้ tune cost/quality ได้ละเอียดกว่า
เอกสารทางการเพิ่มเติมดูได้ที่ Anthropic Structured Outputs documentation และสำหรับ JSON Schema spec ครบถ้วนที่ Claude รองรับ JSON Schema 2020-12 core specification
คำถามที่พบบ่อย
Structured Outputs ใช้กับ Claude 3.5 ได้ไหม?
ใช้ไม่ได้เต็มที่ Claude 3.5 รุ่นเก่ารองรับเฉพาะ tool use แบบ schema constraint บางส่วน Structured Outputs ที่บังคับ response format แบบเข้มทำงานบน Claude Opus 4.5 ขึ้นไป รวมถึง Opus 4.8, Sonnet 4.6, Haiku 4.5 และ Fable 5 ผมแนะนำให้ migrate ขึ้น 4.6+ เพราะมีฟีเจอร์ใหม่หลายอย่างที่คุ้มค่ามาก
ทำไม schema ของผมถูก reject ด้วย error "additionalProperties must be false"?
Claude บังคับให้ทุก object ใน schema ระบุ additionalProperties: false เพื่อกัน hallucination แบบเงียบ ถ้าใช้ Pydantic v2 ให้ใส่ model_config = ConfigDict(extra="forbid") ใน model หรือถ้าเขียน schema เองให้เพิ่ม field นี้ใน object ทุกระดับรวมถึง nested object ด้วย
ใช้ Structured Outputs กับ streaming ได้ไหม?
ใช้ได้ครับ เพิ่ม stream=True หรือใช้ client.messages.stream() ได้เลย แต่ระวังว่าคุณจะ parse JSON ครบสมบูรณ์ก็ต่อเมื่อ stream จบ ไม่สามารถ parse partial JSON ได้ระหว่างทาง ถ้าต้องการแสดง partial result ใน UI ผมแนะนำให้ใช้ library อย่าง partial-json-parser มา parse stream chunk-by-chunk
Structured Outputs ใช้กับ extended thinking ได้ไหม?
ใช้ได้ครับ ตั้งค่า thinking={"type": "adaptive"} คู่กับ output_config.format ได้ตามปกติ บน Claude Opus 4.8 และ Sonnet 4.6 โมเดลจะใช้ adaptive thinking ก่อน reasoning แล้วค่อย output JSON ที่ผ่าน schema เหมาะสำหรับงานที่ต้องเข้าใจเอกสารยาวก่อน extract เช่นสัญญาทางกฎหมาย
ค่าใช้จ่ายของ schema compilation ครั้งแรกคุ้มไหม?
คุ้มถ้าคุณเรียก schema เดียวกันมากกว่า 2-3 ครั้งใน 24 ชั่วโมง เพราะหลังจาก compile แล้ว cache 24 ชั่วโมงทำให้ไม่มี overhead ต่อ request ถ้า workflow ของคุณเรียก schema ที่ไม่ซ้ำกันเลย (เช่น schema generated dynamic ต่อ user) อาจไม่คุ้มเท่าใช้ tool use แบบ strict: true แทน
Structured Outputs รองรับภาษาไทยใน enum value ไหม?
รองรับครับ enum value เป็น Unicode string ใส่ภาษาไทยได้ปกติ ผมใช้ใน production สำหรับ category classification เช่น enum: ["บัญชี", "ขาย", "เทคนิค"] แต่ลองทดสอบให้ดีก่อน deploy เพราะบาง edge case ที่มี zero-width character หรือ combining mark อาจทำให้ output ไม่ตรง enum เป๊ะๆ