บล็อก

ให้ AI Server เล่น Doom: บันทึกการสร้างเดโม โมเดลตัดสินใจ 10 ครั้งต่อวินาที ตั้งแต่ศูนย์ พร้อมบั๊กทุกตัวที่เจอ

TypeSafe เปิดตัว Jev ด้วยเดโมที่คนพูดถึงมากที่สุด: ให้โมเดลเล่น Doom โดยตัดสินใจ ~10 ครั้งต่อวินาทีจาก “ข้อความบรรยายสถานการณ์” ไม่ใช่ภาพ ในราคา ~$7 ต่อชั่วโมง เราอยากรู้ว่าฟีเจอร์ Classify ของ AI Server (ที่ใช้โมเดลในเครื่องลูกค้าอยู่แล้ว ไม่มีโมเดลใหม่) ทำแบบเดียวกันได้ไหม บทความนี้คือบันทึกทั้งวัน: เครื่องมือที่ใช้ โค้ดส่วนสำคัญ ตัวเลขทุกรอบ และบั๊ก 3 ตัวที่สอนเรามากกว่าผลลัพธ์สุดท้าย (สถานะ: เดโมทำงานบนเครื่อง DGX Spark ของเรา ดูผ่านเครือข่ายภายใน ยังไม่ขึ้นเว็บสาธารณะ)

ตอนที่ 1: ทำไมต้องเป็น Doom

Doom เป็นเกมที่ “การตัดสินใจถูกต้อง” วัดได้ทันที: ฆ่าศัตรูได้กี่ตัว รอดนานแค่ไหน และมันต้องการการตัดสินใจถี่มาก (เกมเดิน 35 เฟรมต่อวินาที) ถ้าโมเดลใช้เวลาตอบ 2 วินาทีแบบแชตปกติ ตัวละครตายก่อนได้คำตอบ

นี่คือจุดที่ “ตัดสินใจโดยไม่ generate” มีความหมายจริง: เราไม่ต้องการให้โมเดลอธิบายว่าทำไมควรหันซ้าย เราต้องการแค่ตัวอักษรเดียวกับความมั่นใจ แล้วรอบถัดไปมาถึงใน 100 มิลลิวินาที

สิ่งที่เราอยากพิสูจน์ 3 ข้อ:

  1. โมเดลใน AI Server (Qwen3-8B และ Qwen3.6-35B ที่ลูกค้าโหลดอยู่แล้ว) ตัดสินใจได้เร็วพอสำหรับเกม
  2. มันตัดสินใจ “ดีกว่ากฎที่คนเขียน” ได้ไหม โดยที่เราไม่ต้องเขียนกฎเลย
  3. ต้องเขียนอะไรบ้าง และส่วนไหนยาก

ตอนที่ 2: เครื่องมือ

  • ViZDoom 1.3.0 ไลบรารี Python ที่ห่อ ZDoom ให้เรียกเกมทีละเฟรม อ่านค่าภายใน (เลือด กระสุน ตำแหน่งวัตถุ) และสั่งปุ่มได้ มาพร้อม freedoom (WAD ฟรี) ไม่ต้องซื้อเกม มี wheel สำหรับ ARM64 ติดตั้งบน Spark ได้ตรงๆ
  • DGX Spark GB10 เครื่องเดียวกับที่ส่งลูกค้า
  • vLLM รัน Qwen3-8B FP8 และ Qwen3.6-35B-A3B NVFP4 (โมเดลในชุด AI Server)
  • Classify ท่าที่ใช้ตลอด: ส่ง chat request ปกติ ตั้ง max_tokens=1, ขอ logprobs, และบังคับ token แรกให้เป็นตัวเลือกเท่านั้นด้วย structured_outputs (บน llama.cpp ใช้ grammar) นี่คือกลไกเดียวกับโมเดล -classify ในบทความก่อนหน้า
  • หน้าเว็บสด: FastAPI ส่งภาพเกมเป็น MJPEG และส่งการตัดสินใจเป็น Server-Sent Events

