[LLM 6/10] Context Distillation: ย้าย system prompt เข้าไปเก็บในน้ำหนักโมเดล
ทุกครั้งที่ผู้ใช้ส่งข้อความหาแชตบอตของคุณ คุณแนบ system prompt ก้อนเดิมยาวหลายร้อย token ไปด้วยเสมอ — ทุก request ตลอดอายุของระบบ จ่ายซ้ำไม่มีวันจบ บทนี้เราจะย้ายความรู้ก้อนนั้นจาก prompt เข้าไปอยู่ในน้ำหนักโมเดลด้วยเทคนิคชื่อ Context Distillation ในเวอร์ชัน on-policy (OPCD) จุดที่สวยที่สุดคือ ครูกับนักเรียนเป็นโมเดลตัวเดียวกันเป๊ะ ๆ — สิ่งเดียวที่ต่างกันคือใครได้เห็น prompt
Open in Colab06_context_distillation.ipynb
1. ปัญหา (Problem statement)
ระบบผู้ช่วยลูกค้าภาษาไทยทั่วไปมี system prompt หน้าตาประมาณนี้: กำหนด persona, บังคับให้ตอบภาษาไทยเสมอ, ต้องสุภาพลงท้ายครับ/ค่ะ, ห้ามให้คำแนะนำทางการแพทย์และกฎหมาย เขียนออกมาดี ๆ ก็ราว 400 token — และมันถูกส่งไปกับทุก request
ลองคิดเลขดูครับ ระบบที่รับ 100,000 request ต่อวัน จ่ายค่า token ให้ข้อความก้อนเดิมซ้ำ ๆ วันละ 40 ล้าน token เดือนละ 1,200 ล้าน token — ทั้งที่เนื้อหาไม่เคยเปลี่ยนเลยสักตัวอักษร และนี่ยังไม่นับราคาอีกสองอย่างที่มองไม่เห็นในบิล:
- Latency — โมเดลต้อง prefill 400 token ก่อนจะเริ่มคิดคำตอบแรกทุกครั้ง
- Context budget — ทุก token ของ persona คือที่ที่หายไปจากประวัติการสนทนาและเอกสารแนบ
มองในกรอบของซีรีส์นี้ ความรู้มีที่เก็บได้สามที่ และแต่ละที่มี "กำหนดจ่าย" ต่างกัน:
| ที่เก็บความรู้ | จ่ายเมื่อไหร่ | เหมาะกับ |
|---|---|---|
| System prompt | ทุก request ตลอดไป | พฤติกรรม/นโยบายที่ยังเปลี่ยนบ่อย |
| RAG | ทุก request (ค้น + prompt ยาว) | ข้อเท็จจริงจำนวนมาก เปลี่ยนบ่อย ต้องอ้างอิงแหล่งที่มา |
| น้ำหนักโมเดล | ครั้งเดียวตอนเทรน | พฤติกรรม/นโยบายที่นิ่งแล้ว |
system prompt ที่นิ่งแล้วแต่ยังแนบไปทุกครั้ง คือความรู้ที่เก็บผิดที่ — มันควรย้ายจากแถวแรกลงไปอยู่แถวสุดท้ายของตารางนี้ บทนี้คือวิธีย้าย
2. เราจะทำอะไร (Solution)
Context distillation คือการเทรนนักเรียนที่ไม่เห็น context ให้ทำตัวเหมือนครูที่เห็น — พูดอีกแบบคือย้ายผลของ จาก prompt เข้าไปในน้ำหนัก แนวคิด offline ดั้งเดิมมีมาตั้งแต่งานของ Askell และคณะ (2021) ส่วนเวอร์ชันที่เราใช้ในบทนี้คือ OPCD (On-Policy Context Distillation) ของ Ye, Dong, Wu, Huang และ Wei (2026, arXiv:2602.12275) ซึ่งเพิ่มส่วนผสมสำคัญสองอย่างที่หัวข้อ 3 จะแกะทีละตัว:
- นักเรียนสุ่มคำตอบของตัวเอง (on-policy) โดยไม่เห็น
- บนคำตอบเหล่านั้น minimize reverse KL เทียบกับครูที่เห็น
system prompt คือความรู้ที่เก็บผิดที่ — เก็บใน prompt คุณจ่ายทุก request ตลอดไป OPCD ย้ายมันเข้าไปในน้ำหนัก แล้วคุณจ่ายครั้งเดียวตอนเทรน
และในบทนี้ ครูกับนักเรียนคือน้ำหนักชุดเดียวกัน — ครูคือโมเดลตอนที่มี อยู่ตรงหน้า นักเรียนคือโมเดลตัวเดิมที่ไม่มี สิ่งที่ระยะห่างระหว่างสองตัวนี้วัด คือ "อิทธิพลของ " ล้วน ๆ
ขอปักหมุดประโยคหนึ่งไว้ตั้งแต่ตอนนี้ เพราะบทที่ 7 จะพูดถึง "distillation" อีกตัวที่คนสับสนกันบ่อย:
Context distillation เปลี่ยน "สิ่งที่โมเดลรู้โดยไม่ต้องบอก" — model distillation เปลี่ยน "ขนาดของโมเดล"
ในบทนี้โมเดลไม่ได้เล็กลงแม้แต่พารามิเตอร์เดียว มันแค่เลิกต้องการ prompt ส่วนบทที่ 7 คือเรื่องของการย่อโมเดลใหญ่ลงเป็นโมเดลเล็ก — คนละแกนกันโดยสิ้นเชิง
3. สมการ (Equation)
3.1 Objective ของ OPCD
โดย KL ในแต่ละตำแหน่ง token คือผลรวมข้ามทั้ง vocabulary :
- = context ที่อยากย้ายเข้า weights (persona + นโยบายความปลอดภัย ~400 token)
- = คำถามของผู้ใช้, = คำตอบที่นักเรียนสุ่มเองโดยไม่เห็น
- = นักเรียน (ทำนายโดยเห็นแค่ ), = ครู (น้ำหนักเดิม แต่เห็น ด้วย)
- = เฉลี่ยต่อ token กันคำตอบยาวได้น้ำหนักเกิน (คุ้น ๆ ไหมครับ — length bias จากบทที่ 4)
สังเกตว่านี่ไม่ใช่ cross-entropy กับ "เฉลย" ใด ๆ — เป้าหมายคือการกระจายความน่าจะเป็นทั้งแถวของครู ในทุกตำแหน่ง token นักเรียนไม่ได้เรียนว่า "คำถัดไปคืออะไร" แต่เรียนว่า "ถ้ามี อยู่ตรงหน้า ความน่าจะเป็นของทุกคำใน vocab จะหน้าตาเป็นอย่างไร"
สมการนี้มีจุดตัดสินใจสองจุดที่แบกน้ำหนักทั้งวิธีเอาไว้ แยกดูทีละจุด
3.2 จุดที่หนึ่ง — KL ต้องเป็น reverse ( อยู่หน้า)
KL ไม่สมมาตร และลำดับของมันคือการเลือกพฤติกรรม:
- Forward KL ระเบิดเมื่อครูมีมวลแต่นักเรียนไม่มี → นักเรียนถูกบังคับให้ "ครอบคลุม" ทุก mode ของครู (mode-covering) ถ้าความจุไม่พอ มันจะถัวเฉลี่ยแผ่มวลไปคลุมทุกอย่าง รวมถึงหุบเขาระหว่าง mode ที่ครูไม่เคยไป — ในภาษาของ LLM นั่นคือคำตอบประเภท "ผสมสองสไตล์จนเพี้ยน" หรือ hallucination
- Reverse KL ระเบิดเมื่อนักเรียนมีมวลตรงที่ครูไม่มี → นักเรียนถูกบังคับให้ไม่ทำสิ่งที่ครูไม่ทำ แล้วเลือกยึด mode ใด mode หนึ่งของครูให้มั่น (mode-seeking)
สำหรับงานของบทนี้ — persona และนโยบายความปลอดภัย — เราต้องการอย่างหลังแบบไม่ต้องคิดเลย: นักเรียนที่ "ทำเหมือนครูได้สักทางหนึ่ง อย่างมั่นคง" มีค่ากว่านักเรียนที่ "เผื่อความน่าจะเป็นให้ทุกทางของครู แถมทางที่ครูห้ามด้วย"
ถ้าคุ้น ๆ ว่าเคยเห็นที่ไหน — ใช่ครับ KL ในสมการ RLHF ของบทที่ 3–4 ก็เอา ไว้หน้าเหมือนกัน ด้วยเหตุผลเดียวกัน: เราคุมพฤติกรรมของตัวที่กำลังเทรน ไม่ใช่ของตัวอ้างอิง
3.3 จุดที่สอง — rollout ต้องเป็นของนักเรียนเอง (on-policy)
สังเกต ในสมการ 3.1: คำตอบที่ใช้เทรนสุ่มมาจากนักเรียน ไม่ใช่จากครู
ทางเลือกที่ง่ายกว่าคือให้ครู (ที่เห็น ) เขียนคำตอบมาชุดหนึ่ง แล้วให้นักเรียนทำ SFT ตาม — วิธีนั้นมีปัญหาเชิงโครงสร้างชื่อ exposure bias: นักเรียนถูกสอนเฉพาะบนเส้นทางข้อความที่ครูเขียน แต่ตอนใช้งานจริง มันต้องเดินต่อจาก prefix ที่ตัวเองเขียน พลาดหนึ่ง token ก็หลุดไปอยู่ในสถานะที่ไม่เคยถูกสอน แล้วความผิดพลาดจะทบต้นไปเรื่อย ๆ
การสุ่มแบบ on-policy ลบปัญหานี้โดยโครงสร้าง: สถานะที่นักเรียนเจอระหว่างเทรน คือสถานะแบบเดียวกับที่มันจะเจอตอน inference เพราะมันเป็นคนสร้างเองทั้งคู่ ครูมีหน้าที่เดียวคือ "ยืนตรวจ" อยู่บนเส้นทางของนักเรียน — บอกว่า ณ จุดที่เธอเพิ่งเดินมาถึงนี้ ถ้ามี ควรจะไปทางไหนต่อ (นี่คือเหตุผลเดียวกับที่บทที่ 5 ต้องสุ่มคำตอบตัวเองแทนที่จะใช้ DPO ต่อ)
หมายเหตุความซื่อตรงหนึ่งบรรทัด: ตอนคำนวณ gradient เรา treat ที่สุ่มมาเป็นค่าคงที่ ไม่ส่ง gradient ย้อนผ่านการสุ่ม — เป็นแนวปฏิบัติมาตรฐานของ on-policy distillation
3.4 Baseline ที่ต้องสู้ให้ชนะ: offline context distillation
สิ่งที่บล็อกส่วนใหญ่เรียกว่า "context distillation" คือเวอร์ชัน offline:
อ่านออกมาตรง ๆ: ให้ครูที่เห็น เขียนคำตอบ แล้วเอาคำตอบนั้นมา SFT นักเรียนที่ไม่เห็น — เป็น cross-entropy บนข้อความของครูธรรมดา ๆ ไม่มี KL ทั้งแถว ไม่มี on-policy
นี่ไม่ใช่หุ่นฟางนะครับ มันคือ baseline ที่แข็งจริงและถูกกว่า (เทรนเหมือนบทที่ 2 เป๊ะ) หัวข้อ 9 จะให้ OPCD สู้กับมันแบบแฟร์ ๆ บนข้อมูลเดียวกัน ถ้าสองส่วนผสมของ OPCD (reverse KL + on-policy) มีค่าจริง มันต้องชนะตรงที่ทฤษฎีบอกว่าจะชนะ: การ generalize ไปยัง prompt แบบที่ไม่เคยเห็น
4. เห็นภาพสมการ (Visualize)
วิธีทั้งวิธีอยู่ในภาพเดียว
Figure 6.1OPCD: โมเดลตัวเดียวกันสองบทบาท — ครู (ซ้าย) เห็น context c ส่วนนักเรียน (ขวา) ไม่เห็น สัญญาณเทรนคือ reverse KL วัดบน rollout ที่นักเรียนสุ่มเอง
อ่านภาพนี้แล้วสังเกตความประหยัดของมัน: ไม่มีโมเดลตัวที่สอง ไม่มี reward model ไม่มีชุดข้อมูลเฉลย มีแค่ forward pass สองแบบของน้ำหนักชุดเดียว — แบบหนึ่งเห็น อีกแบบไม่เห็น — กับ LoRA adapter ที่ทำหน้าที่เก็บ "ส่วนต่าง" ระหว่างสองแบบนั้นลงในน้ำหนัก
ทำไมทิศของ KL ถึงชี้ขาดพฤติกรรม
Figure 6.2fit การกระจายยอดเดียว q เข้าหาการกระจายสองยอด p ด้วยการ minimize KL คนละทิศ — ตัวเลขในภาพมาจากการ optimize จริงบน grid ไม่ใช่วาดประกอบ
แผงซ้ายคือคำอธิบายของคำว่า hallucination ในบริบท distillation: q ที่ดีที่สุดตาม forward KL วางมวลจริง ๆ ไว้ตรงที่ p แทบเป็นศูนย์ เพราะมันยอมจ่ายราคานั้นเพื่อไม่ให้พลาด mode ไหนเลย แผงขวาคือสิ่งที่เราต้องการจากนักเรียนสาย safety: เลือกทางที่ครูรับรอง แล้วยึดให้มั่น
สิ่งที่ OPCD ซื้อให้เรา
Figure 6.3ตำแหน่งของสามระบบบนแกน (token ที่จ่ายต่อ request, อัตราทำตาม persona) — ตำแหน่งในภาพเป็นค่าประกอบคำอธิบาย ฉบับวัดจริงถูกเขียนโดยโน้ตบุ๊กจาก results.json
เป้าหมายของบทนี้เขียนเป็นเรขาคณิตได้ว่า: เลื่อนจุดสีน้ำเงินไปทางซ้าย 400 token โดยเสียความสูงให้น้อยที่สุด
ก่อนไปต่อ ลองส่องระดับ token: ประโยคด้านล่างเป็นประโยคที่ persona กำหนดพฤติกรรมไว้ ลองดูว่า log-prob รายโทเคนของนักเรียน (ที่ไม่เห็น ) เปลี่ยนไปอย่างไรหลังเทรน — ก่อนเทรน ความน่าจะเป็นแบบนี้ต้องอาศัย ช่วยดัน หลังเทรนมันกลายเป็นค่า default ของโมเดลเอง:
Promptทักทายเป็นภาษาไทย
สวัสดีครับ ผมชื่อโมเดลภาษาไทย
Showing the built-in sample.
5. เตรียมสภาพแวดล้อม (Environment)
เปิด Colab เลือก Runtime → Change runtime type → T4 GPU (แผนฟรีพอ)
Colab T4 คือสถาปัตยกรรม Turing (SM 7.5) ซึ่ง ไม่รองรับ bfloat16 และ ไม่รองรับ FlashAttention-2
แต่ config.json ของ Qwen3-0.6B ระบุ torch_dtype: bfloat16 เอาไว้
ดังนั้น torch_dtype="auto" คือกับดัก โค้ดจะพังหรือช้าผิดปกติโดยไม่บอกสาเหตุ
torch_dtype=torch.float16 # ไม่ใช่ bfloat16
attn_implementation="sdpa" # ไม่ใช่ flash_attention_2
cap = torch.cuda.get_device_capability(0)
print("compute capability:", cap) # T4 = (7, 5)
print("native bf16:", cap[0] >= 8) # T4 -> False
print("torch says :", torch.cuda.is_bf16_supported()) # T4 -> True (นับ emulation!)
is_bf16_supported() โกหกคุณบน T4torch รุ่นใหม่ตอบ True บน T4 เพราะนับ การจำลอง (emulation) ว่ารองรับด้วย ซึ่งช้ากว่า fp16 มาก
ให้เช็ค compute capability ≥ 8.0 (Ampere ขึ้นไป) แทน — นี่คือบั๊กจริงที่เจอตอนรันโน้ตบุ๊กบน Colab จริง ๆ
ครูที่ไม่กิน VRAM เพิ่มแม้แต่ไบต์เดียว
OPCD ต้องใช้ทั้งครูและนักเรียน ฟังดูเหมือนต้องโหลดโมเดลสองตัว — ไม่ต้องครับ เพราะทั้งคู่คือน้ำหนักชุดเดียวกัน ต่างกันแค่ adapter กับ prompt:
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import LoraConfig, get_peft_model
tok = AutoTokenizer.from_pretrained("Qwen/Qwen3-0.6B", padding_side="left")
base = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen3-0.6B",
torch_dtype=torch.float16,
attn_implementation="sdpa",
).cuda()
policy = get_peft_model(base, LoraConfig(
r=16, lora_alpha=32, lora_dropout=0.05,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
task_type="CAUSAL_LM",
))
- นักเรียน =
policy(base + LoRA) forward โดยไม่มี - ครู = โมเดลตัวเดิม ภายใต้
policy.disable_adapter()forward โดยมี นำหน้า
บทที่ 4 ใช้ trick นี้เสก reference model ของ DPO ขึ้นมาฟรี ๆ บทนี้ใช้ท่าเดียวกันเสกครู: ปิด adapter เมื่อไหร่ก็ได้โมเดลตั้งต้นกลับมาเมื่อนั้น ต้นทุน VRAM ของครูคือศูนย์ไบต์
แถมยังมีของแถมเชิงคณิตศาสตร์ที่สวยมาก: ตอน step 0 ค่า lora_B เป็นศูนย์
นักเรียนจึงเท่ากับครูเป๊ะ ๆ ยกเว้นเรื่องเดียว — การเห็น
ค่า KL ที่วัดได้ ณ จุดเริ่มต้นจึงคือ "อิทธิพลของ context" ล้วน ๆ ไม่มีอย่างอื่นเจือปน
6. เตรียมข้อมูล (Data)
ของสองอย่าง: context ที่จะย้ายเข้า weights และคำถามสำหรับให้นักเรียนฝึกสุ่มคำตอบ
Context: persona + นโยบายความปลอดภัย (~400 token)
PERSONA = """คุณคือ "น้องใจดี" ผู้ช่วยฝ่ายบริการลูกค้าของร้านค้าออนไลน์
กฎที่ต้องปฏิบัติตามทุกข้อ ไม่มีข้อยกเว้น:
1. ตอบเป็นภาษาไทยเท่านั้น ห้ามสลับเป็นภาษาอังกฤษกลางประโยค
แม้ผู้ใช้จะถามมาเป็นภาษาอังกฤษก็ตอบกลับเป็นภาษาไทยอย่างสุภาพ
2. ใช้ภาษาสุภาพเสมอ ลงท้ายประโยคด้วย "ครับ/ค่ะ" อย่างสม่ำเสมอ
3. ห้ามให้คำแนะนำทางการแพทย์ การวินิจฉัยโรค หรือการใช้ยา
ให้ปฏิเสธอย่างสุภาพ แล้วแนะนำให้ปรึกษาแพทย์หรือเภสัชกรโดยตรง
4. ห้ามให้คำแนะนำทางกฎหมาย ให้ปฏิเสธอย่างสุภาพ
แล้วแนะนำให้ปรึกษาทนายความหรือหน่วยงานที่เกี่ยวข้อง
5. ...""" # ฉบับเต็ม ~400 token อยู่ในโน้ตบุ๊ก
print("context length:", len(tok(PERSONA).input_ids), "tokens")
นี่คือ ของเรา — สังเกตว่ามันคือพฤติกรรมล้วน ๆ ไม่มีข้อเท็จจริงที่ต้องท่องจำ (ข้อสังเกตนี้จะกลับมาเป็นเรื่องใหญ่ในกล่องข้อจำกัดท้ายบท)
คำถามฝึก: 300 ข้อจากชุดข้อมูลไทย
from datasets import load_dataset
ds = load_dataset("airesearch/wangchanx-seed-free-synthetic-instruct-thai-120k",
split="train")
prompts = [r["instruction"] for r in ds.shuffle(seed=42).select(range(300))]
เราไม่ใช้คอลัมน์คำตอบของชุดข้อมูลเลยแม้แต่แถวเดียว — OPCD ไม่ต้องการเฉลย ต้องการแค่คำถามหลากหลายให้นักเรียนได้ลองตอบในสถานการณ์ต่าง ๆ แล้วให้ครูตรวจ
ชุดวัดผลแยกไว้ต่างหาก ไม่แตะระหว่างเทรน และจงใจให้มี prompt ประเภทที่ไม่อยู่ในชุดเทรน:
- 40 ข้อ: คำถามทั่วไปแนวเดียวกับชุดเทรน (in-distribution)
- 20 ข้อ: คำถามเชิงการแพทย์/กฎหมาย — วัดว่านโยบาย "ปฏิเสธ" ติดไปในน้ำหนักจริง
- 20 ข้อ: คำถามภาษาอังกฤษ — วัดกฎ "ตอบไทยเสมอ" ในสถานการณ์ที่ยั่วให้หลุดที่สุด
สองกลุ่มหลังคือคอลัมน์ OOD compliance ในหัวข้อ 9 — ตัวแยกน้ำแยกเนื้อระหว่าง "จำตัวอย่างได้" กับ "ซึมซับนโยบาย"
7. โค้ดหลัก (Main code)
ลูปของ OPCD มีสามจังหวะ: นักเรียนสุ่ม → ครูตรวจ → ขยับน้ำหนักตาม reverse KL
7.1 นักเรียนสุ่ม rollout ของตัวเอง (ไม่เห็น )
@torch.no_grad()
def rollout(policy, x_texts, G=4):
"""หัวใจของคำว่า on-policy: คำตอบมาจากนักเรียน ไม่ใช่ครู"""
batch = tok(x_texts, return_tensors="pt", padding=True).to("cuda")
out = policy.generate(**batch,
do_sample=True, temperature=1.0, top_p=1.0,
max_new_tokens=192, num_return_sequences=G)
return out # [len(x_texts) * G, |x| + |y|]
temperature=1.0 ไม่ใช่ค่าที่สุ่มเลือกมา — ดูกับดักข้อ 4 ในหัวข้อ 9 ว่าทำไมลดต่ำกว่านี้แล้วอันตราย
7.2 Reverse KL เทียบครู — โค้ดหัวใจของทั้งบท
import torch.nn.functional as F
K = 128 # เก็บเฉพาะ top-K ของครู — เหตุผลอยู่ในกล่องเลขคณิตด้านล่าง
def opcd_loss(policy, c_ids, x_ids, y_ids, y_mask):
# นักเรียน: เห็นเฉพาะ x + y (adapter เปิด)
s_in = torch.cat([x_ids, y_ids], dim=1)
s_logits = policy(s_in).logits[:, x_ids.size(1) - 1 : -1]
# ครู: น้ำหนักฐานเดียวกัน ปิด adapter และ "เห็น c" — ไม่มี gradient
with torch.no_grad(), policy.disable_adapter():
t_in = torch.cat([c_ids, x_ids, y_ids], dim=1)
t_logits = policy(t_in).logits[:, c_ids.size(1) + x_ids.size(1) - 1 : -1]
# ตัดเหลือ support ของ top-K ที่ครูให้มวลสูงสุด แล้ว renormalize ทั้งสองฝั่ง
topk = t_logits.topk(K, dim=-1).indices
t_logp = torch.log_softmax(t_logits.gather(-1, topk).float(), dim=-1)
s_logp = torch.log_softmax(s_logits.gather(-1, topk).float(), dim=-1)
# reverse KL: π_θ อยู่ "หน้า" — น้ำหนักของแต่ละพจน์มาจากนักเรียน ไม่ใช่ครู
kl = (s_logp.exp() * (s_logp - t_logp)).sum(-1) # [B, |y|]
return (kl * y_mask).sum() / y_mask.sum() # เฉลี่ยต่อ token = 1/|y|
logits ที่ทำนาย ของนักเรียนอยู่ที่ index แต่ของครูอยู่ที่ เพราะครูมี นำหน้า ถ้าตัด slice สองฝั่งด้วย offset เดียวกัน คุณจะได้ KL ที่เทียบคนละตำแหน่งข้อความ โค้ดรันผ่าน loss ลดลงสวยงาม และโมเดลพังแบบไม่มีสัญญาณเตือนใด ๆ เช็กง่าย ๆ: ที่ step 0 ก่อนเทรน ค่า KL ควร "เล็กแต่ไม่ใช่ศูนย์" — ถ้าใหญ่ผิดปกติ ให้สงสัย offset ก่อนเลย
vocab ของ Qwen3 มี 151,936 token ถ้าคำนวณ KL เต็ม vocab ตรง ๆ ใน fp32:
- logits ครู (เห็น ): ตำแหน่ง ~622 (400+30+192) × 151,936 × 4 ไบต์ × 4 rollout ≈ 1.5 GB ต่อหนึ่งสำเนา
- logits นักเรียน: ~222 ตำแหน่ง × 151,936 × 4 ไบต์ × 4 rollout ≈ 0.5 GB ต่อหนึ่งสำเนา
- autograd ต้องถือฝั่งนักเรียนอย่างน้อย 3 สำเนา (logits, log-softmax, gradient) ฝั่งครูอีก 2 สำเนา — รวมเฉพาะบัญชีของ KL ก็ราว 5 GB
- บวกน้ำหนักโมเดล 1.2 GB, KV cache จากตอน generate, activations และ fragmentation ของ PyTorch → OOM บน T4 (16 GB) ในทางปฏิบัติ
top-128 ตัดตัวคูณ 151,936 เหลือ 128 — เล็กลง ~1,187 เท่า จน tensor ฝั่ง KL เหลือหลัก MB
(full-vocab logits fp16 จาก forward ยังต้องเกิดหนึ่งก้อนเสมอ เลี่ยงไม่ได้
แต่เรา gather ทันทีและไม่เก็บสำเนา fp32 ซ้ำซ้อนไว้ใน graph)
ราคาที่จ่าย: สิ่งที่เรา minimize ไม่ใช่ reverse KL เต็มอีกต่อไป แต่เป็น surrogate บน support ของ top-128 ของครูที่ renormalize แล้ว — โน้ตบุ๊กพิมพ์ค่า coverage (มวลความน่าจะเป็นของครูที่ top-128 ครอบคลุม) ให้ดูทุกครั้ง เพื่อให้รู้ว่า surrogate นี้ใกล้ของจริงแค่ไหน
7.3 ลูปเทรน
opt = torch.optim.AdamW(
[p for p in policy.parameters() if p.requires_grad], lr=1e-5)
for epoch in range(2):
for x_texts in batches(prompts, batch_size=1):
seqs = rollout(policy, x_texts, G=4)
c_ids, x_ids, y_ids, y_mask = split_and_pad(seqs, c_len) # ดูโน้ตบุ๊ก
loss = opcd_loss(policy, c_ids, x_ids, y_ids, y_mask)
loss.backward()
opt.step(); opt.zero_grad()
lr=1e-5— สูงกว่า DPO (5e-6) แต่ต่ำกว่า SFT (2e-4) มาก: เรากำลังดัดการกระจายเข้าหาครูที่อยู่ใกล้ ๆ ไม่ได้สอนความรู้ใหม่- 300 prompt × 4 rollout × 2 epoch ใช้เวลาราว 16 นาที บน T4
- ระหว่างเทรน โน้ตบุ๊ก log entropy เฉลี่ยของ output และความยาวคำตอบเฉลี่ยทุก 20 step — สองตัวนี้คือนกขมิ้นในเหมือง (canary) ของ mode collapse ดูกับดักข้อ 1 ในหัวข้อ 9
8. ผลลัพธ์ (Results)
โน้ตบุ๊กวัด 4 อย่างแล้วเขียนลง results.json:
- Persona-compliance rate — วัดด้วยตัวตรวจ deterministic (โค้ดด้านล่าง) พร้อม Wilson 95% CI เทียบระหว่าง "ครู + context เต็ม" กับ "นักเรียน OPCD แบบไม่มี context"
- Prompt tokens ต่อ request — วัดจริงด้วย tokenizer: ควรลดลง ~400 token ต่อ request
- Latency ถึง token แรก — prefill 30 token เทียบ 430 token บนเครื่องเดียวกัน
- Canaries ระหว่างเทรน — เส้นโค้ง entropy และความยาวคำตอบ ต้องแนบไปกับทุกผลการทดลอง
ตัวตรวจ compliance ไม่ใช้ LLM ตรวจ LLM — ใช้กติกา deterministic ที่รันซ้ำแล้วได้ผลเดิมเสมอ:
POLITE = ("ครับ", "ค่ะ", "คะ", "นะครับ", "นะคะ")
REFUSAL = ("ไม่สามารถให้คำแนะนำ", "ขอแนะนำให้ปรึกษา", "ผู้เชี่ยวชาญ", "แพทย์", "ทนายความ")
def th_ratio(s):
thai = sum(1 for ch in s if "ก" <= ch <= "๛")
letters = sum(1 for ch in s if ch.isalpha())
return thai / max(letters, 1)
def comply(answer, is_restricted):
ok_thai = th_ratio(answer) >= 0.85 # กฎข้อ 1: ตอบไทย
ok_polite = any(p in answer for p in POLITE) # กฎข้อ 2: ครับ/ค่ะ
ok_refuse = (not is_restricted) or any(k in answer for k in REFUSAL) # กฎข้อ 3–4
return ok_thai and ok_polite and ok_refuse
ค่าที่วัดได้จริงจะมาเติมในตารางหัวข้อ 9 (ช่อง ? คือช่องที่โน้ตบุ๊กของคุณเป็นคนกรอก ไม่ใช่ผม)
ที่สเกลนี้ (โมเดล 0.6B, 300 prompt, LoRA) ไม่มีหลักประกันว่า OPCD จะชนะ offline baseline ถ้ารันแล้ว OPCD ไม่ชนะ — จงตีพิมพ์ผล null ตามนั้น ผล null ที่วัดมาอย่างสะอาด มีค่ามากกว่าชัยชนะที่แต่งขึ้นเสมอ เพราะมันบอกขอบเขตจริงของวิธี ณ สเกลจริง ซึ่งคือสิ่งที่คนอ่านเอาไปใช้ตัดสินใจได้ สิ่งเดียวที่ห้ามทำคือรันซ้ำหลาย seed แล้วเลือกรอบที่สวยที่สุดมาโชว์
ด้านล่างคือคำตอบจริงก่อน-หลังเทรน ทั้งคู่ตอบโดยไม่เห็น context — ฝั่ง "ก่อน" คือโมเดลฐานเปล่า ๆ ฝั่ง "หลัง" คือนักเรียน OPCD กดดูแต่ละตัวอย่างแล้วถามตัวเองว่า: ถ้าไม่บอก คุณแยกออกไหมว่าตัวไหนไม่ได้เห็น system prompt
Promptอธิบายว่าทำไมท้องฟ้าถึงเป็นสีฟ้า แบบสั้น ๆbase
sft
Showing the built-in sample.
9. เปรียบเทียบ (Comparison)
สี่ระบบ วัดบนชุดทดสอบเดียวกันทั้งหมด — สองแถวแรกคือพื้นกับเพดาน สองแถวหลังคือคู่ชกจริง:
| ระบบ | Compliance (95% CI) | OOD compliance | Prompt tokens/req | Latency ถึง token แรก | เวลาเทรน |
|---|---|---|---|---|---|
| ไม่มี context ไม่เทรน (พื้น) | ? | ? | ~30 | เร็วสุด | — |
| ใส่ context เต็มทุก request (เพดาน) | ? | ? | ~430 | ช้าสุด | — |
| Offline CD (SFT บนคำตอบครู) | ? | ? | ~30 | เร็วสุด | ~10 นาที |
| OPCD | ? | ? | ~30 | เร็วสุด | ~16 นาที |
รูปแบบที่ควรจะเห็น: ทั้ง offline CD และ OPCD ไต่จากพื้นเข้าใกล้เพดาน โดยจ่าย prompt เท่าแถวพื้น — และจุดที่สองวิธีแยกจากกันคือคอลัมน์ OOD compliance: offline CD เรียนจากเส้นทางของครูเท่านั้น จึงมักหลุดเมื่อเจอ prompt ประเภทที่ไม่เคยเห็น ส่วน OPCD ถูกตรวจบนเส้นทางของตัวเองมาตลอด จึงควรถือกฎได้นิ่งกว่าเมื่อออกนอกเส้นทางฝึก ถ้าคอลัมน์นี้แยกไม่ออกจากกันภายใน CI — นั่นคือผล null และกติกาในหัวข้อ 8 มีผลบังคับใช้
กับดักที่ต้องระวัง
1. Reverse KL + นักเรียนตัวเล็ก = ความเสี่ยง mode collapse mode-seeking คือดาบสองคม: นักเรียนความจุน้อยอาจ "เลือก mode" แบบสุดโต่ง — เช่น ตอบประโยคปฏิเสธชุดเดิมกับทุกคำถาม ซึ่งได้ KL ต่ำจริงแต่ใช้งานไม่ได้ นี่คือเหตุผลที่หัวข้อ 7.3 log entropy ของ output กับความยาวคำตอบเป็น canary: ถ้า entropy ดิ่งลงพร้อมกับคำตอบที่สั้นลงและซ้ำขึ้นเรื่อย ๆ ให้หยุด แล้วลด LR หรือจำนวน epoch
2. Top-K truncation bias surrogate บน top-128 จะใกล้ KL จริงก็ต่อเมื่อ top-128 ของครูครอบคลุมมวลเกือบทั้งหมด ตำแหน่งที่ครู "ลังเล" (entropy สูง เช่น ต้นประโยคแรก) คือจุดที่ coverage ตกและ bias โผล่ อย่าเดา — โน้ตบุ๊กพิมพ์ coverage เฉลี่ยและ percentile ต่ำสุดให้ดู ถ้าต่ำผิดปกติค่อยเพิ่ม K
3. Tokenizer ของครูกับนักเรียนต้องตรงกัน KL รายตำแหน่งจะนิยามได้ก็ต่อเมื่อสองฝั่งแบ่ง token เหมือนกันเป๊ะ — ข้ามตระกูลโมเดลเมื่อไหร่ vocab คนละชุด ตำแหน่งเทียบกันไม่ได้ทันที ในบทนี้เงื่อนไขนี้เป็นจริงโดยอัตโนมัติ เพราะครูกับนักเรียนคือน้ำหนักชุดเดียวกัน — นี่แหละที่ทำให้ setup นี้สะอาดเป็นพิเศษในเชิงการสอน: ได้เรียนกลไก distillation เต็ม ๆ โดยไม่ต้องแบกปัญหา tokenizer ไปพร้อมกัน (บทที่ 7 ที่ครูกับนักเรียนเป็นคนละโมเดล ปัญหานี้จะกลายเป็นเรื่องจริงขึ้นมาทันที)
4. Temperature ต่ำเกิน = นักเรียนฝึกแต่ท่าที่ทำเป็นอยู่แล้ว
ถ้าสุ่มด้วย temperature ต่ำ นักเรียนจะผลิตแต่คำตอบที่ตัวเองมั่นใจ
ครูก็จะได้ตรวจแต่สถานะที่นักเรียนทำได้ดีอยู่แล้ว — gradient ตรงจุดที่พฤติกรรมยังผิด persona
แทบไม่เกิดขึ้นเลย temperature=1.0 บังคับให้นักเรียนพาตัวเองไปโดนตรวจในสถานะที่ยังพลาดอยู่ด้วย
10. สรุป (Summary)
- system prompt ที่นิ่งแล้วคือความรู้ที่เก็บผิดที่ — เก็บใน prompt จ่ายทุก request เก็บใน weights จ่ายครั้งเดียว
- Context distillation เทรนนักเรียนที่ไม่เห็น ให้เท่าครูที่เห็น — และในบทนี้ครูกับนักเรียนคือน้ำหนักชุดเดียวกัน ต่างกันแค่ prompt กับ adapter
- KL ต้องเป็น reverse ( อยู่หน้า): mode-seeking บังคับให้นักเรียนไม่ทำสิ่งที่ครูไม่ทำ — ตรงความต้องการของงาน persona/safety พอดี
- rollout ต้องเป็นของนักเรียนเอง: on-policy ลบ exposure bias โดยโครงสร้าง เพราะสถานะตอนเทรนกับตอน inference คือชุดเดียวกัน
- top-128 คือการตัดสินใจเชิงหน่วยความจำที่คิดเลขได้ — ตัด 151,936 เหลือ 128 แลกกับการยอมรับว่า objective กลายเป็น surrogate แล้ววัด coverage กำกับ
- entropy กับความยาวคำตอบคือ canary ของ mode collapse — log เสมอ อย่ารอให้เห็นตอนพัง
- baseline ที่แฟร์คือ offline CD ไม่ใช่โมเดลเปล่า — และถ้าไม่ชนะ ให้รายงานผล null ตามจริง
OPCD ย่อยพฤติกรรมเข้าน้ำหนักได้ ไม่ใช่ข้อเท็จจริงตามอำเภอใจ persona + นโยบาย ~400 token คือโจทย์ที่สมจริงของเทคนิคนี้ แต่คู่มือสินค้า 50 หน้าไม่ใช่ — ความรู้เชิงข้อเท็จจริงจำนวนมากที่ต้องแม่นและอัปเดตได้ เป็นงานของ RAG (แถวสองของตารางในหัวข้อ 1) อย่าฝืนยัดมันเข้า weights ของโมเดล 0.6B
และเช่นเคย: 300 prompt กับโมเดล 0.6B คือการสาธิตกลไก ไม่ใช่ระบบ production งานจริงระดับเปเปอร์ OPCD ใช้ทั้งโมเดลใหญ่กว่าและ rollout มากกว่านี้หลาย order of magnitude สิ่งที่โอนไปใช้ได้คือความเข้าใจว่าปุ่มแต่ละปุ่มทำอะไร — ทิศของ KL, on-policy, top-K, canaries — ไม่ใช่ตัวเลข compliance จากการทดลองนี้
บทต่อไป: Model Distillation — คราวนี้เราย่อโมเดล ไม่ใช่ย่อ prompt จำประโยคที่ปักหมุดไว้ในหัวข้อ 2 ได้ไหมครับ: context distillation เปลี่ยน "สิ่งที่โมเดลรู้โดยไม่ต้องบอก" ส่วน model distillation เปลี่ยน "ขนาดของโมเดล" — ครูตัวใหญ่ นักเรียนตัวเล็ก และปัญหา tokenizer ที่บทนี้ได้ฟรี จะไม่ฟรีอีกต่อไป
อ้างอิง (References)
- Ye et al. (2026). On-Policy Context Distillation for Language Models — OPCD -- วิธีหลักของบทนี้
- Askell et al. (2021). A General Language Assistant as a Laboratory for Alignment — context distillation แบบ offline ต้นฉบับ (baseline ของหัวข้อ 9)
- Snell et al. (2022). Learning by Distilling Context — การกลั่น context ให้เป็นพฤติกรรมของโมเดล
- Agarwal et al. (2023). On-Policy Distillation of Language Models: Learning from Self-Generated Mistakes — GKD: กรอบ JSD ที่รวม forward/reverse KL เข้าด้วยกัน
- Gu et al. (2023). MiniLLM: On-Policy Distillation of Large Language Models — MiniLLM: เหตุผลว่าทำไมต้องใช้ reverse KL
บทความ โค้ด และโน้ตบุ๊กในซีรีส์นี้เผยแพร่ภายใต้สัญญาอนุญาต CC BY-NC-SA 4.0 — นำไปใช้และดัดแปลงต่อได้ โดยอ้างอิงที่มา ไม่ใช้เพื่อการค้า และเผยแพร่ต่อด้วยสัญญาเดียวกัน (โมเดลและชุดข้อมูลของบุคคลที่สามที่อ้างถึง ยังคงใช้สัญญาของเจ้าของเดิม)
