← บล็อก

ข้อความหนึ่งข้อความเดินทางยังไง

ภาพรวมแชทบอทร้านสัตว์เลี้ยงที่รู้ว่าเมื่อไรไม่ควรตอบ

AIอ่าน 32 นาที

Petcare Assistant เป็นผู้ช่วย AI และ CRM บน LINE สำหรับร้านสัตว์เลี้ยง ทั้งคลินิก ร้านอาบน้ำตัดขน และที่รับฝากเลี้ยง ระบบเป็น multi-tenant คือหลายร้านใช้ระบบเดียวกัน ผมทำคนเดียว มีเดโมอยู่ที่ https://petcare.rattanun.dev ต่อกับ LINE OA จริง

บทความอื่นในชุดนี้เจาะทีละเรื่อง เช่น ฐานข้อมูล eval การเลือกโมเดล หรือบั๊กที่บอททำจริง บทความนี้เป็นภาพรวม เขียนให้เจ้าของร้านที่อยากรู้ว่าบอทจะทำอะไรกับลูกค้าของเขา และให้คนที่สัมภาษณ์งานซึ่งอยากเห็นทั้งระบบในบทความเดียว ผมจะเดินตามข้อความหนึ่งข้อความตั้งแต่ LINE ส่งเข้ามาจนลูกค้าได้คำตอบ และเล่าว่าระบบนี้ต่างจากแชทบอททั่วไปตรงไหน เรื่องที่มีบทความเจาะลึกแล้วจะใส่ลิงก์ไว้แทนการเล่าซ้ำ ชื่อลูกค้าและสัตว์ในตัวอย่างเป็นข้อมูล seed ของร้านตัวอย่าง “Happy Paws”

แชทบอททั่วไปทำงานยังไง และพังตรงไหน

แชทบอท AI ส่วนใหญ่มีโครงเดียวกัน ข้อความเข้ามาก็ส่งให้โมเดลพร้อม system prompt ยาว ๆ ที่บอกว่าร้านขายอะไร ราคาเท่าไร ห้ามพูดอะไร แล้วส่งสิ่งที่โมเดลเขียนกลับไปให้ลูกค้า ทุกการตัดสินใจอยู่ในโมเดล

โครงนี้ใช้ได้ดีกับคำถามทั่วไป แต่กับร้านสัตว์เลี้ยงมันพังในสี่จุด

  • เรื่องด่วนรอโมเดลไม่ได้ ถ้าลูกค้าพิมพ์ว่าหมากินช็อกโกแลต แต่ API ของโมเดลล่มหรือช้า ลูกค้าจะไม่ได้รับอะไรเลย ทั้งที่เป็นข้อความที่สำคัญที่สุดของวัน
  • โมเดลตอบสิ่งที่ไม่มีข้อมูลอย่างมั่นใจ ถ้าไม่รู้ว่าร้านรับฝากกระต่ายไหม มันก็จะเดาจากความรู้ทั่วไป
  • ไม่มีทางให้คนเข้ามาแทน เจ้าของร้านจะไม่กล้าเปิดบอท ถ้าไม่แน่ใจว่าตัวเองกดรับแชทกลับมาได้ทันที (PLAN ข้อ 4 เขียนไว้ว่า “แอดมินต้องคุมได้ทุกเมื่อ”)
  • กฎที่อยู่ใน prompt อย่างเดียวไม่ได้ผลทุกครั้ง “ห้ามแนะนำยา” ผ่านในการรันส่วนใหญ่ แต่ไม่ทุกครั้ง

ระบบนี้ย้ายการตัดสินใจเหล่านี้ออกจากโมเดลมาไว้ในโค้ด โมเดลยังเป็นคนเขียนคำตอบ แต่จะได้เขียนหรือไม่ เขียนจากข้อมูลอะไร และคำตอบนั้นจะได้ถูกส่งหรือไม่ เป็นเรื่องที่โค้ดตัดสิน

ทางเดินของข้อความหนึ่งข้อความ

ทางเดินของข้อความ: webhook ตรวจลายเซ็นและกันซ้ำ, worker ตรวจฉุกเฉินและเพดาน spam ก่อน debounce, respond job ดูว่าใครถือแชทแล้วเรียก ChatEngine ผ่าน guard และส่ง, memory poller ดึงข้อมูลเข้า Customer Card ภายหลัง

ข้อความหนึ่งข้อความผ่านสี่ช่วง

1. webhook ตอบ LINE ทันที handleLineWebhook หา channel ของร้านจาก channel id ตรวจลายเซ็นของ LINE แล้วเก็บ event ลงตาราง inbound_events แล้วตอบ 200 ทันทีโดยยังไม่ได้ประมวลผลอะไร ตารางนี้มี unique key บน (channel_id, webhook_event_id) ถ้า LINE ส่ง event เดิมซ้ำ insert จะไม่เกิดผลอะไร ลายเซ็นที่ไม่ผ่านกับ channel ที่ไม่มีอยู่จริงได้ 401 แบบเดียวกัน คนที่ยิงมาสุ่มจะได้ไม่รู้ว่า channel ไหนมีจริง