ทั้งหมดอยู่ในไฟล์เดียว doom_agent.py ราว 200 บรรทัด กับ web.html อีก 40 บรรทัด

ตอนที่ 3: วงจรการทำงาน

ทุกๆ 4–5 เฟรมของเกม (ประมาณ 7–9 ครั้งต่อวินาที):

เกม (ViZDoom) ──state ตัวเลข──▶ แปลงเป็นข้อความไทย ──▶ Classify (โมเดล ตอบ 1 token + ความน่าจะเป็น)
   ▲                                                                   │
   └──────────────── กดปุ่ม (หันซ้าย/หันขวา/ยิง/เดิน…) ◀──────────────┘

หลักการที่ยืมมาจากทุกโปรเจกต์ Jev ในชุมชน: โค้ดธรรมดาเป็นคนบอก “ข้อเท็จจริง” และ “ทางเลือกที่ถูกกฎ” ส่วนโมเดลเป็นคนเลือก โค้ดไม่ตัดสินใจว่าควรยิงเมื่อไหร่ โมเดลไม่ต้องรู้ว่าปุ่มไหนคืออะไร

ตอนที่ 4: ขั้นที่ 1 ทำให้เกมรันโดยไม่มีจอ

import vizdoom as vzd
g = vzd.DoomGame()
g.load_config(".../scenarios/defend_the_center.cfg")
g.set_window_visible(False)
g.set_screen_resolution(vzd.ScreenResolution.RES_640X480)
g.set_labels_buffer_enabled(True)      # รายชื่อวัตถุที่อยู่ในจอ พร้อมตำแหน่ง
g.set_objects_info_enabled(True)       # ทุกวัตถุในแมป แม้ไม่เห็น
g.set_available_game_variables([HEALTH, AMMO2, KILLCOUNT, DAMAGE_TAKEN])
g.init()

ฉากแรกที่เลือกคือ defend_the_center: ตัวละครยืนกลางห้อง ศัตรูเดินเข้ามาจากรอบด้าน ทำได้แค่ 3 อย่าง หันซ้าย หันขวา ยิง กระสุน 26 นัด เป็นโจทย์ที่ “การตัดสินใจ” ชัดที่สุด ทดสอบ 200 เฟรมใช้เวลา 0.15 วินาที เกมไม่ใช่คอขวดแน่นอน

ตอนที่ 5: ขั้นที่ 2 แปลงสถานการณ์เป็นข้อความ (ส่วนที่ยากที่สุด)

โมเดลไม่เห็นภาพ มันเห็นข้อความแบบนี้:

เลือด 64 กระสุน 22 ฆ่าแล้ว 0 เพิ่งโดนโจมตี 36
ศัตรูที่เห็น: ปีศาจชมพู(ประชิด) อยู่ขวา (ห่างจากกลาง 34%) ระยะประชิด; ทหารเลื่อยยนต์ อยู่ซ้าย ระยะไกล
การกระทำล่าสุด: ยิง → ยิง → หันซ้าย

ที่มาของแต่ละบรรทัด:

  • ตัวเลขจาก game variables ตรงๆ
  • “ซ้าย/ขวา/กลางเป้า” คำนวณจากตำแหน่งกึ่งกลางของ label บนจอเทียบกับกึ่งกลางจอ (640 พิกเซล) “ระยะ” ประเมินจากความสูงของ label (สูงกว่า 200 พิกเซล = ประชิด)
  • “การกระทำล่าสุด” 3 ครั้งช่วยไม่ให้โมเดลสลับซ้าย-ขวาไปมา

ข้อความนี้ยาวราว 60–120 token และ นี่คือที่ที่บั๊กทั้งหมดของวันนี้ซ่อนอยู่ (ตอนที่ 8, 10)

ตอนที่ 6: ขั้นที่ 3 ถามโมเดล

