productize.life
TH EN
AI Engineering · ความถูกต้อง

อย่าขอร้อง ให้โมเดล ตอบ JSON บังคับด้วย schema แล้วจบ

เราเสียไปสามรอบเต็มๆ กับโมเดลที่รับปากใน prompt แล้วไม่ทำตาม กว่าจะยอมรับความจริงว่า JSON ที่เชื่อถือได้ไม่ได้มาจากการเขียนคำสั่งให้เข้มขึ้น แต่มาจากการบังคับที่ชั้น decoding (ขั้นที่โมเดลเลือก token) บทความนี้เล่าวิธีเปิด structured outputs ใน vLLM พร้อมสองบทเรียนที่เอกสารไม่ได้บอก

Yim· เขียนด้วยกันกับ Dobby (AI Oracle)/10 กรกฎาคม 2569

ช่วงที่ 1สามรอบ ที่โมเดลรับปาก แล้วเบี้ยว

ระบบแกะบิลของเราต้องการผลลัพธ์เป็น JSON โครงสร้างเดียวกันทุกใบ ชื่อผู้ขาย ยอดเงิน วันที่ รายการย่อย เพื่อส่งต่อให้ชั้นตรวจเลขที่เป็นโค้ดล้วนๆ ทำงานต่อ

prompt ของเราเขียนชัดขนาดนี้ Return ONLY a JSON object with these keys แล้วก็ยังลิสต์ key ให้ครบทุกตัว

ผลจากการรันจริงสามรอบ บิลใบเดิม โมเดลถอดข้อความทั้งใบออกมาเล่าเรื่อยเปื่อย 31 วินาทีบ้าง 56 วินาทีบ้าง จนชนเพดาน token แล้วก็ไม่มีวงเล็บปีกกาสักตัว ระบบเราได้แต่บันทึกผลว่า no-json (หา JSON ในคำตอบไม่เจอ)

จุดที่อยากให้จำคือ นี่ไม่ใช่โมเดลแย่ ตัวที่เราใช้ตอนนั้นถูกจูนมาเพื่องานถอดข้อความ (transcription) เพราะงั้นพอเจอภาพบิล สัญชาตญาณของโมเดลคือถอดทุกอย่างที่เห็น คำสั่งใน prompt สู้การจูนไม่ได้ แล้วต่อให้เปลี่ยนเป็นโมเดลตระกูล instruct (จูนมาให้ทำตามคำสั่ง) ที่เชื่อฟังกว่า ก็ยังเป็นการ "ขอ" อยู่ดี วันดีคืนดีเจอเอกสารหน้าตาแปลกก็มีสิทธิ์เบี้ยวอีก

ช่วงที่ 2ย้ายการบังคับ จาก prompt ไปที่ชั้น decoding

ทางออกที่เราใช้แล้วจบจริงคือ structured outputs ของ vLLM หลักการคือแทนที่จะหวังให้โมเดลเลือก token ถูก เราตัดตัวเลือกที่ผิด schema ทิ้งตั้งแต่ก่อนโมเดลจะเลือก ทุก token ที่ออกมาจึงเดินอยู่ในกรอบ JSON ที่เรากำหนดเท่านั้น เหมือนเปลี่ยนจากป้ายขอความร่วมมือเป็นรางที่รถออกนอกเส้นไม่ได้

โค้ดฝั่งเรา สั้นกว่าที่คิด

from vllm import LLM, SamplingParams
from vllm.sampling_params import StructuredOutputsParams

EXTRACT_SCHEMA = {
    "type": "object",
    "properties": {
        "vendor_name": {"type": "string"},
        "date": {"type": "string"},
        "total": {"type": "string"},
        "line_items": {"type": "array", "items": {
            "type": "object",
            "properties": {"desc": {"type": "string"}, "amount": {"type": "string"}},
            "required": ["desc", "amount"]}},
    },
    "required": ["vendor_name", "date", "total", "line_items"],
}

sampling = SamplingParams(
    temperature=0.0, max_tokens=2500,
    structured_outputs=StructuredOutputsParams(json=EXTRACT_SCHEMA),
)

เท่านี้คำตอบที่ได้ parse ผ่าน json.loads ทุกครั้ง ไม่ต้องมี regex งมหาปีกกาอีกต่อไป

ข้อสังเกตหนึ่งที่ควรรู้ก่อนใช้ schema ควรกำหนดเฉพาะโครง อย่าใส่ constraint จุกจิกเกินจำเป็น ยิ่ง grammar (โครงกฎที่คอมไพล์จาก schema ไว้คุม token) ซับซ้อน การ compile รอบแรกยิ่งช้า และช่องที่ข้อมูลอาจไม่มี ให้เป็น string ว่างได้ ดีกว่าบังคับ required ทุกช่องจนโมเดลต้องเดาค่ามาเติม

ช่วงที่ 3บทเรียนที่หนึ่ง: fallback เงียบ คือระเบิดเวลา

ตอนแรกเราเขียนโค้ดแบบระวังตัว เผื่อ import ไม่ได้ก็ถอยไปใช้วิธีเดิม

try:
    from vllm.sampling_params import GuidedDecodingParams  # ชื่อเก่า
    ...
except Exception:
    sampling = SamplingParams(temperature=0.0, max_tokens=2500)  # ถอยเงียบๆ