2. worker คัดก่อนเรียกโมเดล poller ใน worker หยิบ event ที่ยังไม่ได้ทำทุก 400 ms คนที่ย้ายแถวออกจากสถานะ received ได้ก่อนคือคนที่ได้งาน worker สองตัวจึงไม่ตอบข้อความเดียวกันซ้ำ จากนั้นหาว่าเป็นลูกค้าคนไหน อยู่ในแชทไหน เก็บข้อความ แล้วผ่านด่านที่ไม่ใช้โมเดลตามลำดับ คือ ตรวจเหตุฉุกเฉิน, สลิปมัดจำและรหัสผูกบัญชี (ตอบด้วยข้อความตายตัว), เพดาน spam และสุดท้ายคือ debounce ถ้าลูกค้าพิมพ์สามบรรทัดติดกัน ระบบจะรอให้เงียบ 2.5 วินาที แต่ไม่เกิน 8 วินาทีนับจากข้อความแรก แล้วตอบรวดเดียวในเทิร์นเดียว ระหว่างรอ LINE จะแสดง loading animation ให้ลูกค้าเห็นว่ามีคนกำลังพิมพ์

3. respond job คิดและส่ง BullMQ รันงานทีละแชท โดยถือ lock ของแชทนั้นใน Redis งานแรกคือเช็กว่าตอนนี้ใครถือแชท (หัวข้อด้านล่าง) ถ้าบอทยังถืออยู่ก็เรียก ChatEngine ซึ่งโหลดประวัติแชท, Customer Card ของลูกค้า (เฉพาะเมื่อยืนยันตัวตนแล้ว), ตารางวันที่ 14 วันข้างหน้า และ tool 12 ตัว ให้โมเดลเรียก tool ได้ไม่เกิน 5 รอบต่อเทิร์น คำตอบที่ได้ต้องผ่าน output guards ผ่านกฎส่งต่อของร้าน แล้วจึงส่งออก โดยใช้ reply token ก่อนเพราะไม่กินโควตาข้อความของร้าน ถ้า token หมดอายุค่อยใช้ push ถ้าบอทให้ลูกค้าไปหน้าจองหรือหน้าราคา ปุ่มจะมาเป็นการ์ด Flex ใต้ข้อความ ลิงก์ในการ์ดสร้างในโค้ดจาก LIFF ID ของร้าน ไม่ได้มาจากโมเดล ถ้าส่งไม่สำเร็จ poller outbound-retry จะลองใหม่ที่ 1 และ 5 นาที

4. ภายหลัง เมื่อแชทเงียบไป 10 นาที หรือมีข้อความค้าง 12 ข้อความ worker จะให้โมเดลอีกตัวอ่านแชทแล้วดึงข้อเท็จจริงเข้า Customer Card เช่น โมจิกลัวเสียงไดร์ ส่วนข้อมูลสุขภาพจะเป็นแค่ข้อเสนอจนกว่าพนักงานยืนยัน เรื่องนี้เล่าละเอียดใน ให้ AI จำลูกค้าและสัตว์เลี้ยงได้

ทั้งเส้นนี้มีโมเดลแค่สองจุด คือกล่องสีฟ้าในภาพ ด่านอื่นเป็นโค้ดธรรมดาที่ทดสอบได้และทำงานเหมือนเดิมทุกครั้ง การที่ webhook เก็บลงฐานข้อมูลก่อนแล้วค่อยให้ worker ทำต่อ ทำให้ถ้า worker ล่มก็ไม่มีข้อความหาย พอ worker กลับมาก็หยิบแถวที่ค้างไปทำต่อ วิธีวัดว่าเส้นนี้รับโหลดได้แค่ไหนอยู่ใน บทความ load test ส่วนวิธีแยกข้อมูลแต่ละร้านด้วย RLS อยู่ใน บทความฐานข้อมูล

ด่านแรกไม่ใช่ AI: เหตุฉุกเฉินและเพดาน spam

ฉุกเฉินต้องทำงานได้แม้โมเดลล่ม

ร้านสัตว์เลี้ยงต่างจากร้านทั่วไปตรงที่บางข้อความเป็นเรื่องชีวิต “หมากินองุ่น” “แมวอ้าปากหายใจ” “โดนรถชน” ข้อความแบบนี้ต้องได้คำตอบที่ถูกต้องทันที โดยไม่ขึ้นกับว่า API ของโมเดลจะตอบหรือไม่ PLAN ข้อ 3.5 จึงกำหนดให้การตรวจรอบแรกเป็นแบบ deterministic

/** Keyword pre-check, deliberately not the model: it must work when the model is down (PLAN 3.5). */
export async function checkEmergency(tx: Transaction, organizationId: string, text: string) {
  const detection = detectEmergency(text);
  if (!detection.matched) return null;
  return {
    keyword: detection.keyword ?? "",
    reply: buildEmergencyReply(await loadEmergencySettings(tx, organizationId)),
  };
}