body = {
  "model": "qwen36-35b", "max_tokens": 1, "temperature": 0,
  "logprobs": True, "top_logprobs": 10,
  "chat_template_kwargs": {"enable_thinking": False},
  "structured_outputs": {"choice": ["A", "B", "C"]},
  "messages": [
    {"role": "system", "content": "คุณคือระบบควบคุมตัวละครในเกม Doom แบบเรียลไทม์ … กติกา: ยิงเมื่อมีศัตรูอยู่กลางเป้า · ศัตรูอยู่ซ้ายให้หันซ้าย … ตอบด้วยตัวอักษรเดียว"},
    {"role": "user", "content": "สถานการณ์:\n<ข้อความจากตอนที่ 5>\n\nการกระทำถัดไปควรเป็นอะไร\nตัวเลือก: A) หันซ้าย  B) หันขวา  C) ยิง"}
  ]}

จาก logprobs ของ token แรกเราหยิบเฉพาะ A/B/C มาทำ softmax ได้ความน่าจะเป็นของแต่ละการกระทำ เช่น ยิง 0.56 / หันขวา 0.34 / หันซ้าย 0.03 แล้วกดปุ่มที่สูงสุด ความน่าจะเป็นนี้ไม่ได้ใช้ควบคุมเกมโดยตรง แต่มันคือสิ่งที่ทำให้หน้าเว็บ “อธิบายตัวเองได้” และช่วยจับบั๊ก

ตอนที่ 7: ขั้นที่ 4 ทำตัวเทียบก่อน แล้วค่อยวัดโมเดล

ก่อนเชื่อผลของโมเดล ต้องมีอะไรให้เทียบ เราเขียน 2 ตัว:

  • สุ่ม เลือกการกระทำมั่วๆ
  • กฎ 3 บรรทัด: ถ้าศัตรูใกล้กลางเป้าที่สุดอยู่ “กลางเป้า” หรือ “เล็กน้อย” ให้ยิง ไม่งั้นหันไปทางนั้น ไม่เห็นใครก็หันขวา

ผลรอบแรก (3 ตอนต่อโหมด):

โหมด ฆ่าเฉลี่ย รอด (วินาทีในเกม)
สุ่ม 0.67 9.0
กฎ 2.00 9.2
โมเดล Qwen3-8B 2.33 9.7

โมเดลตัดสินใจ 16–18 ครั้งต่อวินาที latency 50–52 ms ต่อครั้ง (prompt ราว 280 token) เร็วกว่าเดโมของ TypeSafe แต่ผลยังไม่น่าตื่นเต้น ทุกโหมดตายใน ~10 วินาที

ตอนที่ 8: บั๊กที่ 1 มองไม่เห็นจากตัวเลข แต่เห็นจากหน้าเว็บทันที

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

ศัตรูที่เห็น: BulletPuff อยู่ขวาเล็กน้อย ระยะไกล

BulletPuff คือเอฟเฟกต์ควันตอนกระสุนกระทบผนัง ViZDoom ส่งทุกวัตถุในจอมาใน labels รวมทั้งเลือดกระเซ็นและควัน เราเอาทุกอย่างที่ไม่ใช่ผู้เล่นมาเป็น “ศัตรู” โมเดลจึงหันไปยิงควันของตัวเอง

แก้ด้วยรายชื่อมอนสเตอร์จริง (Zombieman, Demon, Imp, …) และแยก “ของเก็บได้” (ยา กระสุน) ออกเป็นอีกบรรทัด ผลหลังแก้:

โหมด ฆ่าเฉลี่ย รอด
สุ่ม 1.33 11.2
กฎ 3.67 9.0
โมเดล Qwen3-8B 5.33 15.1

โมเดลกระโดดจาก 2.33 เป็น 5.33 กฎก็ดีขึ้นจาก 2.0 เป็น 3.67 บทเรียนข้อแรกของวัน: คุณภาพของ “ข้อความสถานการณ์” มีผลมากกว่าโมเดล และหน้าเว็บที่โชว์สิ่งที่โมเดลเห็นคือเครื่องมือดีบักที่ดีที่สุด

