บทความ › AI

ให้ LLM ตอบเป็น JSON ที่ตรวจได้: structured outputs, Pydantic และ retry

บังคับให้โมเดลตอบตาม JSON schema ด้วย structured outputs ของ Claude และ OpenAI ตรวจซ้ำด้วย Pydantic หรือ Zod ส่ง error กลับให้แก้ และกับดักที่ schema ไม่ได้กันให้

กลาง อ่าน 12 นาที 22 ตุลาคม 2569

อ่านจบแล้วจะรู้
  • ใช้ 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 modeOpenAI {"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 บน toolstrict: true บน function
helper ใน Python SDKclient.messages.parse(output_format=Model) → parsed_outputclient.responses.parse(text_format=Model) → output_parsed
ทุก objectadditionalProperties: falseadditionalProperties: 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 ซ้ำฝั่งเราเสมอ

Pydantic modelนิยามครั้งเดียวJSON schemaส่งไปกับ requestAPI บังคับรูปร่างstructured outputsPydantic ตรวจเงื่อนไขช่วงค่า รูปแบบไม่ผ่าน: ส่ง error กลับให้โมเดลแก้ จำกัดรอบครบรอบ: ส่งให้คนไม่เดา
schema คุมรูปร่าง Pydantic คุมเงื่อนไข ที่เหลือส่งให้คน

ตัวอย่าง 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_tokens JSON จะถูกตัดกลางทาง parse ไม่ผ่าน เข้า retry ได้ แต่ถ้าเจอบ่อยให้เพิ่ม max_tokens แทน
  • retry ส่ง error กลับ ข้อความจาก Pydantic บอกตรง ๆ ว่า field ไหนผิดเพราะอะไร ตอนทดสอบด้วย client จำลอง รอบแรกโมเดล “ตอบ” "order_id": "48213" ได้ error String 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)

  1. ถ้าแต่ละรอบพลาดอิสระกัน โอกาสพลาดครบ 3 รอบ = 0.02³ = 0.000008 = 8 ใน 1 ล้านข้อความ
  2. จำนวนเรียกเฉลี่ยต่อข้อความ = 1 + 0.02 + 0.02² = 1.0204 คือ ค่าใช้จ่ายเพิ่มราว 2% (และรอบ retry ยาวกว่าเพราะมีประวัติแชตแนบไป)
  3. แต่ในความจริงรอบต่าง ๆ ไม่อิสระกัน 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 ไม่ครบ

คำสั่งและค่าตั้งในบทความทดสอบบนระบบตัวอย่าง ลองบนเครื่องทดสอบก่อนใช้กับระบบจริงเสมอ

อ่านต่อ

#AIขั้นสูง

CaMeL: กัน prompt injection ด้วยการออกแบบระบบ ไม่ใช่ด้วย prompt ที่ดีกว่า

งานวิจัย CaMeL จาก Google DeepMind แยก control flow ออกจากข้อมูลที่ไม่น่าเชื่อถือ ติดป้าย capability ให้ทุกค่า แล้วตรวจ policy ตอนเรียก tool ผลบน AgentDojo และข้อจำกัด

อ่าน 8 นาที
#AIกลาง

Context rot: ยิ่งใส่ข้อมูลให้ AI ยาว ยิ่งตอบพลาด แล้วควรใส่อะไรใน context

งานวิจัยของ Chroma ทดสอบ 18 โมเดล พบว่าคุณภาพคำตอบลดลงเมื่อ input ยาวขึ้น และข้อมูลที่คล้ายแต่ผิดทำให้แย่ลงอีก พร้อมกฎใช้งานจริงว่าควรใส่อะไรใน context

อ่าน 9 นาที
#AIกลาง

ลดค่า AI API ถูกลง 7 เท่า: prompt caching, เลือกโมเดล, Batch และบิลตัวอย่าง

เงินค่า LLM API หายไปตรงไหน และคันโยกเรียงตามผลตอบแทน: prompt caching, โมเดลเล็กสำหรับงานง่าย, Batch API, ตัด output, cache คำตอบ พร้อมบิลรายเดือนทีละขั้น

อ่าน 8 นาที