AI Engineer › Prompt และ structured output

ดึงและตรวจ JSON จากผลลัพธ์ของโมเดล

ประมาณ 35 นาที

เป้าหมายของบท

  • parse JSON จากผลลัพธ์ของโมเดลแบบ strict และดึงจากข้อความที่ปนกันด้วย regex
  • ตรวจ schema (ฟิลด์ที่ต้องมี enum ชนิด ช่วงค่า) และ coerce เฉพาะกรณีที่ปลอดภัย
  • มองผลลัพธ์ของโมเดลเป็นข้อมูลที่ต้องตรวจก่อนใช้

โปรแกรมต้องการข้อมูลเชิงโครงสร้าง แต่โมเดลตอบเป็นข้อความ

เมื่อต้องส่งผลของ LLM ไปเก็บลงฐานข้อมูลหรือให้โค้ดอื่นใช้ เราขอให้ตอบเป็น JSON — และต้องเตรียมรับความจริงว่า บางครั้งมันไม่ใช่ JSON ที่ถูกต้อง เช่น มีคำเกริ่นนำ ห่อด้วย ```json, ใช้เครื่องหมาย ’ แทน “, ขาดฟิลด์ หรือค่าผิดชนิด

ตัวอย่างในบทนี้ใช้ผลลัพธ์ที่ จำลอง ไว้ 8 แบบ (list RAW) เพื่อให้ทดสอบซ้ำได้ ไม่ได้เรียกโมเดลจริง — ของจริงลดปัญหาได้ด้วยการใช้ความสามารถ structured output / tool use ที่ให้ระบุ JSON schema (ตามเอกสารของผู้ให้บริการ) แต่ก็ยัง ต้องตรวจฝั่งเราเสมอ

ขั้น 1: parse แบบเข้มงวดก่อน

import json
try:
    obj = json.loads(text)
except json.JSONDecodeError:
    obj = None

จาก 8 ตัวอย่าง parse ตรงๆ ได้ 5 ตัว (ตัวที่พังคือ: มีข้อความนำหน้า, ใช้ single quote, ไม่มี JSON เลย)

ขั้น 2: ดึง JSON ออกจากข้อความที่ปนกัน

import re
match = re.search(r"\{.*\}", text, re.S)     # re.S ให้ . ข้ามบรรทัดใหม่ได้
obj = json.loads(match.group(0)) if match else None

วิธีแบบ lenient (ลอง strict ก่อน ไม่ได้ค่อยดึงด้วย regex) ทำให้ได้ 6 จาก 8 — ตัวที่ใช้ single quote ยังพัง (เพราะไม่ใช่ JSON) และตัวที่ตอบเป็นประโยคไม่มี JSON ให้ดึง regex แบบ {.*} เป็นแบบง่าย: ใช้ได้เมื่อมีวัตถุ JSON ก้อนเดียว ถ้ามีหลายก้อนต้องเขียนให้รัดกุมกว่านี้

ขั้น 3: ตรวจ schema (parse ได้ ≠ ใช้ได้)

ตรวจทุกครั้งว่า: มีฟิลด์ที่ต้องมีครบ, ค่าอยู่ในชุดที่อนุญาต (enum), ชนิดถูก, ช่วงค่าถูก

ผลลัพธ์ ปัญหา
"confidence": "0.7" ชนิดผิด (string) — แก้ด้วยการ coerce เป็น float ได้
"category": "billing" ไม่อยู่ในป้ายกำกับที่อนุญาต
ไม่มี confidence ขาดฟิลด์
"confidence": 1.4 นอกช่วง 0–1

การ coerce (แปลงชนิดให้) ควรทำเฉพาะกรณีที่ชัดเจนและปลอดภัย (สตริงตัวเลข → float) ส่วนค่าที่ผิดความหมาย (หมวดไม่มีจริง, ค่านอกช่วง) ต้องปฏิเสธหรือขอให้โมเดลแก้ (บทถัดไป) ผลของ 8 ตัวอย่าง: ผ่านตรวจแบบไม่ coerce 2 ตัว, แบบ coerce 3 ตัว

หลักการ: ถือว่าผลลัพธ์ของโมเดลเป็นข้อมูลที่ไม่น่าไว้ใจ เหมือนอินพุตจากผู้ใช้ — ตรวจก่อนใช้เสมอ (ไลบรารีอย่าง pydantic / jsonschema ทำหน้าที่นี้ในงานจริง; ที่นี่เขียนเองเพื่อเข้าใจหลักการ)

ตัวอย่างแก้โจทย์

กด "ขั้นต่อไป" ทีละขั้น ลองรันและแก้โค้ดในแต่ละขั้นได้

parse แบบ strict vs lenient

ทดลองกับผลลัพธ์จำลอง 8 แบบ

  1. parse ตรงๆ ด้วย json.loads — ได้กี่ตัว (ที่เหลือ JSONDecodeError): 5

    Python

ตรวจ schema

parse ได้แล้วยังต้องตรวจความถูกต้อง

  1. ผลตรวจแบบไม่ coerce ของแต่ละตัวอย่าง (None = ผ่าน)

    Python

เติมช่องว่าง

แทน ___ ด้วยโค้ดที่ถูกต้อง แล้วกดตรวจคำตอบ

ยังไม่ได้ทำ

parse สตริง JSON แล้วอ่านค่า category เก็บไว้ในตัวแปร category เติม ___ ให้ครบ

แบบฝึกท้ายบท

บทนี้จะนับว่าเสร็จเมื่อผ่านข้อพื้นฐานและประยุกต์ครบ ข้อท้าทายได้ดาวเพิ่ม

🟢 พื้นฐาน

🟢 พื้นฐานยังไม่ได้ทำ

จาก RAW: parse ด้วย json.loads ตรงๆ สำเร็จกี่ตัว (n_strict) และล้มเหลวกี่ตัว (n_fail)

🟢 พื้นฐานยังไม่ได้ทำ

ตัวอย่างแรกของ RAW (index 0) parse แล้วได้ dict — เก็บ category ไว้ใน first_category และ confidence ไว้ใน first_conf

🟢 พื้นฐานยังไม่ได้ทำ

ดึงข้อความ JSON ออกจาก RAW[1] (ที่มีคำเกริ่นนำและ ```json) ด้วย re.search(r"{.*}", text, re.S) เก็บข้อความที่จับได้ไว้ในตัวแปร extracted