ทำไมโมเดลชนะกฎ? ดูจากจำนวนการกระทำ: กฎกด “ยิง” 73 จาก 74 ครั้งจนกระสุนหมด ส่วนโมเดล “รอ” และหันบ่อยกว่า (หันซ้าย 23–44 ครั้งต่อตอน) ยิงเมื่อเห็นว่าคุ้ม

ตอนที่ 9: หน้าเว็บสดทำงานอย่างไร

ไม่มีอะไรซับซ้อน: agent เก็บเฟรมล่าสุดเป็น JPEG ไว้ในตัวแปร FastAPI ให้ /stream ส่งเป็น multipart MJPEG (เบราว์เซอร์แสดงใน <img> ได้เลย) และ /events ส่ง JSON ของการตัดสินใจล่าสุดเป็น Server-Sent Events หน้าเว็บวาดแถบความน่าจะเป็นทั้ง 3–7 การกระทำ ข้อความ state และสถิติของตอนที่แล้ว ภายหลังเพิ่มปุ่ม “เริ่มตอนใหม่” กับ “หยุดชั่วคราว” ที่ยิง POST ไปตั้ง flag ให้ loop อ่าน

โหมด real-time: หลังกดปุ่ม agent คำนวณว่าเวลาในเกมควรอยู่ตรงไหน (35 tics/วินาที) แล้วหน่วงให้ตรง ที่ 8B ใช้เวลาจริงแค่ 45% ของแต่ละรอบ ที่ 35B (112 ms) ต้องขยับเป็นตัดสินใจทุก 5 เฟรม (7 ครั้ง/วินาที) ถึงจะทันเวลาจริง

หน้าเว็บสด defend_the_center
หน้าเว็บสดในฉาก defend_the_center: ภาพเกม, การตัดสินใจล่าสุดพร้อมความน่าจะเป็น และข้อความที่โมเดลเห็น

ตอนที่ 10: ย้ายไป deathmatch แล้วโมเดล “เลิกยิง” (บั๊กที่ 2 และ 3)

defend_the_center ง่ายไป เราขยับไป deathmatch: แมปใหญ่ ศัตรูหลายชนิดยิงจากไกล มีของเก็บ ทำได้ 7 อย่าง (ยิง เดินหน้า หันซ้าย หันขวา เดินซ้าย เดินขวา ถอย) รอบ 2 นาที

ผลรอบแรก: Qwen3-8B ฆ่าได้ 1.0 (กฎ 3.5) และ Qwen3.6-35B ฆ่า 1.5 แต่ ยิง 0 ครั้งจาก 1,000+ การตัดสินใจ เดินหน้าอย่างเดียว

สงสัยโมเดลก่อน จึงลองท่า “2 คำถามใน pass เดียว” (ควรยิงไหม? / ควรไปทางไหน?) ก็ยังยิง 0 เลยเอา state ที่เขียนมือไปถามตรงๆ: “ศัตรูอยู่กลางเป้า” → ตอบ “ใช่ ยิง” 0.999 / “ศัตรูอยู่ซ้าย ไกล” → “ไม่ยิง หันซ้าย” / “ไม่เห็นศัตรู” → “ไม่ยิง หันขวา” ตรรกะของโมเดลถูกทุกข้อ

งั้นบั๊กอยู่ที่ state เราเปิด log ทุกการตัดสินใจแล้วนับ: 190 การตัดสินใจ เห็นศัตรู 0 ครั้ง แมปใหญ่ ศัตรูอยู่นอกจอเกือบตลอด labels ให้เฉพาะสิ่งที่อยู่ในจอ โมเดลจึงเห็นแต่ “ไม่เห็นศัตรู” และทำตามกติกาซื่อๆ คือเดินหน้า ส่วนที่ “ฆ่าได้ 1–3” มาจากมอนสเตอร์ตีกันเอง

