ภาพประกอบสไตล์การ์ตูนดราม่า นักพัฒนายืนเผชิญหน้ากับเซิร์ฟเวอร์ LLM ที่กลายร่างเป็นวิญญาณนักสู้ตัวใหญ่ มีข้อความ Insufficient Memory ลอยเด่นกลางภาพ

รัน Local LLM ช่วยเขียนเว็บ แล้วเจอ OOM: บันทึกปัญหาจากสนามจริง

พี่โชว์Engineering

ปกติเวลาเราใช้ AI ช่วยเขียนโค้ด เรามักจะใช้ผ่าน API หรือหน้าเว็บ ซึ่งสะดวกและมี resource ให้ใช้เหลือเฟือ แต่ครั้งนี้ผมอยากลองของ โดยเปลี่ยนมารัน LLM บนเครื่อง Mac ของตัวเองดูบ้าง อยากรู้ว่ามันจะพาโปรเจกต์เดินจากศูนย์ไปจนขึ้นออนไลน์ได้จริงไหม

โจทย์คือสั่งประโยคเดียวสั้นๆ: “สร้างเว็บไซต์ท่องเที่ยว จังหวัดใดก็ได้ที่มีทะเล รูปประกอบเป็นตัวการ์ตูนน่ารักๆ และสร้างรูปด้วย codex cli”

การใช้ Local LLM มีข้อดีคือ โค้ดกับข้อมูลไม่ต้องออกจากเครื่อง ไม่ต้องจ่ายค่า API และเราสามารถมอนิเตอร์การทำงานของระบบได้ทั้งหมด ซึ่งเครื่องมือที่ผมใช้มี mlx_lm สำหรับรันโมเดลฝั่งเซิร์ฟเวอร์, opencode สำหรับรับคำสั่งมาเขียนไฟล์จริงในโปรเจกต์ และ codex CLI สำหรับเจนรูปการ์ตูนประกอบเว็บอย่างเดียว ไม่ได้แตะโค้ด

การติดตั้งและการรัน Local LLM ด้วย MLX

สำหรับคนใช้ Mac ที่เป็น Apple Silicon ตัวเลือกที่ดีที่สุดตอนนี้คือการใช้ MLX ของ Apple เอง เพราะมันถูกรีดประสิทธิภาพมาเพื่อชิปของ Mac โดยเฉพาะ

เริ่มจากการติดตั้ง library ที่จำเป็น (แนะนำให้สร้าง virtual environment ก่อน):

pip install mlx-lm

หลายคนอาจจะสงสัยว่าสเปคเครื่องตัวเองรันโมเดลตัวไหนได้บ้าง? ผมแนะนำให้ลองเข้าไปที่เว็บ llm-cal.p7z.pw ซึ่งเป็น VRAM calculator ให้กรอกสเปคเครื่องแล้วคำนวณ memory, KV cache และ throughput ที่จะได้ พร้อมแนะนำโมเดลที่เหมาะสม สำหรับเครื่องของผม ระบบแนะนำว่าสามารถรันโมเดลใหญ่อย่าง Qwen3.8-27B ได้สบายๆ

จากนั้นเราจะรันโมเดลเป็น HTTP server เพื่อให้ opencode เรียกใช้ได้ผ่าน API โดยเลือกใช้โมเดลที่ผ่านการทำ Quantization (ลดขนาด) เป็น 4-bit เพื่อประหยัด RAM:

export MLX_MODEL="mlx-community/Qwen3.8-27B-4bit"
python -m mlx_lm.server --model "$MLX_MODEL" --host 127.0.0.1 --port 8080

เพียงเท่านี้เราก็จะได้ Local LLM พร้อมใช้งานบน http://127.0.0.1:8080

ปัญหาคลาสสิก: รันไปสักพักแล้ว OOM (Out of Memory)

ภาพการ์ตูนดราม่า นักพัฒนาสะดุ้งถอยหลังเมื่อหน้าจอแตกร้าวและมีข้อความ error ทะลักออกมาท่วมจอ

เครื่องที่ผมใช้รันคือ MacBook Pro 16” (M5 Pro, 18-core CPU / 20-core GPU, RAM 48GB) ซึ่งแม้สเปคจะแรงแค่ไหน แต่ในช่วงแรกที่ให้โมเดลช่วยร่างโครงเว็บ ทุกอย่างไหลลื่นดีมาก พอเริ่มสั่งงานต่อเนื่องยาวๆ ปรากฏว่า RAM Usage พุ่งทะลุไปถึง 38GB ในบางครั้ง — เหลือที่ว่างอีกไม่ถึง 10GB จาก 48GB ที่มี สุดท้ายเซิร์ฟเวอร์ก็หยุดนิ่งไปเฉยๆ และแสดง Error ใน Terminal แบบนี้:

Exception in thread Thread-1 (_generate):
Traceback (most recent call last):
  File "/opt/homebrew/Cellar/[email protected]/3.14.7/Frameworks/Python.framework/Versions/3.14/lib/python3.14/threading.py", line 1082, in _bootstrap_inner
    self._context.run(self.run)
  File "/opt/homebrew/Cellar/[email protected]/3.14.7/Frameworks/Python.framework/Versions/3.14/lib/python3.14/threading.py", line 1024, in run
    self._target(*self._args, **self._kwargs)
  File "/Users/myuser/.venvs/mlx/lib/python3.14/site-packages/mlx_lm/server.py", line 853, in _generate
    prompt_responses, gen_responses = batch_generator.next()
  File "/Users/myuser/.venvs/mlx/lib/python3.14/site-packages/mlx_lm/generate.py", line 1855, in next
    return self._next()
  File "/Users/myuser/.venvs/mlx/lib/python3.14/site-packages/mlx_lm/generate.py", line 1841, in _next
    self._prompt_batch.prompt(prompts)
  File "/Users/myuser/.venvs/mlx/lib/python3.14/site-packages/mlx_lm/generate.py", line 1161, in prompt
    mx.eval([c.state for c in self.prompt_cache])
RuntimeError: [METAL] Command buffer execution failed: Insufficient Memory (00000008:kIOGPUCommandBufferCallbackErrorOutOfMemory).

สำหรับคนที่ไม่คุ้นกับ Python เวลาเจอ Error ยาวๆ ให้เลื่อนไปดู บรรทัดสุดท้าย เสมอ ในที่นี้ [METAL] คือชิปกราฟิกของ Mac และ Insufficient Memory หมายถึงหน่วยความจำที่ GPU ขอใช้ไม่พอแล้ว

ต้นตอของปัญหามาจากบรรทัดที่บอกว่า self.prompt_cache เวลาที่เราคุยกับโมเดลแบบต่อเนื่อง ระบบจะเก็บประวัติการสนทนา (Context) ไว้ในหน่วยความจำเพื่อให้ตอบกลับได้เร็วขึ้น ยิ่งคุยยาว Context ยิ่งใหญ่จนกิน RAM หมด และทำให้โปรแกรมแครชในที่สุด

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

วิธีแก้แบบบ้านๆ: รีสตาร์ทเซิร์ฟเวอร์

ภาพการ์ตูนดราม่า นักพัฒนายืนกลางวงกลมของภาพตัวเองที่กำลังกดปิดเปิดสวิตช์ซ้ำไปมาเป็นวงจรไม่รู้จบ

ตอนแรกผมใช้วิธีเช็คว่าเซิร์ฟเวอร์ยังตอบสนองอยู่ไหม ถ้าไม่ก็สั่ง kill process แล้วเปิดใหม่:

# เช็คสถานะ
curl -sS -m 10 http://127.0.0.1:8080/v1/models || echo "Server is down"

# ถ้าค้างก็ฆ่าทิ้งแล้วเปิดใหม่
pkill -f 'mlx_lm.server' || true
python -m mlx_lm.server --model "$MLX_MODEL" --host 127.0.0.1 --port 8080 &

การรีสตาร์ทช่วยเคลียร์ prompt_cache ให้ว่างเปล่า ทำให้รันต่อได้จริง แต่มันค่อนข้างเสียเวลาและกวนใจเวลาเรากำลังโฟกัสกับงาน — ที่สำคัญคือมันเป็นวิธีที่ต้องมีคนนั่งเฝ้าหน้าจอตลอด กว่าจะรู้ว่าค้างบางทีก็นั่งรอไปแล้วหลายนาที

จุดเปลี่ยน: ถาม Claude แล้วเลิกนั่งกดเอง

ภาพการ์ตูนดราม่า นักพัฒนายืนคู่กับวิญญาณผู้ช่วยที่กางโล่กั้นวิญญาณหน่วยความจำไว้ พร้อมแถวสถานะสีเขียวที่ไล่ขึ้นด้านหลัง

พอทนรีสตาร์ทเองไม่ไหว ผมเลยเอา Error ทั้งก้อนไปถาม Claude ว่าตกลงมันพังเพราะอะไรกันแน่ และควรแก้ที่จุดไหน คำตอบที่ได้เปลี่ยนวิธีคิดของผมไปเลย เพราะแทนที่จะบอกวิธี “กดเปิดใหม่ให้ไวขึ้น” มันชี้ให้เปลี่ยนแนวคิดจาก “พยายามไม่ให้มันพัง” มาเป็น “พังแล้วก็ลุกให้ไว” พร้อมกับเพิ่มลิมิตไม่ให้มันกิน RAM เร็วเกินไป

  1. จำกัดความยาวคำตอบ ด้วยการเติม flag --max-tokens 2048 เพื่อป้องกันไม่ให้โมเดลตอบยาวเกินจน RAM พุ่ง
  2. เลือกโมเดลให้เหลือ headroom ใช้ 4-bit quantization แทนที่จะไล่หาตัวใหญ่สุดที่ “พอดีเป๊ะ” เพราะพอดีเป๊ะแปลว่าไม่มีที่เหลือให้พลาด
  3. เขียนสคริปต์ Auto-Restart ให้รันเซิร์ฟเวอร์แบบ loop ไปเลย ถ้าแครชก็แค่รอ 3 วินาทีแล้วเปิดขึ้นมาใหม่:
