ให้ LLM ตอบเป็น JSON ที่ตรวจได้: structured outputs, Pydantic และ retry
บังคับให้โมเดลตอบตาม JSON schema ด้วย structured outputs ของ Claude และ OpenAI ตรวจซ้ำด้วย Pydantic หรือ Zod ส่ง error กลับให้แก้ และกับดักที่ schema ไม่ได้กันให้
- ใช้ structured outputs (constrained decoding) แทนการขอใน prompt ว่า "ตอบเป็น JSON" โมเดลจะสร้าง token ที่ผิด schema ไม่ได้
- schema ที่ API รับมีข้อจำกัด เช่น Claude ไม่รับ minLength หรือ maximum จึงต้องตรวจซ้ำด้วย Pydantic หรือ Zod ฝั่งเราเสมอ
- refusal, ชน max_tokens และตัวพิมพ์ของ enum ทำให้ได้ output ที่ไม่ตรง schema ได้ ต้องเช็ก stop_reason ก่อน parse
- retry โดยส่งข้อความ error กลับไปให้โมเดลแก้ จำกัดจำนวนรอบ แล้วส่งที่เหลือให้คน อย่าเดาค่าเอง
ฟีเจอร์ AI ส่วนใหญ่ไม่ได้ต้องการเรียงความ แต่ต้องการ ข้อมูล เช่น หมวดของ ticket เลขออเดอร์ ระดับความด่วน เพื่อส่งต่อให้โค้ดทำงานต่อ ถ้าให้โมเดลตอบเป็นข้อความแล้วไปตัดด้วย regex วันหนึ่งมันจะพังแบบเงียบ ๆ ปี 2026 ผู้ให้บริการหลักมีโหมดที่บังคับให้ output ตรง JSON schema แล้ว บทนี้คือวิธีใช้ให้ถูก และส่วนที่ schema ไม่ได้กันให้
ทดสอบบน Python 3.12, anthropic 2.1.0, pydantic 2.14.1 (ตรรกะ validate และ retry ทดสอบด้วย client จำลอง ชื่อ parameter ของ API ตรวจกับเอกสาร Claude และ OpenAI ณ ตุลาคม 2026)
สามระดับของ “ตอบเป็น JSON”
| ระดับ | วิธี | รับประกันอะไร |
|---|---|---|
| 1. ขอใน prompt | “ตอบเป็น JSON ตามนี้…” | ไม่มี อาจมีข้อความนำหน้า มี code fence ครอบ หรือ field หาย |
| 2. JSON mode | OpenAI {"type": "json_object"} | parse ได้ แต่ไม่ตรง schema |
| 3. Structured outputs | ส่ง JSON schema ให้ API | ตรง schema ยกเว้นกรณีพิเศษด้านล่าง |
ระดับ 3 ทำงานด้วย constrained decoding ตอนโมเดลสร้างทีละ token ระบบจะตัด token ที่ทำให้ผิด schema ทิ้ง โมเดลจึงสร้าง "priority": "urgent" ไม่ได้ถ้า enum มีแค่ low กับ high ข้อแลกคือครั้งแรกที่ใช้ schema ใหม่ ระบบต้อง compile grammar ก่อน ช้ากว่าปกติ ฝั่ง Claude เก็บ grammar ไว้ 24 ชั่วโมงนับจากใช้ครั้งล่าสุด
ชื่อ parameter ณ ตุลาคม 2026
ชื่อพวกนี้เปลี่ยนมาแล้วหลายรอบ ตรวจเอกสารก่อนใช้เสมอ
| Claude (Anthropic) | OpenAI | |
|---|---|---|
| คำตอบเป็นข้อมูล | output_config.format = {"type": "json_schema", "schema": ...} | Responses API: text.format = {"type": "json_schema", "name", "schema", "strict": true} · Chat Completions: response_format |
| tool ที่ input ตรง schema | "strict": true บน tool | strict: true บน function |
| helper ใน Python SDK | client.messages.parse(output_format=Model) → parsed_output | client.responses.parse(text_format=Model) → output_parsed |
| ทุก object | additionalProperties: false | additionalProperties: false และทุก field อยู่ใน required |
| field ที่ไม่บังคับ | ไม่ใส่ใน required (ไม่เกิน 24 ตัวต่อ request) | ใส่ใน required แล้วให้ type รับ null |
ฝั่ง Claude, parameter เดิม output_format ระดับบนสุดถูกย้ายไปเป็น output_config.format แล้ว ใช้แบบเดิมต้องใส่ beta header structured-outputs-2025-11-13 ไม่งั้นได้ 400 ฝั่ง JavaScript ของ OpenAI มี zodTextFormat แปลง schema ของ Zod ให้
schema ที่ API ไม่รับ
นี่คือจุดที่หลายคนพลาด JSON Schema เต็มรูปแบบกับ subset ที่ API รับไม่เท่ากัน
- Claude ไม่รับ
minimum/maximum,minLength/maxLength,minItemsเกิน 1, schema แบบ recursive และadditionalPropertiesที่ไม่ใช่falseใช้แล้วได้ 400 นอกจากนี้จำกัด strict tool 20 ตัว และ field แบบ union (anyOf) 16 ตัวต่อ request - OpenAI root ต้องเป็น object และใช้
anyOfที่ root ไม่ได้ ไม่รับallOf,not,if/then/elseซ้อนได้ 10 ชั้น property รวม 5,000 ตัว enum รวม 1,000 ค่า
SDK ของ Claude ช่วยแปลง Pydantic model ให้ ลองดูผลของ anthropic.transform_schema() กับ field summary: str = Field(max_length=200) จะได้
"summary": {"type": "string", "title": "Summary", "description": "{maxLength: 200}"}
เงื่อนไขความยาวถูก ย้ายไปเป็นข้อความใน description โมเดลเห็นเป็นคำแนะนำ แต่ไม่ถูกบังคับ เช่นเดียวกับ pattern ในตัวอย่างด้านล่าง นี่คือเหตุผลที่ต้อง validate ซ้ำฝั่งเราเสมอ
ตัวอย่าง Python: schema เดียว ใช้ทั้งส่งและตรวจ
คัดแยก ticket ฝ่ายบริการลูกค้า Pydantic model เป็นแหล่งความจริงเดียว ใช้ทั้งสร้าง schema ส่งให้ API และตรวจคำตอบ
pip install anthropic==2.1.0 pydantic==2.14.1
export ANTHROPIC_API_KEY=sk-ant-... # key ของคุณ
from typing import Literal
import anthropic
from pydantic import BaseModel, Field, ValidationError, field_validator
class Ticket(BaseModel):
category: Literal["billing", "delivery", "refund", "other"]
priority: Literal["low", "high"]
order_id: str | None = Field(pattern=r"^ORD-\d{5}$")
summary: str = Field(max_length=200)
@field_validator("category", "priority", mode="before")
@classmethod
def lower(cls, v):
return v.lower() if isinstance(v, str) else v
SCHEMA = anthropic.transform_schema(Ticket) # Pydantic -> JSON schema the API accepts
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY
def triage(text: str, tries: int = 3) -> Ticket | None:
messages = [{"role": "user", "content": f"Triage this support message:\n\n{text}"}]
for _ in range(tries):
r = client.messages.create(
model="claude-haiku-5-5",
max_tokens=1024,
messages=messages,
output_config={"format": {"type": "json_schema", "schema": SCHEMA}},
)
if r.stop_reason == "refusal":
return None # not a format problem; retrying won't help
raw = next((b.text for b in r.content if b.type == "text"), "")
try:
return Ticket.model_validate_json(raw)
except ValidationError as e:
messages += [
{"role": "assistant", "content": raw},
{"role": "user", "content": f"Validation failed:\n{e}\nReturn corrected JSON."},
]
return None # send to a human queue, never guess
if __name__ == "__main__":
print(triage("Order ORD-48213 arrived broken, I want my money back today."))
จุดที่ตั้งใจใส่
field_validatorแปลงตัวพิมพ์เล็ก เอกสาร Claude เตือนว่าตัวพิมพ์ใหญ่เล็กของค่า enum ไม่รับประกัน อาจได้"Refund"ทั้งที่ enum คือ"refund"และ response จบปกติไม่มีสัญญาณอะไร- refusal ไม่ retry ได้ HTTP 200 และ
stop_reason: "refusal"output อาจไม่ตรง schema ถามซ้ำก็ได้ผลเดิม - ชน
max_tokensJSON จะถูกตัดกลางทาง parse ไม่ผ่าน เข้า retry ได้ แต่ถ้าเจอบ่อยให้เพิ่มmax_tokensแทน - retry ส่ง error กลับ ข้อความจาก Pydantic บอกตรง ๆ ว่า field ไหนผิดเพราะอะไร ตอนทดสอบด้วย client จำลอง รอบแรกโมเดล “ตอบ”
"order_id": "48213"ได้ errorString should match pattern '^ORD-\d{5}$'รอบสองแก้เป็นORD-48213ผ่าน - ครบรอบแล้วยังไม่ผ่าน ส่งให้คน อย่าเติมค่า default เอง ticket ที่จัดหมวดผิดแบบเงียบ ๆ แย่กว่า ticket ที่รอคน
ถ้าไม่ต้องการคุม retry เอง client.messages.parse(output_format=Ticket) ทำงานแปลง schema, ส่ง และ validate ให้ในขั้นเดียว ฝั่ง TypeScript ใช้ Zod แบบเดียวกัน: Ticket.safeParse(JSON.parse(raw)) แล้วดู result.success
enum เล็ก ๆ ดีกว่า
schema บังคับได้แค่ว่าค่าอยู่ใน enum ไม่ได้บังคับว่าเลือก ถูก ยิ่งตัวเลือกเยอะและคล้ายกัน ยิ่งเลือกผิดง่าย ทั้งโมเดลและคน
- เริ่มที่ 3-7 ค่าที่แยกกันชัด มี
otherเสมอ แล้วดูใน log ว่าotherเต็มไปด้วยอะไร ค่อยแตกหมวดใหม่ - ถ้าจำเป็นต้องมีหลายสิบหมวด แบ่งเป็นสองชั้น (หมวดใหญ่ แล้วหมวดย่อยในเรียกที่สอง)
- ชื่อค่าให้อ่านแล้วเข้าใจ (
refundไม่ใช่cat_07) โมเดลใช้ชื่อเป็นข้อมูลในการตัดสิน
ตัวอย่างคำนวณ: retry 3 รอบช่วยได้แค่ไหน
สมมติวัดจากชุด eval แล้วพบว่า 2% ของคำตอบตก validation (ส่วนใหญ่เป็นเงื่อนไขที่ API ไม่บังคับ เช่น pattern)
- ถ้าแต่ละรอบพลาดอิสระกัน โอกาสพลาดครบ 3 รอบ = 0.02³ = 0.000008 = 8 ใน 1 ล้านข้อความ
- จำนวนเรียกเฉลี่ยต่อข้อความ = 1 + 0.02 + 0.02² = 1.0204 คือ ค่าใช้จ่ายเพิ่มราว 2% (และรอบ retry ยาวกว่าเพราะมีประวัติแชตแนบไป)
- แต่ในความจริงรอบต่าง ๆ ไม่อิสระกัน input ที่ทำให้พลาดครั้งแรกมักทำให้พลาดซ้ำ ตัวเลขจริงจะแย่กว่าข้อ 1 นี่คือเหตุผลที่ต้องมีทางออกไปหาคน และต้องนับว่ากี่ข้อความไปจบที่คิวคน
ถ้าเกิน 1-2% ของข้อความต้อง retry อย่าเพิ่มจำนวนรอบ ให้กลับไปแก้ schema หรือ prompt แล้ววัดด้วย ชุด eval
ข้อผิดพลาดที่เจอบ่อย
- เชื่อว่าตรง schema = ถูก schema กันรูปร่าง ไม่กันค่ามั่ว เลขออเดอร์ที่ไม่มีอยู่จริงก็ตรง pattern ได้ ตรวจกับฐานข้อมูลก่อนใช้
- schema ซับซ้อนเกิน field ที่ไม่บังคับเยอะ union เยอะ ซ้อนลึก ฝั่ง Claude อาจได้ 400 “Schema is too complex for compilation” แยกเป็นหลายเรียกจะดีกว่า
- ให้โมเดลเขียนเหตุผลยาวใน field เอกสาร Claude เตือนว่าขอ reasoning ทีละขั้นใน property อาจทำให้เกิด refusal ประเภท
reasoning_extractionใช้ field คำอธิบายสั้น ๆ แทน - เปลี่ยน schema บ่อย ทุกครั้งต้อง compile grammar ใหม่ และฝั่ง Claude การเปลี่ยน
output_config.formatทำให้ prompt cache ของบทสนทนานั้นใช้ไม่ได้ - ใส่ข้อมูลอ่อนไหวใน schema schema ถูก cache ไว้ได้ถึง 24 ชั่วโมงแยกจากข้อความ ชื่อ field และค่า enum ห้ามมีข้อมูลส่วนตัว
- ไม่คิดเรื่อง input จากภายนอก output ที่ถูก validate จำกัดความเสียหายได้ แต่ไม่ได้ทำให้ input ปลอดภัย (ป้องกัน prompt injection)
schema บังคับรูปร่าง Pydantic ตรวจเงื่อนไข ฐานข้อมูลยืนยันความจริง ต้องมีครบสามชั้น
แหล่งอ้างอิง
คำถามที่พบบ่อย
JSON mode กับ structured outputs ต่างกันยังไง
JSON mode รับประกันแค่ว่าเป็น JSON ที่ parse ได้ แต่ field อาจขาดหรือชนิดผิด structured outputs บังคับให้ตรง schema ที่ส่งไป เอกสาร OpenAI เรียก structured outputs ว่าเป็นวิวัฒนาการของ JSON mode และแนะนำให้ใช้เมื่อโมเดลรองรับ
ใช้ tool use แทน structured outputs ได้ไหม
ได้ ถ้าต้องการให้โมเดลเรียกฟังก์ชัน ให้ตั้ง strict true บน tool แล้ว input ของ tool จะตรง schema แต่ถ้าแค่อยากได้คำตอบเป็นข้อมูล ใช้ output format ตรง ๆ อ่านง่ายกว่า ทั้งสองแบบใช้ใน request เดียวกันได้
มี structured outputs แล้วยังต้อง validate อีกทำไม
เพราะ schema รับประกันแค่รูปร่าง ไม่รับประกันว่าค่าถูก และบางเงื่อนไข เช่น ความยาวสูงสุดหรือช่วงตัวเลข API ไม่บังคับให้ SDK แค่ย้ายไปเขียนใน description นอกจากนี้ refusal กับ max_tokens ก็ทำให้ได้ output ไม่ครบ
คำสั่งและค่าตั้งในบทความทดสอบบนระบบตัวอย่าง ลองบนเครื่องทดสอบก่อนใช้กับระบบจริงเสมอ