detectEmergency เทียบข้อความกับรายการคำ 105 คำใน packages/core/src/emergency.ts แบบ substring หลังตัดช่องว่างออก รายการนี้เขียนจากคำที่เจ้าของสัตว์พิมพ์จริง ไม่ได้เขียนแบบตำราสัตวแพทย์ เช่น มี “โกแลต” แทน “กินช็อกโกแลต” เพราะคนพิมพ์ ช็อก ช็อค ช๊อก แล้วแต่คน และมี “อ้าปากหายใจ” เพราะเจ้าของมักบรรยายอาการแมว ไม่ได้บอกชื่ออาการ ส่วนคำที่ตั้งใจไม่ใส่ก็มีคอมเมนต์อธิบาย เช่น “สลบ” อยู่ใน “วางยาสลบ” ซึ่งเป็นบริการที่ร้านขาย รายการนี้ยอมให้แจ้งเกินดีกว่าพลาด คอมเมนต์หัวไฟล์เขียนไว้ว่า “a false alarm costs one staff glance, a miss can cost a pet’s life” ร้านเพิ่มคำของตัวเองได้ในหน้าตั้งค่าแชท คำเหล่านี้เทียบด้วยวิธีเดียวกันต่อจากรายการกลาง เพิ่มได้อย่างเดียว ลบคำในรายการกลางไม่ได้ และคำที่สั้นกว่าสองตัวอักษรถูกปฏิเสธ เพราะจะตรงกับแทบทุกข้อความ

ถ้าเจอคำในรายการ raiseEmergency จะทำสี่อย่าง และไม่เรียกโมเดลเลย

  1. ส่งข้อความตายตัวที่ประกอบจากการตั้งค่าของร้าน คือเบอร์ฉุกเฉินและโรงพยาบาลสัตว์ 24 ชั่วโมง พร้อมปุ่มโทรใต้ข้อความ ข้อความนี้ไม่มีส่วนไหนมาจากโมเดล จึงแต่งเบอร์ขึ้นมาเองไม่ได้ และไม่มีทางพูดให้เรื่องดูเบาลง
  2. เปลี่ยนแชทเป็นโหมดพนักงาน (mode = human) และสถานะ urgent แชทจะไปขึ้นแท็บ “ฉุกเฉิน” ใน inbox ตามที่ PLAN 4.3 กำหนดว่าขั้นนี้ “ปิดไม่ได้”
  3. บันทึก emergency_detected ลง timeline ของแชท พร้อมคำที่ตรวจเจอ
  4. แจ้งกลุ่ม LINE ของพนักงาน พร้อมปุ่ม “รับเรื่อง” และ “คืนให้บอท” ปุ่มทั้งสองใช้ payload ที่เซ็นแล้วและมีวันหมดอายุ และคนกดต้องเป็นพนักงานที่มีสิทธิ์

ข้อความในกลุ่มพนักงานส่งไม่เกินครั้งเดียวต่อลูกค้าต่อ 15 นาที เพราะถ้ามีคนพิมพ์ “หมาชัก” 300 ครั้ง กลุ่มจะได้ push 300 ครั้ง และกินโควตาข้อความรายเดือนของร้าน แต่ลูกค้าได้ข้อความตายตัวทุกครั้ง และแชทถูกทำเครื่องหมายด่วนทุกครั้ง

รายการคำจะขาดอยู่หนึ่งคำเสมอ บน LINE OA จริงเคยมีลูกค้าพิมพ์ว่าหมากินกาแฟ ตอนนั้นยังไม่มีคำนี้ในรายการ โมเดลตอบถูกว่าอันตราย แต่คนในร้านไม่รู้เรื่องเลย ระบบจึงมีชั้นที่สอง คือ tool report_emergency ให้โมเดลแจ้งการประเมินของตัวเองแบบมีโครงสร้าง พอโมเดลเรียก tool นี้ โค้ดจะวางข้อความฉุกเฉินของร้านไว้หน้าคำตอบของโมเดล แล้วส่งเข้า raiseEmergency ตัวเดียวกับทางแรก ไม่ว่าจะเจอจากคำหรือจากโมเดล ผลก็คือสถานะฉุกเฉินแบบเดียวกัน เรื่องนี้เล่าเต็มใน บั๊กที่ AI แชทบอทของผมทำจริง เรื่องที่ 3 และ 4

ตัวเลขที่บอกว่าชั้นที่สองจำเป็น มาจาก eval วันที่ 2026-09-30 ตอนเคสฉุกเฉินไปถึงโมเดลโดยมีแค่ prompt สั่ง หมวด safety ผ่าน 22/27 คำตอบที่ตกมีแบบ “น้องทานองุ่นไป 5 ลูก ไม่เป็นไรนะคะ” หรือรับปากว่าเจ้าหน้าที่จะติดต่อกลับ ขณะที่หมากำลังชัก หลังให้โค้ดบังคับใส่ข้อความ “ไปหาหมอตอนนี้” ไว้หน้าคำตอบ หมวดนี้ขึ้นเป็น 27/27 (docs/eval-log.md ใน repo) รอบล่าสุดวันที่ 2026-11-05 หมวด safety ของชุดหลักผ่าน 65/66 และชุด hold-out ที่บอทไม่เคยถูกจูนผ่าน 10/12 วิธีวัดและเหตุผลที่ต้องมีชุด hold-out อยู่ใน บทความ eval

spam ไม่โดนบล็อก แต่ถูกส่งให้คน

ด่านถัดมาคือ applyChatLimits มันอยู่หลังด่านฉุกเฉินโดยตั้งใจ เหตุฉุกเฉินจริงต้องไม่โดนเพดาน spam กั้น ตัวเลขตกลงกับเจ้าของโปรเจกต์ไว้วันที่ 2026-10-03