บั๊กที่ 2 ที่แก้ระหว่างทาง: เกณฑ์ “กลางเป้า” (เบี่ยงจากกลางจอไม่เกิน 6%) ใช้ค่าเดียวทุกระยะ ศัตรูไกลตัวเล็กไม่มีทางเข้าเกณฑ์ แก้ให้เกณฑ์ขึ้นกับระยะ (ไกล 14%, ใกล้ 10%, ประชิด 6%) และบอกตัวเลขตรงๆ “ห่างจากกลาง 34%” ให้โมเดลชั่งเอง

บั๊กที่ 3 แก้ด้วย “เรดาร์”: ViZDoom มี state.objects ที่บอกตำแหน่งของทุกวัตถุในแมปแม้ไม่เห็น เราเอาตำแหน่งผู้เล่นและมุมหันมาคำนวณมุมสัมพัทธ์กับระยะของศัตรูใกล้สุด แล้วเพิ่มบรรทัด:

เรดาร์: ศัตรูใกล้สุด ซอมบี้ปืน ระยะ 544 ทางขวา 26° · ศัตรูทั้งหมดในระยะ 1500: 6 ตัว

กับกติกาในระบบอีกข้อ “ไม่เห็นศัตรูให้หันตามเรดาร์ ตรงหน้าแล้วเดินเข้าไป” (TypeSafe เองก็บอกว่าเดโม Doom ใช้ “structured game state” ไม่ใช่แค่สิ่งที่เห็น)

ผลหลังใส่เรดาร์ (35B, 2 ตอน):

โหมด deathmatch ฆ่าเฉลี่ย รอด
กฎ ยิงอย่างเดียว 3.5 16 วิ
กฎ + เรดาร์ (หันหา เดินเข้าหา) 1.0 11 วิ
Qwen3-8B + เรดาร์ 0.5 17 วิ
Qwen3.6-35B + เรดาร์ 5.0 23 วิ

จาก log 406 การตัดสินใจของ 35B: เห็นศัตรู 279 ครั้ง → ยิง 267 · ศัตรูกลางเป้า 252 → ยิง 248 · ไม่เห็น 127 → เดินหน้า 77 / หันตามเรดาร์ 46 และความน่าจะเป็นมีนัยจริง: ศัตรูห่างกลาง 34% → ยิง 0.56 / หันขวา 0.34 (ลังเลถูกจุด) ห่าง 15% → ยิง 0.975

วิดีโอ deathmatch: Qwen3.6-35B ตัดสินใจ 7 ครั้งต่อวินาที (บันทึกจากหน้าเว็บสด)
หน้าเว็บสด deathmatch
deathmatch หลังใส่เรดาร์: โมเดลยิง 99% เมื่อศัตรูอยู่กลางเป้า

ตอนที่ 11: 8B กับ 35B ต่างกันตรงไหน และเรื่องอคติตัวอักษร

  • ฉากง่าย (3 ทางเลือก): 8B 5.33 ≈ 35B 5.0 โมเดลเล็กพอ
  • ฉากยาก (7 ทางเลือก + เรดาร์ + กติกา 6 ข้อ): 8B 0.5 กับ 35B 5.0 ต่างกัน 10 เท่า 8B ยิงพร่ำเพรื่อตอนศัตรูยังไม่กลางเป้า และไม่เดินหน้าเลยทั้งที่เรดาร์บอกตรงหน้า
  • 8B ในฉากง่ายกด “หันซ้าย” 23–44 ครั้ง แต่ “หันขวา” 0–1 ครั้ง ทั้งที่ศัตรูมาทั้งสองข้าง ส่วน 35B สมดุล (36/65) นี่คืออคติต่อตัวเลือก A ของโมเดลเล็กที่เราเจอในบทความก่อน ทดสอบ 35B แบบสลับลำดับตัวเลือกทุกครั้งได้ 4.0 เทียบ 5.0 (อยู่ในความแปรปรวน) แปลว่า 35B ไม่ได้พึ่งลำดับ
  • ท่า “2 คำถามใน pass เดียว” ทำงานถูกต้องแต่แพงกว่า (170 ms เพราะต้องใช้ prompt logprobs ทั้งก้อน) จึงกลับมาใช้ 1 คำถาม 7 ตัวเลือก

