บทความก่อนหน้าเราเล่าเรื่อง Jev ของ TypeSafe: โมเดลที่ไม่ generate ข้อความ แต่ “ชี้” คำตอบจากตัวเลือกที่ให้ พร้อมความน่าจะเป็น คำถามถัดไปที่ทีมถามกันทันทีคือ “ทำแบบนี้บนเครื่องลูกค้าด้วยโมเดลที่มีอยู่แล้วได้ไหม” บทความนี้เล่าตั้งแต่การทดลองแรกจนถึงแบบร่าง API ที่ชื่อว่า Classify รวมทั้งสิ่งที่พลาด สิ่งที่วัดได้จริง และตัวเลขที่ทำให้เราเปลี่ยนแบบกลางทาง (สถานะ: ทดสอบบนเครื่องพัฒนาแล้ว ยังไม่ปล่อยให้ลูกค้า)
ตอนที่ 1: โจทย์ที่แท้จริงไม่ใช่ “แชต”
ลูกค้าของเราหลายรายไม่ได้ต้องการให้ AI คุยกับใคร แต่ต้องการให้มัน ตัดสินใจเล็กๆ จำนวนมาก: ตั๋วซัพพอร์ตใบนี้อยู่หมวดไหน เร่งด่วนแค่ไหน ต้องส่งให้คนดูไหม รีวิวนี้ลูกค้าพอใจหรือไม่ ข้อความนี้ควรส่งไปโมเดลใหญ่หรือโมเดลเล็ก
วิธีเดิมคือถาม LLM แบบแชตแล้วให้มันตอบเป็นข้อความ ซึ่งมีปัญหา 3 อย่างที่เราเจอกับมือตอนทดสอบ:
- ช้า โมเดลต้องพิมพ์คำตอบทีละ token แม้เราต้องการแค่ตัวอักษรเดียว
- แกะยาก ถามให้ตอบ “A” อย่างเดียว โมเดล 8B ก็ยังตอบ “คำตอบคือ: **C)” หรือ “A) บวก (พอใจ/…” ทำให้โปรแกรมต้องเดา พอ parse แบบเข้มงวดความแม่นจาก 0.52 เหลือ 0.44 ทั้งที่โมเดลรู้คำตอบ
- ไม่รู้ว่ามั่นใจแค่ไหน คำตอบเป็นข้อความ ไม่มีตัวเลขบอกว่าควรเชื่อหรือส่งให้คนตรวจ
Jev แก้ทั้งสามข้อด้วยการไม่ให้โมเดลพูดเลย และเราพบว่าหลักการนั้นทำได้กับโมเดลที่ลูกค้าโหลดอยู่แล้ว โดยไม่ต้องมีโมเดลใหม่แม้แต่ตัวเดียว
ตอนที่ 2: กลไกที่อยู่ใน LLM ทุกตัวอยู่แล้ว
ตอน LLM อ่าน prompt (ขั้น prefill) มันคำนวณ “ความน่าจะเป็นของ token ถัดไป” ไว้ทุกตำแหน่งอยู่แล้ว ปกติเราใช้แค่ตำแหน่งสุดท้ายเพื่อผลิตคำต่อไป แต่ถ้าเราเขียน prompt ให้จบที่ “คำตอบ: ” แล้วไปอ่านว่าโมเดลอยากพิมพ์ A, B, C หรือ D ด้วยน้ำหนักเท่าไหร่ เราก็ได้คำตอบพร้อมความน่าจะเป็นโดยไม่ต้อง generate
ท่านี้ทำได้ 2 ระดับ:
- ยิงทีละคำถามผ่าน endpoint chat ปกติ ตั้ง
max_tokens=1ขอ logprobs และบังคับให้ token แรกเป็นตัวเลือกเท่านั้น (vLLM ใช้ structured output, llama.cpp ใช้ grammar) ใช้ได้ทุก engine ทุก client - แพ็กหลายคำถามใน forward pass เดียว เขียนสถานการณ์ + คำถามทุกข้อ + ช่องคำตอบหลอก แล้วขอ prompt logprobs กลับมาทั้งก้อน อ่านที่ตำแหน่งช่องคำตอบแต่ละข้อ ทำได้เฉพาะ vLLM
เราเขียนไลบรารีเล็กๆ ทำทั้งสองแบบ แล้วเริ่มวัด
ตอนที่ 3: การทดลองแรกกับตั๋วซัพพอร์ตไทย
เราแต่งตั๋วซัพพอร์ตภาษาไทย 30 ใบ (ของมาช้า, โดนตัดบัตรซ้ำ, เครื่องเปิดไม่ติด, ถามโปรโมชั่น) แล้วถาม 4 คำถามต่อใบ: หมวด, ความเร่งด่วน 1–5, ต้องส่งให้คนไหม, อารมณ์ลูกค้า และทดสอบกับชุด Wisesight (ความรู้สึกจากข้อความไทย 300 ข้อ 3 ป้าย)
ผลบน Qwen3.6-35B-A3B ซึ่งเป็นโมเดลที่อยู่ในสินค้า:
| ตัดสินใจแบบไม่ generate | ถามแบบแชตปกติ | |
|---|---|---|
| ความแม่น Wisesight | 0.583 | 0.543 |
| ความน่าจะเป็น | มี (calibrate แล้ว ECE 0.09) | ไม่มี |
| ปัญหา parse | ไม่มี | มี |
และการแพ็ก 4 คำถามใน pass เดียวให้ความแม่นเท่ากับถามทีละข้อ แต่เร็วกว่า 3 เท่า
ตอนที่ 4: บทเรียนเรื่อง “ความมั่นใจ”
ตัวเลขที่โมเดลให้ตรงๆ เชื่อไม่ได้ ตั๋ว “โดนตัดบัตรสองรอบ ขอเงินคืน” โมเดล 8B ตอบ “ยกเลิก/คืนสินค้า” ด้วยความมั่นใจ 1.00 ทั้งที่เฉลยคือ “บิล/ชำระเงิน” มันเห็นคำว่า “เงินคืน” แล้วฟันธง
วิธีแก้มี 2 ชั้น ชั้นแรกคือ temperature scaling: หาตัวเลขตัวเดียวมาหารความมั่นใจของโมเดลให้ตรงกับความถูกจริง โดยใช้ข้อมูลที่มีเฉลยราว 100 ข้อ ผลคือค่า ECE (ระยะห่างระหว่าง “บอกว่ามั่นใจ” กับ “ถูกจริง”) ของโมเดล 8B ลดจาก 0.38 เหลือ 0.04 ชั้นที่สองคือ เขียนตัวเลือกให้ไม่ทับซ้อน เช่นระบุว่า “บิล = ถูกเรียกเก็บผิด/ใบกำกับ” และ “ยกเลิก = ขอคืนของ” ซึ่งกลายเป็นฟิลด์ hint ในแบบร่าง API
ตอนที่ 5: ถึงเวลาคิดแบบสินค้า ไม่ใช่แบบแล็บ
ทีมตั้งคำถามว่า ทำไมลูกค้าต้องรู้เรื่อง logprobs หรือ grammar เลย? ถ้าลูกค้าแค่เปลี่ยนชื่อโมเดลจาก qwen38-ud-q2 เป็น qwen38-ud-q2-classify แล้วส่ง chat request ปกติพร้อมรายการตัวเลือก ให้ AI Server จัดการพารามิเตอร์ทั้งหมดเองล่ะ?
เราลงมือทำเป็น hook ใน LiteLLM gateway ที่ลูกค้าใช้อยู่แล้ว มันทำ 4 อย่าง: ตัด suffix ออกแล้วส่งไปโมเดลจริง, บังคับพารามิเตอร์ (1 token, temperature 0, logprobs, ปิด thinking), หาตัวเลือกจาก request แล้วใส่ constraint ให้ตรง engine, และแปลงผลกลับเป็น JSON ที่อ่านง่าย:
{"label": "ยกเลิก/คืนสินค้า", "confidence": 0.56,
"probs": {"บิล/ชำระเงิน": 0.36, "จัดส่ง": 0.08, "เทคนิค": 0.01,
"ยกเลิก/คืนสินค้า": 0.56, "สอบถามทั่วไป": 0.00}}
ทดสอบผ่าน gateway บนเครื่องพัฒนา ใช้ได้ทั้ง llama.cpp (พร้อม MTP ที่ลูกค้าเปิดอยู่) และ vLLM ความแม่นเท่ากับยิง engine ตรง ค่า overhead ของ gateway ราว 10 ms ถ้าลืมส่งตัวเลือกได้ error 400 เป็นภาษาไทย โมเดลปกติที่ไม่มี suffix ไม่ถูกแตะ
ตอนที่ 6: บั๊กเงียบที่เกือบหลุด
ระหว่างทดสอบเราส่งพารามิเตอร์ guided_choice ให้ vLLM เพื่อบังคับตัวเลือก ผลออกมาเป็นตัวอักษรถูกต้องทุกครั้ง จนกระทั่งเราลองบังคับให้ตอบ “Z” ซึ่งไม่มีในตัวเลือก โมเดลกลับตอบ “สวัสดี” ตามปกติ แปลว่า vLLM รุ่นใหม่ (0.24) เพิกเฉยพารามิเตอร์นี้เงียบๆ ไม่มี error ผลก่อนหน้าดูดีเพราะโมเดลตอบตัวอักษรเองพอดี พารามิเตอร์ที่ถูกต้องคือ structured_outputs: {"choice": [...]}
บทเรียน: constraint ต้องทดสอบด้วยเคสที่ “ควรพัง” ไม่ใช่เคสที่ควรผ่าน
ตอนที่ 7: วัดความเร็วให้ตรงกับความจริง
ฟีเจอร์นี้ตอบ 1 token เสมอ ดังนั้น “tok/s” ไม่มีความหมาย สิ่งที่ต้องวัดคือเวลาต่อการตัดสินใจ (เท่ากับ TTFT) และจำนวนการตัดสินใจต่อวินาที เราวัดผ่าน gateway ตัวเดียวกับที่ลูกค้าใช้ แยกกรณี warm (สถานการณ์เดิมซ้ำ engine จำ prefix ได้) กับ cold (ข้อความใหม่ทุกครั้ง) และเทียบกับการถาม LLM แบบปกติบนโมเดลเดียวกัน ที่ให้ตอบเป็นข้อความสั้นๆ พร้อมเหตุผล 1 ประโยค (64 token)
เวลาต่อ 1 การตัดสินใจ ตั๋ว 1 ใบ (prompt ~100 token, cold)
| engine / โมเดล | LLM ปกติ (TTFT + พิมพ์ 64 token) | Classify | เร็วกว่า |
|---|---|---|---|
| llama.cpp Qwen3.8-27B UD-Q2 + MTP | 385 ms + 3.2 s = 3.6 s | 554 ms | 6.5× |
| vLLM Qwen3-8B FP8 | 85 ms + 2.5 s = 2.6 s | 58 ms | 44× |
ถ้าสถานการณ์เดิมถูกถามซ้ำ (warm) Classify บน llama.cpp เหลือ 147 ms และ vLLM เหลือ 52 ms ขณะที่ LLM ปกติยังต้องพิมพ์คำตอบเท่าเดิม
ปริมาณงานเมื่อยิงพร้อมกัน (ตั๋วสั้น)
| engine | LLM ปกติ (ทีละใบ) | Classify ทีละใบ | Classify ยิงพร้อมกัน |
|---|---|---|---|
| llama.cpp UD-Q2 27B | ~0.3 ใบ/วินาที | 1.8 ใบ/วินาที | 6 ใบ/วินาที (4 สาย) |
| vLLM 8B FP8 | ~0.4 ใบ/วินาที | 17 ใบ/วินาที | 60 ใบ/วินาที (8 สาย) |
คิดเป็นตั๋ว 1,000 ใบ 1 คำถาม: LLM ปกติบน llama.cpp ราว 1 ชั่วโมง เทียบ Classify ยิงพร้อมกันราว 3 นาที และบน vLLM จาก ~40 นาที เหลือ 17 วินาที
ความแม่นและ “รู้ว่ามั่นใจแค่ไหน” (Wisesight 300 ข้อ)
| LLM ปกติ ตอบตัวอักษรเดียว | Classify | |
|---|---|---|
| Qwen3-8B ความแม่น | 0.523 (0.440 ถ้า parse เข้มงวด) | 0.577 |
| Qwen3.6-35B ความแม่น | 0.543 | 0.583 |
| เวลาต่อข้อ (8B) | 329 ms | 91 ms |
| ความน่าจะเป็นต่อตัวเลือก | ไม่มี | มี (ECE 0.04 หลัง calibrate) |
| ตอบนอกตัวเลือก | เกิดได้ (3/300 parse ไม่ได้) | เป็นไปไม่ได้ |
prompt ยาว สถานการณ์ 2–4K token แบบ cold ใช้ 3.4 s บน llama.cpp และ 0.67 s บน vLLM ทั้ง Classify และ LLM ปกติเท่ากัน เพราะเป็น prefill เดียวกัน ส่วนต่างทั้งหมดของ Classify มาจากการไม่ต้องพิมพ์คำตอบ
ข้อค้นพบสำคัญ 3 ข้อ:
- grammar และ logprobs ไม่มีต้นทุน (0 ms) แยกวัดตรงกับ engine แล้ว ต้นทุนทั้งหมดคือ prefill
- TTFT ของ Classify เท่ากับแชตปกติ สิ่งที่ประหยัดคือ decode ทั้งหมด ไม่ใช่ prefill ดังนั้นตัวคูณจะยิ่งสูงเมื่อคำตอบแบบแชตยาว และจะต่ำเมื่อ prompt ยาวมาก
- vLLM ถูกกว่า 10 เท่า ทั้ง latency และ throughput เพราะ batch หลายการตัดสินใจพร้อมกันได้ ถ้าจะใช้ปริมาณมากควรรันบน vLLM
ตอนที่ 8: จาก 1 คำถามต่อ request สู่ bulk
ตัวเลข “ยิงพร้อมกัน 8 สายได้ 60 ต่อวินาที เทียบทีละอันได้ 17” ทำให้ทีมถามต่อว่า แล้วถ้าลูกค้าส่งมาทั้งไฟล์เลยล่ะ? server จะคุม concurrency เอง เรียงแถวที่สถานการณ์เดียวกันให้ติดกันเพื่อใช้ cache แพ็กหลายคำถามเป็น pass เดียวบน vLLM และตัด overhead ต่อ request ทิ้ง ประมาณการ 1,000 ตั๋ว × 4 คำถามบน vLLM 8B จาก ~4 นาทีเหลือ ~1 นาที
นั่นคือที่มาของแบบร่าง API ชื่อ Classify ที่มี 3 ทางเข้าบนโครงข้อมูลเดียวกัน:
- model
<id>-classifyสำหรับโปรแกรมที่ใช้ OpenAI client อยู่แล้ว 1 คำถามต่อ request POST /v1/classify1 สถานการณ์ หลายคำถาม ได้ผลเป็น dict ตามชื่อคำถามPOST /v1/classify/bulkส่ง JSON หรือ CSV คอลัมน์id,situationเป็นงานคิว ดูความคืบหน้าได้ ดาวน์โหลดผลเป็น CSV ที่เปิดใน Excel ได้ทันที พร้อมสรุปจำนวนต่อป้าย
หน่วยพื้นฐานคือ “คำถาม” หนึ่งข้อ (ข้อความ + ตัวเลือก 2–16 ตัว + hint กันทับซ้อน) และ “ผล” หนึ่งชุด (label, index, confidence, probs, และ value สำหรับคะแนน) ทำไมชื่อ classify ไม่ใช่ decide? เพราะคนทั่วไปเข้าใจทันทีว่าได้ป้ายกับความน่าจะเป็น ครอบทั้งใช่/ไม่ใช่ เลือกหมวด และให้คะแนน และไม่ผูกกับชื่อบริษัทอื่น
ตอนที่ 9: สิ่งที่ยังไม่เสร็จ และสิ่งที่ได้เรียน
ยังไม่เสร็จ: การติดตั้ง hook อัตโนมัติผ่าน hub, การโชว์ชื่อ -classify ในรายการโมเดล, ค่า temperature ต่อโมเดลที่ calibrate ด้วยข้อมูลไทย, งานคิวสำหรับ bulk และเอกสารลูกค้า ทั้งหมดอยู่ในแบบร่างที่ทีมกำลังรีวิว
สิ่งที่ได้เรียนตลอดวันนี้:
- ไอเดียของ Jev ไม่ต้องการโมเดลลับ ต้องการแค่ “อ่านความน่าจะเป็นเฉพาะตัวเลือก” ซึ่ง LLM ทุกตัวมีอยู่แล้ว
- ความมั่นใจดิบของโมเดลเล็กเชื่อไม่ได้ ต้อง calibrate และต้องเขียนตัวเลือกให้ชัด
- ฟีเจอร์ที่ดีสำหรับลูกค้าคือ “เปลี่ยนชื่อโมเดลแล้วใช้ได้” ไม่ใช่ API ใหม่ที่ต้องเรียนรู้
- ตัวเลขความเร็วต้องวัดแบบ cold และวัดผ่านเส้นทางที่ลูกค้าใช้จริง ไม่ใช่ยิง engine ตรง
- และ constraint ใดๆ ต้องทดสอบด้วยเคสที่ควรพัง
สรุปประโยคเดียว: ข่าวเมื่อเช้ากลายเป็นแบบร่างฟีเจอร์ตอนค่ำ เพราะสิ่งที่ Jev ขาย คือวิธีใช้โมเดล ไม่ใช่โมเดล และวิธีใช้นั้นอยู่ในเครื่องลูกค้าเราอยู่แล้ว
ทดสอบ 20 ก.ย. 2026 บน DGX Spark GB10 · โมเดล: Qwen3.6-35B-A3B NVFP4 (vLLM), Qwen3.8-27B UD-Q2 (llama.cpp + MTP), Qwen3-8B FP8 (vLLM) · ชุดข้อมูล: ตั๋วซัพพอร์ตไทย 30 ใบ (แต่งเอง) และ Wisesight sentiment 300 ข้อ · ฟีเจอร์ยังอยู่ระหว่างพัฒนา ตัวเลขอาจเปลี่ยนเมื่อปล่อยจริง