export const CHAT_LIMITS = {
  messagesPerWindow: 10, // per conversation per minute
  turnsPerDay: 30, // bot turns per conversation, in the shop's own day
  maxMessageLength: 1_000,
  pauseMinutes: REQUEST_HUMAN_PAUSE_MINUTES, // 60
  spendPerContactPerDayUsd: 0.25,
  // …
} as const;

(ตัดคอมเมนต์ยาวออกและย่อเหลือบรรทัดเดียว ของจริงอยู่ใน packages/pipeline/src/limits.ts)

สิ่งที่ต่างจากระบบทั่วไปคือ เมื่อเกินเพดาน ระบบไม่บล็อกลูกค้า แต่ใช้ทางเดียวกับตอนลูกค้ากด “คุยกับพนักงาน” แชทเปลี่ยนเป็น “รอคนตอบ” บอทหยุด 60 นาที และลูกค้าได้ข้อความแจ้งครั้งเดียวต่อการพักหนึ่งรอบ เช่น “วันนี้ผู้ช่วย AI ตอบครบจำนวนแล้วค่ะ ขอส่งต่อให้พนักงานดูแลต่อนะคะ” ลูกค้าที่พิมพ์เยอะเพราะมีปัญหาจริงก็ยังได้คุยกับคน ส่วนคนที่ตั้งใจเล่นบอทจะเผาเงินค่าโมเดลของร้านได้ไม่เกิน 0.25 ดอลลาร์ต่อวัน

ทุกตัวนับมาจากฐานข้อมูล (messages, conversation_turns, usage_events) ไม่ได้นับใน memory ถ้า worker รีสตาร์ตหรือเพิ่ม worker อีกตัว ตัวนับก็ไม่เริ่มใหม่ “วันนี้” ก็นับจากเที่ยงคืนตามเขตเวลาของร้าน ซึ่ง Postgres เป็นคนคำนวณ ส่วนเพดานที่อยู่ด้านนอกแชท เช่น งบรายเดือนของร้านและโควตาของบัญชีเดโม อยู่ใน เปิด demo ให้คนแปลกหน้าลองเล่น

ตอบจากข้อมูลของร้าน และสิ่งที่ตอบไม่ได้กลายเป็นงานให้ร้านสอน

ข้อมูลสองแบบ: ตาราง กับ ความรู้

PLAN ข้อ 13.1 แบ่งข้อมูลร้านด้วยคำถามเดียว คือ “ถ้าตอบผิดแล้วร้านเสียเงินหรือเสียความเชื่อใจ → ต้องเป็นข้อมูลโครงสร้าง” ราคา โปรโมชัน เวลาเปิด สาขา และนโยบายมัดจำ เก็บเป็นตาราง และโมเดลอ่านผ่าน tool เฉพาะ เช่น quote_price คำนวณราคาจากน้ำหนัก ขน และสาขาด้วยโค้ด ส่วน get_active_promotions จะไม่เห็นโปรที่หมดอายุแล้ว ส่วนความรู้ที่เป็นคำอธิบาย เช่น ก่อนผ่าตัดต้องงดน้ำงดอาหารไหม หรือหลังทำหมันต้องดูแลยังไง เก็บเป็น Q&A และเอกสาร แล้วค้นด้วย search_knowledge

prompt บอกโมเดลว่าเรื่องไหนต้องใช้ tool ตัวไหน และถ้าเอกสารขัดกับ tool ให้เชื่อ tool ผลข้างเคียงที่น่าสนใจคือบอทแทบไม่ได้ใช้ฐานความรู้ ถ้าถามว่า “ฝากเลี้ยงต้องฉีดวัคซีนอะไรบ้าง” มันเรียก get_policy ไม่ได้เรียก search_knowledge ซึ่งเป็นสิ่งที่ตั้งใจไว้ (docs/decisions.md, “The eval suite cannot see a retrieval change”)

RAG ที่ Q&A ของร้านมาก่อนเสมอ

searchKnowledge แปลงคำถามเป็น embedding ขนาด 1,024 มิติ แล้วค้นใน pgvector สองกลุ่ม กลุ่มแรกคือ Q&A ที่ร้านเขียน กลุ่มที่สองคือชิ้นเอกสาร (ชิ้นละไม่เกิน 800 ตัวอักษร และซ้อนกัน 100 ตัวอักษร) ได้ผลรวมไม่เกิน 5 รายการ และตัดรายการที่คะแนนต่ำกว่า 0.3 ทิ้ง Q&A ที่ผ่านเกณฑ์จะอยู่เหนือเอกสารเสมอ แม้เอกสารจะได้คะแนนสูงกว่า เพราะเป็นคำตอบที่คนในร้านเขียนเอง

ระบบมี reranker เป็น service Python แยก (cross-encoder bge-reranker-base) ใช้เรียงผลภายในแต่ละกลุ่มใหม่ เปิดด้วย RERANKER_URL และมีงบเวลา 800 ms ถ้าไม่ได้ตั้งค่า หมดเวลา หรือ service ล่ม ก็ใช้ลำดับเดิมจาก embedding กฎของไฟล์นี้คือ “the bot must answer when that service is down” บนชุดทดสอบ 14 คำถาม reranker ทำให้คำตอบที่ถูกติดสามอันดับแรกเพิ่มจาก 7/14 เป็น 11/14 ที่เลือกตัว base แทนตัวที่เก่งกว่า เพราะตัวที่เก่งกว่าใช้เวลา 3.4–5 วินาทีบน CPU ซึ่งเกินงบทุกครั้ง (docs/decisions.md, “The reranker ships with the weaker of the two models on purpose”)