ตอนที่ 12: ตัวเลขความเร็วและต้นทุน

โมเดล latency ต่อการตัดสินใจ ตัดสินใจต่อวินาที หมายเหตุ
Qwen3-8B FP8 (vLLM) 50–65 ms 14–18 prompt 280–530 token
Qwen3.6-35B-A3B NVFP4 (vLLM) 88–114 ms 8.5–10.5 เท่าเดโม TypeSafe

ต้นทุน: ไม่มีค่า API เพราะรันบนเครื่องลูกค้าเอง GPU ถูกใช้เต็มเวลาหนึ่งสตรีม (TypeSafe คิด ~$7 ต่อชั่วโมง) และเพราะ vLLM รวมหลายคำขอเป็น batch ได้ เครื่องเดียวน่าจะเล่นพร้อมกันได้ 4–6 เกม ซึ่งจะทดสอบต่อไป

ตอนที่ 13: บทเรียนและสิ่งที่จะทำต่อ

  1. โมเดลไม่ได้โง่ ข้อความสถานการณ์ต่างหากที่ไม่พอ บั๊กทั้ง 3 ตัวอยู่ที่ representation งาน “แปลงโลกเป็นข้อความ” คือ 80% ของ agent แบบนี้
  2. หน้าเว็บที่โชว์สิ่งที่โมเดลเห็น จับบั๊กได้เร็วกว่า log ตัวเลขทุกแบบ
  3. มี baseline ก่อนเสมอ ถ้าไม่มี “กฎ 3 บรรทัด” เราจะไม่รู้ว่า 2.33 ของรอบแรกแย่ และ 5.33 ของรอบสองดี
  4. โค้ดบอกข้อเท็จจริง โมเดลตัดสิน เราไม่ได้เขียนกฎยิง/หันเป็นโค้ดเลย แต่ 35B ทำตามกติกาในระบบได้ 98% ของครั้งที่เห็นศัตรู
  5. ความน่าจะเป็นมีค่ามากกว่าคำตอบ ตัวเลข 0.56 vs 0.34 บอกว่าโมเดลลังเล ซึ่งเป็นสัญญาณให้ปรับ state หรือใช้ threshold

ต่อไป: ให้ตัวละครหลบกระสุนและไปเก็บเลือด (ตอนนี้ตายใน ~23 วินาทีเพราะยืนยิงอย่างเดียว) โดยถาม 2 คำถามให้ “เคลื่อนที่พร้อมยิง”, เล่นหลายเกมพร้อมกันเพื่อวัด batch, และนำหน้าเว็บนี้ขึ้น aiserver.in.th ผ่านตัว relay แบบเดียวกับ Video Studio

สรุปประโยคเดียว: เดโมที่ TypeSafe ใช้เปิดตัวโมเดลลับ ทำได้ในหนึ่งวันด้วยโมเดลที่อยู่ในเครื่องลูกค้าเราอยู่แล้ว และสิ่งที่ยากไม่ใช่โมเดล แต่คือการบอกโมเดลว่าโลกเป็นอย่างไร


ทดสอบ 20 ก.ย. 2026 บน DGX Spark GB10 · ViZDoom 1.3.0 + freedoom · โมเดล Qwen3-8B FP8, Qwen3.6-35B-A3B NVFP4 บน vLLM · ตัวเลขทั้งหมดจากบันทึกแล็บ ผลต่อตอนแปรปรวนสูง (2–9 ตัวใน 3 ตอน) ควรอ่านเป็นแนวโน้มไม่ใช่ค่าแม่นยำ