MCP Optimization: ลด round-trip ของ resolver ด้วย Jev (ตอนที่ 3)
ปัญหาที่ MCP เจอทุกวัน: การปิงปอง
MarketDX เปิดให้ผู้ช่วย AI (เช่น Claude) เรียกข้อมูลตลาดผ่าน MCP — ชุด "เครื่องมือ" ที่ LLM เรียกใช้ได้เอง ค้นหุ้น ดึงงบ สกรีน ฯลฯ
ฟังดูง่าย แต่มีขั้นตอนหนึ่งที่แพงเงียบ ๆ คือ การแปลงคำพูดคนให้เป็นค่าที่ระบบเข้าใจ
ลองดูคำถามจริง:
"หุ้นในกลุ่มค้าปลีกที่มีสินทรัพย์หมุนเวียนเพิ่มขึ้นก้าวกระโดด"
ก่อนจะสกรีนได้ ระบบต้องแปลง 3 ชิ้นนี้เป็น "โทเคน" ที่ฐานข้อมูลรู้จัก:
- "สินทรัพย์หมุนเวียน" →
CurrentAssets - "เพิ่มขึ้นก้าวกระโดด" →
accelerating - "กลุ่มค้าปลีก" →
2550(รหัส GICS)
งานนี้เราทำด้วยเครื่องมือตระกูล find_* (find_fsconcept, find_fspattern, find_gics ...) ปัญหาคือมันทำงานแบบนี้:
- LLM เรียก
find_fsconcept("สินทรัพย์หมุนเวียน")→ ระบบคืน รายการผู้สมัคร (top-k) กลับมาสัก 10 ตัว - LLM อ่านแล้วเลือก อันที่ใช่
- ทำซ้ำกับ
find_fspattern→ คืน top-k → เลือก - ทำซ้ำกับ
find_gics→ คืน top-k → เลือก - แล้วค่อย เรียกเครื่องมือสกรีนจริงด้วยค่าที่เลือกได้
นับ round-trip ดู: สี่รอบไปกลับ กว่าจะได้เริ่มงานจริง ทุกรอบมี latency ของเน็ต บวกกับที่หนักกว่านั้น — LLM ต้องเผา reasoning token อ่าน top-k แล้วตัดสินใจเลือกทุกครั้ง
นี่คือ "การปิงปอง" ที่ MCP เจอแทบทุกคำถาม ยิ่งคำถามซับซ้อน (หลาย concept) ยิ่งปิงปองหลายรอบ ผู้ใช้ก็รอนานขึ้น
find_* ทำงานยังไงข้างใน (vector search 101)
ก่อนไปต่อ ขออธิบายว่า find_* แปลง "สินทรัพย์หมุนเวียน" เป็น CurrentAssets ได้ยังไง เพราะมันคือหัวใจของเรื่องนี้
หัวใจคือเทคนิคชื่อ vector embedding — แปลง "ความหมาย" ของข้อความให้เป็นชุดตัวเลขยาว ๆ (เวกเตอร์หลายพันมิติ) ข้อความที่ความหมายใกล้กันจะได้เวกเตอร์ที่อยู่ใกล้กันในปริภูมิ
ระบบเราทำสองอย่าง:
- เตรียมล่วงหน้า (ครั้งเดียว): ทุกแนวคิดใน catalog (เช่น "Total Revenue", "Current Assets", "Gross Margin") ถูกแปลงเป็นเวกเตอร์ด้วยโมเดล embedding (เราใช้ของ Gemini) แล้วเก็บไว้ในฐานข้อมูล
- ตอนมีคำถาม: เอาคำที่ผู้ใช้ถาม ("สินทรัพย์หมุนเวียน") มาแปลงเป็นเวกเตอร์แบบเดียวกัน แล้ว หาเพื่อนบ้านที่ใกล้ที่สุด (nearest-neighbor ด้วย cosine similarity) — เวกเตอร์ยิ่งใกล้ = ความหมายยิ่งคล้าย
เรียงผลตามความใกล้ เอา k อันบนสุด = top-k (จริง ๆ มีอีกชั้นคือจับคำตรงตัว/ชื่อพ้อง — lexical match — มาก่อน semantic แต่หลักการรวมคือ "คืนผู้สมัครที่คล้ายที่สุดมาสัก 10 ตัว")
จุดอ่อนอยู่ตรงนี้: "ใกล้ในเชิงเวกเตอร์" ไม่ได้แปลว่า "ใช่" เสมอ คำที่ความหมายเฉียด ๆ กัน — "ผลตอบแทนผู้ถือหุ้น" กับ "เงินปันผลต่อหุ้น", "ค่าเผื่อหนี้สงสัยจะสูญ" กับ "ตั้งสำรองหนี้สงสัยจะสูญ" — เวกเตอร์มันใกล้กันมากจนโผล่ปนกันใน top-k และบางทีตัวผิดดันอยู่อันดับสูงกว่าตัวถูกด้วยซ้ำ นั่นคือเหตุผลที่ระบบ ไม่กล้า ฟันธงอันเดียว
ทำไมต้องคืน top-k ตั้งแต่แรก?
คำถามที่ดีคือ — ทำไม find_* ไม่คืนคำตอบเดียวไปเลย ให้จบ?
เพราะ อันดับ 1 ไม่ได้ถูกเสมอ เราวัดจริงกับ find_fspattern (ตัวหา"รูปแบบ" ของงบ): ถ้าเชื่ออันดับ 1 ของ vector search ดื้อ ๆ จะถูกแค่ 71% แต่ถ้าดู top-10 ทั้งชุด — คำตอบที่ถูก อยู่ในนั้น 100%
find_fspattern (51 รูปแบบ):
เชื่ออันดับ 1 ดื้อ ๆ → ถูก 71%
คำตอบอยู่ใน top-10 → 100%
เห็นช่องว่าง 71% → 100% ไหม? นั่นแหละคือเหตุผลที่มี top-k — ระบบไม่มั่นใจพอจะฟันธง เลยโยนตัวเลือกกลับให้ LLM ตัดสิน และนั่นคือต้นตอของการปิงปอง
ไอเดีย: ย้าย "การเลือก" ไปไว้ฝั่งเซิร์ฟเวอร์
ถ้าปัญหาคือ "ระบบไม่กล้าเลือก เลยโยนกลับให้ LLM" — แล้วถ้าเรามีตัวที่ กล้าเลือกแทน อยู่ฝั่งเซิร์ฟเวอร์ล่ะ?
นี่คือจุดที่ Jev เข้ามา จากตอน 1-2 เรารู้แล้วว่ามันเป็น "โมเดลตัดสินใจ" ที่เร็วและถูก — ให้ตัวเลือกกับคำถาม มันคืน คำตอบเดียว + ความมั่นใจ (confidence) ไม่ต้อง generate ข้อความยาว
โครงใหม่จึงเป็น:
เดิม: user → LLM เลือก route → find_* คืน top-k → LLM เลือก → เรียก tool จริง (ปิงปอง)
ใหม่: user → LLM ส่งคำดิบเข้า tool → tool ให้ Jev เลือกเอง → ทำงานต่อได้เลย (รอบเดียว)
LLM ส่ง "สินทรัพย์หมุนเวียน" เข้าเครื่องมือสกรีนตรง ๆ เครื่องมือเรียก Jev แปลงเป็น CurrentAssets ข้างในเอง แล้วสกรีนต่อได้เลย — ตัดขั้นตอนโยน top-k กลับไปให้ LLM เลือกทิ้งทั้งหมด
คำถามคือ Jev เลือกแม่นพอจะทำแบบนี้ไหม เราลองสองสถานการณ์
กรณี catalog เล็ก: ให้ Jev เลือกจากทั้งชุดทีเดียว
find_fspattern มีรูปแบบทั้งหมด 51 ตัว — เล็กพอจะยัดทั้งชุดเป็นตัวเลือกให้ Jev เลือกในครั้งเดียว
อ่านผล:
find_fspattern (51 รูปแบบ, คำถามไทย/อังกฤษ):
Jev เลือกทีเดียว → ถูก 92.9%
vector อันดับ 1 (แบบเดิม) → ถูก 71.4%
Jev ทิ้งอันดับ-1 แบบเดิมขาด และเข้าใกล้เพดาน 100% ที่ปกติต้องพึ่ง LLM มาเลือกซ้ำ — แต่ทำในครั้งเดียว ไม่ต้องปิงปอง
เซอร์ไพรส์: เคสที่ "พลาด" ส่วนใหญ่เป็นความผิดของเรา
ตอนแรกเรามีเคสที่ดูเหมือน Jev พลาด: คำถาม "มูลค่าหุ้นเพิ่มขึ้นเป็นสองเท่า" Jev ตอบผิดเป็น "สูงกว่าค่าอื่น" แทนที่จะเป็น "เติบโตทบต้น" (compound growth)
เกือบโทษ Jev แล้ว — แต่พอไปดู สิ่งที่เราป้อนให้มัน เราส่งแค่ "นิยามสั้น" ของแต่ละรูปแบบ ซึ่งนิยามของ compound growth เขียนเน้นแต่ "อัตราต่อปี (CAGR)" ไม่มีคำว่า "เป็นสองเท่า" เลย ทั้งที่ในฐานข้อมูลจริงมีคำอธิบายอีกฟิลด์ที่เขียนชัดว่า "มูลค่าเพิ่มเป็นสองเท่า/สามเท่า"
วิธีแก้: ป้อนคำอธิบายให้ครบเหมือนที่ระบบค้นหาใช้จริง — แก้ปุ๊บ Jev ตอบถูกทันที
บทเรียนนี้ย้ำสิ่งที่เราเจอตั้งแต่ตอน 2: ก่อนโทษโมเดล ให้ดูสิ่งที่เราป้อนมันก่อน เกือบทุกเคสที่ "พลาด" กลายเป็น context ที่เราให้ไม่ครบ หรือเฉลยของเราเองที่แคบเกินไป
กรณีหัวใจ: candidate เยอะเกินจะยัดหมด — vector + Jev
นี่คือกรณีที่เกิดบ่อยและสำคัญที่สุด catalog ส่วนใหญ่ใหญ่เกินกว่าจะยัดทั้งชุดให้ Jev — ตัวหาแนวคิดงบการเงิน (find_fsconcept) มีถึง 428 รายการ (บรรทัดงบ + อัตราส่วน + ตัวคูณราคา) ตัวหาหุ้นมีเป็นหมื่น ตัวหาข้อมูลเศรษฐกิจมีเป็นหลักหมื่น
โยนทั้ง 428 ตัวให้ Jev เลือกทีเดียวทำไม่ได้ (ทั้งแพงและเกินขีดจำกัด) แต่เราไม่จำเป็นต้องทิ้งของเดิม — เอา vector search ที่มีอยู่แล้วมาช่วยกรองก่อน:
คำดิบ → [vector search กรองเหลือ top-15] → [Jev เลือก 1 จาก 15] → โทเคน
vector search ทำสิ่งที่มันเก่ง (กรองจากพันเหลือสิบ) Jev ทำสิ่งที่มันเก่ง (เลือกตัวที่ใช่จากสิบ) — และการเลือกยังอยู่ฝั่งเซิร์ฟเวอร์ ไม่ต้องปิงปองกลับหา LLM
อ่านผล:
find_fsconcept (428 รายการ, คำถามอังกฤษ):
vector อันดับ 1 (แบบเดิม) → ถูก 68%
vector top-15 → Jev เลือก → ถูก 100%
Jev แก้ ทุกเคส ที่ vector จัดอันดับ 1 ผิด และเป็นเคสยากจริง เช่น:
- "ผลตอบแทนผู้ถือหุ้น (ROE)" — vector เดาเป็น "เงินปันผลต่อหุ้น" → Jev เลือก
roeถูก - "หนี้ที่มีภาระดอกเบี้ยรวม" — vector เดาเป็น "ดอกเบี้ยค้างจ่าย" → Jev เลือก
total_debtถูก - "งานระหว่างก่อสร้าง", "เงินลงทุนที่ถือจนครบกำหนด", "กำไรขาดทุนเบ็ดเสร็จอื่นสะสม" — ศัพท์บัญชีลึกที่ vector ลอยหมด แต่ Jev เลือกถูก
ที่น่าทึ่งกว่าคือมันแยกแยะ "ประเภท" ได้ถูก: "กำไรขั้นต้น" → บรรทัดในงบ (gross_profit) ส่วน "อัตรากำไรขั้นต้น" → อัตราส่วน (gross_margin) — คนละตัวกัน Jev ไม่สับสน
แล้วต้นทุนล่ะ? — vector + Jev ยังถูกกว่า
หลายคนคงคิดว่าเพิ่ม Jev เข้าไปก็ต้องแพงขึ้นสิ ตรงกันข้าม
| วิธี | Jev อ่านกี่ตัวเลือก | ต้นทุน / 1,000 ครั้ง |
|---|---|---|
| ยัดทั้ง catalog (51 ตัว) | 51 | $0.35 |
| vector กรองก่อน → Jev (10-15 ตัว) | ~10 | $0.08 |
การกรองด้วย vector ก่อน ถูกลง ~4 เท่า เพราะต้นทุนหลักของ Jev คือความยาวของตัวเลือกที่ป้อนเข้าไป — อ่าน 10 ตัวย่อมถูกกว่าอ่าน 400 ตัว
แถมค่า embedding ของ vector แทบเป็นศูนย์ เพราะเราเก็บ vector ของคำค้นที่เคยเจอไว้ถาวรอยู่แล้ว (คำค้นซ้ำ = ไม่ต้องคำนวณใหม่) ส่วนตัว Jev เอง — คิดเงินเฉพาะ input, output ฟรี, และถูกกว่า LLM ทั่วไปหลายเท่า
เทียบเป็น round-trip: จาก 4 รอบ → 1 รอบ ผู้ใช้ได้คำตอบไวขึ้นชัดเจน และ LLM ฝั่ง client ไม่ต้องเสียสมาธิมานั่งเลือก top-k ทุกคำถาม — มันเอา token ไปคิดเรื่องที่สำคัญกว่าได้ นี่คือ UX ของ MCP ที่ดีขึ้นจริง
บทเรียนที่ต้องจำ (มีจริง)
การจะเอาไปใช้จริงต้องซื่อสัตย์กับข้อจำกัด — เราเจอ 4 เรื่อง
1. context ที่ป้อนสำคัญกว่าที่คิด เกือบทุกเคสที่ Jev "พลาด" คือเราป้อนคำอธิบายไม่ครบ หรือเฉลยของเราเองแคบเกิน แก้คำอธิบายในฐานข้อมูล = ดีขึ้นทั้งระบบ (ทั้ง vector และ Jev กินคำอธิบายชุดเดียวกัน)
2. "ถามยืนยัน" ต้องฉลาด ไม่งั้นทำลาย UX ที่เพิ่งแก้มา บางครั้ง Jev ก็ไม่มั่นใจจริง ๆ — คำถามกำกวมเอง หรือมีตัวเลือกสูสีสองตัว เราออกแบบให้เครื่องมือ ดู "รูปทรง" ของความมั่นใจ ไม่ใช่แค่ตัวเลขเดียว:
- มั่นใจชัด → ใช้เลย
- ก้ำกึ่งแต่พอได้ → ตอบไปก่อน พร้อมแนบหมายเหตุว่าตีความยังไง (ไม่บล็อก)
- สองตัวสูสีจริง + ต่างกันจริง → ค่อยถามผู้ใช้ยืนยัน (เกิดน้อยมาก)
หลักคือ: ถามยืนยันเฉพาะตอนที่ "คำตอบที่ถูกมีอยู่จริงแต่ Jev เลือกไม่ขาด" เท่านั้น ไม่ใช่ถามทุกครั้งที่ไม่มั่นใจ — ไม่งั้นก็กลับไปปิงปองเหมือนเดิม
3. หลายภาษา = ให้แปลเป็นอังกฤษก่อน (และนี่คือข้อจำกัดของ vector ไม่ใช่ Jev) เราลองศัพท์บัญชีลึกหลายภาษา เจอว่า "ค่าความนิยม" (goodwill) ภาษาไทย ตกไปอยู่อันดับ 45 ของ vector — หลุด top-15 ไป Jev เลยไม่เห็นให้เลือก ดูเผิน ๆ เหมือน Jev พัง
แต่เราพิสูจน์แล้วว่า เป็นความอ่อนของ vector (embedding) ข้ามภาษา ไม่ใช่ Jev — ถ้าถามคำเดียวกันเป็นภาษาอังกฤษ ("goodwill") มันขึ้นอันดับ 1 ทันที Jev เองก็รู้ว่า のれん (ญี่ปุ่น) = goodwill ด้วยซ้ำ
โชคดีที่เครื่องมือ MCP ของเรากำหนดให้ client แปลเป็นอังกฤษก่อนส่งอยู่แล้ว — ปัญหานี้จึงไม่เกิดในทางปฏิบัติ
4. เส้นแบ่งบาง ๆ ทางบัญชี — Jev ผ่านสวย เราจัดชุดทดสอบโหดสุด: คู่ศัพท์ที่ต่างกันนิดเดียวแต่คนละความหมาย
| คู่ที่ต้องแยก | Jev แยกออกไหม |
|---|---|
| หุ้นทุนซื้อคืน (treasury stock) ↔ การซื้อหุ้นคืน (buybacks) | ✅ |
| ตั้งสำรอง (P&L) ↔ ค่าเผื่อ (งบดุล) | ✅ |
| สินทรัพย์ภาษีรอตัดบัญชี ↔ หนี้สินภาษีรอตัดบัญชี | ✅ |
| หมุนเวียน ↔ ไม่หมุนเวียน | ✅ |
| หนี้มีดอกเบี้ย ↔ หนี้สินรวม ↔ เงินกู้ (ไม่รวมลีส) | ✅ |
25 คู่โหด — Jev ถูก 100% ด้วยความมั่นใจเฉลี่ย 0.998 เทียบกับ vector อันดับ 1 ที่ถูกแค่ 48% นี่คือจุดที่ห่วงที่สุด (ตอบผิดแบบมั่นใจ) แต่กลับผ่านสบายในภาษาอังกฤษ
สรุป: เมื่อไหร่ควรยุบ resolver ด้วย Jev
จากที่ลองมา แนวทางที่ชัดคือ:
- catalog เล็ก (ไม่กี่สิบ): ให้ Jev เลือกจากทั้งชุดทีเดียว
- catalog ใหญ่ (ร้อย/พัน/หมื่น): ใช้ vector search กรองก่อนเหลือ ~10-15 → ให้ Jev เลือก — ยังยุบ round-trip และถูกลงพร้อมกัน
- มีทางลัด cache: คำที่เคยแปลแล้ว (เช่น "current assets" →
CurrentAssets) เก็บผลไว้ ครั้งต่อไปคืนเลย ไม่ต้องเรียก Jev ซ้ำ
ทั้งหมดนี้ทำให้ MCP จาก "ปิงปองสี่รอบต่อคำถาม" เหลือ "รอบเดียว" — เร็วขึ้น ถูกลง และ LLM ฝั่ง client เบาลง
ข้อควรระวัง: อย่าเพิ่งสลับทั้งหมดในวันเดียว วิธีที่ปลอดภัยคือรัน Jev คู่ขนานกับของเดิมบน traffic จริงสักพัก เทียบผล ดู distribution ของความมั่นใจ แล้วค่อยเปิดทีละส่วน — และเตรียม fallback ไว้เผื่อ Jev ช้าหรือล่ม (เราวัดได้ว่าปกติ ~1 วินาที แต่มี tail ที่พุ่งได้เป็นสิบวินาที)
แต่ทิศทางชัด: resolver คือ "ภาษี round-trip" ที่ MCP จ่ายทุกคำถาม — Jev ช่วยจ่ายภาษีนั้นแทน และแม้ candidate จะเยอะแค่ไหน การจับคู่ vector + Jev ก็ยังยุบมันได้ ทั้งเร็วขึ้นและถูกลง