ถ้าไม่เจอ บอทไม่เดา

prompt สั่งว่าถ้า tool และ Customer Card ไม่มีคำตอบ ให้บอกว่าไม่มีข้อมูลและเจ้าหน้าที่จะช่วย แม้แต่คำแนะนำทั่วไปเรื่องสัตว์เลี้ยงก็ห้ามตอบจากความรู้ของโมเดลเอง ข้อนี้ไม่ได้มีไว้แค่กันความผิด แต่ทำให้บอกได้ว่าบอทไม่รู้อะไร isNoAnswerReply ใน packages/core/src/no-answer.ts จับคำแบบ “ไม่มีข้อมูล” “ขอส่งเรื่อง” ได้ เพราะคำพวกนี้ถูกกำหนดไว้ใน prompt และตั้งใจไม่จับ “ไม่ทราบว่า…” เพราะในภาษาไทยมันเป็นวิธีเปิดคำถามอย่างสุภาพ (“ไม่ทราบว่าสะดวกวันไหนคะ”) ตอนแรกที่นับคำนี้ด้วย บทสนทนาจองคิวถูกส่งให้พนักงานกลางทาง

วงจรความรู้: ลูกค้าถาม, บอทไม่เจอและไม่เดา, คำถามเข้าคิวของร้าน, พนักงานตอบครั้งเดียวเป็น Q&A, ครั้งหน้าบอทตอบเอง

คำถามที่ตอบไม่ได้คือรายการงานของร้าน

เมื่อเทิร์นไหนจบโดยไม่มีคำตอบ engine จะเรียก recordUnansweredQuestion ใน transaction เดียวกับที่บันทึกเทิร์น คำถามของลูกค้าถูกเก็บลง unanswered_questions ตามที่ลูกค้าพิมพ์มา แชทที่พนักงานทดสอบใน simulator ไม่ถูกนับ เพราะไม่ใช่ช่องว่างของความรู้

ในหน้า “ความรู้ของบอท” ของ dashboard มีแท็บ “คำถามที่บอทตอบไม่ได้” แต่ละคำถามมีป้าย “ถูกถาม N ครั้ง” ซึ่งนับจากคำถามที่หน้าตาเหมือนกันหลัง normalize ใน SQL ร้านจึงตอบอันที่ถามบ่อยก่อนได้ พอพนักงานพิมพ์คำตอบแล้วกด “บันทึกเป็น Q&A” resolveUnansweredQuestion จะสร้าง Q&A พร้อม embedding และปิดคำถามเดียวกันที่ลูกค้าคนอื่นถามค้างไว้ไปพร้อมกัน ถ้าคำถามไม่ควรตอบก็กด “ไม่ต้องตอบ” ทุกการกระทำเขียน audit log

วงจรนี้คือเหตุผลที่ร้านไม่ต้องเขียนฐานความรู้ให้ครบตั้งแต่วันแรก ลูกค้าเป็นคนบอกว่าความรู้ยังขาดตรงไหน ร้านตอบครั้งเดียว แล้วบอทก็ตอบเองได้ตลอดไป ซึ่ง PLAN 13.6 เรียกว่า “วงจรปรับปรุง”

เมื่อไรบอทควรหยุด

mode กับ status เป็นคนละเรื่อง

แชทแต่ละแชทมีสองค่า mode บอกว่าใครเป็นคนตอบ คือ bot (บอทตอบเอง), assist (บอทร่าง พนักงานกดส่ง) หรือ human (พนักงานตอบเอง) ส่วน status บอกว่าแชทต้องการอะไร คือ open, waiting_human (รอคนตอบ), urgent (ฉุกเฉิน) หรือ closed การสลับทุกครั้งผ่าน service ตัวเดียว (setConversationMode) ซึ่งเขียน event ลง timeline และ audit ไว้ใน transaction เดียวกับการเปลี่ยนค่า

แชทใหม่เริ่มที่ “โหมดปกติของแชทใหม่” ที่ร้านตั้งไว้ และโหมดนอกเวลาทำการมีผลเป็นรายเทิร์น ไม่ได้เปลี่ยน mode ที่เก็บไว้ ทุกครั้งที่บอทจะตอบ ระบบเช็กเวลาทำการของสาขา (ใช้ service เดียวกับที่บอทใช้ตอบเรื่องเวลาเปิด รวมวันหยุดพิเศษ) ถ้าร้านปิดอยู่ เทิร์นนั้นจะทำงานตามโหมดนอกเวลา เช่น ร้านที่ให้บอทร่างในเวลาทำการ ตั้งให้บอทตอบเองตอนกลางคืนได้ และเช้าวันรุ่งขึ้นแชทก็ยังเป็น assist โดยไม่มีใครต้องสลับกลับ แชทที่พนักงานรับไว้ไม่ถูกแตะ ถ้าโหมดนอกเวลาเป็น “พนักงานตอบเอง” บอทจะไม่ตอบ แชทเข้าคิวรอคนตอบ และลูกค้าได้ข้อความครั้งเดียวว่าพนักงานจะตอบเมื่อร้านเปิด พร้อมเบอร์ฉุกเฉิน ด่านตรวจเหตุฉุกเฉินทำงานก่อนขั้นนี้เสมอ จึงไม่ขึ้นกับโหมด

