Chapter 2 — JSON Mode & Structured Outputs
ทำไมต้องชื่อนี้และเรียนไปเพื่ออะไร?
ใน Day 1 เราเชื่อมท่อสื่อสารรับข้อความสตริงกลับมาได้แล้ว แต่ในโลกของ Software Engineering ถ้า LLM ยังตอบเป็นข้อความแชทอิสระ (Free-form text) เช่น "ผมคิดว่าฟังก์ชันนี้มีบั๊กนะครับ ลองแก้แบบนี้ดู..." โปรแกรม Python ของเราจะไม่มีทางนำผลลัพธ์ไปเขียน if-else, ตรวจเช็คสถานะ, หรือส่งต่อให้ Tool อื่นทำงานต่ออัตโนมัติได้เลย
Day 2 จึงเป็นการใส่บังเหียนให้กับโมเดลด้วย JSON Mode & Structured Output บังคับให้ Ollama ตอบกลับมาในรูปของ Schema ที่เคร่งครัด พร้อมทั้งเขียน Parser ชั้นดีที่แปลงสตริง JSON ให้เป็น Python Dictionary หรือ Data Model ที่แข็งแรงพร้อมรับมือกับ Malformed Output
โค้ดของ Day 2 จะต่อยอดจากโฟลเดอร์ se_agent/ ของ Day 1 โดยมีการอัปเดต core/client.py ให้รองรับ JSON format และเพิ่มโมดูล core/parser.py สำหรับจัดการ Validation และ Error Recovery:
ทำหน้าที่เป็น Defensive Parsing Layer ดักจับกรณีที่ LLM เผลอห่อข้อความมาใน ```json ... ``` หรือข้อความเกริ่นนำ แล้วแปลงสตริงเป็น Python dict อย่างปลอดภัย หาก JSON พังจะโยน Custom Exception ให้ระบบส่งแก้ตัว
เป็นสคริปต์รันบน Terminal เพื่อทดสอบกระบวนการตั้งแต่ส่ง Schema กำหนดใน System Prompt ➔ รับ JSON ดิบ ➔ ผ่าน Parser ➔ นำฟิลด์ผลลัพธ์ไปประมวลผลต่อในตรรกะโปรแกรมจริง
- `format="json"` ของ Ollama: บังคับระดับ Token Sampler ให้โมเดลตอบตามไวยากรณ์ JSON ที่ valid เสมอ (ปิดปีกกาถูกต้อง ไม่หลุดเป็นข้อความอิสระ)
- Schema ใน System Prompt: กำหนด **Semantic Structure** บอกโมเดลว่าต้องมี key ชื่ออะไรบ้าง ชนิดข้อมูลเป็นอย่างไร (เช่น `{"has_bug": bool, "severity": str}`)
import ollama
from typing import List, Dict, Any, Optional
class OllamaLLMClient:
"""
Wrapper ควบคุมการสื่อสารกับโมเดลภาษาในเครื่องผ่าน Ollama Daemon
"""
def __init__(self, model_name: str = "qwen2.5-coder:7b", host: Optional[str] = None):
self.model_name = model_name
self.client = ollama.Client(host=host) if host else ollama.Client()
def check_health(self) -> bool:
"""ตรวจสอบว่า daemon พร้อมใช้งานและมีโมเดลเป้าหมายอยู่หรือไม่"""
try:
models_response = self.client.list()
available_models = [m.model for m in models_response.models]
return any(self.model_name in name for name in available_models)
except Exception:
return False
def chat(
self,
messages: List[Dict[str, str]],
temperature: float = 0.2,
json_mode: bool = False
) -> str:
"""
ส่งประวัติบทสนทนาและรับข้อความตอบกลับจากโมเดล
:param json_mode: หากเป็น True จะส่ง format="json" บังคับให้โมเดลตอบเฉพาะ JSON ไวยากรณ์เป๊ะ
"""
try:
# กำหนด parameters สำหรับการส่ง chat
chat_kwargs: Dict[str, Any] = {
"model": self.model_name,
"messages": messages,
"options": {"temperature": temperature}
}
# [จุดสำคัญของ Day 2]: ถ้าเปิด json_mode ให้ระบุ format="json"
if json_mode:
chat_kwargs["format"] = "json"
response = self.client.chat(**chat_kwargs)
return response.message.content
except ollama.ResponseError as e:
raise RuntimeError(f"Ollama Response Error (Status {e.status_code}): {e.error}")
except Exception as e:
raise ConnectionError(f"ไม่สามารถเชื่อมต่อ Ollama daemon ที่ localhost:11434 ได้: {str(e)}")
import json
import re
from typing import Dict, Any
class JSONDecodeError(Exception):
"""Custom exception สำหรับข้อผิดพลาดเมื่อผลลัพธ์ไม่สามารถ parse เป็น JSON ได้"""
pass
class StructuredOutputParser:
"""
ตัวจัดการถอดรหัสข้อความจากโมเดลให้กลายเป็น Python Dictionary
พร้อมระบบ Clean Markdown Code blocks (```json ... ```)
"""
@staticmethod
def clean_text(raw_text: str) -> str:
"""
ตัด Markdown code fences ออก เช่น ```json ... ``` เพื่อให้ได้สตริง JSON แท้
"""
text = raw_text.strip()
# ตัดแท็ก ```json หรือ ``` ที่หัวและท้ายถ้าโมเดลเผลอส่งติดมา
if text.startswith("```"):
text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.IGNORECASE)
text = re.sub(r"\s*```$", "", text)
return text.strip()
@classmethod
def parse(cls, raw_text: str) -> Dict[str, Any]:
"""
แปลงสตริงผลลัพธ์จาก LLM เป็น Python Dictionary
"""
cleaned = cls.clean_text(raw_text)
try:
parsed_data = json.loads(cleaned)
if not isinstance(parsed_data, dict):
raise JSONDecodeError(f"ผลลัพธ์ไม่ได้อยู่ในรูปแบบ JSON Object (dict) แต่ได้: {type(parsed_data).__name__}")
return parsed_data
except json.JSONDecodeError as err:
raise JSONDecodeError(
f"ไม่สามารถแปลงเป็น JSON ได้: {str(err)}\n"
f"ข้อความดิบที่ได้รับมา:\n{raw_text}"
)
ชำแหละไวยากรณ์สำคัญใน core/parser.py
re.sub(r"^```(?:json)?\s*", "", text, flags=re.IGNORECASE)
แม้โมเดลรุ่นใหม่จะอยู่ใน JSON Mode บางครั้งโมเดลก็อาจติดนิสัยห่อข้อความด้วย Markdown fence การใช้ Regex ตัดทิ้งช่วยป้องกัน json.JSONDecodeError ได้อย่างเด็ดขาด
if not isinstance(parsed_data, dict):
ป้องกันกรณีโมเดลส่ง JSON array เช่น ["item1", "item2"] หรือค่าเดี่ยวๆ มา ซึ่งจะทำให้โค้ดส่วนอื่นที่เรียกใช้แบบคีย์ data["status"] เกิดข้อผิดพลาด
chat_kwargs["format"] = "json"
ส่งคำสั่งไปยัง Ollama C++ daemon ให้เปิดใช้งาน Constrained Grammar Token Sampling การันตีว่าจะไม่มีการหลุดไวยากรณ์ JSON แม้แต่อักขระเดียว
from core.client import OllamaLLMClient
from core.parser import StructuredOutputParser, JSONDecodeError
def main():
print("🚀 เริ่มทดสอบ Day 2: Structured Outputs & JSON Mode บน Mac M3...")
client = OllamaLLMClient(model_name="qwen2.5-coder:7b")
if not client.check_health():
print("❌ ข้อผิดพลาด: ไม่พบ Ollama Daemon หรือโมเดล qwen2.5-coder:7b")
return
# 1. กำหนด System Prompt บังคับ Schema ที่ชัดเจน
system_instruction = (
"You are an automated Code Reviewer. Analyze code and respond ONLY in valid JSON format.\n"
"Schema:\n"
"{\n"
' "has_bug": boolean,\n'
' "error_type": string,\n'
' "line_number": integer,\n'
' "suggested_fix": string\n'
"}"
)
sample_bad_code = """
def calculate_average(numbers):
total = sum(numbers)
return total / len(numbers) # บั๊ก: numbers อาจเป็น list ว่าง (ZeroDivisionError)
"""
messages = [
{"role": "system", "content": system_instruction},
{"role": "user", "content": f"ตรวจหาบั๊กในโค้ดนี้:\n{sample_bad_code}"}
]
print("📡 กำลังส่ง Prompt พร้อมเปิดใช้งาน json_mode=True...")
raw_response = client.chat(messages, temperature=0.1, json_mode=True)
print("\n--- ผลลัพธ์สตริงดิบจาก Ollama ---")
print(raw_response)
print("---------------------------------")
# 2. นำไปผ่าน Structured Parser
try:
parsed_result = StructuredOutputParser.parse(raw_response)
print("\n✅ Parsing สำเร็จ! ข้อมูลกลายเป็น Python Dictionary เรียบร้อย:")
print(f" 🐞 Has Bug: {parsed_result.get('has_bug')}")
print(f" 🏷️ Error Type: {parsed_result.get('error_type')}")
print(f" 📍 Line Number: {parsed_result.get('line_number')}")
print(f" 💡 Suggested Fix: {parsed_result.get('suggested_fix')}")
# ยืนยันว่าโปรแกรมสามารถนำค่าไปเขียน Logic ต่อได้จริง
if parsed_result.get("has_bug"):
print("\n🎉 ยินดีด้วยครับ! โปรแกรมสามารถนำฟิลด์ 'has_bug' ไปต่อยอดใน ReAct Loop ของ Day 3 ได้แล้ว!")
except JSONDecodeError as err:
print(f"❌ ล้มเหลวในการ Parse: {err}")
if __name__ == "__main__":
main()
เปิด Terminal บน MacBook M3 แล้วทำตาม 4 ขั้นตอนนี้เพื่อทดสอบ Structured Output ทันที:
cd ~/se_agent
source .venv/bin/activate
touch core/parser.py test_structured_output.py
- นำโค้ด core/client.py ใน ส่วนที่ 4 (ไฟล์ที่ 1) ไปอัปเดตทับไฟล์เดิม
- นำโค้ด core/parser.py ไปวางใน core/parser.py
- นำโค้ด test_structured_output.py ไปวางใน test_structured_output.py
python test_structured_output.py
คุณจะเห็นผลลัพธ์เป็น JSON ดิบจาก Ollama และถูกแปลงเป็น Python Dict ดึงค่า has_bug: True ออกมาแสดงผลอย่างสวยงามใน Terminal ทันที!
ข้อที่ 1: วัตถุประสงค์หลักของการเปิดใช้งาน `format="json"` ใน `ollama.Client.chat()` คืออะไร?
ข้อที่ 2: แม้จะใส่ `format="json"` แล้ว เหตุใดเราจึงยังต้องระบุ Schema (เช่น `{"has_bug": boolean, ...}`) ใน System Prompt ซ้ำอีก?
ข้อที่ 3: ในไฟล์ `core/parser.py` ฟังก์ชัน `clean_text()` ใช้ Regex จัดการกับปัญหาคลาสสิกข้อใดของ LLM?
ข้อที่ 4: ฟังก์ชันมาตรฐานของ Python ตัวใดที่ใช้แปลงสตริง JSON ให้กลายเป็น Dictionary ในหน่วยความจำ?
ข้อที่ 5: เหตุใดใน `core/parser.py` จึงต้องมีเงื่อนไข `if not isinstance(parsed_data, dict):`?
ข้อที่ 6: การแยกโมดูล `core/parser.py` ออกจาก `core/client.py` เป็นไปตามหลักการออกแบบซอฟต์แวร์ข้อใด?
ข้อที่ 7: ลักษณะข้อใดบ่งชี้ว่าเป็น Invalid JSON Syntax ที่ทำให้ `json.loads()` โยน Exception?
ข้อที่ 8: เมื่อได้ Python Dictionary จาก Parser มาแล้ว การดึงค่าฟิลด์ด้วย `data.get("has_bug", False)` ดีกว่า `data["has_bug"]` อย่างไร?
ข้อที่ 9: ในงาน Agent ระดับ Production หากพบว่า `StructuredOutputParser.parse()` โยน `JSONDecodeError` ทางออกที่ดีที่สุดในลูป ReAct คืออะไร?
ข้อที่ 10: ค่า `temperature` ที่แนะนำสำหรับการทำ Structured Output & JSON Mode ควรกำหนดไว้ที่เท่าใด?
ข้อที่ 11: หากต้องการแปลง Python Dictionary กลับเป็นสตริง JSON ที่สวยงามและอ่านง่าย (Pretty Print) เราจะใช้คำสั่งใด?
ข้อที่ 12: โมเดล `qwen2.5-coder:7b` มีความแม่นยำในการทำ JSON Mode สูงเพราะสาเหตุใด?
ข้อที่ 13: เมื่อเราทำ JSON Mode สำเร็จใน Day 2 สะพานนี้จะส่งผลดีอย่างไรต่อ **Day 3 (Tool Calling & Function Calling)**?
ข้อที่ 14: ในโค้ด `test_structured_output.py` เหตุใดเราจึงใช้ Prompt เป็นภาษาอังกฤษในการกำหนด Schema?
ข้อที่ 15: ในฐานะวิศวกรซอฟต์แวร์ เมื่อคุณเห็นว่าโมเดลตอบกลับมาเป็น JSON และแปลงเป็น Python Dictionary เพื่อสั่งงานต่อได้ รู้สึกอย่างไร?
🏁 สรุปหมุดเชื่อมโยง Day 2 (Chapter Wrap-up)
ใน Day 2 — JSON Mode & Structured Outputs คุณได้อัปเกรด core/client.py ให้รองรับ format="json" สร้างโมดูล core/parser.py ดักจับความผิดพลาดทางไวยากรณ์ และทดสอบรัน test_structured_output.py บนชิป Apple Silicon M3 ได้สำเร็จจริง ตอนนี้สมองกลของคุณพร้อมพูดภาษาเดียวกับโปรแกรมคอมพิวเตอร์แล้ว พร้อมก้าวสู่ Day 3: Concept of Tool Calling (Function Calling) เพื่อให้โมเดลเริ่มสั่งงานฟังก์ชันภายนอกได้แล้วครับ!