หลายทีมเริ่มมีโมเดล open-weight ที่ host เองไว้ใช้ภายใน คำถามถัดมาคือจะให้มันทำงานอะไรได้จริงบ้าง งานที่เหมาะมากคืองานตามรอบที่ไม่ต้องตอบทันที เช่น คัดกรองข่าวช่องโหว่ (CVE) ช่วงกลางคืน ระหว่างที่คนในทีมยังไม่เข้างาน แต่ถ้าเปิดให้โมเดลสร้าง ticket หรือส่งแจ้งเตือนเองตั้งแต่วันแรก เราจะไม่มีทางรู้เลยว่ามันตัดสินถูกแค่ไหน และถ้ามันพลาด คนที่ต้องเก็บกวาดก็คือทีมตอนเช้า

บทความนี้เล่าวิธีที่เราใช้ คือรันงานกะดึกด้วย LLM แบบ shadow mode: ให้โมเดลทำงานเดียวกับรอบกลางวันแบบเงียบ ๆ อ่านข้อมูลได้อย่างเดียว เขียนผลลงไฟล์เท่านั้น แล้วเอาผลมาเทียบกับรอบกลางวันหนึ่งสัปดาห์ก่อนจะตัดสินใจว่าจะไว้ใจมันได้แค่ไหน ตัวอย่างโค้ดและชื่อไฟล์ในบทความเป็นค่าสมมติทั้งหมด ให้ปรับตามระบบของคุณเอง

แผนภาพงาน LLM กะดึกแบบ shadow mode: systemd timer เรียกงานทุกชั่วโมงช่วง 21:05 ถึง 07:05 ดึงข้อมูล CVE แบบอ่านอย่างเดียว กรองด้วยโค้ด ส่งให้โมเดลที่ host เองตอบเป็น JSON แล้วเขียนรายงานลงไฟล์ ไม่สร้าง ticket และไม่แจ้งเตือน ตอนเช้าทีมเทียบผลกับรอบกลางวัน
ภาพรวม: ข้อมูลไหลเข้าแบบอ่านอย่างเดียว งานเขียนผลได้แค่ไฟล์บนเครื่องตัวเอง และคนเป็นผู้ตัดสินใจตอนเช้า (คลิกที่ภาพเพื่อดูขนาดเต็ม)

ทำไมต้อง shadow mode

shadow mode คือการให้ระบบใหม่ทำงานคู่กับระบบเดิมโดยไม่มีผลต่อโลกภายนอก แล้วเก็บผลไว้เทียบ แนวคิดเดียวกับการ deploy แบบ shadow traffic ของบริการเว็บ เหตุผลที่เราเลือกวิธีนี้กับ LLM มีสามข้อ

  • เราไม่รู้ว่าโมเดลแม่นแค่ไหนกับงานของเรา คะแนน benchmark ทั่วไปบอกไม่ได้ว่ามันจะแยก CVE ที่เกี่ยวกับ stack ของเรากับที่ไม่เกี่ยวได้ดีแค่ไหน
  • ความผิดพลาดแต่ละแบบมีราคาไม่เท่ากัน การพลาด CVE สำคัญไปหนึ่งตัว (miss) แย่กว่าการเตือนเกินไปหลายตัว (false positive) มาก เราต้องวัดสองอย่างนี้แยกกัน
  • เราได้ข้อมูลที่มี label มาฟรี ทุกเช้าทีมตัดสินใจกับ CVE ชุดเดียวกันอยู่แล้ว คู่ "คำตัดสินกลางคืน กับคำตัดสินตอนเช้า" จึงกลายเป็นชุดข้อมูลสำหรับปรับ prompt ในรอบถัดไป

สถาปัตยกรรมของงานหนึ่งรอบ