🟢 พื้นฐานยังไม่ได้ทำ

แปลง dict {"category": "shipping", "confidence": 0.92} กลับเป็นสตริง JSON ด้วย json.dumps (ค่าเริ่มต้น) เก็บไว้ในตัวแปร dumped

🟡 ประยุกต์

🟡 ประยุกต์ยังไม่ได้ทำ

ใช้ parse_lenient (ให้ไว้แล้ว) กับทุกตัวใน RAW — มีกี่ตัวที่ได้ผล (ไม่ใช่ None) เก็บไว้ในตัวแปร n_lenient และ index (นับจาก 0) ของตัวที่ล้มเหลวทุกตัวเก็บเป็น list ไว้ใน failed_idx

🟡 ประยุกต์ยังไม่ได้ทำ

ใช้ first_error (ให้ไว้แล้ว ไม่ coerce) กับผลของ parse_lenient — กี่ตัวที่ผ่าน (n_valid_strict) เมื่อ error เป็น None ถือว่าผ่าน

🟡 ประยุกต์ยังไม่ได้ทำ

เหมือนข้อ e6 แต่เปิด coerce=True — ผ่านกี่ตัว (n_valid_coerced) และ index ของตัวที่ผ่านเก็บเป็น list ไว้ใน valid_idx

🔴 ท้าทาย

🔴 ท้าทายยังไม่ได้ทำ

นับชนิดของ error ของทั้ง 8 ตัว (first_error แบบไม่ coerce) — เก็บเฉพาะที่ไม่ใช่ None เป็น dict ชนิด → จำนวน (Counter แปลงเป็น dict) ไว้ในตัวแปร error_counts

🔴 ท้าทายยังไม่ได้ทำ

เก็บเฉพาะผลที่ผ่านการตรวจ (coerce=True) เข้า DataFrame ที่มีคอลัมน์ category และ confidence (แปลง confidence เป็น float) — เก็บจำนวนแถวไว้ใน n_rows และค่าเฉลี่ย confidence (ปัด 4 ตำแหน่ง) ไว้ใน mean_conf

Quiz

1. ทำไม parse JSON ได้แล้วยังต้องตรวจ schema
2. ใน RAW ตัวอย่างที่ใช้ single quote {'category': ...} ทำไม json.loads ล้มเหลว
3. การ coerce สตริง "0.7" เป็น float 0.7 ควรทำเมื่อใด
4. ตัวอย่างบทนี้ผ่านตรวจ 2 ตัวแบบไม่ coerce และ 3 ตัวแบบ coerce ข้อสรุปที่ถูกคือ

สรุป

  • ผลลัพธ์ของโมเดลอาจไม่ใช่ JSON ที่ถูกต้อง: strict parse ได้ 5 จาก 8 ตัวอย่างจำลอง, lenient (ดึงด้วย regex) ได้ 6
  • parse ได้ ≠ ใช้ได้: ตรวจฟิลด์ครบ enum ชนิด และช่วงค่า (ผ่าน 2 ตัวแบบไม่ coerce, 3 ตัวแบบ coerce)
  • coerce เฉพาะกรณีชัดเจน (สตริงตัวเลข) ส่วนค่าผิดความหมายให้ปฏิเสธหรือขอให้โมเดลแก้; ตัวอย่างในบทนี้เป็นผลลัพธ์จำลอง ไม่ได้เรียกโมเดลจริง

คำถามสัมภาษณ์

คุณจะทำให้ระบบที่ใช้ผลลัพธ์ JSON จาก LLM ทนต่อข้อมูลผิดรูปแบบได้อย่างไร

ใช้ structured output/tool schema ของผู้ให้บริการเพื่อลดปัญหา แต่ตรวจฝั่งเราเสมอ: parse แบบ strict ก่อน ถ้าไม่ได้ลองดึง JSON จากข้อความ ตรวจฟิลด์/ชนิด/enum/ช่วงค่าด้วย schema validator (เช่น pydantic, jsonschema) coerce เฉพาะกรณีปลอดภัย และถ้าไม่ผ่านให้ retry พร้อมส่ง error กลับให้โมเดลแก้ หรือ fallback/ส่งให้คนตรวจ พร้อมบันทึก log อัตราความล้มเหลว

ทำไมผลลัพธ์ของ LLM ควรถูกมองเป็นข้อมูลที่ไม่น่าไว้ใจ

เพราะโมเดลสร้างข้อความตามความน่าจะเป็น ไม่ได้รับประกันรูปแบบหรือความถูกต้อง อาจมีฟิลด์ขาด ชนิดผิด ค่านอกช่วง หรือถูกชักนำด้วย prompt injection การใช้โดยไม่ตรวจอาจทำให้ข้อมูลเสียหรือเกิดช่องโหว่ จึงควรปฏิบัติเหมือนอินพุตจากภายนอกและตรวจก่อนใช้