คำขอสำเร็จ ผลลัพธ์เป็น JSON ที่ถูกต้อง SDK ก็ยังทำงานเงียบๆ แต่ทว่าแอปพลิเคชันกลับพังทลายลง

นี่คือเรื่องราวของสิ่งที่เกิดขึ้นเมื่อคุณปฏิบัติกับการเปลี่ยนผู้ให้บริการ LLM เหมือนเป็นการเปลี่ยนค่าคอนฟิก แทนที่จะเป็นการเดิมพันเชิงโครงสร้าง คุณเพียงแค่ใส่ base URL ใหม่ เปลี่ยน API key และคงรูปแบบ request body ไว้เหมือนเดิมเพราะเอกสารระบุว่าเป็นเอนด์พอยต์ที่รองรับ OpenAI (OpenAI-compatible endpoints) สำหรับการทดสอบ "hello world" พื้นฐาน มันทำงานได้ดี คุณฉลองกับความสำเร็จนั้น แต่เมื่อทราฟฟิกจริงเริ่มเข้ามา รอยต่อก็เริ่มปริแตก

ภาพลวงตาของความเข้ากันได้ในระดับ Wire

ความเข้ากันได้ในระดับ HTTP นั้นเป็นเพียงเปลือกนอก รหัสสถานะ 200 และ JSON body หมายความว่าเซิร์ฟเวอร์ยอมรับข้อความของคุณ แต่มันไม่ได้หมายความว่าเซิร์ฟเวอร์จะคิดเหมือนกับเซิร์ฟเวอร์ตัวก่อนหน้า เอนด์พอยต์ที่รองรับ OpenAI อาจมีโครงสร้างคำขอ (request shape) ที่เหมือนกัน แต่พวกมันไม่ได้มีข้อตกลงด้านพฤติกรรม (behavioral contract) ที่เหมือนกัน ผู้ให้บริการสองรายสามารถรับเพย์โหลดที่เหมือนกันเป๊ะ แต่กลับส่งคำตอบที่แตกต่างกันในลักษณะที่แนบเนียนและสร้างความเสียหายได้

โค้ดของคุณตั้งสมมติฐานไว้ คุณสมมติว่า message.content เป็นสตริงเพราะมันเคยเป็นแบบนั้นเสมอมา คุณสมมติว่าการเรียกใช้เครื่องมือ (tool call) จะมาพร้อมกับ JSON ที่สะอาดและพาร์สได้ คุณสมมติว่า finish_reason จะส่งสัญญาณตามที่คุณเข้าใจ สมมติฐานเหล่านี้จะมองไม่เห็นจนกว่ามันจะกลายเป็นข้อผิดพลาดที่ร้ายแรง

พิจารณาเหตุการณ์ที่ทำให้ทุกอย่างพังทลาย:

const text = response.choices[0].message.content.trim();

บรรทัดนี้ดูไม่มีพิษมีภัย มันทำงานได้มาหลายสัปดาห์ จนกระทั่งผู้ให้บริการรายใหม่ส่ง tool call กลับมา ในวินาทีนั้น message.content ไม่ใช่สตริงว่าง แต่มันคือ null เพย์โหลดจริงๆ อยู่ใน message.tool_calls แต่ตัวพาร์สได้ข้ามไปแล้ว และพยายามเรียกใช้ .trim() กับค่าที่ไม่มีอยู่จริง API ไม่ได้แจ้งข้อผิดพลาด เลเยอร์เครือข่ายก็ไม่ได้บ่น แต่ตัวพาร์สของคุณเองนั่นแหละที่ฆ่าคำขอนี้ทิ้ง

จุดที่ผู้ให้บริการเริ่มแตกต่างกันอย่างเงียบๆ

ความแตกต่างเหล่านี้ไม่ได้ประกาศไว้ใน changelog แต่มันซ่อนอยู่ในรายละเอียดของ response object เพื่อรอจังหวะเกิด edge cases

