เทคนิคการจำกัด Output Space เพื่อเพิ่มประสิทธิภาพ Narrow Automation สำหรับ SLM

ปัจจุบันความสนใจในวงการ AI มักมุ่งไปที่โมเดลระดับแนวหน้า (frontier-scale reasoning) แต่ในโลกอุตสาหกรรมจริง ปริมาณงานส่วนใหญ่ต้องการเพียงการทำงานอัตโนมัติเฉพาะเจาะจงหรือ Narrow Automation เท่านั้น
งานเหล่านี้ประกอบด้วยการส่งต่อตั๋วสนับสนุน (support ticket) การดึงข้อมูลจากฟอร์ม การติดแท็กเอกสาร หรือการคัดกรองข้อมูลให้มนุษย์ตรวจสอบ ซึ่งมีลักษณะร่วมคือมีอินพุตจำกัด มีรูปแบบคำตอบตายตัว และมีปริมาณการใช้งานมหาศาล
ลักษณะงานดังกล่าวเหมาะอย่างยิ่งสำหรับ Small Language Models (SLMs) ที่รันได้บน GPU ตัวเดียวหรือแม้แต่ CPU โดยให้คำตอบในระดับมิลลิวินาที ซึ่งเป็นทางเลือกที่คุ้มค่ากว่าการเรียกใช้ Large Language Model (LLM) ผ่าน API ที่อาจมีค่าใช้จ่ายสูงกว่าถึง 1,000 เท่าต่อรายการ
อย่างไรก็ตาม ปัญหามักเกิดจากการที่ทีมวิศวกรมักติดนิสัยการใช้โมเดลขนาดใหญ่มาใช้กับโมเดลขนาดเล็ก เช่น การเขียน Prompt ยาวๆ และปล่อยให้โมเดลสร้างข้อความอิสระก่อนจะใช้ regular expressions ตามหาข้อมูล ซึ่งความไร้ประสิทธิภาพนี้จะกลายเป็นคอขวดทันทีเมื่อรันบน SLM ในเครื่อง และส่งผลให้อัตราความผิดพลาดสูงขึ้นอย่างเห็นได้ชัด
บทความนี้จะเริ่มซีรีส์เกี่ยวกับการเพิ่มประสิทธิภาพ Narrow Automation สำหรับ SLM โดยในตอนแรกนี้จะครอบคลุมเทคนิคที่มีประโยชน์ที่สุดคือ การจำกัด output space แทนที่จะเป็นการรอพาร์สข้อความที่สร้างขึ้นมา เพื่อให้ได้เกณฑ์มาตรฐานที่ชัดเจน การทดสอบทั้งหมดจะใช้ Qwen2.5-0.5B-Instruct ในรูปแบบ float16 ผ่าน Hugging Face Transformers บนเครื่อง M2 Macbook Air พร้อม RAM 24GB
ขั้นแรก ให้ตั้งค่าสภาพแวดล้อม Python และติดตั้งแพ็กเกจที่จำเป็น:
pip install torch transformers accelerateทำไมต้องจำกัด Output Space?
งานจำแนกประเภท (classification) มีชุดคำตอบที่ตายตัวอยู่แล้ว เช่น การคัดแยกตั๋วเป็น billing, technical, หรือ account แต่โดยปกติเรามักขอให้โมเดลเขียนคำตอบยาวๆ แล้วค่อยมาไล่หาคำที่ต้องการ
วิธีนี้มีข้อเสียสองประการคือ หนึ่ง ช้าเกินไป เพราะโมเดลต้องรัน forward pass ทีละโทเคนตามลำดับ และสอง ไม่น่าเชื่อถือ เพราะโมเดลขนาดเล็กอาจตอบนอกเหนือจากคำสั่งที่คุณกำหนดไว้ ทำให้ต้องสร้างกฎสำรอง (fallback rule) ซึ่งเป็นจุดสะสมของข้อผิดพลาด
วิธีแก้ไขคือเปลี่ยนจากการสร้างข้อความ (generating) มาเป็นการให้คะแนน (scoring) แทน โดยการรัน forward pass เพียงครั้งเดียวเพื่ออ่านการกระจายตัวของโทเคนถัดไป (next-token distribution) และจำกัดการตัดสินใจไว้ที่ชุดเลเบลที่กำหนดเท่านั้น วิธีนี้จะทำให้คำตอบถูกต้องตามโครงสร้าง 100% และได้คะแนนความมั่นใจ (confidence score) แถมมาด้วย
การพาร์สข้อความอิสระ (Free Text)
ตัวอย่างโค้ดด้านล่างคือการทำงานแบบพื้นฐานที่ปล่อยให้โมเดลสร้างข้อความอิสระแล้วค่อยมาพาร์สภายหลัง:
import os
import time
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
MODEL_ID = "Qwen/Qwen2.5-0.5B-Instruct"
torch.set_num_threads(os.cpu_count() or 1)
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
model = AutoModelForCausalLM.from_pretrained(MODEL_ID, dtype=torch.float32)
model.eval()
# our toy data to classify (600 records)
LABELS = ["billing", "technical", "account"]
tickets = [
"My card was charged twice for the same invoice.",
"The mobile app crashes whenever I open the settings page.",
"I need to change the email address on my profile.",
] * 200
def build_prompt(ticket):
messages = [
{
"role": "system",
"content": "You classify support tickets. Answer with exactly one of: billing, technical, account.",
},
{"role": "user", "content": f"Ticket: {ticket}\nCategory:"},
]
return tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
# nothing constrains the output here, so we let the model write a short answer and search it for a label (tokens++)
# each new token costs its own forward pass, and one ticket per call means no batching to amortize that (time++)
prompts = [build_prompt(t) for t in tickets]
predictions = []
# time inference
start = time.time()
for n, prompt in enumerate(prompts, start=1):
# this loop runs for minutes on CPU, so report progress rather than sitting silent
if n % 50 == 0:
rate = (time.time() - start) / n
print(f" {n}/{len(prompts)} tickets ({rate:.2f}s each)", flush=True)
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
with torch.inference_mode():
output = model.generate(
**inputs,
max_new_tokens=8,
do_sample=False,
pad_token_id=tokenizer.eos_token_id,
)
# generate() returns prompt + continuation, so slice the prompt off before decoding
generated = output[0, inputs["input_ids"].shape[1] :]
text = tokenizer.decode(generated, skip_special_tokens=True).strip().lower()
# substring match against the label list
predictions.append(next((label for label in LABELS if label in text), "UNPARSED"))
duration = time.time() - start
# output task metrics
print(f"Free-form generation took: {duration:.2f} seconds")
print(f"Unparseable outputs: {predictions.count('UNPARSED')} / {len(predictions)}")
# sample of inference output
for ticket, label in zip(tickets[-3:], predictions[-3:], strict=True):
print(f"{ticket} -> {label}")ผลลัพธ์ที่ได้:
Loading weights: 100%|███████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 290/290 [00:01<00:00, 263.89it/s]
50/600 tickets (0.19s each)
100/600 tickets (0.19s each)
150/600 tickets (0.19s each)
200/600 tickets (0.19s each)
250/600 tickets (0.19s each)
300/600 tickets (0.19s each)
350/600 tickets (0.19s each)
400/600 tickets (0.19s each)
450/600 tickets (0.19s each)
500/600 tickets (0.19s each)
550/600 tickets (0.19s each)
600/600 tickets (0.19s each)
Free-form generation took: 134.01 seconds
Unparseable outputs: 0 / 600
My card was charged twice for the same invoice. -> billing
The mobile app crashes whenever I open the settings page. -> technical
I need to change the email address on my profile. -> technicalสังเกตว่าการประมวลผลใช้เวลาถึง 134 วินาที แม้ข้อมูลจะดูไม่มีปัญหาในเบื้องต้นก็ตาม
การจำกัด Output Space
คราวนี้ลองมาดูเวอร์ชันที่มีการจำกัดขอบเขต (constrained version) ซึ่งจะให้คะแนนชุดเลเบลโดยตรงจากการทำ forward pass เพียงครั้งเดียว:
import os
import time
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
MODEL_ID = "Qwen/Qwen2.5-0.5B-Instruct"
torch.set_num_threads(os.cpu_count() or 1)
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
model = AutoModelForCausalLM.from_pretrained(MODEL_ID, torch_dtype=torch.float32)
model.eval()
# our toy data to classify (600 records)
LABELS = ["billing", "technical", "account"]
tickets = [
"My card was charged twice for the same invoice.",
"The mobile app crashes whenever I open the settings page.",
"I need to change the email address on my profile.",
] * 200
def build_prompt(ticket):
messages = [
{
"role": "system",
"content": "You classify support tickets. Answer with exactly one of: billing, technical, account.",
},
{"role": "user", "content": f"Ticket: {ticket}\nCategory:"},
]
return tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
# prompt ends with "<|im_start|>assistant\n", so the model's next token starts the label
# comparing the logits of each label's FIRST token is enough to pick a winner, provided those first tokens are distinct
label_first_ids = [tokenizer.encode(label, add_special_tokens=False)[0] for label in LABELS]
assert len(set(label_first_ids)) == len(LABELS), (
"Labels share a first token; score full label sequences instead (see notes)."
)
label_first_ids = torch.tensor(label_first_ids, device=model.device)
# one forward pass per ticket, no generation loop: the decision contained entirely in the next-token logits
prompts = [build_prompt(t) for t in tickets]
predictions = []
confidences = []
# time inference
start = time.time()
for prompt in prompts:
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
with torch.inference_mode():
logits = model(**inputs).logits[0, -1, :]
# softmax over just the label logits, so the probabilities sum to 1 across the candidates
probs = torch.softmax(logits[label_first_ids].float(), dim=-1)
best = int(probs.argmax())
predictions.append(LABELS[best])
confidences.append(float(probs[best]))
duration = time.time() - start
# output task metrics
print(f"Constrained scoring took: {duration:.2f} seconds")
print(f"Unparseable outputs: {predictions.count('UNPARSED')} / {len(predictions)}")
# sample of inference output
for ticket, label, confidence in zip(
tickets[-3:], predictions[-3:], confidences[-3:], strict=True
):
print(f"{ticket} -> {label} (confidence {confidence:.3f})")ผลลัพธ์ที่ได้:
Loading weights: 100%|███████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 290/290 [00:01<00:00, 285.60it/s]
Constrained scoring took: 94.51 seconds
Unparseable outputs: 0 / 600
My card was charged twice for the same invoice. -> billing (confidence 0.793)
The mobile app crashes whenever I open the settings page. -> technical (confidence 0.798)
I need to change the email address on my profile. -> technical (confidence 0.673)วิธีนี้ช่วยลดเวลาทำงานลงได้ประมาณ 30% และขจัดโอกาสที่จะเกิดข้อผิดพลาดในการพาร์สไปโดยสิ้นเชิง
คำอธิบายเพิ่มเติมเกี่ยวกับเทคนิคนี้:
- การอ่าน
logits[0, -1, :]ช่วยให้เราเห็นการกระจายตัวของโทเคนถัดไปได้ทันทีโดยไม่ต้องใช้ฟังก์ชันgenerate()ซ้ำๆ - การทำ
argmaxบนดัชนีเวกเตอร์ที่ระบุ ทำให้ผลลัพธ์ที่อยู่นอกชุดคำศัพท์เป็นไปไม่ได้ในเชิงโครงสร้าง (Structural constraint) - คะแนนความมั่นใจที่ได้จาก Softmax มีประโยชน์มากในการคัดกรองงาน เช่น ถ้าคะแนนต่ำกว่า 0.6 เราสามารถส่งให้มนุษย์ตรวจสอบแทนได้ทันที
- ต้องระวังเรื่อง Tokenization เพราะตัวสร้างโทเคนอาจมองคำที่มีช่องว่างกับไม่มีช่องว่างต่างกัน ในกรณีนี้เราใช้
encode(label)โดยตรงเนื่องจากเทมเพลตจบท้ายด้วยขึ้นบรรทัดใหม่ - หากเลเบลมีโทเคนแรกเหมือนกัน ให้ลองเปลี่ยนชื่อเลเบลเป็นอักษรเดี่ยว (เช่น A, B, C) แล้วใส่คำอธิบายใน Prompt แทน
บทสรุป
เทคนิค constrained scoring คือหัวใจสำคัญในการเปลี่ยนให้โมเดลภาษาขนาดเล็ก (SLM) กลายเป็นเครื่องมือทรงพลังสำหรับ Narrow Automation โดยการตัดวงจรการสร้างข้อความอิสระออกไป และใช้การบังคับผลลัพธ์ผ่านโครงสร้างแทน
เมื่อเราเลิกปฏิบัติกับโมเดลขนาดเล็กเหมือนเป็นแชทบอททั่วไป แต่ใช้งานมันผ่านสัญญาผลลัพธ์ที่ชัดเจน (enforced output contract) โมเดลเหล่านี้จะไม่ใช่การประนีประนอมเรื่องประสิทธิภาพอีกต่อไป แต่จะกลายเป็นโซลูชันที่มีประสิทธิภาพสูงสุดสำหรับการผลิตจริง
ความคิดเห็น (0)
เข้าสู่ระบบเพื่อร่วมแสดงความเห็น
สมัครสมาชิกมาเป็นคนแรกที่แสดงความเห็นกันเลยโบร