ดูเผินๆ ก็รอบคอบดี แต่ vLLM 0.24 เปลี่ยนชื่อคลาสนี้เป็น StructuredOutputsParams ไปแล้ว ผลคือ import พังทุกครั้ง ระบบถอยไปโหมดไม่บังคับทุกครั้ง โดยไม่มี error ให้เห็นสักบรรทัด บิลก็ยัง fail แบบเดิมเป๊ะ เราไล่หาอยู่พักใหญ่กว่าจะเจอบรรทัด fallback ใน log

กฎที่เราตั้งหลังจากนั้น ถ้า fallback ตัวไหนกระทบความถูกต้องของงาน ต้องพิมพ์สถานะออก log แล้วมีคน (หรือสคริปต์) ยืนยันว่าโหมดหลักทำงานอยู่จริงหลัง deploy ทุกครั้ง แค่ "ไม่ error" ไม่ใช่หลักฐานว่าทำงาน

ช่วงที่ 4บทเรียนที่สอง: กันเหนียวเรื่อง truncation

structured outputs การันตีว่า token เดินตาม schema แต่ยังมีเคสเดียวที่หลุดได้ คือ generation ชนเพดาน max_tokens กลางคัน JSON เลยขาดท้าย เราเลยมี parser สำรองที่พยายามปิดวงเล็บให้ก่อนยอมแพ้

start = text.find("{")
if start >= 0:
    frag = text[start:].rstrip().rstrip(",")
    frag = re.sub(r',\s*"[^"]*$', "", frag)   # ตัด key ที่เขียนค้าง
    for pad in ("", "}", "}}", '"}', '"}]}', "]}"):
        try:
            return json.loads(frag + pad)
        except Exception:
            continue

ชั้นนี้แทบไม่เคยถูกใช้ แต่วันที่เอกสารยาวผิดปกติโผล่มา ระบบจะได้ข้อมูลบางส่วนกลับไปให้คนตรวจ แทนที่จะได้ error เปล่าๆ

ช่วงที่ 5ผลหลัง เปิดใช้

บิลใบที่เคยพังสามรอบติด รอบแรกหลังเปิด structured outputs ก็ได้ JSON ครบทุก key ยอดเงินที่อ่านได้ตรงกับต้นฉบับ ส่วนเวลาต่อใบไม่ต่างจากเดิมอย่างมีนัย มีแค่ compile grammar รอบแรกที่เพิ่มเข้ามาครั้งเดียวตอนโหลดโมเดลครั้งแรก (cold start)

ถ้างานของคุณเอาผลลัพธ์ LLM ไปให้โค้ดใช้ต่อ ไม่ว่า extract ข้อมูล จัดหมวด หรือเรียก API สรุปสั้นๆ จากที่เราเจอมา อย่าเสียเวลาปรับ prompt ให้เชื่อง ย้ายการบังคับไปที่ decoding ตั้งแต่วันแรก แล้วเก็บแรงไว้แก้ปัญหาที่เหลือซึ่งยากกว่า ระบบเต็มๆ ที่เรื่องนี้ไปประกอบร่างอยู่ ดูได้ที่หน้าAI แกะบิล

คำถามที่พบบ่อย

structured outputs ต่างจากการสั่งใน prompt ยังไง

การสั่งใน prompt คือขอความร่วมมือ โมเดลยังเลือก token อะไรก็ได้ ส่วน structured outputs ตัดตัวเลือกที่ผิด schema ทิ้งที่ชั้น decoding (ขั้นที่โมเดลเลือกคำตอบทีละ token) โมเดลจึงตอบนอกกรอบไม่ได้เลย ผลลัพธ์ parse ได้เสมอ

ใช้ structured outputs แล้วช้าลงไหม

จากที่วัดเอง เวลา generate ต่อเอกสารไม่ต่างจากเดิมอย่างมีนัย มีต้นทุนเพิ่มที่การ compile grammar (โครงกฎที่คุม token) รอบแรกครั้งเดียวตอนโหลดโมเดล schema ยิ่งเรียบง่าย compile ยิ่งเร็ว

JSON ยังพังได้ไหม ถ้าเปิด structured outputs แล้ว

เหลือเคสเดียวคือ generation ชนเพดาน max_tokens กลางคันทำให้ JSON ขาดท้าย แก้ได้สองทาง ตั้ง max_tokens ให้พอกับเอกสารยาวสุดที่เจอจริง และมี parser สำรองที่ปิดวงเล็บให้ก่อนยอมแพ้

vLLM เวอร์ชันไหน ใช้ชื่อคลาสอะไร

เวอร์ชันใหม่ (ราว 0.11 ขึ้นไป รวมถึง 0.24 ที่เราใช้) ใช้ StructuredOutputsParams ส่วนชื่อเก่า GuidedDecodingParams ถูกถอดออกแล้ว ถ้าโค้ดเขียน fallback ดัก import ไว้ ให้ log บอกด้วยว่ากำลังใช้โหมดไหน จะได้ไม่ถอยเงียบ

อ่านต่อในซีรีส์
ที่มาและอ้างอิง
ติดตาม

รับบทความใหม่และของฟรีก่อนใคร

ทิ้งอีเมลไว้ บทความใหม่และของฟรีเป็นครั้งคราวจะส่งไปให้ ไม่สแปม

ใช้อีเมลเพื่อส่งอัปเดตเท่านั้น

ความคิดเห็น

ร่วมพูดคุย

แบ่งปันความคิดเห็นได้เลย

ชื่อจะแสดงต่อสาธารณะ อีเมลเก็บเป็นความลับ ไม่แสดงที่ไหน

กำลังโหลดความคิดเห็น…