คุณต้องการวางแผนทริปไปกัว (Goa) โดยมีเวลา 5 วัน งบประมาณ 25,000 รูปี และมีความชอบส่วนตัวคือชายหาดและอาหารทะเล โดยปกติแล้ว สิ่งนี้หมายถึงการต้องเปิดเบราว์เซอร์ทิ้งไว้สิบแท็บ อ่านกระทู้เก่าๆ และพยายามประกอบแผนการเดินทางด้วยตัวเอง แต่ลองจินตนาการถึงการส่ง POST request เพียงครั้งเดียว แล้วได้รับแผนการเดินทางแบบรายวันที่มีโครงสร้างชัดเจน พร้อมคำแนะนำเรื่องอาหาร รายการกิจกรรม และการแบ่งงบประมาณอย่างละเอียดกลับมา นั่นคือสิ่งที่โปรเจกต์นี้มอบให้
เราจะสร้าง REST API โดยใช้ Spring Boot และ Azure OpenAI ซึ่ง API นี้จะรับค่าจุดหมายปลายทาง, งบประมาณ, ระยะเวลา และความสนใจ จากนั้นจะส่งคืน JSON ที่สะอาดตา ซึ่งแอปพลิเคชันฝั่ง frontend หรือ mobile สามารถนำไปแสดงผลได้ทันที ไม่มีการทำ scraping ไม่มีการเขียนแผนการเดินทางแบบ hardcoded แต่เป็นการใช้โมเดล AI ที่ถูก prompt ให้ทำหน้าที่เป็นนักวางแผนการเดินทาง
สิ่งที่ API ส่งคืนกลับมา
ผลลัพธ์ที่ได้ไม่ใช่ข้อความ Markdown เป็นก้อนๆ ที่คุณต้องใช้ regex เพื่อแยกส่วน แต่มันคือ JSON object ที่มีโครงสร้างซึ่งประกอบด้วยกิจกรรมในแต่ละวัน คำแนะนำเรื่องอาหาร และการแจกแจงงบประมาณ สำหรับทริปกัว คุณอาจได้รับข้อมูลของวันแรกที่ระบุว่าแบ่งงบ 500 รูปีสำหรับอาหารเช้าที่ร้านอาหารริมหาด (beach shack), กิจกรรมช่วงเช้าที่ Palolem และมื้อค่ำอาหารทะเลในช่วงเย็นในพื้นที่เฉพาะเจาะจง แต่ละวันจะมีช่วงเวลา ค่าใช้จ่ายโดยประมาณ และแท็กอย่าง "beach" หรือ "food" โครงสร้างนี้มีความสำคัญมาก เพราะแอปท่องเที่ยวสมัยใหม่ไม่ต้องการการ parse ข้อความยาวๆ แต่ต้องการ object ที่สามารถนำไป map เข้ากับ RecyclerView หรือ React components ได้ทันที
Stack ที่ใช้และเหตุผลที่เหมาะสม
โปรเจกต์นี้ใช้ Spring Boot 3.5 ร่วมกับ Spring AI โดย Spring AI คือส่วนประกอบสำคัญ เพราะมันช่วยสร้าง abstraction ของ ChatModel ที่เป็นหนึ่งเดียว ทำให้คุณไม่ต้องเขียน HTTP client เพื่อเชื่อมต่อกับ Azure OpenAI โดยตรง คุณเพียงแค่เปลี่ยน dependencies และ properties แทนที่จะต้องแก้โค้ดในส่วน service
คุณต้องใช้ four dependencies ในไฟล์ build ของคุณ:
spring-boot-starter-webสำหรับ REST layerspring-ai-starter-model-azure-openaiเพื่อเชื่อมต่อกับ LLM ผ่าน interface ของ Spring AIspringdoc-openapiสำหรับการทำ Swagger documentation อัตโนมัติLombokเพื่อลดโค้ด boilerplate ใน request และ response POJOs
Spring AI จะทำหน้าที่อยู่ระหว่าง business logic ของคุณและผู้ให้บริการ LLM ซึ่งการวางตำแหน่งแบบนี้เป็นความตั้งใจ เพื่อให้คลาส @Service ของคุณสะอาดและไม่ยึดติดกับผู้ให้บริการรายใดรายหนึ่ง (provider-agnostic)
การทำ Prompt Engineering ด้วย PromptTemplates
การ hardcode prompt ไว้ใน Java string เป็นวิธีที่ทำให้ซอฟต์แวร์ดูแลรักษาได้ยากอย่างรวดเร็ว หากทีมผลิตภัณฑ์ตัดสินใจว่าต้องการให้ AI ใช้ภาษาที่เป็นกันเองมากขึ้น หรือต้องการให้ปฏิเสธการประมาณการงบประมาณที่สูงเกินเกณฑ์ที่กำหนด คุณก็ไม่ควรต้องมานั่ง recompile service ใหม่
Spring AI มี PromptTemplate ให้ใช้งาน โดยคุณสามารถเก็บโครงร่างของ prompt ไว้ใน resource file หรือ template string ที่กำหนดไว้ และเว้นที่ว่าง (placeholders) สำหรับตัวแปรต่างๆ เช่น {destination}, {budget}, {days}, และ {interests} เมื่อถึงเวลา runtime ตัว service จะสร้าง object Prompt และฉีดค่าจากผู้ใช้เข้าไป
ควรแยก system messages ออกจาก user messages โดยใช้ system message เพื่อกำหนด persona ตัวอย่างเช่น คุณอาจบอกโมเดลว่ามันคือนักวางแผนการเดินทางที่เชี่ยวชาญเส้นทางในอินเดีย ให้ความสำคัญกับงบประมาณ และต้องเข้มงวดในการส่งคืนเฉพาะ JSON โดยไม่มี markdown fences ส่วน user message ให้ใช้สำหรับส่งรายละเอียดเฉพาะของทริป การแยกส่วนแบบนี้จะช่วยเมื่อคุณต้องการทำ A/B test สำหรับ persona ต่างๆ ในภายหลังโดยไม่ต้องเปลี่ยน API contract
Service Layer: การสื่อสารกับ Azure OpenAI
คลาส @Service มีหน้าที่เพียงอย่างเดียว คือการสร้าง prompt, เรียกใช้โมเดล, ทำความสะอาด response และ parse ผลลัพธ์
ให้ทำการ inject ChatClient หรือ ChatModel ของ Spring AI จากนั้น render PromptTemplate ด้วยค่าที่ได้รับจาก request แล้วจึงเรียก method chat ผลลัพธ์ที่ได้จะมาในรูปแบบ String และนี่คือจุดที่บทเรียนส่วนใหญ่มักจะหยุดลง แต่เป็นจุดที่โค้ดสำหรับใช้งานจริง (production code) เริ่มต้นขึ้น
บางครั้ง LLM มักจะเพิ่มคำเกริ่นนำที่สุภาพมาด้วย คุณอาจได้รับ response ที่เปิดด้วยประโยคว่า "Here is your itinerary" แล้วตามด้วย JSON ที่ถูกหุ้มด้วย triple backticks หากคุณพยายาม deserialize ข้อมูลนั้นโดยตรงด้วย Jackson แอปของคุณจะ crash ทันที ดังนั้นควรเพิ่ม helper method เล็กๆ เพื่อสแกน raw string หาเครื่องหมายปีกกาเปิดอันแรกและปีกกาปิดอันสุดท้าย เพื่อดึงเฉพาะ JSON payload ออกมา จากนั้นจึงทำการ validate block ที่ดึงมาได้ โดยตรวจสอบว่ามี field ที่จำเป็นครบถ้วนและค่าตัวเลขต่างๆ มีความสมเหตุสมผลก่อนที่จะส่ง object กลับไปยัง controller
การทำ defensive parsing นี้ไม่ใช่ทางเลือก แต่เป็นสิ่งที่จำเป็น เพราะมันคือเส้นแบ่งระหว่างโปรเจกต์ตัวอย่าง (demo) กับ API ที่เชื่อถือได้
การจัดการข้อผิดพลาดแบบระบบที่มีมาตรฐาน
API ภายนอกมีโอกาสล้มเหลวได้เสมอ Azure OpenAI อาจส่งคืน error เกี่ยวกับ rate limit, ข้อผิดพลาดในการยืนยันตัวตน หรือ error 500 ชั่วคราว หากคุณปล่อยให้ error เหล่านี้แสดงผลเป็น stack trace ต่อหน้าผู้ใช้ คุณจะเสียความน่าเชื่อถือทันที
ใช้ @RestControllerAdvice เพื่อดักจับ exception ทั่วทั้งระบบ โดยการแมป Spring AI exceptions, HttpClientErrorException และ RuntimeException ทั่วไป ให้เป็น error responses ที่มีรูปแบบสม่ำเสมอ ส่งคืน JSON body ที่มีข้อความที่ชัดเจน, HTTP status เช่น 429 สำหรับการจำกัดอัตราการเรียกใช้งาน (rate limits) และรายละเอียดที่เพียงพอเพื่อให้ client สามารถลองใหม่ (retry) หรือบันทึก log ปัญหาได้ ผู้ใช้ควรเห็นข้อความอย่างเช่น "Service temporarily busy. Please retry in 30 seconds" แทนที่จะเห็นหน้าจอที่เต็มไปด้วยชื่อ Java class
อย่า Hardcode ความลับ (Secrets) ไว้ในโค้ดเด็ดขาด
Azure OpenAI API key ของคุณไม่ควรอยู่ใน application.properties ที่ถูก commit เข้า Git ควรแยกมันออกมา (Externalize) โดยใช้ environment variables ที่อ้างอิงใน Spring configuration เช่น ${AZURE_OPENAI_KEY} และ ${AZURE_OPENAI_ENDPOINT} ให้เก็บไฟล์ .env ไว้ในเครื่องสำหรับการพัฒนา และเพิ่มไฟล์นี้ลงใน .gitignore จากนั้นโหลดผ่าน relaxed binding ของ Spring Boot หาก key รั่วไหล คุณสามารถเปลี่ยน key ใหม่ (rotate) ได้จากที่เดียว แทนที่จะต้องสร้าง artifact ใหม่ทั้งหมด
การทดสอบผ่าน Swagger
dependency springdoc-openapi จะเปิดใช้งาน Swagger UI endpoint ในขณะ runtime เมื่อแอปพลิเคชันของคุณเริ่มทำงาน ให้เปิด /swagger-ui.html ในเบราว์เซอร์ คุณสามารถกรอกข้อมูลตัวอย่างของ Goa ได้โดยตรง เช่น destination เป็น "Goa," budget เป็น 25000, days เป็น 5, interests เป็น "beaches, food" จากนั้นกด execute และดู JSON itinerary ที่ปรากฏขึ้น วิธีนี้ช่วยให้คุณตรวจสอบการเปลี่ยนแปลงของ prompt, ตรวจสอบการทำ serialization และแชร์ playground ที่ใช้งานได้จริงกับนักพัฒนา frontend ก่อนที่ฝ่ายใดฝ่ายหนึ่งจะเขียน unit test
การเปลี่ยน Provider โดยไม่ต้องเขียนโค้ดใหม่
สตาร์ทอัพมักมีการเปลี่ยน provider อยู่เสมอ เช่น เครดิต Azure อาจหมดอายุ หรือคุณอาจต้องการรัน inference กับ Ollama instance ในเครื่องเพื่อลดค่าใช้จ่าย เนื่องจาก Spring AI ทำการ abstraction อินเทอร์เฟซ ChatModel ไว้ การเปลี่ยนจึงเป็นเรื่องทางเทคนิคที่ทำได้ง่าย เพียงแค่เปลี่ยน Maven dependency จาก spring-ai-starter-model-azure-openai เป็น starter ตัวอื่น อัปเดตไฟล์ properties ด้วย endpoint และ key ใหม่ แล้วไม่ต้องไปแตะต้อง service class ของคุณเลย API contract ที่แอปมือถือของคุณเรียกใช้อยู่ก็จะยังคงเหมือนเดิมทุกประการ
ความสามารถในการเคลื่อนย้าย (portability) นี้ทำให้สถาปัตยกรรมนี้มีประโยชน์อย่างยิ่งสำหรับผลิตภัณฑ์ที่ใช้งานจริง คุณไม่ได้ผูกมัดตัวเองไว้กับ Azure แต่คุณกำลังใช้มันเป็นเพียงเครื่องยนต์ตัวหนึ่งที่เชื่อมต่อเข้ากับ Spring pipeline ที่สะอาดตา
บทสรุปที่สำคัญ
โมเดล AI ไม่ใช่แอปพลิเคชันของคุณ แต่มันคือบริการภายนอกที่ส่งคืนข้อความซึ่งคาดเดาไม่ได้ จงปฏิบัติต่อมันด้วยความเข้มงวดเช่นเดียวกับที่คุณทำกับ payment gateway หรือ weather API ของบุคคลที่สาม แยก credentials ออกมา ตรวจสอบทุก response ทำความสะอาด payload ก่อนการ parse และจัดการ error แบบ global เพื่อไม่ให้ผู้ใช้ของคุณต้องเห็น stack trace
ปล่อยให้ AI จัดการงานสร้างสรรค์ในการสร้างแผนการเที่ยว Goa ด้วยงบประมาณ 25,000 รูปี ส่วนคุณจัดการงานด้านโครงสร้างพื้นฐาน (plumbing) เมื่อทั้งสองส่วนแยกออกจากกัน คุณจะได้ระบบที่สามารถนำออกสู่ตลาด (ship) ได้จริง
สามารถอ่านบทความต้นฉบับที่เป็นแรงบันดาลใจให้บทความนี้ได้ ที่นี่
สนใจพูดคุยเกี่ยวกับ Spring AI และโปรเจกต์ที่คล้ายกันหรือไม่? เข้าร่วม GyaanSetu learning community ได้เลย
