AI Agent คืออะไร และต่างจาก Workflow อย่างไร
แยกให้ชัดระหว่าง AI Agent กับ Workflow อัตโนมัติ ว่าต่างกันตรงไหน และควรเลือกใช้อันไหน
AI เขียนโค้ดได้เร็วมาก แต่บั๊กที่รอดไปถึง production คือบั๊กที่เครื่องเราจำลองไม่ได้ — build tool, header ของ host และ security policy บันทึกจากการสร้างเว็บนี้ทั้งเว็บในวันเดียว พร้อมวิธีปิดช่องว่างระหว่าง local กับของจริง
เว็บไซต์ที่คุณกำลังอ่านอยู่นี้ถูกสร้างขึ้นทั้งหมดในวันเดียวด้วย Claude Code
โดยเขียนสเปกไว้ในไฟล์ AGENTS.md ก่อน แล้วให้ agent ไล่ทำตามทีละเฟส
ตั้งแต่วางโครงโปรเจกต์ ระบบคอนเทนต์ หน้าเว็บ ระบบค้นหา เทสต์ ไปจนถึง deploy
สิ่งที่น่าสนใจไม่ใช่ความเร็ว แต่คือ ประเภทของบั๊กที่เหลืออยู่หลังจบวัน เพราะมันเปลี่ยนไปจนต้องเปลี่ยนวิธีทำงานตาม
สเปกใน AGENTS.md แบ่งงานเป็น 6 เฟส และงานเดินตามลำดับนั้นจริง —
ดูได้จาก git log ของ repo นี้ที่ commit เรียงตามเฟสแทบตรงตัว
| เฟส | ขอบเขต | ตัวอย่างสิ่งที่ทำ |
|---|---|---|
| 1 | Foundation | ตั้งโปรเจกต์ Astro, TypeScript, Tailwind, lint/format |
| 2 | Content | content collections + schema ของแต่ละ collection |
| 3 | Core pages | หน้าแรก บทความ โปรเจกต์ builder log about contact |
| 4 | Discovery | ค้นหา หมวดหมู่ แท็ก RSS sitemap structured data |
| 5 | Quality | accessibility, responsive, unit test, e2e test, 404 |
| 6 | Launch | OG image, analytics, Search Console, deploy production |
เฟส 1–5 แทบไม่มีปัญหาอะไรที่ต้องคิดนาน โค้ดออกมา แล้วก็รันได้ ปัญหาจริง ๆ มากระจุกอยู่ที่รอยต่อระหว่าง “รันบนเครื่องเรา” กับ “รันบนของจริง”
ข้อสังเกตที่ชัดที่สุดของวันนั้นคือ
บั๊กเชิงตรรกะ (logic bug) ถูกจับได้เร็วมาก บั๊กที่รอดมาถึง production คือบั๊กที่ เครื่อง local ไม่มีทางจำลองได้
เหตุผลตรงไปตรงมา: logic bug อยู่ในโค้ดที่ agent เขียนและอ่านได้ทั้งก้อน แต่บั๊กอีกกลุ่มหนึ่งไม่ได้อยู่ในโค้ดเลย — มันอยู่ใน สิ่งที่ build tool เขียนทับให้ และ สิ่งที่ host ใส่เพิ่มให้ตอน serve ซึ่งทั้งสองอย่างนี้ไม่ปรากฏตัวตอนรันในเครื่อง
สามหัวข้อถัดไปคือกรณีจริงทั้งสามแบบ
ระบบค้นหาของเว็บนี้ใช้ Pagefind ซึ่งต้องโหลด bundle ด้วย dynamic import()
ตอนเปิดกล่องค้นหาครั้งแรก เขียนแบบตรงไปตรงมาก็คือ
const pagefind = await import('/pagefind/pagefind.js');
โค้ดนี้ถูก แต่ใช้ไม่ได้ เพราะ Vite จะเขียน dynamic import() ทุกจุดใหม่
ให้กลายเป็น helper ของตัวเองชื่อ __vitePreload และในสคริปต์ที่ Astro inline ลงไปใน HTML
argument ที่ชื่อ __VITE_PRELOAD__ ไม่ถูกแทนที่ด้วยค่าจริง
ผลคือ browser เจอ identifier ที่ไม่มีอยู่ แล้ว throw ReferenceError
ตั้งแต่ก่อนจะเริ่ม import ด้วยซ้ำ และเพราะโค้ดครอบ try/catch ไว้เพื่อรองรับกรณี
“ยังไม่ได้ build index” มันเลยไปโผล่เป็นข้อความว่าดัชนีค้นหายังไม่ถูกสร้าง
ซึ่งชี้ผิดที่โดยสิ้นเชิง
ทางแก้คือย้าย import ออกไปอยู่ใน is:inline script ที่ Vite ไม่แตะ
<script is:inline>
window.__loadPagefind = () => (window.__pagefindPromise ??= import('/pagefind/pagefind.js'));
</script>
บทเรียนที่เอาไปใช้ต่อได้: โค้ดที่คุณอ่านในไฟล์ไม่ใช่โค้ดที่ browser รัน เมื่ออาการพังไม่ตรงกับโค้ดที่เห็น ให้ไปเปิดดู output ที่ build ออกมาจริง ๆ ก่อนจะไปแก้ logic
หลังจากใส่ Content Security Policy เข้าไปตามหลัก security ระบบค้นหาพังทันทีบนเว็บจริง
สาเหตุคือ Pagefind ทำงานด้วย WebAssembly และ script-src จะบล็อกการ compile WASM
ถ้าไม่ระบุ 'wasm-unsafe-eval' ไว้ด้วย
"script-src": "'self' 'unsafe-inline' 'wasm-unsafe-eval' …"
ประเด็นสำคัญไม่ใช่ตัว directive ที่ขาด แต่คือ ไม่มีวิธีเจอมันจากในเครื่อง
astro preview ไม่ได้ใส่ CSP ให้ มีแต่ host จริงเท่านั้นที่ใส่
วันนั้นเทสต์ e2e ทั้ง 35 ตัวผ่านหมดในขณะที่เว็บจริงค้นหาไม่ได้
security header ชุดแรกถูกเขียนไว้ในไฟล์ public/_headers
ซึ่งเป็นรูปแบบที่ Cloudflare Pages และ Netlify อ่าน แต่ Vercel ไม่อ่าน
ต้องเขียนเป็น vercel.json แยกอีกไฟล์
ไฟล์ config ที่ host ไม่รู้จักจะไม่แจ้ง error ใด ๆ — มันแค่เงียบ build ผ่าน deploy ผ่าน เว็บขึ้น และไม่มี header สักตัว จะรู้ก็ต่อเมื่อไปยิงดู response header ของเว็บจริงเท่านั้น
รวบสามกรณีข้างบนเข้าด้วยกันแล้วจะเห็นรูปแบบเดียวกัน: สิ่งที่ทำให้พังไม่มีอยู่ในสภาพแวดล้อมที่เทสต์รัน
| สิ่งที่ตรวจ | astro dev | astro preview | production |
|---|---|---|---|
| โค้ดหลัง build ผ่าน Vite | ไม่ตรง | ตรง | ตรง |
| ดัชนี Pagefind | ไม่มี | มี | มี |
| security header จาก host | ไม่มี | ไม่มี | มี |
| Content Security Policy | ไม่มี | ไม่มี | มี |
การอ่าน vercel.json | ไม่ | ไม่ | ใช่ |
แถวสามแถวล่างคือหลุมทั้งหมด เทสต์ e2e ที่รันบน astro preview
ครอบคลุมพฤติกรรมของหน้าเว็บได้ครบ แต่ครอบคลุม สภาพแวดล้อมที่หน้าเว็บนั้นไปอยู่ ไม่ได้เลย
เมื่อรู้ว่าเทสต์แบบรันเว็บเข้าไม่ถึงชั้นนี้ วิธีที่ใช้จริงคือเขียน unit test
ที่ assert ตัวไฟล์ config โดยตรง (tests/unit/security-headers.test.ts) โดยตรวจสามอย่าง
vercel.json และ public/_headers'wasm-unsafe-eval' เพื่อให้ค้นหายังทำงานit("allows WebAssembly, which Pagefind's search index requires", () => {
expect(vercelCsp).toContain("'wasm-unsafe-eval'");
});
เทสต์แบบนี้ไม่ได้พิสูจน์ว่าเว็บทำงาน มันพิสูจน์แค่ว่า การตั้งค่าที่เคยทำให้พังจะไม่ย้อนกลับมา ซึ่งเป็นสิ่งที่เทสต์แบบเปิดเว็บทำแทนไม่ได้
หลักที่ได้จากตรงนี้มีสองข้อ
อีกเรื่องที่เปลี่ยนวิธีทำงานชัดเจนคือการมี AGENTS.md ที่ระบุกฎไว้ล่วงหน้า
ก่อนจะเริ่มเขียนโค้ดบรรทัดแรก
สเปกไฟล์นี้ไม่ได้มีแค่ว่าจะใช้เทคโนโลยีอะไร แต่รวมถึงกฎที่ปกติต้องมาคุยกันทุกครั้ง เช่น
status: building ไม่ใช่ซ่อนด้วย draftผลที่วัดได้คือกฎพวกนี้ถูกบังคับใช้ตอนทำงานจริงโดยไม่ต้องพูดซ้ำ
schema ของ content collection ในโปรเจกต์นี้ถูกเขียนให้ เข้มโดยตั้งใจ —
frontmatter ผิดคือ build พัง ไม่ใช่หน้าเว็บเพี้ยน — และฟิลด์ที่บรรยายผลลัพธ์จริง
อย่าง results ถูกทำให้ optional โดยเจตนา เพื่อไม่ให้มีแรงจูงใจไปแต่งตัวเลขมาใส่ให้ field ครบ
ในโค้ดจริงจะเห็นคอมเมนต์กำกับไว้ตรง ๆ ว่า
/** Only fill in when there is evidence. Never estimate (§31). */
results: z.array(z.string()).optional(),
จุดที่ควรใส่ความเห็นส่วนตัวจึงถูกเว้นว่างไว้เป็น HTML comment แทนที่จะถูกแต่งขึ้นมา (รวมถึงในบทความนี้ด้วย)
ไม่ใช่ทุกอย่างจะเป็นเรื่อง production มีอีกสามข้อที่คนละประเภทแต่ก็เจ็บพอกัน
เวอร์ชันล่าสุดไม่ใช่เวอร์ชันที่ใช้ได้ — ตอนนั้น TypeScript 7 ออกแล้ว
แต่ @astrojs/check ประกาศ peer dependency เป็น ^5 || ^6 และ typescript-eslint เป็น <6.1.0
ถ้าลงเวอร์ชันล่าสุดไปเลย ทั้งคำสั่ง type check และ lint จะพังพร้อมกัน
ต้องล็อกไว้ที่ typescript: ^6.0.3 ตามที่เห็นใน package.json
พฤติกรรม default ของ browser แย่งงานเราไป — กด Escape แล้วกล่องค้นหาไม่ปิด
เพราะ <input type="search"> เอา Escape ไปเคลียร์ค่าในช่องก่อนแล้วหยุด event ไว้
ทำให้ Escape-to-close ของ <dialog> ไม่ได้ทำงาน ต้องดักเอง
input?.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return;
event.preventDefault();
dialog?.close();
});
เนื้อหาตัวอย่างเป็น draft: true ทั้งหมด — ซึ่งแปลว่า production build จะได้เว็บเปล่า
เทสต์เป็นตัวจับได้ ไม่ใช่คน และนี่คือกรณีที่เทสต์คุ้มค่าที่สุดของวัน:
มันจับสิ่งที่ไม่มีใครคิดจะไปตรวจ
สิ่งที่เอาไปใช้กับโปรเจกต์อื่นได้โดยไม่ต้องเป็นเว็บ Astro
AI ทำให้ขั้นตอน “เขียนโค้ดให้ได้ตามที่ต้องการ” สั้นลงมาก แต่ขั้นตอน “ตรวจว่ามันทำงานในโลกจริงหรือเปล่า” ยังยาวเท่าเดิม และตอนนี้มันกลายเป็นส่วนที่กินเวลามากที่สุดของงาน
หนึ่งบทเรียน AI ที่ใช้ได้จริง การแกะโปรเจกต์ หรือข้อคิดจากการสร้างของ สัปดาห์ละครั้ง