LLM/VLM อ่านสลิปแล้ว “เดา” ค่าที่อ่านไม่ชัดได้ มันอาจคืนยอดเงินที่ดูสมเหตุสมผลแต่ไม่มีอยู่จริง หรือใส่เลขบัญชีมั่ว ๆ guardrail สองชั้นช่วยได้: ชั้นแรกบังคับ โครงสร้าง ด้วย Pydantic v2 strict ชั้นสองตรวจ เนื้อหา กับความจริงด้วย edit distance
ปัญหา: โมเดลแต่งข้อมูล
เมื่อ prompt บอกให้คืน JSON ที่มี amount, ref, account โมเดล มักคืนค่าที่ “รูปร่างถูก” เสมอ แม้ภาพจะเบลอจนอ่านไม่ออก นั่นคือ hallucination ที่อันตราย: มันไม่ error มันแค่โกหกอย่างมั่นใจ เราต้องไม่เชื่อ output ดิบ ๆ
Pydantic v2 strict=True
ขั้นแรก ปฏิเสธ output ที่ผิดประเภทตั้งแต่ต้น โหมด strict ของ Pydantic v2 จะไม่ “ช่วยแปลง” "1200" เป็น 1200 ให้ ถ้าโมเดลคืน string มาในช่อง int มันจะ fail ทำให้เราจับ output ที่ผิดรูปได้ทันทีแทนที่จะปล่อยให้ค่าเพี้ยนลึกเข้าไปในระบบ
1from decimal import Decimal2from datetime import datetime3from pydantic import BaseModel, ConfigDict, Field, field_validator45class SlipExtraction(BaseModel):6 # strict=True: ห้าม coercion ข้ามชนิด, extra='forbid': ห้าม field เกิน7 model_config = ConfigDict(strict=True, extra="forbid")89 amount: Decimal = Field(gt=0, le=Decimal("10000000"))10 ref_code: str = Field(min_length=6, max_length=40)11 sender_account: str = Field(pattern=r"^[0-9xX*-]{6,20}$")12 transferred_at: datetime13 bank_name: str14 confidence: float = Field(ge=0, le=1)1516 @field_validator("ref_code")17 @classmethod18 def normalize_ref(cls, v: str) -> str:19 cleaned = v.strip().upper()20 if not cleaned:21 raise ValueError("ref_code ว่างไม่ได้")22 return cleanedบังคับ structured output
อย่าพึ่งให้โมเดลคืน JSON เป็นข้อความแล้วมา parse เอง ใช้ฟีเจอร์ structured output / tool calling ของ ผู้ให้บริการ ส่ง JSON schema ที่ Pydantic gen ให้ แล้ว validate ซ้ำด้วย model_validate
1import json2from pydantic import ValidationError3from anthropic import Anthropic4from .schema import SlipExtraction56client = Anthropic()78def extract_slip(image_b64: str) -> SlipExtraction:9 tool = {10 "name": "report_slip",11 "description": "รายงานข้อมูลที่อ่านได้จากสลิปโอนเงิน",12 "input_schema": SlipExtraction.model_json_schema(),13 }14 resp = client.messages.create(15 model="claude-sonnet-4-5",16 max_tokens=1024,17 tools=[tool],18 tool_choice={"type": "tool", "name": "report_slip"},19 messages=[{20 "role": "user",21 "content": [22 {"type": "image", "source": {23 "type": "base64", "media_type": "image/jpeg", "data": image_b64,24 }},25 {"type": "text", "text":26 "อ่านค่าจากสลิป ถ้าช่องไหนอ่านไม่ชัด ให้ลด confidence อย่าเดา"},27 ],28 }],29 )30 block = next(b for b in resp.content if b.type == "tool_use")31 # validate ซ้ำเสมอ - schema ที่ส่งให้โมเดลไม่ได้การันตีว่ามันจะทำตาม32 return SlipExtraction.model_validate(block.input)Levenshtein / edit distance
โครงสร้างถูกไม่ได้แปลว่าเนื้อหาถูก ชั้นที่สองเทียบค่าที่โมเดลอ่านได้กับ “ความจริง” ที่เรามี (เลขอ้างอิงที่ลงทะเบียนไว้, ชื่อธนาคารในรายการ) ด้วย Levenshtein distance: จำนวนการแก้ตัวอักษร (เพิ่ม/ลบ/แทน) น้อยที่สุดเพื่อเปลี่ยนสตริงหนึ่งเป็นอีกสตริง
1def levenshtein(a: str, b: str) -> int:2 if a == b:3 return 04 if not a:5 return len(b)6 if not b:7 return len(a)89 prev = list(range(len(b) + 1))10 for i, ca in enumerate(a, start=1):11 cur = [i]12 for j, cb in enumerate(b, start=1):13 cost = 0 if ca == cb else 114 cur.append(min(15 prev[j] + 1, # ลบ16 cur[j - 1] + 1, # เพิ่ม17 prev[j - 1] + cost # แทน18 ))19 prev = cur20 return prev[-1]2122def similarity_ratio(a: str, b: str) -> float:23 if not a and not b:24 return 1.025 return 1 - levenshtein(a, b) / max(len(a), len(b))ตรวจสลิปกับความจริง
1from dataclasses import dataclass2from .levenshtein import similarity_ratio3from .schema import SlipExtraction45KNOWN_BANKS = ["ธนาคารกสิกรไทย", "ธนาคารไทยพาณิชย์", "ธนาคารกรุงเทพ",6 "ธนาคารกรุงไทย", "ธนาคารกรุงศรีอยุธยา", "พร้อมเพย์"]78@dataclass9class GuardResult:10 ok: bool11 reason: str | None = None1213def guard(extracted: SlipExtraction, expected_ref: str) -> GuardResult:14 # 1) confidence ต่ำ = ส่งคนรีวิว ไม่ auto-approve15 if extracted.confidence < 0.75:16 return GuardResult(False, "confidence ต่ำเกินไป ต้องให้คนตรวจ")1718 # 2) เลขอ้างอิงต้องเกือบตรงกับที่ลงทะเบียนไว้ (กัน OCR สลับเลข)19 ref_sim = similarity_ratio(extracted.ref_code, expected_ref.upper())20 if ref_sim < 0.9:21 return GuardResult(False, f"เลขอ้างอิงไม่ตรง (ใกล้เคียง {ref_sim:.0%})")2223 # 3) ชื่อธนาคารต้อง map เข้ารายการจริงได้24 best = max(similarity_ratio(extracted.bank_name, b) for b in KNOWN_BANKS)25 if best < 0.8:26 return GuardResult(False, "ชื่อธนาคารไม่ตรงกับรายการที่รู้จัก")2728 return GuardResult(True)สองชั้นป้องกัน
- ชั้นโครงสร้าง - Pydantic strict + structured output: กรอง output ที่ “รูปร่างผิด”
- ชั้นเนื้อหา - edit distance + business rules: กรองค่าที่ รูปร่างถูกแต่ “ไม่ตรงความจริง”
- เมื่อผ่านทั้งสองชั้น + confidence สูง จึง auto-approve ที่เหลือเข้าคิว human review เสมอ
เช็กลิสต์ production
- ใช้ structured output ของผู้ให้บริการ แล้ว validate ซ้ำด้วย Pydantic strict เสมอ
- บังคับ
extra="forbid"เพื่อจับ field ที่โมเดลแถมมา - เทียบค่าสำคัญกับความจริงด้วย edit distance ไม่ใช่เชื่อ OCR ดิบ ๆ
- ออกแบบให้ fail-closed: ไม่แน่ใจ = ส่งคนตรวจ ไม่ใช่อนุมัติ
- เก็บ raw output + เหตุผลที่ปฏิเสธ เพื่อปรับ threshold และ audit
สรุปสำคัญ
- Pydantic v2 strict=True ปฏิเสธการ coercion ข้ามชนิด จับ output ที่รูปร่างผิดทันที
- ใช้ extra='forbid' เพื่อจับ field ที่โมเดลแถมมาเอง
- ออกแบบให้ fail-closed เมื่อไม่แน่ใจให้ส่งคนตรวจ ไม่ใช่อนุมัติ
ควิซท้ายบท
01โหมด strict=True ของ Pydantic v2 ต่างจากโหมดปกติอย่างไร
02การตั้ง ConfigDict(extra="forbid") ช่วยจับ hallucination แบบใด
03Levenshtein distance วัดอะไร และใช้ตรวจสลิปอย่างไร
04หลักการ 'fail-closed' ในงานเงินหมายความว่าอย่างไร