สร้างเซิร์ฟเวอร์ AI จากเครื่องเดสก์ท็อป WSL2 + Ollama + OpenClaw
เครื่องเดสก์ท็อปตัวเดียวที่มีการ์ดจอสองใบ ถ้าวางระบบให้ดีก็ทำเป็นเซิร์ฟเวอร์ AI ในบ้านได้สบายๆ ผมเอาเครื่องตัวที่ใช้ทำงานอยู่มาลง LLM ทั้งตระกูล รันได้จริงทุกวัน บันทึกนี้คือลำดับขั้นทั้งหมดที่ผมทำ ตั้งแต่ลง Windows จนถึง benchmark
สรุปใน 1 นาที
- อาการ/ปัญหา
- อยากทำเซิร์ฟเวอร์ AI ในบ้านจากเดสก์ท็อปที่มีการ์ดจอสองใบ ตั้งแต่เครื่องเปล่าจนรันได้ทุกวัน
- ใครควรอ่าน
- คนที่อยากรัน LLM เองบน Windows 11 ด้วย WSL2 และ GPU หลายใบ
- ทางแก้แบบสั้น
- ลง Windows 11 IoT, driver CUDA, WSL2 กับ Ubuntu, Ollama สำหรับสองการ์ด, แยก Linux user ต่อ instance แล้วรัน OpenClaw เป็น systemd service
ช่วงนี้กระแสรัน LLM ในเครื่องตัวเองมาแรง ไม่ต้องพึ่ง cloud ทุกงาน ข้อมูลไม่ต้องวิ่งออกนอกบ้าน และต้นทุนต่อ token เป็นศูนย์เพราะใช้ไฟบ้านล้วนๆ ผมเลยลองเอาเครื่องเดสก์ท็อปที่ใช้งานอยู่ประจำมาจัดวางใหม่ให้กลายเป็นเซิร์ฟเวอร์ AI เต็มตัว รันโมเดลได้พร้อมกันหลายตัว มีหลาย instance แยกกัน และเปิดทิ้งไว้ทำงาน 24 ชั่วโมงโดยไม่ต้องมานั่งเฝ้า
บันทึกนี้ผมเขียนเรียงตามลำดับที่ลงมือทำจริงทุกขั้น ไม่ได้ตัดทอน เพราะหลายจุดมันพลาดง่ายมากถ้าข้ามไป โดยเฉพาะเรื่อง systemd บน WSL2 กับเรื่อง tool calling ของบางโมเดล ใครจะทำตามก็หยิบเฉพาะส่วนที่ต้องใช้ได้เลย ไม่จำเป็นต้องเหมือนผมทุกอย่าง สเปกต่างกัน OS ต่างกัน หลักการเดียวกันหมด
ภาพรวมสถาปัตยกรรม
ก่อนลงรายละเอียด ผมขอวางภาพรวมให้เห็นทั้งกองก่อน เพราะมันมีหลายชั้นซ้อนกันอยู่ ฐานล่างสุดคือ Windows ที่ถือ NVIDIA driver ไว้ ถัดขึ้นมาคือ WSL2 รัน Ubuntu ที่เปิด systemd แล้วบนนั้นค่อยวาง Ollama เป็น service ระดับระบบ กับ OpenClaw หลาย instance ที่แยกเป็นคนละ Linux user
จุดที่ต้องเข้าใจคือ Ollama รันเป็น service กลางตัวเดียว ฟังอยู่ที่ localhost:11434 ส่วน OpenClaw แต่ละ instance เป็น user แยกกัน มี config และ credential ของตัวเอง แต่ทุกตัวยิงไปขอโมเดลที่ Ollama ตัวเดียวกันผ่าน localhost การแยกแบบนี้ทำให้เพิ่ม instance ใหม่ได้โดยไม่ต้องโหลดโมเดลซ้ำ และถ้า instance ไหนพังก็ไม่ลามไปตัวอื่น
1. สเปกเครื่องที่ใช้
เครื่องที่ผมใช้เป็นเดสก์ท็อปธรรมดาที่ประกอบเอง จุดสำคัญอยู่ที่การ์ดจอสองใบ เพราะ Ollama เลือกใช้ใบที่ VRAM เยอะที่สุดสำหรับแต่ละโมเดลให้อัตโนมัติ
ทำไมต้องสองใบ เหตุผลคือใบใหญ่ VRAM 16 GB ผมให้รับงาน LLM inference เป็นหลัก ทั้ง weight ของโมเดลและ KV cache ส่วนใบเล็ก VRAM 8 GB เก็บไว้เป็น fallback หรือไว้ทำงาน image generation แยก และเวลาเจอโมเดลใหญ่จริงๆ Ollama ก็กระจายโหลดข้ามสองใบให้ได้ ใครมีใบเดียวก็ทำได้ แค่ต้องเลือกขนาดโมเดลให้พอดี VRAM ที่มี
แนะนำให้เผื่อ storage เป็น NVMe SSD สัก 500 GB ขึ้นไป เพราะโมเดลแต่ละตัวกินที่หลายกิกะ พอลงหลายตัวมันบวมเร็วกว่าที่คิด
2. ลง Windows 11 IoT Enterprise LTSC
ผมเลือก Windows 11 IoT Enterprise LTSC เป็นฐาน เหตุผลตรงๆ คือมันสะอาด ไม่มี bloatware ที่มากับ Windows ตัวบ้านทั่วไป ไม่มี Store ยัด ad ไม่มีอะไรกินทรัพยากรเบื้องหลังโดยไม่จำเป็น และที่สำคัญคือ update น้อยกว่ามาก เพราะเป็นสาย Long-Term Servicing Channel เครื่องที่จะเปิดทิ้งไว้ทำ server แบบนี้ ยิ่ง update เด้งน้อยยิ่งดี ส่วนความสามารถที่จำเป็นอย่าง WSL2, CUDA, Hyper-V มันรองรับครบเหมือน Pro/Enterprise ทุกอย่าง
ขั้นตอนลงก็ปกติ ทำ USB boot ด้วย Rufus ตั้ง partition scheme เป็น GPT กับ target เป็น UEFI ถ้าเครื่องไม่ผ่าน hardware check เรื่อง TPM 2.0 Rufus มี option ให้ tick ข้ามได้ เวลาลงผมเลือก edition IoT Enterprise LTSC และใช้ local account ไม่ผูก Microsoft account เพื่อให้คุมเครื่องได้เต็มที่
หลังลงเสร็จมีไม่กี่อย่างที่ต้องตั้งให้มันเหมาะกับการเป็น server
ตั้ง Power เป็น Never sleep เพื่อไม่ให้เครื่องหลับตอนไม่มีคนใช้ เปิด Remote Desktop ถ้าจะ RDP เข้ามา ตั้ง Active Hours ของ Windows Update ให้ไม่ชนเวลางาน และปิด Delivery Optimization ไว้เพื่อไม่ให้มันแอบโหลด/อัปโหลด update ในเบื้องหลังจนกินแบนด์วิดท์
3. ลง GPU driver (CUDA)
เรื่อง driver เป็นจุดที่คนทำสองการ์ดต่างรุ่นมักสะดุด เพราะการ์ดเก่ากับการ์ดใหม่ใช้สถาปัตยกรรมคนละยุค ตัวเก่าเป็น Pascal ตัวใหม่เป็น Blackwell เราต้องการ driver ที่รองรับทั้งสองสถาปัตยกรรมในตัวเดียว ซึ่งต้องเป็น driver รุ่นใหม่พอที่จะมองเห็น Blackwell ได้ และรองรับ CUDA รุ่นที่เราจะใช้
โหลด driver จากหน้าเว็บ NVIDIA โดยเลือกตามรุ่นการ์ดใบใหม่กว่า เพราะ driver ที่ออกมารองรับการ์ดใหม่จะครอบคลุมการ์ดเก่าในตระกูลก่อนหน้าอยู่แล้ว ลงเป็น Game Ready หรือ Studio Driver ก็ได้
ติดตั้ง NVIDIA driver บน Windows ฝั่งเดียวพอ WSL2 จะใช้ driver ตัวนี้ผ่าน GPU passthrough ให้อัตโนมัติ ห้ามลง NVIDIA driver ซ้ำเข้าไปใน WSL2/Ubuntu เด็ดขาด เพราะมันจะ conflict กับ driver ฝั่ง Windows แล้วพังทั้งคู่
ตรวจว่า driver ทำงานครบโดยเปิด PowerShell แล้วเรียก nvidia-smi
nvidia-smi
สิ่งที่ต้องเห็นคือ GPU ทั้งสองใบโผล่ครบในตาราง พร้อม Driver Version และ CUDA Version ที่ driver รองรับ ถ้าเห็นแค่ใบเดียว ให้ไปตรวจ BIOS ว่าเปิดใช้ PCIe slot ครบทั้งสองช่องหรือยัง บางเมนบอร์ดต้องตั้ง bifurcation หรือเปิด slot รองให้ทำงานพร้อมกัน
4. ลง WSL2
WSL2 คือชั้นที่ทำให้เรารัน Linux จริงบน Windows ได้แบบมี kernel ของตัวเอง และเข้าถึง GPU ผ่าน passthrough ได้ เปิด PowerShell แบบ Admin แล้วสั่ง
wsl --install --no-distribution
คำสั่งนี้จะเปิด Virtual Machine Platform เปิด Windows Subsystem for Linux และตั้งให้ WSL 2 เป็นค่า default ส่วนที่ใส่ --no-distribution ไว้ เพราะผมยังไม่อยากให้มันลง distro ให้อัตโนมัติ จะเลือก distro เองทีหลัง พอสั่งเสร็จต้อง restart Windows หนึ่งรอบ
หลัง restart ตรวจเวอร์ชันให้แน่ใจว่าเป็น WSL2 จริง
wsl --version
wsl --set-default-version 2
5. ลง Ubuntu บน WSL2
ผมเลือก Ubuntu 24.04 เพราะมันเป็น LTS และมี systemd เวอร์ชันใหม่พอที่ใช้ได้สบาย
wsl --install -d Ubuntu-24.04
ตอนลงครั้งแรกมันจะถามให้ตั้ง username กับ password ตัวนี้จะกลายเป็น admin user ที่มี sudo ผมตั้งให้ UID เป็น 1000 ตามค่า default แล้วใช้เป็นบัญชีหลักในการสั่งงานทั้งระบบ จากนั้นเข้า WSL แล้ว update ของให้ครบ พร้อมลง package พื้นฐานที่ต้องใช้ตลอด
wsl -d Ubuntu-24.04
sudo apt-get update && sudo apt-get upgrade -y
sudo apt-get install -y \
curl wget unzip git build-essential \
python3 python3-pip python3-venv \
htop neofetch jq
6. ตั้งค่า WSL2 ให้เหมาะกับงาน server
ขั้นนี้สำคัญมากกว่าที่หลายคนคิด เพราะค่า default ของ WSL2 ไม่ได้ออกแบบมาให้รัน service ค้างไว้ทั้งวัน ผมแก้ที่สองที่ คือ /etc/wsl.conf ที่อยู่ใน Ubuntu และ .wslconfig ที่อยู่บน Windows
เริ่มที่ /etc/wsl.conf ภายใน Ubuntu
[automount]
enabled = false
mountFsTab = true
[boot]
systemd = true
[interop]
appendWindowsPath = false
ค่าที่สำคัญที่สุดในไฟล์นี้คือ systemd = true ถ้าไม่เปิดอันนี้ เราจะใช้ systemctl ไม่ได้เลย แล้ว Ollama กับ OpenClaw ที่ต้องรันเป็น service ก็จะวางไม่ได้ ส่วน automount = false ผมตั้งไว้เพื่อไม่ให้ WSL เอา drive ฝั่ง Windows มา mount อัตโนมัติ จะ mount เฉพาะที่ต้องการผ่าน fstab เอง และ appendWindowsPath = false ทำให้ PATH ฝั่ง Linux สะอาด ไม่เอา PATH ของ Windows มาปน ซึ่งทำให้เร็วขึ้นและไม่งงเวลา debug
ส่วน .wslconfig บน Windows อยู่ที่ /home/<user>/.wslconfig (ฝั่ง Windows คือ C:\Users\<user>\.wslconfig) อันนี้เป็น optional แต่ผมแนะนำให้ตั้ง เพราะค่า default WSL2 จะกินแค่ครึ่งหนึ่งของ RAM เครื่อง ซึ่งสำหรับงานรัน LLM มันน้อยไป
[wsl2]
memory=38GB
swap=4GB
processors=24
เหตุที่ให้ RAM เยอะ เพราะ Ollama จะใช้ RAM เป็น fallback เมื่อ VRAM ไม่พอ ถ้าให้ RAM น้อย พอเจอโมเดลใหญ่ที่ล้น VRAM มันจะไปไม่รอด พอแก้สองไฟล์เสร็จต้อง restart WSL ให้ค่ามีผล
wsl --shutdown
wsl -d Ubuntu-24.04
แล้วตรวจว่า systemd ขึ้นมาเป็น PID 1 จริง
systemctl --version
ps -p 1 -o comm=
บรรทัดที่สองต้องเห็นคำว่า systemd ไม่ใช่ init ถ้ายังเห็น init แปลว่า systemd ยังไม่ทำงาน ให้ย้อนไปตรวจ wsl.conf แล้ว shutdown ใหม่อีกรอบ
7. ลง CUDA Toolkit ใน WSL2
ตรงนี้มีจุดที่คนเข้าใจผิดบ่อย ขอแยกให้ชัด NVIDIA driver ไม่ต้องลงใน WSL2 เพราะใช้ของฝั่ง Windows ผ่าน passthrough อยู่แล้ว แต่ CUDA Toolkit ซึ่งเป็นตัว compiler กับ library นั้นต้องลงในฝั่ง WSL ถ้าจะ compile โค้ด CUDA เอง สำหรับ Ollama จริงๆ ไม่จำเป็นต้องลง Toolkit เพราะมันใช้ binary ที่ compile มาแล้ว แต่ผมลงไว้เผื่อใช้กับ PyTorch หรือ TensorFlow ทีหลัง
ก่อนลง ตรวจให้แน่ใจว่า WSL มองเห็น GPU ครบทั้งสองใบ
nvidia-smi
ถ้าเห็นครบก็ลง CUDA Toolkit ผ่าน repo ของ NVIDIA สาย wsl-ubuntu โดยเฉพาะ ห้ามใช้ repo ตัวปกติเพราะมันจะลาก driver มาด้วย
wget https://developer.download.nvidia.com/compute/cuda/repos/wsl-ubuntu/x86_64/cuda-keyring_1.1-1_all.deb
sudo dpkg -i cuda-keyring_1.1-1_all.deb
sudo apt-get update
sudo apt-get install -y cuda-toolkit-13-1
แล้วเซ็ต PATH ให้ระบบหา nvcc กับ library เจอ
echo 'export PATH="/usr/local/cuda/bin:$PATH"' >> ~/.bashrc
echo 'export LD_LIBRARY_PATH="/usr/local/cuda/lib64:$LD_LIBRARY_PATH"' >> ~/.bashrc
source ~/.bashrc
nvcc --version
ตัวเลข CUDA Version ที่ขึ้นใน nvidia-smi คือเวอร์ชันสูงสุดที่ driver รองรับ ไม่ใช่เวอร์ชันที่ติดตั้งจริง ส่วนเวอร์ชันที่ติดตั้งจริงให้ดูจาก nvcc --version ทั้งสองตัวไม่จำเป็นต้องเท่ากัน อย่าสับสนตรงนี้
8. ลง Ollama
Ollama คือตัวที่รับหน้าที่รันโมเดลให้ ติดตั้งด้วยสคริปต์เดียวจบ
curl -fsSL https://ollama.com/install.sh | sh
สคริปต์นี้จะวาง binary ไว้ที่ /usr/local/bin/ollama สร้าง user ชื่อ ollama แยกต่างหากสำหรับรัน service สร้าง systemd service ให้พร้อม และที่สำคัญคือมันเพิ่ม user ollama เข้า group video กับ render เพื่อให้เข้าถึง GPU ได้ จากนั้นมัน start service ให้เลย ตรวจว่าขึ้นครบ
ollama --version
sudo systemctl status ollama
curl -s http://localhost:11434/api/tags | python3 -m json.tool
อันสุดท้ายต้องได้ JSON ตอบกลับ (รายการ models อาจยังว่างเพราะเรายังไม่ pull อะไร) แค่ได้ JSON ก็แปลว่า API ทำงานแล้ว
9. ตั้งค่า Ollama service สำหรับการ์ดสองใบ
service ที่ติดตั้งมาตอนแรกยังใช้ค่า default ผมแก้ service file ให้มันใช้การ์ดทั้งสองใบและเปิด option ที่ช่วยเรื่องประสิทธิภาพ
[Unit]
Description=Ollama Service
After=network-online.target
[Service]
ExecStart=/usr/local/bin/ollama serve
User=ollama
Group=ollama
Restart=always
RestartSec=3
Environment="PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
Environment="CUDA_VISIBLE_DEVICES=0,1"
Environment="OLLAMA_FLASH_ATTENTION=1"
Environment="OLLAMA_NUM_PARALLEL=2"
Environment="OLLAMA_MAX_LOADED_MODELS=2"
Environment="OLLAMA_KEEP_ALIVE=5m"
[Install]
WantedBy=default.target
ตัวแปรแต่ละตัวมีเหตุผลของมัน CUDA_VISIBLE_DEVICES=0,1 ทำให้ Ollama เห็นการ์ดทั้งสองใบ แล้วมันจะเลือกใบที่ VRAM เยอะที่สุดให้แต่ละโมเดลเอง OLLAMA_FLASH_ATTENTION=1 เปิด flash attention ทำให้ inference เร็วขึ้นและกิน VRAM น้อยลง OLLAMA_NUM_PARALLEL=2 ให้รับสอง request พร้อมกันได้ ซึ่งจำเป็นเพราะผมมีสอง instance ที่อาจยิงเข้ามาพร้อมกัน OLLAMA_MAX_LOADED_MODELS=2 ให้ load สองโมเดลค้างใน VRAM พร้อมกันได้ถ้าพื้นที่พอ และ OLLAMA_KEEP_ALIVE=5m ให้โมเดลค้างอยู่ใน VRAM 5 นาทีหลังใช้งานล่าสุด พ้นจากนั้นค่อย unload เพื่อคืน VRAM
พอแก้เสร็จ reload แล้ว enable ให้ start อัตโนมัติ
sudo systemctl daemon-reload
sudo systemctl enable ollama
sudo systemctl restart ollama
sudo systemctl status ollama
10. Pull โมเดล และทำ custom tool template
ขั้นนี้แบ่งเป็นสองส่วน ส่วนแรกคือ pull โมเดลที่จะใช้ ส่วนที่สองคือแก้ปัญหาโมเดลบางตัวที่ไม่รองรับ tool calling ซึ่งเป็นจุดที่ทำให้ OpenClaw ใช้งานโมเดลนั้นไม่ได้
เริ่มที่ pull โมเดลหลักก่อน ผมเลือกหลายตัวให้ครอบคลุมงานต่างกัน ตัวเด่นด้านภาษาไทยและ reasoning ตัวที่คิดลึก ตัวเขียนโค้ด และตัวทั่วไปขนาดใหญ่
ollama pull qwen3:14b # ~9 GB ภาษาไทย/อังกฤษ, reasoning, tools
ollama pull deepseek-r1:14b # ~9 GB คิดลึก reasoning
ollama pull deepseek-coder-v2:16b # ~9 GB เขียนโค้ด
ollama pull gpt-oss:20b # ~13 GB งานทั่วไป
ollama list
ทีนี้มาถึงปัญหา โมเดลตระกูล deepseek ทั้ง deepseek-r1:14b และ deepseek-coder-v2:16b ไม่มี {{.Tools}} อยู่ใน template ของมัน พอ OpenClaw ส่ง tool definition เข้าไป Ollama จะ reject ทันทีด้วย error 400 does not support tools วิธีแก้คือสร้าง custom model จาก Modelfile ที่เติม tool template เข้าไป โดยใช้ weight เดิม ไม่ต้องโหลดใหม่ มันแค่เพิ่ม template layer ทับลงไปบนตัวเดิม
สร้างโฟลเดอร์เก็บ Modelfile ก่อน (ผมวางไว้ที่ /home/<user>/modelfiles)
mkdir -p /home/<user>/modelfiles
Modelfile ของ deepseek-r1-tools หัวใจคือบล็อก {{- if .Tools }} ที่บอกโมเดลว่ามี function ให้เรียก และให้ตอบกลับเป็น JSON ครอบด้วย tag <tool_call>
FROM deepseek-r1:14b
TEMPLATE """{{- if .System }}{{ .System }}{{ end }}
{{- if .Tools }}
# Tools
You may call one or more functions to assist with the user query.
You are provided with function signatures within <tools></tools> XML tags:
<tools>
{{- range .Tools }}
{"type": "function", "function": {{ .Function }}}
{{- end }}
</tools>
For each function call, return a JSON object with function name and arguments within <tool_call></tool_call> XML tags:
<tool_call>
{"name": <function-name>, "arguments": <args-json-object>}
</tool_call>
{{- end }}
{{- range $i, $_ := .Messages }}
{{- $last := eq (len (slice $.Messages $i)) 1}}
{{- if eq .Role "user" }}<|User|>{{ .Content }}
{{- else if eq .Role "assistant" }}<|Assistant|>
{{- if .ToolCalls }}
<tool_call>
{{- range .ToolCalls }}
{"name": "{{ .Function.Name }}", "arguments": {{ .Function.Arguments }}}
{{- end }}
</tool_call>
{{- else }}{{ .Content }}{{- end }}
{{- if not $last }}<|end▁of▁sentence|>{{- end }}
{{- else if eq .Role "tool" }}<|User|><tool_response>
{{ .Content }}
</tool_response>
{{- end }}
{{- if and $last (ne .Role "assistant") }}<|Assistant|>
{{- end -}}
{{- end }}"""
ตัว deepseek-coder-v2-tools ใช้รูปแบบ template ต่างกันเพราะ chat format ของมันไม่เหมือน r1 แต่หลักการเดียวกันคือเติมบล็อก Tools เข้าไปก่อนข้อความ user
FROM deepseek-coder-v2:16b
TEMPLATE """{{- if .Messages }}<|begin▁of▁sentence|>
{{- $system := "" }}
{{- range $i, $_ := .Messages }}
{{- if eq .Role "system" }}
{{- $system = printf "%s %s" $system .Content }}
{{- else if eq .Role "user" }}
{{- if $system }}{{ $system }}
{{- if $.Tools }}
# Tools
You have access to the following functions. To call a function, respond with a JSON object within <tool_call></tool_call> tags:
<tools>
{{- range $.Tools }}
{"type": "function", "function": {{ .Function }}}
{{- end }}
</tools>
{{- end }}
{{ $system = "" }}
{{ end }}User: {{ .Content }}
{{ if eq (len (slice $.Messages $i)) 1 }}Assistant:
{{- end }}
{{- else if eq .Role "assistant" }}Assistant:
{{- if .ToolCalls }}
<tool_call>
{{- range .ToolCalls }}
{"name": "{{ .Function.Name }}", "arguments": {{ .Function.Arguments }}}
{{- end }}
</tool_call>
{{- else }} {{ .Content }}{{- end }}<|end▁of▁sentence|>
{{- end }}
{{- end }}
{{- end }}"""
สร้าง custom model จาก Modelfile ทั้งสอง เลือกได้สองแบบ แบบ A สร้างเป็นชื่อใหม่ที่ลงท้าย -tools ไว้ใช้คู่กับตัวเดิม แบบ B เขียนทับตัวเดิมเลยเพื่อให้ตัวเดิมมี tool template ในตัว ผมเลือกแบบ A เพราะอยากเก็บตัวเดิมไว้เทียบ
ollama create deepseek-r1-tools:14b -f /home/<user>/modelfiles/Modelfile-deepseek-r1-tools
ollama create deepseek-coder-v2-tools:16b -f /home/<user>/modelfiles/Modelfile-deepseek-coder-v2-tools
โมเดลที่ไม่ต้องทำ Modelfile เลยคือ qwen3:14b, gpt-oss:20b, hermes3:8b พวกนี้รองรับ tools มาในตัวอยู่แล้ว เสร็จแล้วทดสอบว่า custom model รับ tool ได้จริง
curl -s http://127.0.0.1:11434/api/chat -d '{
"model": "deepseek-r1-tools:14b",
"messages": [{"role": "user", "content": "Say OK"}],
"tools": [{"type": "function", "function": {"name": "test", "description": "test", "parameters": {"type": "object", "properties": {}}}}],
"stream": false
}' | python3 -c "import sys,json; r=json.load(sys.stdin); print('OK' if 'message' in r else r.get('error','FAIL'))"
ต้องได้คำว่า OK ออกมา ถ้าได้ does not support tools แปลว่า template ยังไม่ติด ให้ย้อนไปตรวจ Modelfile แล้ว create ใหม่
11. สร้าง Linux user แยกแต่ละ instance
แนวคิดที่ผมวางคือ ให้แต่ละ OpenClaw instance เป็น Linux user คนละคน เพื่อให้ config, credential และ session แยกขาดจากกันสนิท ส่วน Ollama ใช้ร่วมกันผ่าน localhost ตัวเดียว
สร้าง user สองตัวสำหรับ instance จากบัญชี admin
sudo adduser --disabled-password --gecos "" claw-a
sudo adduser --disabled-password --gecos "" claw-b
sudo chsh -s /bin/bash claw-a
sudo chsh -s /bin/bash claw-b
user ที่สร้างใหม่บางทีได้ /bin/sh (dash) มา ซึ่ง source ใช้ไม่ได้ .bashrc ไม่โหลดอัตโนมัติ และทั้ง fnm กับ Claude CLI เขียน config ลง .bashrc พอ shell เป็น dash มันจะหาคำสั่งไม่เจอตอน login ครั้งถัดไป จึงต้อง chsh เป็น /bin/bash ทุกตัว
ปิดข้อความ MOTD ที่เด้งตอน login ให้เงียบ และตรวจว่า shell ถูกต้อง
sudo -u claw-a touch /home/claw-a/.hushlogin
sudo -u claw-b touch /home/claw-b/.hushlogin
getent passwd claw-a | cut -d: -f7 # ต้องเห็น /bin/bash
getent passwd claw-b | cut -d: -f7 # ต้องเห็น /bin/bash
12. ลง Claude CLI ในแต่ละ user
Claude Code รุ่นใหม่มี native installer ไม่ต้องพึ่ง Node.js ในการลงตัวมันเอง ขั้นตอนนี้ทำซ้ำในทุก user เริ่มที่ claw-a
sudo -iu claw-a
curl -fsSL https://claude.ai/install.sh | bash
source ~/.bashrc
claude --version
claude doctor
เรื่อง auth บน WSL2 มีจุดที่ต้องระวัง คือการ login ผ่าน OAuth callback มันไม่ติด เพราะ localhost ฝั่ง WSL ไม่ได้เชื่อมตรงกับ browser ฝั่ง Windows วิธีที่ผมใช้และได้ผลคือทำ setup-token จากฝั่ง Windows ที่มี browser แล้วเอา token มาเซ็ตใน WSL
export CLAUDE_CODE_OAUTH_TOKEN="<token>"
echo 'export CLAUDE_CODE_OAUTH_TOKEN="<token>"' >> ~/.bashrc
จากนั้นข้าม onboarding ด้วยการสร้างไฟล์ config ให้มันรู้ว่าผ่าน onboarding แล้ว
mkdir -p ~/.claude
cat > ~/.claude.json << 'ENDOFFILE'
{
"hasCompletedOnboarding": true
}
ENDOFFILE
claude -p "hello, are you working?"
ถ้าได้คำตอบกลับมาก็แปลว่าเชื่อมต่อ Claude ได้แล้ว ใครจะใช้ API key แทน subscription ก็เซ็ต ANTHROPIC_API_KEY ลง .bashrc แทน แล้ว exit ออกไปทำซ้ำกับ claw-b
13. ลง Node.js ด้วย fnm
OpenClaw ต้องการ Node.js เวอร์ชันใหม่ ผมใช้ fnm (Fast Node Manager) ติดตั้งแบบ per-user เพราะแต่ละ instance อาจอยากคุมเวอร์ชัน Node เองได้อิสระ ทำในแต่ละ user เหมือนกัน
sudo -iu claw-a
curl -fsSL https://fnm.vercel.app/install | bash
ตอนลงอาจเห็นข้อความ Could not infer shell type ซึ่งเป็นเรื่องปกติเพราะ sudo -iu ไม่ได้ส่ง shell type ให้ installer ไม่ต้องตกใจ ขั้นต่อไปคือเซ็ต .bashrc ให้ถูกลำดับ จุดนี้พลาดบ่อย คือ export PATH ต้องมาก่อน eval
echo 'export PATH="$HOME/.local/share/fnm:$PATH"' >> ~/.bashrc
echo 'eval "$(fnm env)"' >> ~/.bashrc
source ~/.bashrc
fnm install 22
fnm use 22
fnm default 22
node -v
npm -v
fnm จะสร้าง dynamic path ขึ้นใหม่ทุกครั้งที่เปิด session ทำให้ path ของ node เปลี่ยนไปเรื่อยๆ ซึ่งเป็นปัญหาตอนเขียน service file ที่ต้องการ path คงที่ ให้เพิ่ม stable path ลงใน .profile ด้วย แล้ว service จะชี้ binary ได้ถูกตัวเสมอ
echo 'export PATH="$HOME/.local/share/fnm/aliases/default/bin:$PATH"' >> ~/.profile
exit
ทำซ้ำกับ claw-b เช่นกัน
14. ลง OpenClaw และ onboard
พอ Node พร้อมก็ลง OpenClaw ผ่าน npm ในแต่ละ user
sudo -iu claw-a
source ~/.bashrc
npm install -g openclaw@latest
openclaw --version
ข้อความ npm warn deprecated ที่โผล่ขึ้นมาเป็นเรื่องปกติของ dependency ภายใน ไม่ต้องไปแก้ ต่อไปคือ onboard ซึ่งจะถามค่าต่างๆ ทีละข้อ จุดที่ต้องวางแผนคือเรื่อง port เพราะแต่ละ instance ต้องใช้ port คนละเบอร์ ผมให้ claw-a ใช้ <port> หนึ่งเบอร์ แล้ว claw-b ใช้อีกเบอร์ที่ไม่ชนกัน
openclaw onboard --install-daemon --gateway-port <port>
ตอน onboard มันจะถามให้เลือกโมเดล (ผมเลือก ollama/qwen3:14b) เลือกวิธี auth ใส่ gateway port เลือก channel (เช่น Telegram ใส่ bot token) และถามว่าจะ install daemon ไหม ตอบ Yes ไปก่อนได้ ถึงมันจะ fail บน WSL2 เพราะเราจะมาแก้เรื่อง service เองทีหลังอยู่แล้ว ข้อความ Health check failed ตอนนี้ก็เป็นเรื่องปกติ เพราะ gateway ยังไม่ได้ start
สุดท้ายเซ็ต Ollama API key ตัว Ollama ไม่ได้ต้องการ key จริง แต่ OpenClaw ต้องการ env var ตัวนี้ไว้ยืนยัน ใส่ค่าอะไรก็ได้
echo 'export OLLAMA_API_KEY="ollama-local"' >> ~/.bashrc
exit
ทำซ้ำกับ claw-b โดยเปลี่ยน port เป็นอีกเบอร์
15. วาง OpenClaw gateway เป็น systemd service
นี่คือส่วนที่หินที่สุดของทั้งงาน เพราะ systemd แบบ user-level บน WSL2 มันไม่ตรงไปตรงมาเหมือนบนเครื่อง Linux จริง เริ่มจากต้องเปิด linger ให้ user service รันได้แม้ไม่มีใคร login อยู่ ทำครั้งเดียวจากบัญชี admin
sudo loginctl enable-linger claw-a
sudo loginctl enable-linger claw-b
loginctl show-user claw-a | grep Linger # ต้องเห็น Linger=yes
onboard ไม่สร้าง service file ให้บน WSL2 ผมเลยต้องเขียนเอง ตัวอย่างของ claw-a จุดสำคัญคือ ExecStart ต้องใช้ full path ทั้ง node และ openclaw และต้องเป็น stable path สาย aliases/default/bin ไม่ใช่ multishell path ที่ fnm สร้างใหม่ทุก session
[Unit]
Description=OpenClaw Gateway (claw-a)
After=network.target
[Service]
Type=simple
ExecStart=/home/claw-a/.local/share/fnm/aliases/default/bin/node /home/claw-a/.local/share/fnm/aliases/default/bin/openclaw gateway --port <port>
Restart=on-failure
RestartSec=5
Environment="PATH=/home/claw-a/.local/share/fnm/aliases/default/bin:/home/claw-a/.local/bin:/usr/local/bin:/usr/bin:/bin"
Environment="HOME=/home/claw-a"
Environment="OLLAMA_API_KEY=ollama-local"
[Install]
WantedBy=default.target
วางไฟล์นี้ไว้ที่ /home/claw-a/.config/systemd/user/openclaw.service แล้วทำแบบเดียวกันกับ claw-b เปลี่ยน path กับ port ให้ตรงตัว
ทีนี้มาถึงจุดที่ทำเอาหลายคนถอดใจ คือ systemctl --user มันไม่ทำงานผ่าน sudo -iu เพราะไม่มี DBUS session bus ให้มันคุยด้วย วิธีแก้คือระบุ env vars ที่ชี้ไปยัง runtime dir และ bus ของ user นั้นตรงๆ โดยใช้ UID ของแต่ละ user
sudo -u claw-a XDG_RUNTIME_DIR=/run/user/1002 \
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1002/bus \
systemctl --user daemon-reload
sudo -u claw-a XDG_RUNTIME_DIR=/run/user/1002 \
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1002/bus \
systemctl --user enable --now openclaw
ทำชุดคำสั่งเดียวกันกับ claw-b โดยเปลี่ยนเลข UID เป็นของมัน เนื่องจากคำสั่งพวกนี้มันยาวและพิมพ์ผิดง่าย ผมตั้ง alias ไว้ใน .bashrc ของ admin เพื่อให้เรียกสั้นๆ ได้
alias sc-a='sudo -u claw-a XDG_RUNTIME_DIR=/run/user/1002 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1002/bus systemctl --user'
alias sc-b='sudo -u claw-b XDG_RUNTIME_DIR=/run/user/1003 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1003/bus systemctl --user'
alias jn-a='sudo -u claw-a XDG_RUNTIME_DIR=/run/user/1002 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1002/bus journalctl --user-unit'
alias jn-b='sudo -u claw-b XDG_RUNTIME_DIR=/run/user/1003 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1003/bus journalctl --user-unit'
หลังจากนี้สั่งงานง่ายขึ้นเยอะ เช่น sc-a status openclaw ดูสถานะ sc-a restart openclaw รีสตาร์ต jn-a openclaw -f ดู log แบบ follow ตรวจให้ทั้งสองตัวขึ้น active (running)
16. ตั้ง model alias และต่อ Telegram
พอ gateway รันได้ก็มาตั้งค่าโมเดลให้ใช้สะดวก เข้าไปใน user แล้วเซ็ต default model กับ alias ที่จำง่าย
sudo -iu claw-a
source ~/.bashrc
openclaw models set "ollama/qwen3:14b"
openclaw models aliases add qwen ollama/qwen3:14b
openclaw models aliases add think ollama/deepseek-r1-tools:14b
openclaw models aliases add code ollama/deepseek-coder-v2-tools:16b
openclaw models aliases add gpt ollama/gpt-oss:20b
มีปัญหาหนึ่งที่กัดเรื่อยๆ คือทุกครั้งที่ restart gateway มันจะไป auto-discover โมเดลจาก Ollama ใหม่ แล้วเขียนทับ config เดิม ทำให้ค่า reasoning: true กลับมาทุกที ซึ่ง Ollama ไม่รองรับ thinking parameter พอมีค่านี้ติดมาก็ error ทันที วิธีแก้ถาวรคือกำหนด models.providers ลงใน config ของ OpenClaw เองให้ทุกโมเดลเป็น reasoning: false ตายตัว ไม่ให้ auto-discovery มาทับ
import json, os
path = os.path.expanduser('~/.openclaw/openclaw.json')
with open(path) as f:
cfg = json.load(f)
cfg.setdefault('models', {})['providers'] = {
'ollama': {
'baseUrl': 'http://127.0.0.1:11434/v1',
'api': 'openai-completions',
'models': [
{'id': m, 'name': m, 'reasoning': False,
'input': ['text'], 'contextWindow': 128000, 'maxTokens': 8192,
'cost': {'input': 0, 'output': 0, 'cacheRead': 0, 'cacheWrite': 0}}
for m in [
'qwen3:14b', 'deepseek-r1-tools:14b',
'deepseek-coder-v2-tools:16b', 'gpt-oss:20b', 'hermes3:8b'
]
]
}
}
with open(path, 'w') as f:
json.dump(cfg, f, indent=2)
print('set reasoning: false for all ollama models')
ส่วน Telegram pairing ทำหลัง gateway start แล้ว ส่งข้อความหา bot ใน Telegram ครั้งแรกจะได้ pairing code มา แล้ว approve
openclaw pairing approve telegram <code>
openclaw models status
openclaw channels status
exit
17. ตรวจสอบทั้งระบบ
พอวางครบทุกชิ้นแล้วผมรัน checklist ไล่ทั้งกองอีกรอบ ตั้งแต่ระดับระบบลงไปถึงแต่ละ instance เพื่อให้แน่ใจว่าทุกชั้นทำงานพร้อมกันจริง
# ระดับระบบ
nvidia-smi # ต้องเห็น 2 GPU
ollama list # ต้องเห็นโมเดลครบ
sudo systemctl status ollama # active (running)
# แต่ละ instance
sc-a status openclaw # active (running)
sc-b status openclaw # active (running)
# ทดสอบ API ภาษาไทยตรงๆ
curl -s http://localhost:11434/api/chat \
-d '{"model":"qwen3:14b","messages":[{"role":"user","content":"สวัสดี"}],"stream":false,"options":{"num_predict":50}}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['message']['content'])"
สถานะที่ถูกต้องคือ Ollama รันอยู่ที่ port 11434 ส่วน gateway ของแต่ละ instance รันอยู่ที่ port ของตัวเองคนละเบอร์ ทั้งสองตัว active (running) และ openclaw models status ของแต่ละ user ต้องขึ้น Auth=yes ทุกโมเดล
18. ปัญหาที่เจอบ่อยและวิธีแก้
ตลอดการวางระบบนี้ ปัญหาที่กัดมากที่สุดวนอยู่ไม่กี่เรื่อง ผมรวบรวมไว้ให้ตรงนี้ เผื่อใครเจอจะได้ไม่ต้องเสียเวลานั่งงม
Failed to connect to bus
- สาเหตุ: sudo -iu ไม่มี DBUS session bus
- แก้: ใส่ XDG_RUNTIME_DIR + DBUS_SESSION_BUS_ADDRESS ชี้ /run/user/<uid>
- ทำเป็น alias ไว้จะสะดวกกว่า
โมเดลไม่มี Tools template
- สาเหตุ: template ไม่มี {{.Tools}}
- แก้: สร้าง custom model ด้วย Modelfile (ขั้น 10)
- ทดสอบด้วย curl /api/chat แนบ tools
อีกสองเรื่องที่เจอประจำ เรื่องแรกคือ fnm: command not found หลัง login ใหม่ ต้นเหตุเกือบทุกครั้งคือลำดับใน .bashrc สลับกัน export PATH ต้องมาก่อน eval "$(fnm env)" เสมอ ถ้าสลับกัน fnm จะหาตัวเองไม่เจอ เรื่องที่สองคือ OpenClaw ขึ้น Missing auth - ollama ให้ไปตรวจว่า OLLAMA_API_KEY มีอยู่ทั้งใน .bashrc และใน service file หรือยัง ขาดที่ใดที่หนึ่งก็ฟ้อง
การ pipe แบบนี้มีโอกาสทำให้ไฟล์กลายเป็น 0 bytes ถ้าจังหวะ process มันชนกัน ผมเจอ openclaw.json หายเกลี้ยงมาแล้ว ให้ backup ก่อนทุกครั้งด้วย cp แล้วแก้ด้วยสคริปต์ python ที่อ่านไฟล์เข้ามาทั้งก้อน แก้ใน memory แล้วเขียนกลับ จะปลอดภัยกว่า
ส่วนเรื่อง Ollama เห็น GPU แค่ใบเดียว ให้ไล่ตรวจสามจุด คือ nvidia-smi เห็นครบไหม CUDA_VISIBLE_DEVICES ใน service file ตั้งเป็น 0,1 หรือเปล่า และ user ollama อยู่ใน group video กับ render ครบไหมด้วยคำสั่ง id ollama
19. การดูแลและอัปเดต
เรื่องที่ต้องจำให้ขึ้นใจคือ ทุกครั้งที่ ollama pull โมเดลตัวเดิมเพื่ออัปเดต weight ต้อง rebuild custom model สาย -tools ทั้งหมดใหม่ เพราะ pull ใหม่มันทับ weight แต่ template ที่เราเติมไว้หายไป ถ้าลืม rebuild พอ OpenClaw ส่ง tool เข้าไปก็จะกลับไปเจอ error เดิม
ollama pull deepseek-r1:14b
ollama create deepseek-r1-tools:14b -f /home/<user>/modelfiles/Modelfile-deepseek-r1-tools
การ update OpenClaw ทำผ่าน npm ในแต่ละ user แล้ว restart gateway ส่วน Claude CLI มัน auto-update ให้เองไม่ต้องยุ่ง อีกเรื่องที่ต้องรู้คือทุกครั้งที่ wsl --shutdown service ทั้งหมดจะดับ พอเปิด WSL ใหม่ต้อง start กลับ
sudo systemctl start ollama
sc-a start openclaw
sc-b start openclaw
ตัว Ollama ถ้า enable ไว้แล้วมันจะ start เองตอน systemd boot แต่ user service ของ OpenClaw ที่เปิด linger ไว้ก็จะกลับมาเองเช่นกัน ตรงนี้แล้วแต่ว่าตั้ง enable ครบหรือยัง
20. Benchmark วัดผลจริง
สุดท้ายผมเขียนสคริปต์ benchmark ไว้วัดทุกโมเดลพร้อมกัน เพื่อดูว่าตัวไหนเหมาะกับงานแบบไหนบนฮาร์ดแวร์ชุดนี้ สคริปต์มันยิง request ผ่าน API แบบ streaming แล้วจับสามค่า คือ TTFT (เวลากว่าจะได้ token แรก) throughput (token ต่อวินาที) และเวลารวม โดยทดสอบสี่ประเภทงาน ตั้งแต่ตอบสั้นๆ ไปจนถึงเขียนเรื่องยาว
หัวใจของสคริปต์คือฟังก์ชัน run_test ที่ยิง request แบบ stream แล้วจับเวลา token แรกกับนับ token ทั้งหมด ส่วนที่เหลือเป็นเรื่องการคำนวณ throughput และจัดตารางแสดงผลให้อ่านง่ายบน terminal
import requests, time, json
OLLAMA_URL = "http://localhost:11434/v1/chat/completions"
def run_test(model, messages, max_tokens=None):
payload = {"model": model, "messages": messages, "stream": True}
if max_tokens:
payload["max_tokens"] = max_tokens
ttft = None
chunks = []
total_tokens = 0
start = time.perf_counter()
resp = requests.post(OLLAMA_URL, json=payload, stream=True, timeout=600)
resp.raise_for_status()
for line in resp.iter_lines():
if not line:
continue
text = line.decode("utf-8", errors="replace")
if not text.startswith("data: "):
continue
data = text[6:]
if data.strip() == "[DONE]":
break
try:
chunk = json.loads(data)
except json.JSONDecodeError:
continue
if ttft is None:
ttft = time.perf_counter() - start
delta = chunk.get("choices", [{}])[0].get("delta", {})
if delta.get("content"):
chunks.append(delta["content"])
usage = chunk.get("usage")
if usage and usage.get("completion_tokens"):
total_tokens = usage["completion_tokens"]
total_time = time.perf_counter() - start
if total_tokens == 0:
total_tokens = max(1, len("".join(chunks)) // 4)
gen_time = total_time - (ttft or 0)
throughput = total_tokens / gen_time if gen_time > 0 else 0
return ttft, total_time, total_tokens, throughput
อีกฟังก์ชันที่ผมชอบคือการคำนวณว่าโมเดลตัวหนึ่งจะรับ context ได้กี่ token ตาม VRAM ที่เหลือหลัง load weight แล้ว คำนวณจากขนาด KV cache ต่อ token เทียบกับ VRAM ว่าง วิธีนี้ช่วยให้รู้ล่วงหน้าว่าโมเดลตัวไหนจะรับ context ยาวได้จริงโดยไม่ล้นออก RAM
def calc_context_capacity(arch, model_size_gb, gpu_vram_mb):
# KV cache bytes ต่อ 1 token
kv_per_token = (2 * arch["block_count"] * arch["head_count_kv"]
* (arch["key_length"] + arch["value_length"]) * 2)
model_size_mb = model_size_gb * 1024
overhead_mb = 500
remaining_mb = gpu_vram_mb - model_size_mb - overhead_mb
if remaining_mb <= 0:
return 0
remaining_bytes = remaining_mb * 1024 * 1024
max_ctx_by_vram = int(remaining_bytes / kv_per_token)
return min(max_ctx_by_vram, arch["context_length"] or max_ctx_by_vram)
ผลที่ได้จากฮาร์ดแวร์ชุดนี้สรุปได้ตามรูปแบบนี้ โมเดลเล็กระดับ 8B ให้ throughput สูงกว่า 20 token ต่อวินาที โมเดลกลาง 14B-16B อยู่ราว 10-18 token ต่อวินาที ส่วนโมเดลใหญ่ 20B ช้ากว่าแต่คุณภาพคำตอบดีกว่า และส่วนใหญ่ TTFT ต่ำกว่าสองวินาที ซึ่งถือว่าใช้งานโต้ตอบจริงได้สบาย
ตัวเลขพวกนี้จะต่างไปตามการ์ดจอและขนาด VRAM ที่แต่ละคนมี เอาไว้เป็นกรอบเทียบคร่าวๆ ว่าโมเดลขนาดไหนคุ้มกับเครื่องเราที่สุด
ทั้งหมดนี้คือลำดับที่ผมวางจริงตั้งแต่เครื่องเปล่าจนรันได้ทุกวัน จุดที่ผมอยากเน้นที่สุดคือเรื่อง systemd บน WSL2 กับเรื่อง tool template ของ deepseek เพราะสองอย่างนี้กินเวลาผมไปมากที่สุด ถ้าผ่านสองด่านนี้ได้ ที่เหลือก็ไหลไปเรื่อยๆ ใครจะใช้การ์ดใบเดียว ใช้ Ubuntu คนละเวอร์ชัน หรือเลือกโมเดลคนละชุด ก็ปรับเอาตามสะดวก หลักการเหมือนกันหมด
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



ความคิดเห็น 0
ยังไม่มีความคิดเห็น — เป็นคนแรกเลย