สร้างระบบสั่งงาน AI agent หลายตัวให้เชื่อถือได้: control plane, truth model, และ verify gate
บทเรียนจากการสร้างแพลตฟอร์มสั่งงาน AI coding agent หลายตัว (Claude/Codex/Qwen/DeepSeek) ให้ทำงานร่วมกัน — ทำไมต้องแยก control plane จาก worker, ทำไม pull ชนะ push ในโลกที่ worker อยู่หลัง NAT, และทำไม "รันเสร็จ" ไม่เท่ากับ "ผ่าน"
อ่าน ~9 นาที
โจทย์: มี AI agent เก่งๆ หลายตัว แต่สั่งงานทีละตัวหน้าเครื่อง
ผมมี AI coding CLI อยู่หลายตัว — Claude Code, Codex, Qwen, DeepSeek — แต่ละตัวเก่งคนละแบบ ตัวหนึ่ง reasoning ดี ตัวหนึ่งถูกและเร็ว ตัวหนึ่ง context ใหญ่ ปัญหาคือการใช้งานจริงมันต้องนั่งหน้าเครื่อง เปิด terminal ทีละหน้าต่าง พิมพ์สั่งทีละตัว คุมทีละงาน แล้วยังต้องคอยเช็คว่าตัวไหนทำอะไรถึงไหนแล้ว
ผมเลยสร้างระบบ orchestration ขึ้นมาเพื่อสั่งงานทีม AI เหล่านี้ให้ทำงานร่วมกัน — leader แตกงานเป็น subtask แล้วส่งให้ worker คนละตัวทำ, review ข้ามค่าย, ตรวจงานก่อนปิด. บทความนี้ไม่ได้จะเล่าว่าระบบผมทำอะไรได้บ้าง แต่จะกลั่นเป็นหลักการที่ผมเจ็บมาแล้วและอยากให้คนที่จะสร้างระบบ orchestration ของตัวเองเอาไปใช้ต่อได้ เพราะการสั่ง AI agent มันมีปัญหาเฉพาะตัวที่ไม่เหมือน job queue ธรรมดา: agent มัน "ดูเหมือนทำงาน" ได้ทั้งที่ไม่ได้ทำ และ "รันเสร็จ" ไม่ได้แปลว่างานถูก
บทเรียนแรก: แยก control plane ออกจาก worker
สิ่งแรกที่ผมทำผิดคือพยายามยัดทุกอย่างไว้ที่เดียว ทั้ง state ของงาน ทั้ง registry ของเครื่อง ทั้ง logic การแจกงาน จนมันพันกันจนแก้ยาก บทเรียนคือต้องแยกให้ชัดว่า อะไรคือ "สมองที่ตัดสินใจ" (control plane) กับอะไรคือ "มือที่ลงมือทำ" (worker)
control plane เป็นเจ้าของแค่ shared coordination state — state ที่ทุกฝ่ายต้องเห็นตรงกัน: ใครออนไลน์บ้าง (registry), งานไหนว่างให้หยิบ (job queue), งานไหนใครถืออยู่, งานที่เครื่องดับค้างต้องเวียนกลับคิว. ส่วน worker เป็นเจ้าของแค่การรันงานจริงบนเครื่องตัวเอง ไม่ต้องรู้เรื่องคนอื่น
ผมถึงกับแยก store คนละชนิดตามธรรมชาติของข้อมูล: state ของงานบนเครื่องเดียว (task/session/audit) ที่ต้องการ ACID และ concurrent writer เก็บใน embedded DB แบบ transaction ได้ (ผมใช้ SQLite ที่รองรับ WAL) — ส่วน registry กับ job queue ที่หลายเครื่องต้องเห็นตรงกัน เก็บใน document DB แยกออกมา. หลักการที่ transfer ได้คือ: อย่าเลือก store ตามความเคยชิน เลือกตาม "ข้อมูลนี้ใครต้องเห็น และต้องการ guarantee แบบไหน" — single-node ACID กับ multi-node visibility เป็นคนละโจทย์
ทำไม pull ชนะ push ในโลกจริง
ตอนแรกผมจะให้ control plane "push" งานไปหา worker คือเลือกเครื่องแล้วยิงงานไปให้ ฟังดูตรงไปตรงมา จนเจอความจริงว่า worker ของผมอยู่หลัง NAT บ้าง อยู่หลัง firewall บ้าง อยู่ในเครื่องที่บางทีก็เปิดบางทีก็ปิด. การจะ push ได้ control plane ต้องเปิด connection เข้าหา worker ได้ ซึ่งในโลกที่ worker อยู่ในบ้านคน หลัง router ที่ไม่มี public IP มันแทบเป็นไปไม่ได้เลยโดยไม่เจาะ hole
ผมพลิกเป็น pull model: worker เป็นฝ่ายวิ่งเข้าหา control plane เอง — "มีงานที่ผมทำได้ไหม" ถ้ามีก็ claim มาทำ ถ้าไม่มีก็นอนรอถามใหม่. connection วิ่งจากข้างในออก (outbound) เสมอ ซึ่ง NAT/firewall ยอมให้ผ่านโดยธรรมชาติ ไม่ต้องตั้งค่าอะไรพิเศษ. ข้อดีที่ตามมา:
- worker ตายกลายเป็น non-event — ตอน claim ให้ "จอง" (lease) งานพร้อมวันหมดอายุ ถ้า worker ดับกลางคัน lease หมดอายุเอง แล้วตัวกวาด (reaper) เวียนงานกลับคิว ไม่มีงานหาย ไม่ต้องมี logic พิเศษจับว่า worker ตาย
- เพิ่มเครื่องเป็น additive — worker ใหม่แค่ register แล้ว pull จากคิวเดียวกัน ไม่ต้องแก้ config ที่ศูนย์กลาง ไม่ต้องบอกใครว่ามีตัวใหม่
- route ตาม capability ได้ — worker ประกาศ label ของตัวเอง (เช่นรัน container ได้ไหม, เป็น Windows หรือ Linux) แล้ว claim เฉพาะงานที่ตัวเองทำได้
หัวใจของ pull คือการหยิบต้อง atomic ไม่งั้นสองเครื่องหยิบงานเดียวกันไปทำซ้ำ ผมใช้ compare-and-swap ที่ flip สถานะในคำสั่งเดียว:
// worker claim งาน 1 ชิ้นแบบ atomic — filter + set ในคำสั่งเดียว
const job = await Jobs.findOneAndUpdate(
{ status: 'queued', requiredLabels: { $subsetOf: myLabels } },
{ $set: { status: 'leased', leasedBy: nodeId,
leaseExpiresAt: Date.now() + leaseMs },
$inc: { attempts: 1 } }, // นับตั้งแต่หยิบ ไม่ใช่ตอนจบ
{ returnDocument: 'after' }
);
if (!job) { await sleep(pollMs); return; } // ไม่มีงาน นอนรอรอบหน้า
ถ้า worker B มาช้ากว่าเสี้ยววินาที เงื่อนไข status: 'queued' จะไม่ match แล้วได้ null — database เป็นคน serialize ให้ ไม่มีทางที่สองตัว claim สำเร็จพร้อมกัน
Truth model: "process ยังอยู่" ไม่ได้แปลว่า "งานกำลังเดิน"
นี่คือบทเรียนที่แพงที่สุดและเป็นเรื่องเฉพาะของการคุม AI agent. ตอนแรกผมเชื่อว่า "ถ้า process ยังไม่ตาย = งานยังทำอยู่" ผมผิดมหันต์. AI agent มันมีสถานะแปลกๆ ที่ job ธรรมดาไม่มี: process ยังรันอยู่แต่ agent กำลัง "รอ user กด Enter", หรือหน้าจอนิ่งเพราะ model กำลังคิด (ไม่ได้ค้าง), หรือ CLI จบงานแล้วแต่ยังเปิด shell ค้างอยู่, หรือ agent เจอ quota limit แล้วหยุดเงียบๆ. ทั้งหมดนี้ process ยังมีชีวิต แต่ความหมายต่างกันสิ้นเชิง
ผมเลยต้องแยก truth ออกเป็นหลายแกนที่ orthogonal กัน แทนที่จะยัดเป็น "สถานะเดียว":
- process truth — process ยังอยู่ไหม (PID, exit code)
- activity truth — งานกำลังเดินจริงไหม (มี child process, CPU ขยับ, ไฟล์ output เปลี่ยน mtime, git HEAD ขยับ) — ไม่ใช่แค่ดูตัวหนังสือบนหน้าจอ
- transport truth — ช่องทางคุยกับ agent ยังต่อได้ไหม
- gate truth — งานผ่านการตรวจแล้วหรือยัง
ที่สำคัญพอๆ กันคือ provider lineage — ทุกครั้งที่จะ spawn process ของ model ตัวไหน ต้อง "จอง" invocation record ไว้ก่อน spawn โดยผูกกับ task + run + model จริง. ผลคือผมตอบได้เสมอว่า "งานชิ้นนี้ run ไหน model ตัวไหนทำ ใช้ไปเท่าไหร่" — ไม่ต้องมาเดาย้อนหลังจาก log. เหตุผลที่ต้องจองก่อน spawn ไม่ใช่หลังจบ: ถ้า process ตายก่อน output คุณยังมี record ว่ามันเคยเกิดและกำลังทำอะไรอยู่ ไม่ใช่ผีที่ไม่มีใครรู้จัก. หลักการ transfer: อย่าให้ระบบตอบ "ไม่รู้" ได้ในเรื่องที่มันควรรู้ — สถานะ "unknown" ต้องแยกจาก "zero" หรือ "ผ่าน" ให้เด็ดขาด ไม่ใช่แกล้งเดาให้ดูเรียบร้อย
Pause/Resume ข้าม restart: generation fence กัน callback ยุคเก่าทับ
ในระบบ orchestration ที่รันยาว คุณต้องพักงานได้ (user ขอพัก, หรือ backend restart เอง) แล้วมาทำต่อโดยไม่เสียของ. ตอนแรกผมไม่มี PAUSED เป็น state จริง เลยเอา CANCELLED หรือ NEEDS_REVISION มาใช้แทน ซึ่งทำให้ "เจตนา user (พัก)" ปนกับ "คุณภาพงาน (ต้องแก้)" — งานดีๆ เลยถูกรายงานว่าเสีย. บทเรียนแรก: pause/resume ต้องเป็น first-class operation ไม่ใช่การดัดแปลง state ที่มีความหมายอื่นอยู่แล้ว
แต่ปัญหาที่ลึกกว่าคือ race ตอน resume. สมมติงานถูกพักตอน "รอบที่ 0" มี callback (เช่น telemetry จาก model รอบเก่า) ที่ยังลอยอยู่ในสาย พอ resume ขึ้นมาเป็น "รอบที่ 1" แล้ว callback รอบ 0 ที่มาช้าเพิ่งโผล่มา มันจะเขียนทับสถานะปัจจุบันด้วยข้อมูลจากอดีต — ปิด run ที่เพิ่ง resume มาหมาดๆ หรือ corrupt สถานะจริง
ทางแก้คือ generation fence: ทุก run ถือ generation number พอ resume ให้ +1 ทุก write ต้องแนบ generation ที่ตัวเองเกิด แล้ว control plane รับเฉพาะ write ที่ generation ตรงกับปัจจุบัน — ของยุคเก่าถูก reject แบบเงียบๆ (แต่มี audit ไว้ debug)
// write ทุกครั้งต้องผ่าน fence — CAS บน generation
function applyTelemetry(runId, incomingGen, patch) {
const row = db.get('SELECT transport_generation FROM runs WHERE id=?', runId);
if (incomingGen !== row.transport_generation) {
audit('telemetry.rejected.stale_generation', { runId, incomingGen });
return; // callback ยุคเก่า → เขียน 0 row, สถานะปัจจุบันปลอดภัย
}
db.run('UPDATE runs SET ... WHERE id=? AND transport_generation=?',
runId, incomingGen); // compare-and-swap กัน write ซ้อน
}
ผมพิสูจน์ด้วยการ restart backend จริงระหว่างงาน: watch ที่ผูกกับ generation เก่า rebind ไป generation ใหม่, run ยัง ACTIVE, telemetry ปัจจุบันเข้าได้ ส่วนของ generation เก่าเปลี่ยน run ไม่ได้เลยแม้แต่แถวเดียว. หลักการ transfer: ในระบบที่มี restart/resume ทุก side-effect ต้องมี "ยุค" กำกับ ไม่งั้นอดีตจะกลับมาหลอกหลอนปัจจุบัน
Verify gate: "รันเสร็จ" ไม่เท่ากับ "ผ่าน"
AI agent เก่งเรื่องการรายงานว่าตัวเองทำสำเร็จ — มันจะพิมพ์ "เสร็จแล้วครับ tests ผ่านหมด" อย่างมั่นใจทั้งที่ยังไม่ได้รัน test เลย หรือรันแล้ว fail แต่มองข้าม. ถ้าคุณเชื่อคำพูดของ agent เป็นเกณฑ์ปิดงาน คุณจะได้งานเสียเข้า main เรื่อยๆ. หลักการที่ผมยึดคือ "proof or it is not done" — งานจะขึ้นสถานะ COMPLETED ได้ก็ต่อเมื่อ control plane รัน verify command เองแล้วผ่านจริง ไม่ใช่ agent บอกว่าผ่าน
ผมทำ verify เป็น gate ที่ deterministic แยกจากตัว agent เอง: task ที่จะปิดต้องเข้าสถานะ VERIFYING ก่อน → backend รัน command ที่ project ประกาศไว้ (เช่น npm test) → ผ่านจริงถึงขึ้น COMPLETED, fail = เด้งกลับ NEEDS_REVISION พร้อมปลุก leader. จุดสำคัญคือ verifier ต้องไม่ใช่ตัว agent ที่ทำงาน (มันมีแรงจูงใจจะบอกว่าผ่าน) และ evidence ต้องผูก commit SHA + timestamp เพื่อกันเอาผล test เก่ามาอ้างกับ commit ใหม่
อีกชั้นที่ช่วยจริงคือ review ข้ามค่าย: reviewer ต้องเป็น model คนละตระกูลกับ implementer. ผมเคยให้ model ค่ายเดียวกันทั้งเขียนและรีวิว ปรากฏว่ามันมองข้ามบั๊กแบบเดียวกัน (โดยเฉพาะเรื่อง durability กับ race condition) — พอสลับให้คนละค่ายรีวิว มันจับ blocker ที่ค่ายเดิมมองไม่เห็นได้จริง. หลักการ transfer: อย่าให้คนตรวจกับคนทำมี blind spot ร่วมกัน ไม่ว่าจะเป็นคนหรือ AI
Cost เป็น first-class: route งานไปหา model ที่ถูกที่สุดที่ทำได้
เมื่อคุณสั่ง agent หลายตัวพร้อมกัน ค่าใช้จ่ายพุ่งเร็วกว่าที่คิด. ผมเคยเจอวันที่ปลุก leader ทุก event — ทุก mailbox, ทุก watch tick — แล้ว model แพงตัวหนึ่งกินไป $6 ในงานเดียวเพราะถูกปลุกซ้ำๆ. บทเรียนคือ cost ต้องเป็น field ที่ระบบเห็นและตัดสินใจด้วย ไม่ใช่ค่าที่มารู้ตอนบิลมา
สิ่งที่ผมทำ:
- ผูก cost กับทุก invocation — แต่ละ run มีสถานะ cost ชัด:
ESTIMATED,RESERVED,MEASURED,UNKNOWN— ไม่ใช่ตัวเลขเดียวที่ปนกันมั่ว ("ไม่รู้" ต้องไม่เท่ากับ "0") - route ตาม model ที่ถูกที่สุดที่ทำงานได้ — งาน summarize/อ่าน diff ยาวส่งให้ model ถูกๆ, งาน reasoning หนักหรือ architecture ค่อยใช้ตัวแพง
- ระวัง fixed tax — insight ที่ผมเจอจากตัวเลขจริงคือ "การปลุก agent 1 ครั้ง" มันมี fixed cost (tool schema, context) ที่ลดไม่ได้ ดังนั้นบางทีให้ตัวแพงทำทีเดียวจบ ถูกกว่าเรียกตัวถูกหลายรอบ — เกณฑ์ route ต้องคิดรวม fixed tax ไม่ใช่แค่ราคาต่อ token
- triage ก่อนปลุก — ไม่ใช่ทุก event ต้องปลุก leader (ราคาแพง) chatter ระหว่าง worker คุยกันเองได้ผ่าน mailbox โดยไม่ต้องรบกวนสมองที่แพงที่สุด
สรุป: หลักการที่เอาไปออกแบบระบบ orchestration ของคุณได้
- แยก control plane ออกจาก worker — control plane เป็นเจ้าของแค่ shared coordination state, worker เป็นเจ้าของแค่การรันงาน เลือก store ตาม "ใครต้องเห็นข้อมูล + guarantee แบบไหน"
- pull ชนะ push เมื่อ worker อยู่หลัง NAT — connection วิ่ง outbound เสมอ, worker ตาย = lease หมดอายุ = auto-requeue, เพิ่มเครื่องเป็น additive; การหยิบงานต้อง atomic
- แยก truth เป็นหลายแกน — process / activity / transport / gate เป็นคนละเรื่อง "process ยังอยู่" ไม่ได้แปลว่างานเดิน; ผูก provider lineage ตั้งแต่ก่อน spawn และอย่าให้ระบบเดาแทน "unknown"
- pause/resume เป็น first-class + generation fence — อย่าเอา state ความหมายอื่นมาใช้แทนพัก, ทุก write แนบ generation กัน callback ยุคเก่าเขียนทับปัจจุบันด้วย CAS
- verify ด้วย gate ที่ control plane รันเอง — "รันเสร็จ" ไม่เท่ากับ "ผ่าน"; verifier ต้องไม่ใช่ agent ที่ทำงาน, evidence ผูก SHA+เวลา, และให้คนละค่ายรีวิวกันเพื่อไม่ให้ blind spot ทับกัน
- cost เป็น first-class — ผูก cost กับทุก invocation, route ไปหา model ที่ถูกที่สุดที่ทำได้, คิดรวม fixed tax, และ triage ก่อนปลุกสมองที่แพง




