หลายทีมเริ่มมีโมเดล open-weight ที่ host เองไว้ใช้ภายใน คำถามถัดมาคือจะให้มันทำงานอะไรได้จริงบ้าง งานที่เหมาะมากคืองานตามรอบที่ไม่ต้องตอบทันที เช่น คัดกรองข่าวช่องโหว่ (CVE) ช่วงกลางคืน ระหว่างที่คนในทีมยังไม่เข้างาน แต่ถ้าเปิดให้โมเดลสร้าง ticket หรือส่งแจ้งเตือนเองตั้งแต่วันแรก เราจะไม่มีทางรู้เลยว่ามันตัดสินถูกแค่ไหน และถ้ามันพลาด คนที่ต้องเก็บกวาดก็คือทีมตอนเช้า
บทความนี้เล่าวิธีที่เราใช้ คือรันงานกะดึกด้วย LLM แบบ shadow mode: ให้โมเดลทำงานเดียวกับรอบกลางวันแบบเงียบ ๆ อ่านข้อมูลได้อย่างเดียว เขียนผลลงไฟล์เท่านั้น แล้วเอาผลมาเทียบกับรอบกลางวันหนึ่งสัปดาห์ก่อนจะตัดสินใจว่าจะไว้ใจมันได้แค่ไหน ตัวอย่างโค้ดและชื่อไฟล์ในบทความเป็นค่าสมมติทั้งหมด ให้ปรับตามระบบของคุณเอง
ทำไมต้อง shadow mode
shadow mode คือการให้ระบบใหม่ทำงานคู่กับระบบเดิมโดยไม่มีผลต่อโลกภายนอก แล้วเก็บผลไว้เทียบ แนวคิดเดียวกับการ deploy แบบ shadow traffic ของบริการเว็บ เหตุผลที่เราเลือกวิธีนี้กับ LLM มีสามข้อ
- เราไม่รู้ว่าโมเดลแม่นแค่ไหนกับงานของเรา คะแนน benchmark ทั่วไปบอกไม่ได้ว่ามันจะแยก CVE ที่เกี่ยวกับ stack ของเรากับที่ไม่เกี่ยวได้ดีแค่ไหน
- ความผิดพลาดแต่ละแบบมีราคาไม่เท่ากัน การพลาด CVE สำคัญไปหนึ่งตัว (miss) แย่กว่าการเตือนเกินไปหลายตัว (false positive) มาก เราต้องวัดสองอย่างนี้แยกกัน
- เราได้ข้อมูลที่มี label มาฟรี ทุกเช้าทีมตัดสินใจกับ CVE ชุดเดียวกันอยู่แล้ว คู่ "คำตัดสินกลางคืน กับคำตัดสินตอนเช้า" จึงกลายเป็นชุดข้อมูลสำหรับปรับ prompt ในรอบถัดไป
สถาปัตยกรรมของงานหนึ่งรอบ
งานเป็นสคริปต์ Python ตัวเดียว รันแบบ oneshot ทุกชั่วโมง แต่ละรอบทำสี่อย่างตามลำดับ
- ดึงข้อมูล เฉพาะ CVE ที่ถูกแก้ไขในหน้าต่างเวลานี้ (ตั้งแต่จบรอบที่แล้ว ลบ 5 นาทีเผื่อซ้อน จนถึงตอนนี้) จาก NVD API, รายการ KEV ของ CISA, CVE record ของ CNA (บอก severity และช่วงเวอร์ชันที่ผู้ผลิตประกาศ แม้ NVD ยังไม่ให้คะแนน CVSS), ฐานข้อมูลผลสแกน dependency ที่มีอยู่แล้ว และรายการ CVE ที่ทีมติดตามอยู่ใน Git
- กรองด้วยโค้ดก่อน ส่งให้โมเดลเฉพาะรายการที่ผ่านเกณฑ์ชัดเจน คือ CVSS ตั้งแต่ 7.0 หรืออยู่ใน KEV หรือเจอในผลสแกน และต้องตรงกับ watchlist ของผลิตภัณฑ์ที่เราใช้ (regex กับคำอธิบาย หรือ vendor:product ใน CPE)
- ให้ LLM ตัดสินทีละรายการ ว่าเกี่ยวกับเราไหม ด่วนแค่ไหน และสรุปเป็นภาษาไทย
- ตรวจและเขียนผล ตรวจ 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|lastModifiedCVE ที่ตัดสินไปแล้วจะไม่ถูกถามซ้ำ เว้นแต่ 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