สามโหมดของแชท: บอทตอบ, บอทช่วยร่าง, พนักงานตอบ พร้อมเส้นทางสลับระหว่างกัน และการพักบอท 60 นาที

ทางที่แชทไปถึงพนักงาน

  • ลูกค้าขอเอง กดปุ่ม “คุยกับพนักงาน” หรือบอกบอท ซึ่งบอทจะเรียก request_human แชทเปลี่ยนเป็นรอคนตอบ บอทพัก 60 นาที ลูกค้าได้ข้อความยืนยันที่บอกว่าผู้ช่วย AI หยุดตอบแล้ว ถ้าอยู่นอกเวลาทำการ ข้อความจะบอกด้วยว่าร้านเปิดอีกทีเมื่อไร และให้เบอร์ฉุกเฉิน เพราะคนที่ขอคุยกับคนตอนกลางคืนอาจมีปัญหาจริง กลุ่มพนักงานได้รับแจ้งไม่เกินครั้งเดียวต่อ 30 นาที
  • prompt สั่งให้บอทส่งต่อ เมื่อลูกค้าบ่น ถามเรื่องอาการหรือยา หรือถามว่าอาหารเหมาะกับน้องที่มีโรคประจำตัวไหม ข้อสุดท้ายนี้ใส่ใน prompt อย่างเดียวไม่พอ ระบบจึงมี critical_fact_guard บังคับซ้ำในโค้ด (ดูหัวข้อ output guards)
  • กฎของร้าน ในหน้าตั้งค่าแชท ร้านสร้างกฎได้สามแบบ คือ ลูกค้าพิมพ์คำที่กำหนด (เช่น “ร้องเรียน”), บอทตอบไม่ได้ N ครั้งติดกัน และ guard บล็อกคำตอบ แต่ละกฎเลือกได้ว่าจะ “ส่งต่อให้พนักงาน” หรือ “แจ้งกลุ่ม LINE แอดมิน” ซึ่งบันทึกลง timeline ส่งข้อความเข้ากลุ่มพนักงาน (ไม่เกินครั้งเดียวต่อลูกค้าต่อ 30 นาที) แล้วให้บอทตอบต่อ กฎตั้งได้ทั้งร้านหรือรายสาขา และ evaluateHandoffRules เป็นฟังก์ชัน pure ที่ทดสอบได้โดยไม่ต้องมีฐานข้อมูล
  • ระบบตัดสินเอง เกินเพดาน spam, guard บล็อกคำตอบสองครั้ง หรือโมเดลไม่ตอบ (ใช้ข้อความตายตัว FIXED_REPLIES) และเหตุฉุกเฉิน ซึ่งเป็นทางเดียวที่เปลี่ยน mode เป็น human ทันทีและปิดไม่ได้
  • พนักงานรับแชทเอง กด “รับแชต” ใน inbox หรือกด “รับเรื่อง” ในกลุ่ม LINE ถ้าพนักงานพิมพ์ตอบในแชทที่บอทกำลังดูแลโดยไม่ได้กดรับก่อน recordAdminMessage จะรับแชทให้อัตโนมัติ เพื่อไม่ให้บอทตอบทับพนักงาน

race ระหว่างบอทกับคน

โมเดลใช้เวลาคิดหลายวินาที ถ้าพนักงานกดรับแชทในช่วงนั้น คำตอบของบอทต้องไม่ถูกส่งออกไป engine จึงอ่าน mode อีกครั้งใน transaction ที่บันทึกคำตอบ

const stored = current?.mode;
const stillBot = stored === "bot" || stored === "assist";
// …
if (!stillBot) return { status: "skipped", reason: "not_bot_mode" };

ค่าใช้จ่ายของเทิร์นนั้นยังถูกบันทึกตามจริง แต่ข้อความจะไม่ถึงลูกค้า

assist: บอทคิด แต่คนกดส่ง

PLAN 4.2 ออกแบบ assist ไว้สำหรับร้านที่ยังไม่ไว้ใจ AI ร้านตั้งเป็นโหมดปกติของแชทใหม่ได้ หรือพนักงานเปิดเป็นรายแชท ในโหมดนี้บอททำงานเต็มทุกขั้น ทั้งเรียก tool ผ่าน guard และผ่านกฎ แต่ผลลัพธ์ถูกเก็บด้วย recordReplyDraft แทนการส่ง ร่างใหม่จะแทนร่างเก่าที่ยังไม่ได้ส่ง ในแชทพนักงานจะเห็นสามปุ่ม คือ “ส่งเลย” “แก้ก่อนส่ง” และ “ไม่ใช้” sendDraft บันทึกว่าร่างถูกส่งตามเดิมหรือถูกแก้ก่อน และ assistStats ใช้ตัวเลขนี้บอกร้านว่าบอทร่างได้ดีแค่ไหน ก่อนที่ร้านจะตัดสินใจปล่อยให้บอทตอบเอง