งานเป็นสคริปต์ Python ตัวเดียว รันแบบ oneshot ทุกชั่วโมง แต่ละรอบทำสี่อย่างตามลำดับ

  1. ดึงข้อมูล เฉพาะ CVE ที่ถูกแก้ไขในหน้าต่างเวลานี้ (ตั้งแต่จบรอบที่แล้ว ลบ 5 นาทีเผื่อซ้อน จนถึงตอนนี้) จาก NVD API, รายการ KEV ของ CISA, CVE record ของ CNA (บอก severity และช่วงเวอร์ชันที่ผู้ผลิตประกาศ แม้ NVD ยังไม่ให้คะแนน CVSS), ฐานข้อมูลผลสแกน dependency ที่มีอยู่แล้ว และรายการ CVE ที่ทีมติดตามอยู่ใน Git
  2. กรองด้วยโค้ดก่อน ส่งให้โมเดลเฉพาะรายการที่ผ่านเกณฑ์ชัดเจน คือ CVSS ตั้งแต่ 7.0 หรืออยู่ใน KEV หรือเจอในผลสแกน และต้องตรงกับ watchlist ของผลิตภัณฑ์ที่เราใช้ (regex กับคำอธิบาย หรือ vendor:product ใน CPE)
  3. ให้ LLM ตัดสินทีละรายการ ว่าเกี่ยวกับเราไหม ด่วนแค่ไหน และสรุปเป็นภาษาไทย
  4. ตรวจและเขียนผล ตรวจ JSON ตาม schema ตัดรายการที่ทีมติดตามอยู่แล้วออก แล้วเขียนรายงานลงไฟล์

ขั้นที่ 2 สำคัญกว่าที่คิด ในหนึ่งคืน NVD อาจมี CVE ถูกแก้ไขหลายพันรายการ ถ้าส่งทุกตัวให้โมเดลที่ตอบตัวละหลายสิบวินาที งานจะไม่มีวันเสร็จ ให้โค้ดทำส่วนที่ตัดสินได้ด้วยกฎ และเก็บโมเดลไว้ใช้กับส่วนที่ต้องอ่านและตีความจริง ๆ เช่น คำว่า "PostgreSQL" ในคำอธิบายอาจหมายถึงตัวฐานข้อมูลเอง หรือแค่เครื่องมือของบุคคลที่สามที่ทำงานกับ PostgreSQL ส่วนนี้แหละที่ LLM ช่วยแยกได้ดี

หน้าต่างเวลากลางคืนและการจำกัดทรัพยากร

เราใช้ systemd timer แทน cron เพราะได้ทั้ง log ใน journal, การจำกัดทรัพยากร และเงื่อนไขก่อนรันในที่เดียว

# /etc/systemd/system/night-triage.timer
[Timer]
OnCalendar=*-*-* 21..23,00..07:05:00 Asia/Bangkok
RandomizedDelaySec=60
Persistent=false

# /etc/systemd/system/night-triage.service
[Unit]
ConditionPathExists=/etc/night-triage/enabled