#!/usr/bin/env bash
# run-llm.sh
set -euo pipefail

: "${MLX_MODEL:?กรุณาตั้งค่าตัวแปร MLX_MODEL ก่อนรันสคริปต์}"
PORT="${PORT:-8080}"

echo "Starting MLX Server with model: $MLX_MODEL"

while true; do
  python -m mlx_lm.server \
    --model "$MLX_MODEL" \
    --host 127.0.0.1 \
    --port "$PORT" \
    --max-tokens 2048 \
    || echo "[watchdog] Server crashed with exit code $?. Restarting in 3 seconds..." >&2
  sleep 3
done

หลังจากใช้สคริปต์นี้ การทำงานก็ราบรื่นขึ้นมาก ต่อให้มีจังหวะที่ OOM ตัวเซิร์ฟเวอร์ก็จะเด้งกลับมาพร้อมทำงานต่อแทบจะทันที จากเดิมที่ล้มทีเสียเวลาหลายนาที เหลือแค่สามวินาทีแล้วไปต่อ จนสุดท้ายผมแทบไม่สนใจแล้วว่ามันล้มไปกี่รอบ เพราะงานไม่ได้หยุด

Deploy ขึ้น GitHub Pages

เมื่อได้ไฟล์เว็บ static ครบถ้วน ก็ถึงเวลา deploy ขึ้น GitHub Pages ผ่าน GitHub Actions:

name: Deploy to GitHub Pages

on:
  push:
    branches: [main]

permissions:
  contents: read
  pages: write
  id-token: write

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    environment:
      name: github-pages
      url: ${{ steps.deploy.outputs.page_url }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/configure-pages@v5
      - uses: actions/upload-pages-artifact@v3
        with:
          path: ./site  # ระบุ path ที่เก็บไฟล์ HTML ของโปรเจกต์
      - id: deploy
        uses: actions/deploy-pages@v4

ภาพการ์ตูนดราม่า นักพัฒนาชูกำปั้นอยู่หน้าหน้าต่างเบราว์เซอร์ยักษ์ที่แสดงเว็บท่องเที่ยวทะเลลายการ์ตูนน่ารักที่ deploy สำเร็จแล้ว

ผลงานที่เสร็จแล้วเปิดดูได้จริงที่ kangana1024.github.io/test-local-llm — เว็บท่องเที่ยวทะเลพร้อมรูปการ์ตูนที่ Local LLM เขียนให้ทั้งหมด

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

สรุปบทเรียน

  • การรัน Local LLM บน Mac ทำได้ง่ายมากด้วย mlx_lm แต่อย่าลืมเลือกโมเดลแบบ 4-bit quantization เพื่อเซฟ RAM
  • OOM เป็นเรื่องปกติ เมื่อทำงานกับ context ยาวๆ การตั้งค่า --max-tokens ช่วยบรรเทาได้
  • “เป็นพักๆ” มักแปลว่า “สะสม” ถ้าอาการมาตอนกำลังไปได้สวย ให้สงสัยของที่โตขึ้นเรื่อยๆ ก่อนเสมอ
  • เขียนสคริปต์ Watchdog ให้เซิร์ฟเวอร์ auto-restart ดีกว่าต้องมานั่งมอนิเตอร์และกดเปิดใหม่เอง
  • อ่าน Error log จากล่างขึ้นบนเสมอ บรรทัดสุดท้ายมักจะบอกสาเหตุที่แท้จริง

วินัยข้อสุดท้ายที่ติดมาจากงานนี้ ผมเคยเขียนไว้ในบทความ ตั้ง Project & DevOps ให้พร้อมรบ — หลักการเดียวกับ ${MLX_MODEL:?...} ในสคริปต์ข้างบน คือ ค่าที่ขาดไม่ได้ ต้องฟ้องตั้งแต่ก่อนเริ่ม ไม่ใช่ปล่อยให้พังกลางทาง

ขอบคุณ Claude

ขอบคุณ Claude ที่ช่วยอ่าน Error ก้อนนั้นแล้วชี้ให้เห็นว่าปัญหาไม่ได้อยู่ตรงที่ผมกำลังเพ่งอยู่ ถ้าไม่ได้คำแนะนำวันนั้น ผมคงยังนั่งกดปิด-เปิดอยู่จนถอดใจไปแล้ว งานนี้ได้จบเพราะมีคนช่วยดูอีกมุมหนึ่งจริงๆ

อ่านต่อ