มีข้อยกเว้นข้อเดียว ถ้าโมเดลประเมินว่าเป็นเหตุฉุกเฉินขณะอยู่ในโหมด assist ร่างยังรอพนักงานตามปกติ แต่ลูกค้าจะได้ข้อความฉุกเฉินตายตัวทันที เพราะเรื่องฉุกเฉินรอให้ใครกด “ส่งเลย” ไม่ได้

คืนแชทให้บอท

พนักงานกด “คืนให้บอท” ได้ทั้งใน inbox และในกลุ่ม LINE บอทจะกลับมาตอบโดยเห็นทุกอย่างที่พนักงานคุยไปแล้ว เพราะข้อความของพนักงานอยู่ในประวัติแชทเดียวกัน ถ้าร้านตั้ง “คืนให้บอทอัตโนมัติ” ไว้ N นาที poller auto-return จะตรวจทุกนาทีหาแชทในโหมดพนักงานที่ไม่มีข้อความจากพนักงานเกินเวลาที่ตั้ง แล้วคืนให้บอทพร้อมบันทึก event mode_changed ด้วย reason auto_return ค่าเริ่มต้นคือ 0 หมายถึงปิด แชทฉุกเฉินจะไม่ถูกคืนอัตโนมัติไม่ว่าจะตั้งไว้เท่าไร

inbox ที่เรียงตามงาน

inbox ของ dashboard มีแท็บ “ฉุกเฉิน” “รอคนตอบ” “ของฉัน” “บอทดูแล” “ปิดแล้ว” และ “ทั้งหมด” พนักงานที่ตอบแชทได้จะเปิด inbox มาเจอแท็บ “รอคนตอบ” ก่อน แต่ละแชทมีป้าย mode และ status เหตุผลที่ส่งต่อ ร่างของบอท (ถ้ามี) และ Customer Card อยู่ด้านข้าง ข้อความใหม่เข้ามาผ่าน Server-Sent Events ทั้งหมดนี้อยู่ใน apps/web/src/app/dashboard/inbox/ ซึ่งแค่เรียก service แล้วแสดงผล การตัดสินว่าใครทำอะไรได้อยู่ใน service

ด่านสุดท้ายก่อนส่ง: output guards

คำตอบที่โมเดลเขียนเสร็จแล้วยังไม่ถูกส่งทันที runOutputGuards ตรวจข้อความด้วยโค้ดเจ็ดตัว

guard ตรวจอะไร
non_empty ข้อความว่าง หรือจบกลางพยางค์ (ไม้หันอากาศ, สระหน้า)
link_guard ลิงก์ที่ไม่ใช่โดเมนของร้าน
pii_guard เบอร์โทรที่ไม่ใช่ของร้าน
price_guard จำนวนเงินที่ไม่ได้มาจากผลของ tool ในเทิร์นนี้
emergency_guard เทิร์นฉุกเฉินที่คำตอบปลอบว่า “ไม่เป็นไร”
medicine_guard ประโยคที่สั่งให้กินยา บอกขนาดยา หรือบอกว่ายาปลอดภัย ตรวจทุกเทิร์น ไม่มีข้อยกเว้น
critical_fact_guard ยืนยันว่าอาหารหรือบริการปลอดภัยกับน้องที่พนักงานยืนยันแล้วว่าแพ้หรือมีโรค

ถ้า guard ตัวไหนไม่ผ่าน โมเดลได้เขียนใหม่หนึ่งครั้ง พร้อมโน้ตที่บอกว่าผิดตรงไหนและต้องเขียนอะไรแทน ถ้ายังไม่ผ่านอีก ลูกค้าจะได้ข้อความตายตัว “ขอส่งเรื่องนี้ให้เจ้าหน้าที่ช่วยตอบนะคะ” และแชทถูกส่งให้พนักงาน คำตอบที่ถูกบล็อกจะไม่ถึงลูกค้าแม้แต่คำเดียว

ข้อห้ามแนะนำยาจึงมีสองชั้น คือ prompt (“Never diagnose illness, recommend medicines, or give doses”) และ medicine_guard ที่ทำงานทุกเทิร์น เรื่องการบอกลูกค้าว่าเป็น AI ก็อยู่ในโค้ดเช่นกัน ข้อความแรกที่บอทตอบลูกค้าใหม่จะมีประโยค “แชทนี้มีผู้ช่วย AI ของร้านตอบให้ก่อนนะคะ หากต้องการคุยกับพนักงาน พิมพ์บอกได้เลยค่ะ” (AI_DISCLOSURE) และข้อความส่งต่อทุกแบบเขียนว่า “ผู้ช่วย AI” ส่วน prompt สั่งให้บอกว่าเป็น AI เมื่อลูกค้าถาม

หลักที่อยู่เบื้องหลังตารางนี้จดไว้ใน docs/decisions.md ว่า “A rule the product depends on does not belong only in a prompt” ทุก guard เกิดจากกฎที่เคยอยู่ใน prompt อย่างเดียวแล้วถูกละเมิดใน eval หรือบน LINE จริง ที่มาของแต่ละตัว และ guard ที่เคยบล็อกคำตอบที่ถูก อยู่ใน บั๊กที่ AI แชทบอทของผมทำจริง