[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /opt/night-triage/run.py
TimeoutStartSec=55min
Nice=10
IOSchedulingClass=idle
CPUQuota=50%
MemoryMax=400M
PrivateTmp=yes

รายละเอียดที่เรียนรู้มา

  • Persistent=false ตั้งใจไว้ ถ้าเครื่องดับตอนตีสาม เราไม่อยากให้ systemd ไล่รันรอบที่ตกหล่นตอนเครื่องกลับมาตอนสาย ซึ่งจะไปชนกับรอบกลางวัน
  • TimeoutStartSec ต้องสั้นกว่าระยะห่างระหว่างรอบ และในสคริปต์ใช้ flock แบบ non-blocking กันรอบซ้อนกันอีกชั้น
  • ไฟล์ enabled คือ kill switch ลบไฟล์เดียวงานก็หยุดทันที ไม่ต้องแก้ unit ไม่ต้องรอ deploy สคริปต์เองก็เช็กไฟล์นี้ซ้ำตอนเริ่มเผื่อมีคนรันด้วยมือ
  • ตัวโมเดลรันอยู่หลัง AI gateway บนเครื่องอื่น เครื่องที่รันงานจึงใช้ทรัพยากรน้อย แต่ยังควรจำกัด CPU และ RAM ไว้ เพราะเครื่องนั้นมักมีงานอื่นอยู่ด้วย

ทำให้ "อ่านอย่างเดียว" เป็นจริง ไม่ใช่แค่สัญญา

คำว่า read-only ในเอกสารไม่มีความหมาย ถ้าโค้ดยังมีทางเขียนออกไปได้ เราจึงบังคับที่ตัวโค้ดและสิทธิ์

  • ไม่มี client ของระบบ ticket หรือแชตอยู่ในโค้ดเลย และไม่ให้ credential ของระบบเหล่านั้นกับเครื่องที่รันงาน
  • เปิดฐานข้อมูลผลสแกนด้วย URI file:/var/lib/scanner/db.sqlite3?mode=ro ถ้าเผลอเขียน SQLite จะ error เอง
  • repo ที่เก็บรายการ CVE ใช้ shallow clone แล้ว git fetch อย่างเดียว ไม่เคย commit หรือ push
  • token ของ AI gateway อยู่ในไฟล์สิทธิ์ 0600 ของ root ไม่อยู่ใน config, ไม่อยู่ใน Git และไม่ถูกพิมพ์ลง log
  • ผลลัพธ์มีแค่ reports/YYYY-MM-DD.json กับ .md ต่อหนึ่งคืน โดยรอบตีสามของวันที่ 14 เขียนต่อท้ายรายงานของคืนวันที่ 13 คนอ่านตอนเช้าจะได้เปิดไฟล์เดียวจบ

prompt และ JSON schema ของคำตอบ

ส่งให้โมเดลครั้งละหนึ่ง CVE พร้อมบริบทที่จำเป็น ได้แก่ คำอธิบาย, CVSS, severity จาก CNA, CPE, อยู่ใน KEV หรือไม่, watchlist ที่ตรง และงานที่เปิดรวมไว้แล้วสำหรับผลิตภัณฑ์นั้น แล้วบังคับให้ตอบเป็น JSON รูปแบบเดียว

{
  "relevant": "yes | no | unsure",
  "reason": "เหตุผลสั้น ๆ",
  "product": "ชื่อผลิตภัณฑ์ใน watchlist",
  "urgency": "critical | high | other",
  "summary_th": "สรุปภาษาไทย 2-3 บรรทัด ห้ามใส่เลขเวอร์ชัน",
  "task_needed": true,
  "use_consolidated": false
}

ฝั่งโค้ดตรวจทุกคำตอบก่อนเชื่อ

ENUMS = {"relevant": {"yes", "no", "unsure"},
         "urgency": {"critical", "high", "other"}}

def validate(raw: str) -> dict:
    text = raw[raw.find("{"): raw.rfind("}") + 1]   # ตัดข้อความนอก JSON
    data = json.loads(text)
    for key, allowed in ENUMS.items():
        if data.get(key) not in allowed:
            raise ValueError(f"bad {key}: {data.get(key)!r}")
    for key in ("task_needed", "use_consolidated"):
        if not isinstance(data.get(key), bool):
            raise ValueError(f"{key} must be boolean")
    return data

ถ้า parse ไม่ผ่าน ให้ถามซ้ำหนึ่งครั้ง ถ้ายังไม่ผ่านจึงเปลี่ยนไปใช้โมเดลสำรองในรายการ และถ้าโมเดลไหนตอบ HTTP 404, 502 หรือ 503 ให้ข้ามโมเดลนั้นไปจนจบรอบ ไม่ต้องเสียเวลาลองทุก CVE คืนแรกที่ทดสอบเราเจอโมเดลสำรองตัวหนึ่งตอบ 502 ติดกันหลายครั้งจริง ๆ ส่วนโมเดลหลักตอบ JSON ถูกต้องทุกครั้ง ใช้เวลาประมาณ 20–60 วินาทีต่อ CVE

กฎสำคัญ: เวอร์ชันห้ามมาจาก LLM

สิ่งที่อันตรายที่สุดในรายงาน CVE คือเลขเวอร์ชันที่ผิด เช่น บอกว่าแก้แล้วในรุ่นที่ไม่มีอยู่จริง เราจึงให้โค้ดสร้าง fixed_versions และ affected_ranges จากช่วงเวอร์ชันใน CPE ของ NVD และ CVE record ของ CNA เท่านั้น ส่วนข้อความที่โมเดลเขียน ถ้ามีอะไรหน้าตาเหมือนเลขเวอร์ชันที่ไม่ปรากฏในคำอธิบายต้นฉบับ ให้แทนด้วย [?] และบันทึกไว้ว่าถูกตัด

VERSION = re.compile(r"\bv?\d+\.\d+(?:\.\d+)*\b")

def scrub_versions(text: str, source: str) -> tuple[str, list[str]]:
    removed = []
    def repl(m):
        if m.group(0) in source:
            return m.group(0)
        removed.append(m.group(0))
        return "[?]"
    return VERSION.sub(repl, text), removed

จำนวนครั้งที่ต้อง scrub ก็เป็นตัวชี้วัดคุณภาพของโมเดลไปในตัว

งบเวลา, cache และงานค้าง

  • กำหนดเพดานต่อรอบ เช่น เรียกโมเดลได้ไม่เกิน 40 ครั้ง หรือรวมไม่เกิน 40 นาที ที่เหลือเก็บเป็น pending ใน state แล้วไปทำต่อรอบหน้า ดีกว่าปล่อยให้รอบหนึ่งลากยาวจนชนรอบถัดไป
  • cache ด้วย id|lastModified CVE ที่ตัดสินไปแล้วจะไม่ถูกถามซ้ำ เว้นแต่ NVD แก้ข้อมูลของมัน
  • เคารพ rate limit ของ NVD ถ้าไม่มี API key ให้ดึงหน้าละ 500 รายการ เว้นช่วงระหว่าง request หลายวินาที และ backoff เมื่อเจอ 403, 429 หรือ 5xx ในการทดสอบ หน้าต่าง 14 ชั่วโมงที่มีการแก้ไขราว 2,500 รายการใช้เวลาดึงประมาณ 6 นาที

เทียบกับรอบกลางวันอย่างไร

ทุกเช้าเอารายงานกลางคืนมาวางคู่กับสิ่งที่รอบกลางวันตัดสินในช่วงเวลาเดียวกัน แล้วบันทึกตัวเลขเหล่านี้

  • Recall (สำคัญที่สุด): CVE ทุกตัวที่รอบกลางวันเปิดงานหรือเพิ่มเข้างานที่รวมไว้ ต้องถูกโมเดลตอบ yes ตัวที่หลุดคือ miss ให้ดูทีละตัวว่าหลุดตั้งแต่ตัวกรองหรือโมเดลตัดสินผิด
  • Precision: ตัวที่โมเดลบอก yes และ task_needed แต่รอบกลางวันข้ามไป บันทึกด้วยว่าสุดท้ายใครถูก บางครั้งโมเดลเจอสิ่งที่คนมองข้ามจริง ๆ
  • ความตรงกันของระดับความด่วน: critical/high ตรงกับลำดับความสำคัญที่ทีมให้หรือไม่
  • คุณภาพสรุปภาษาไทย: สุ่มอ่านวันละ 5 รายการ ดูว่าถูกต้อง ไม่แต่งข้อเท็จจริงเพิ่ม และไม่มีเลขเวอร์ชัน
  • ตัวเลขด้าน ops: JSON ที่ parse ไม่ผ่าน, จำนวน retry และ fallback, เวลาต่อ CVE, รอบที่ชนเพดาน และ error จาก NVD
night = {c["id"] for c in night_report if c["relevant"] == "yes"}
day   = {c["id"] for c in day_tasks}          # ที่รอบกลางวันเปิดงานจริง
recall    = len(night & day) / len(day) if day else 1.0
misses    = sorted(day - night)               # ต้องอ่านทุกตัว
extra     = sorted(night - day)               # ไปดูว่าใครถูก

เมื่อไรควร promote และเมื่อไรควรถอย

ตั้งเกณฑ์ไว้ก่อนเริ่มทดลอง จะได้ไม่ตัดสินใจตามความรู้สึก ของเราคือหลังครบหนึ่งสัปดาห์ต้องมี recall อย่างน้อย 95% สำหรับ CVE ที่ทีมเปิดงานจริง และ ไม่มีเลขเวอร์ชันที่แต่งขึ้นเลย ถ้าผ่าน ขั้นต่อไปก็ยังไม่ใช่การให้โมเดลเปิด ticket เอง แต่เป็นการให้มันร่างงานไว้ให้คนกดยืนยันตอนเช้า

ถ้าไม่ผ่าน ให้ปรับตามลำดับ: แก้ตัวกรองและ watchlist ก่อน, แล้วปรับ prompt และเพิ่มตัวอย่าง (few-shot) จากคู่ที่ตัดสินผิด การ fine-tune เช่น LoRA ค่อยพิจารณาเมื่อเป็นโมเดลที่เรา host เองและมีข้อมูลที่ label แล้วมากพอ ส่วนการถอยนั้นง่าย เพราะงานไม่มีผลข้างเคียงอยู่แล้ว ลบไฟล์ enabled แล้วปิด timer ก็จบ

กับดักที่เจอ

  • โมเดลแบบ reasoning ต้องการ max_tokens เยอะ ถ้าตั้งน้อย มันคิดยังไม่ทันจบก็โดนตัด ได้ JSON ครึ่งเดียว ตั้งเผื่อไว้หลายพัน token และตั้ง timeout ต่อ request ให้สอดคล้องกัน
  • keyword กว้างโดยตั้งใจ ดีกว่าแคบแล้วพลาด แต่ต้องยอมรับว่าโมเดลจะได้งานเพิ่ม และต้องวัด precision ให้เห็น
  • inventory ที่เป็น snapshot จะเก่า เช่น รายชื่อปลั๊กอินที่ติดตั้งอยู่ ถ้าไม่ refresh พร้อม inventory หลัก ของที่เพิ่มทีหลังจะหลุดตัวกรองไปเงียบ ๆ
  • การตัดรายการซ้ำเป็นแค่การประมาณ เพราะงานกลางคืนไม่ได้แตะระบบ ticket ก่อนเปิดงานจริงตอนเช้าต้องเช็กในระบบ ticket อีกครั้งเสมอ
  • อย่าวัดแค่ "ตอบถูก" เวลาต่อรายการและอัตรา fallback บอกได้ว่าระบบพร้อมรับงานจริงหรือยัง

สรุป

การเอา LLM ที่ host เองมาทำงานตามรอบไม่ได้ยากที่ตัวโมเดล แต่ยากที่การทำให้มันปลอดภัยพอจะลองกับงานจริง shadow mode ตอบโจทย์นี้: ให้ทำงานเดียวกับคนแบบอ่านอย่างเดียว, ให้โค้ดดูแลส่วนที่ต้องแม่นยำ เช่น การกรองและเลขเวอร์ชัน, ตรวจคำตอบด้วย schema และวัดผลกับรอบกลางวันด้วยเกณฑ์ที่ตั้งไว้ก่อน ถ้าผลไม่ดีก็ปิดได้ในคำสั่งเดียว และถ้าผลดี เราจะได้ทั้งความมั่นใจและชุดข้อมูลสำหรับปรับโมเดลต่อ ถ้าคุณกำลังตั้งโครงสร้างพื้นฐานสำหรับงานแบบนี้บนคลัสเตอร์เล็ก ๆ อ่านต่อได้ที่ ติดตั้ง k3s + Argo CD + SealedSecrets บน VM เดียว ให้เข้าได้เฉพาะทาง Tailscale