รูปแบบการเรียกใช้เครื่องมือ (Tool-call formatting). ผู้ให้บริการรายหนึ่งส่ง tool arguments เป็น JSON object ที่ผ่านการตรวจสอบแล้ว แต่อีกรายอาจส่งมาเป็น escaped string ภายในฟิลด์หนึ่ง ส่วนรายที่สามอาจแยก tool call ที่ยาวออกเป็นหลายๆ streaming deltas บังคับให้คุณต้องทำ buffering ข้อมูลก่อนที่จะรู้ด้วยซ้ำว่าโครงสร้างนั้นถูกต้องหรือไม่ หากแอปพลิเคชันของคุณคาดหวังข้อมูลก้อนเดียว (blob) ที่พาร์สได้ มันจะพังทันที

เหตุผลในการสิ้นสุด (Finish reasons). OpenAI ใช้สตริงเฉพาะเจาะจง เช่น "stop", "length", "tool_calls", และ "content_filter" แต่ผู้ให้บริการที่รองรับอาจส่ง "end_turn" หรือแค่ละเว้นฟิลด์นี้ไปเมื่อโมเดลถึงขีดจำกัดของ token หากตรรกะการ retry หรือ fallback ของคุณรอค่า "length" เพื่อตรวจจับการถูกตัดข้อความ ระบบจะค้างอยู่เฉยๆ ในขณะที่ผู้ใช้เห็นคำตอบที่ยังไม่จบ

ฟิลด์การใช้งาน (Usage fields). ผู้ให้บริการบางรายตัดจำนวน token ออกจาก streaming responses เพื่อลด latency ลงไม่กี่มิลลิวินาที บางรายจะแนบ usage มาให้เฉพาะใน chunk สุดท้าย หรือละเว้นไปเลยในการเรียกแบบ non-streaming หากคุณเก็บเงินลูกค้าตามจำนวน token และโค้ดบัญชีของคุณคาดหวังว่า usage.total_tokens จะต้องมีอยู่ในทุก response object ระบบการเรียกเก็บเงินของคุณจะบันทึกค่าเป็นศูนย์อย่างเงียบๆ

พฤติกรรมการสตรีม (Streaming behavior). Server-sent events ควรจะเป็นมาตรฐาน แต่ผู้ให้บริการกลับ flush buffers ด้วยความถี่ที่ต่างกัน ขอบเขตของ event ก็ต่างกัน ผู้ให้บริการรายหนึ่งจบ stream ด้วยสัญญาณ [DONE] แต่อีกรายอาจตัดการเชื่อมต่อทิ้งไปเฉยๆ โดยไม่มีสัญญาณบอก หาก client ของคุณบล็อกเพื่อรอเครื่องหมายปิดที่เฉพาะเจาะจง มันก็จะค้างไปเลย

ข้อผิดพลาดและไทม์เอาต์ (Errors and timeouts). การจำกัดอัตราการเรียกใช้งาน (rate limit) อาจมาในรูปแบบ 429 พร้อมกับ header retry-after จากผู้ให้บริการรายหนึ่ง แต่อาจมาเป็น 502 ที่คลุมเครือจากอีกราย ผู้ให้บริการบางรายยอมรับคำขอแล้วเงียบหายไปสองนาทีก่อนจะเกิด network timeout ซึ่ง OpenAI SDK จะไม่ช่วยปรับเปลี่ยนสิ่งเหล่านี้ให้กลายเป็น exception types ตามที่ log ของคุณคาดหวังได้อย่างน่าอัศจรรย์

การพาร์สข้อมูลเชิงป้องกันสำหรับรูปแบบที่คาดเดาไม่ได้

วิธีแก้ไม่ใช่การเชื่อใจ schema แต่คือการปฏิบัติกับทุกการตอบกลับเหมือนเป็นผู้ต้องสงสัย

อย่าทึกทักว่า content เป็นสตริง ให้ตรวจสอบก่อนที่จะนำไปใช้งาน

const content = response.choices?.[0]?.message?.content;
const text = typeof content === "string" ? content.trim() : "";