สิ่งที่ยังไม่เรียบร้อย

  • กฎ “บอทตอบไม่ได้ N ครั้งติดกัน” นับจากข้อความที่บอทส่งจริง ร่างในโหมด assist จึงไม่ถูกนับ (STATUS, “Known gaps”)
  • ถ้าพนักงานตอบผ่านแอป LINE OA Manager โดยตรง ข้อความนั้นจะไม่ผ่าน webhook ระบบจึงไม่รู้ว่ามีคนคุยอยู่ ทางที่ใช้ได้ตอนนี้คือให้ตอบผ่าน inbox ของระบบ หรือพักบอทก่อน (PLAN 4.4)
  • ในเดโมไม่มีพนักงานจริง แชทที่ถูกส่งต่อจึงไม่มีใครตอบ

สรุป

  1. ข้อความถูกเก็บลงฐานข้อมูลก่อนประมวลผล ทำให้กันซ้ำได้และ replay ได้ LINE ได้ 200 ภายในไม่ถึงวินาที
  2. ด่านแรกไม่ใช่โมเดล เหตุฉุกเฉินตอบด้วยข้อความตายตัวจากการตั้งค่าของร้าน และยังทำงานเมื่อโมเดลล่ม
  3. ถ้าโมเดลรู้สิ่งที่รายการคำไม่รู้ ให้มันบอกผ่าน tool แล้วให้โค้ดเป็นคนทำงานต่อ
  4. เกินเพดาน spam แล้วส่งให้คน ไม่บล็อก
  5. ข้อมูลที่ตอบผิดแล้วเสียเงิน (ราคา โปร เวลา นโยบาย) มาจากตาราง ความรู้ที่เป็นคำอธิบายมาจาก RAG ซึ่ง Q&A ของร้านมาก่อนเสมอ
  6. บอทที่ยอมบอกว่าไม่รู้ ทำให้ระบบเก็บคำถามนั้นเป็นงานให้ร้านสอนได้
  7. แยก mode ออกจาก status ให้ทุกการสลับผ่าน service ตัวเดียว และเช็ก mode อีกครั้งก่อนส่ง
  8. assist ให้ร้านเริ่มจากให้บอทร่าง แล้ววัดว่าร่างถูกแก้บ่อยแค่ไหน ก่อนปล่อยให้ตอบเอง
  9. กฎที่ธุรกิจพึ่งพาต้องอยู่ในโค้ด prompt เป็นแค่ชั้นแรก

อ่านต่อ

ที่มา

  • packages/pipeline/src/webhook.ts (handleLineWebhook), events.ts (storeInboundEvent, claimInboundEvent), inbound.ts (handleCustomerMessage), emergency.ts (checkEmergency, raiseEmergency), alerts.ts (15 นาทีของการแจ้งฉุกเฉิน), limits.ts (CHAT_LIMITS, LIMIT_REPLIES), handoff.ts (ข้อความตอบ “คุยกับพนักงาน”), postback.ts (REQUEST_HUMAN_PAUSE_MINUTES), respond.ts (runRespondJob), cards.ts, customer.ts (AI_DISCLOSURE), outbound.ts, outbound-retry.ts, types.ts (DEBOUNCE_MS, DEBOUNCE_DEADLINE_MS)
  • packages/core/src/engine.ts (createChatEngine, MAX_REGENERATIONS, การเช็ก mode ก่อนบันทึก), prompt.ts (buildSystemPrompt, FIXED_REPLIES), tools.ts และ booking-tools.ts (12 tools), guards.ts (runOutputGuards), emergency.ts (KEYWORDS 105 คำ, buildEmergencyReply, withEmergencyDirective), no-answer.ts, memory/types.ts (EXTRACTION_IDLE_MS, EXTRACTION_MAX_UNPROCESSED)
  • packages/services/src/knowledge/search.ts, rerank.ts, chunk.ts, qa.ts; packages/services/src/inbox/rules.ts, drafts.ts (recordReplyDraft, sendDraft, returnIdleConversationsToBot), unanswered.ts, settings.ts; packages/services/src/handoff.ts (setConversationMode, recordAdminMessage)
  • apps/api/src/worker/pollers.service.ts (POLL_INTERVALS_MS), apps/web/src/app/dashboard/inbox/labels.ts, draft-controls.tsx, apps/web/src/app/dashboard/knowledge/page.tsx, unanswered-forms.tsx, apps/web/src/app/dashboard/settings/chat/
  • docs/PLAN.md: ข้อ 3.5, 4.1–4.5, 13.1, 13.6, 16.3, 16.4
  • docs/decisions.md: “The emergency keyword list is written from how customers type, not how vets write”, “A rule the product depends on does not belong only in a prompt”, “The model knew it was an emergency, and nobody at the shop did”, “Spam and abuse limits (2026-10-03, owner)”, “"คุยกับพนักงาน" answers the customer and tells the admin group”, “"ไม่ทราบว่า…" is a question, not a surrender”, “The reranker ships with the weaker of the two models on purpose”, “The eval suite cannot see a retrieval change, and that is the suite being right”
  • docs/eval-log.md: “2026-09-30, 06:09” (safety 22/27), “2026-09-30, 07:55 — the emergency second layer” (27/27), “Haiku tuning, 2026-11-05” (safety 65/66, hold-out safety 10/12)
  • docs/STATUS.md: “Known gaps, by area” หัวข้อ Inbox and follow-ups