อย่าสมมติว่า tool arguments เป็น JSON ที่ถูกต้อง โมเดลเป็นเพียงผู้เสนอการกระทำ โค้ดของคุณต้องเป็นผู้ตัดสินว่าข้อเสนอนั้นปลอดภัยพอที่จะดำเนินการหรือไม่ ให้ครอบการพาร์ส tool argument ทุกครั้งด้วย try-catch หาก JSON.parse เกิด error ให้ถือว่า tool call นั้นเป็นข้อมูลขยะที่ผิดรูปแบบและส่งต่อไปยัง failure handler วงเล็บที่หลอนขึ้นมาหรือเครื่องหมายคำพูดที่หายไป ไม่ควรถูกปล่อยให้ลุกลามกลายเป็น unhandled exception

หากมี tool_calls แต่ไม่มี content แอปพลิเคชันของคุณควรรับรู้ถึงการเปลี่ยนสถานะ ผู้ใช้ไม่ได้ต้องการคำตอบแชท แต่ระบบได้รับคำสั่งงาน (work order) นั่นคือสองเส้นทางที่ต่างกัน และ router ของคุณควรแยกแยะความแตกต่างได้ก่อนที่จะพยายามจัดการกับสตริง

การทดสอบพฤติกรรมก่อนการติดตั้งใช้งาน (Deploy)

การส่งข้อความ "hi" ไปยัง endpoint เป็นเพียงการพิสูจน์ว่าเครือข่ายทำงานได้ แต่มันไม่ได้พิสูจน์อะไรเลยเกี่ยวกับแอปพลิเคชันของคุณ

ก่อนที่คุณจะเปลี่ยนเส้นทางทราฟฟิกในระบบ production ให้รันชุดการทดสอบพฤติกรรม (behavioral test suite) ที่เจาะจงกับผู้ให้บริการ (provider) รายใหม่:

  • การตอบกลับเป็นข้อความปกติ (Normal text response). ตรวจสอบว่ามี content อยู่จริง เป็น string และสามารถส่งผ่าน sanitization pipeline ของคุณได้โดยไม่เกิดข้อผิดพลาดในการแปลงประเภทข้อมูล (casting errors)
  • การบังคับเรียกใช้เครื่องมือ (Forced tool call). ตั้งค่า tool_choice เป็น required เพื่อยืนยันว่าผู้ให้บริการปฏิบัติตาม และตรวจสอบว่า content ที่ส่งมาเป็น null, string ว่าง หรือเป็น key ที่หายไป ซึ่งแต่ละสถานะเหล่านี้จำเป็นต้องมี handler ของตัวเอง
  • อาร์กิวเมนต์ของเครื่องมือที่ผิดรูปแบบ (Malformed tool arguments). จำลองสถานการณ์ที่โมเดลส่ง JSON ที่เสียกลับมาใน tool arguments เพื่อให้แน่ใจว่า parser ของคุณจะปฏิเสธข้อมูลเหล่านั้นอย่างเหมาะสม แทนที่จะทำให้ worker ล่ม
  • การตอบกลับที่ใกล้ขีดจำกัดของ token (Response near the token limit). ทดสอบจนเกือบเต็ม context window และตรวจสอบ finish_reason หากผู้ให้บริการส่งค่าที่ไม่คาดคิดกลับมาเมื่อเกิดการตัดข้อความ (truncation) ตรรกะการสรุปความ (summarization) หรือการลองใหม่ (retry logic) ของคุณต้องรู้วิธีรับมือ

สิ่งเหล่านี้คือการทดสอบแบบ integration tests ไม่ใช่ unit tests เพราะมันเป็นการทดสอบความสัมพันธ์ที่แท้จริงระหว่างโค้ดของคุณกับ "บุคลิก" ของผู้ให้บริการ คุณควรผ่านการทดสอบเหล่านี้ก่อนที่จะถือว่าการย้ายระบบ (migration) เสร็จสิ้น

สร้างสัญญาภายใน (Internal Contract)

ความแตกต่างของผู้ให้บริการควรหยุดอยู่แค่ที่ขอบเขตเครือข่ายของคุณ อย่าปล่อยให้มันหลุดรอดเข้าไปใน business logic

สร้าง normalization layer ที่รับ raw SDK response มาแล้วส่งออกเป็น object ที่แอปพลิเคชันของคุณเป็นเจ้าของจริงๆ โดยการแปลงความแปลกประหลาดเฉพาะตัวของผู้ให้บริการ (provider-specific eccentricities) ให้กลายเป็นรูปแบบภายในที่เสถียร หาก Provider A ส่ง tool arguments มาเป็น string แต่ Provider B ส่งมาเป็น object ตัว mapper ของคุณควรจะปรับทั้งคู่ให้กลายเป็นโครงสร้าง ToolRequest ของคุณเอง หากข้อมูลการใช้งาน (usage) หายไป mapper ของคุณควรจะประมาณค่าขึ้นมาหรือทำเครื่องหมายแจ้งเตือนไว้ แต่ต้องไม่ปล่อยให้ undefined หลุดเข้าไปในโมดูลติดตามค่าใช้จ่าย (cost-tracking modules) ของคุณ

หาก finish_reason ไม่เป็นไปตามมาตรฐาน ให้แปลงมันเป็น enum ของสถานะสิ้นสุด (terminal states) ของคุณเอง เช่น COMPLETE, TRUNCATED, TOOL_CALL, FILTERED แอปพลิเคชันของคุณควรตัดสินใจว่าจะทำอย่างไรโดยอิงจาก abstraction ที่สะอาดเหล่านี้ ไม่ใช่การไปไล่ตรวจจับ (sniffing) จาก raw strings ที่มาจากเซิร์ฟเวอร์ของบุคคลที่สาม

เลเยอร์นี้จะเปลี่ยนการสลับผู้ให้บริการจากการไล่แก้ปัญหาแบบไม่จบไม่สิ้น (whack-a-mole) ให้เหลือเพียงการแก้ไขไฟล์เดียว คุณแค่เขียน mapper ใหม่ รันการทดสอบพฤติกรรม แล้วก็จบไป โดยที่แอปพลิเคชันของคุณไม่ต้องถูกแตะต้องเลย

นี่คือการอัปเกรด Dependency ไม่ใช่แค่การปรับแต่ง Config

การเปลี่ยนผู้ให้บริการ LLM ไม่เหมือนกับการสลับ CDN endpoint แต่มันใกล้เคียงกับการเปลี่ยนฐานข้อมูลจาก PostgreSQL เป็น MySQL มากกว่า คุณคงไม่ทึกทักเอาเองว่า connection string แบบเดียวกันจะหมายถึงพฤติกรรมการ query ที่เหมือนกันทุกประการ คุณย่อมต้องทดสอบเรื่อง locking semantics, เส้นทางการย้ายข้อมูล (migration paths) และความแปลกประหลาดของการทำ indexing LLM ก็สมควรได้รับการปฏิบัติแบบเดียวกัน พวกมันคือระบบความน่าจะเป็น (probabilistic systems) ที่ปลอมตัวมาในรูปแบบของ standard APIs และการตอบกลับของพวกมันก็มาพร้อมกับข้อสมมติฐานเกี่ยวกับรูปแบบ (formatting), การตัดข้อความ (truncation) และลำดับการควบคุม (control flow) ซึ่งสามารถทำให้แอปพลิเคชันของคุณพังพินาศได้โดยไม่แจ้งเตือนข้อผิดพลาดทางเครือข่ายแม้แต่ครั้งเดียว

บั๊กไม่ได้อยู่ที่การเชื่อมต่อ แต่มันอยู่ที่ความเข้าใจผิดว่าความเข้ากันได้ (compatibility) หมายถึงความเหมือนกัน ซึ่งมันไม่ใช่ จงตรวจสอบรูปแบบ (shape) ทดสอบกรณีขอบเขต (edges) และเป็นเจ้าของสัญญา (contract) นั้นเสียเอง


Source: The Bug Only Happened After I Switched LLM Providers

Community: GyaanSetu AI on Telegram