ข้ามไปยังเนื้อหาหลัก

[LLM 6/10] Context Distillation: ย้าย system prompt เข้าไปเก็บในน้ำหนักโมเดล

· อ่าน 25 นาที
Kobkrit Viriyayudhakorn
CEO, iApp Technology

ทุกครั้งที่ผู้ใช้ส่งข้อความหาแชตบอตของคุณ คุณแนบ 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 cc ให้ทำตัวเหมือนครูที่เห็น cc — พูดอีกแบบคือย้ายผลของ cc จาก prompt เข้าไปในน้ำหนัก แนวคิด offline ดั้งเดิมมีมาตั้งแต่งานของ Askell และคณะ (2021) ส่วนเวอร์ชันที่เราใช้ในบทนี้คือ OPCD (On-Policy Context Distillation) ของ Ye, Dong, Wu, Huang และ Wei (2026, arXiv:2602.12275) ซึ่งเพิ่มส่วนผสมสำคัญสองอย่างที่หัวข้อ 3 จะแกะทีละตัว:

  1. นักเรียนสุ่มคำตอบของตัวเอง (on-policy) โดยไม่เห็น cc
  2. บนคำตอบเหล่านั้น minimize reverse KL เทียบกับครูที่เห็น cc
แนวคิดหลักของบทนี้

system prompt คือความรู้ที่เก็บผิดที่ — เก็บใน prompt คุณจ่ายทุก request ตลอดไป OPCD ย้ายมันเข้าไปในน้ำหนัก แล้วคุณจ่ายครั้งเดียวตอนเทรน

และในบทนี้ ครูกับนักเรียนคือน้ำหนักชุดเดียวกัน — ครูคือโมเดลตอนที่มี cc อยู่ตรงหน้า นักเรียนคือโมเดลตัวเดิมที่ไม่มี cc สิ่งที่ระยะห่างระหว่างสองตัวนี้วัด คือ "อิทธิพลของ cc" ล้วน ๆ

ขอปักหมุดประโยคหนึ่งไว้ตั้งแต่ตอนนี้ เพราะบทที่ 7 จะพูดถึง "distillation" อีกตัวที่คนสับสนกันบ่อย:

Context distillation เปลี่ยน "สิ่งที่โมเดลรู้โดยไม่ต้องบอก" — model distillation เปลี่ยน "ขนาดของโมเดล"

ในบทนี้โมเดลไม่ได้เล็กลงแม้แต่พารามิเตอร์เดียว มันแค่เลิกต้องการ prompt ส่วนบทที่ 7 คือเรื่องของการย่อโมเดลใหญ่ลงเป็นโมเดลเล็ก — คนละแกนกันโดยสิ้นเชิง

3. สมการ (Equation)

3.1 Objective ของ OPCD

L(θ)=E(x,c),  yπθ(x)[1yt=1yDKL(πθ(x,y<t)πteacher(c,x,y<t))]\mathcal{L}(\theta) = \mathbb{E}_{(x,c),\; y\sim\pi_\theta(\cdot|x)}\left[\frac{1}{|y|}\sum_{t=1}^{|y|} \mathbb{D}_{\text{KL}}\Big(\pi_\theta(\cdot \mid x, y_{<t}) \,\Big\|\, \pi_{\text{teacher}}(\cdot \mid c, x, y_{<t})\Big)\right]

โดย KL ในแต่ละตำแหน่ง token คือผลรวมข้ามทั้ง vocabulary V\mathcal{V}:

DKL(πθπteacher)=vVπθ(vx,y<t)logπθ(vx,y<t)πteacher(vc,x,y<t)\mathbb{D}_{\text{KL}}\Big(\pi_\theta \,\Big\|\, \pi_{\text{teacher}}\Big) = \sum_{v\in\mathcal{V}} \pi_\theta(v \mid x, y_{<t})\,\log\frac{\pi_\theta(v \mid x, y_{<t})}{\pi_{\text{teacher}}(v \mid c, x, y_{<t})}
  • cc = context ที่อยากย้ายเข้า weights (persona + นโยบายความปลอดภัย ~400 token)
  • xx = คำถามของผู้ใช้, yy = คำตอบที่นักเรียนสุ่มเองโดยไม่เห็น cc
  • πθ\pi_\theta = นักเรียน (ทำนายโดยเห็นแค่ xx), πteacher\pi_{\text{teacher}} = ครู (น้ำหนักเดิม แต่เห็น cc ด้วย)
  • 1y\frac{1}{|y|} = เฉลี่ยต่อ token กันคำตอบยาวได้น้ำหนักเกิน (คุ้น ๆ ไหมครับ — length bias จากบทที่ 4)

สังเกตว่านี่ไม่ใช่ cross-entropy กับ "เฉลย" ใด ๆ — เป้าหมายคือการกระจายความน่าจะเป็นทั้งแถวของครู ในทุกตำแหน่ง token นักเรียนไม่ได้เรียนว่า "คำถัดไปคืออะไร" แต่เรียนว่า "ถ้ามี cc อยู่ตรงหน้า ความน่าจะเป็นของทุกคำใน vocab จะหน้าตาเป็นอย่างไร"

สมการนี้มีจุดตัดสินใจสองจุดที่แบกน้ำหนักทั้งวิธีเอาไว้ แยกดูทีละจุด

3.2 จุดที่หนึ่ง — KL ต้องเป็น reverse (πθ\pi_\theta อยู่หน้า)

KL ไม่สมมาตร และลำดับของมันคือการเลือกพฤติกรรม:

  • Forward KL DKL(πteacherπθ)\mathbb{D}_{\text{KL}}(\pi_{\text{teacher}} \| \pi_\theta) ระเบิดเมื่อครูมีมวลแต่นักเรียนไม่มี → นักเรียนถูกบังคับให้ "ครอบคลุม" ทุก mode ของครู (mode-covering) ถ้าความจุไม่พอ มันจะถัวเฉลี่ยแผ่มวลไปคลุมทุกอย่าง รวมถึงหุบเขาระหว่าง mode ที่ครูไม่เคยไป — ในภาษาของ LLM นั่นคือคำตอบประเภท "ผสมสองสไตล์จนเพี้ยน" หรือ hallucination
  • Reverse KL DKL(πθπteacher)\mathbb{D}_{\text{KL}}(\pi_\theta \| \pi_{\text{teacher}}) ระเบิดเมื่อนักเรียนมีมวลตรงที่ครูไม่มี → นักเรียนถูกบังคับให้ไม่ทำสิ่งที่ครูไม่ทำ แล้วเลือกยึด mode ใด mode หนึ่งของครูให้มั่น (mode-seeking)

สำหรับงานของบทนี้ — persona และนโยบายความปลอดภัย — เราต้องการอย่างหลังแบบไม่ต้องคิดเลย: นักเรียนที่ "ทำเหมือนครูได้สักทางหนึ่ง อย่างมั่นคง" มีค่ากว่านักเรียนที่ "เผื่อความน่าจะเป็นให้ทุกทางของครู แถมทางที่ครูห้ามด้วย"

ถ้าคุ้น ๆ ว่าเคยเห็นที่ไหน — ใช่ครับ KL ในสมการ RLHF ของบทที่ 3–4 ก็เอา π\pi ไว้หน้าเหมือนกัน ด้วยเหตุผลเดียวกัน: เราคุมพฤติกรรมของตัวที่กำลังเทรน ไม่ใช่ของตัวอ้างอิง

3.3 จุดที่สอง — rollout ต้องเป็นของนักเรียนเอง (on-policy)

สังเกต yπθ(x)y\sim\pi_\theta(\cdot|x) ในสมการ 3.1: คำตอบที่ใช้เทรนสุ่มมาจากนักเรียน ไม่ใช่จากครู

ทางเลือกที่ง่ายกว่าคือให้ครู (ที่เห็น cc) เขียนคำตอบมาชุดหนึ่ง แล้วให้นักเรียนทำ SFT ตาม — วิธีนั้นมีปัญหาเชิงโครงสร้างชื่อ exposure bias: นักเรียนถูกสอนเฉพาะบนเส้นทางข้อความที่ครูเขียน แต่ตอนใช้งานจริง มันต้องเดินต่อจาก prefix ที่ตัวเองเขียน พลาดหนึ่ง token ก็หลุดไปอยู่ในสถานะที่ไม่เคยถูกสอน แล้วความผิดพลาดจะทบต้นไปเรื่อย ๆ

การสุ่มแบบ on-policy ลบปัญหานี้โดยโครงสร้าง: สถานะที่นักเรียนเจอระหว่างเทรน คือสถานะแบบเดียวกับที่มันจะเจอตอน inference เพราะมันเป็นคนสร้างเองทั้งคู่ ครูมีหน้าที่เดียวคือ "ยืนตรวจ" อยู่บนเส้นทางของนักเรียน — บอกว่า ณ จุดที่เธอเพิ่งเดินมาถึงนี้ ถ้ามี cc ควรจะไปทางไหนต่อ (นี่คือเหตุผลเดียวกับที่บทที่ 5 ต้องสุ่มคำตอบตัวเองแทนที่จะใช้ DPO ต่อ)

หมายเหตุความซื่อตรงหนึ่งบรรทัด: ตอนคำนวณ gradient เรา treat yy ที่สุ่มมาเป็นค่าคงที่ ไม่ส่ง gradient ย้อนผ่านการสุ่ม — เป็นแนวปฏิบัติมาตรฐานของ on-policy distillation

3.4 Baseline ที่ต้องสู้ให้ชนะ: offline context distillation

สิ่งที่บล็อกส่วนใหญ่เรียกว่า "context distillation" คือเวอร์ชัน offline:

Loffline(θ)=Eyπteacher(c,x)[t=1ylogπθ(ytx,y<t)]\mathcal{L}_{\text{offline}}(\theta) = -\mathbb{E}_{y\sim\pi_{\text{teacher}}(\cdot|c,x)}\left[\sum_{t=1}^{|y|}\log\pi_\theta(y_t \mid x, y_{<t})\right]

อ่านออกมาตรง ๆ: ให้ครูที่เห็น cc เขียนคำตอบ แล้วเอาคำตอบนั้นมา SFT นักเรียนที่ไม่เห็น cc — เป็น cross-entropy บนข้อความของครูธรรมดา ๆ ไม่มี KL ทั้งแถว ไม่มี on-policy

นี่ไม่ใช่หุ่นฟางนะครับ มันคือ baseline ที่แข็งจริงและถูกกว่า (เทรนเหมือนบทที่ 2 เป๊ะ) หัวข้อ 9 จะให้ OPCD สู้กับมันแบบแฟร์ ๆ บนข้อมูลเดียวกัน ถ้าสองส่วนผสมของ OPCD (reverse KL + on-policy) มีค่าจริง มันต้องชนะตรงที่ทฤษฎีบอกว่าจะชนะ: การ generalize ไปยัง prompt แบบที่ไม่เคยเห็น

4. เห็นภาพสมการ (Visualize)

วิธีทั้งวิธีอยู่ในภาพเดียว

แผนภาพสองกล่อง โมเดลเดียวกันสองข้าง ฝั่งครูได้รับ context ที่ไฮไลต์ไว้พร้อมคำถาม ฝั่งนักเรียนได้รับเฉพาะคำถาม มีลูกศรเส้นประจากนักเรียนไปครูแทน reverse KL บน rollout ของนักเรียนแผนภาพสองกล่อง โมเดลเดียวกันสองข้าง ฝั่งครูได้รับ context ที่ไฮไลต์ไว้พร้อมคำถาม ฝั่งนักเรียนได้รับเฉพาะคำถาม มีลูกศรเส้นประจากนักเรียนไปครูแทน reverse KL บน rollout ของนักเรียน

Figure 6.1OPCD: โมเดลตัวเดียวกันสองบทบาท — ครู (ซ้าย) เห็น context c ส่วนนักเรียน (ขวา) ไม่เห็น สัญญาณเทรนคือ reverse KL วัดบน rollout ที่นักเรียนสุ่มเอง

อ่านภาพนี้แล้วสังเกตความประหยัดของมัน: ไม่มีโมเดลตัวที่สอง ไม่มี reward model ไม่มีชุดข้อมูลเฉลย มีแค่ forward pass สองแบบของน้ำหนักชุดเดียว — แบบหนึ่งเห็น cc อีกแบบไม่เห็น — กับ LoRA adapter ที่ทำหน้าที่เก็บ "ส่วนต่าง" ระหว่างสองแบบนั้นลงในน้ำหนัก

ทำไมทิศของ KL ถึงชี้ขาดพฤติกรรม

กราฟสองแผงเปรียบเทียบ forward KL ที่ทำให้ q แผ่คลุมทั้งสองยอดรวมถึงหุบเขาที่ p แทบเป็นศูนย์ กับ reverse KL ที่ทำให้ q เลือกยึดยอดเดียวของ pกราฟสองแผงเปรียบเทียบ forward KL ที่ทำให้ q แผ่คลุมทั้งสองยอดรวมถึงหุบเขาที่ p แทบเป็นศูนย์ กับ reverse KL ที่ทำให้ q เลือกยึดยอดเดียวของ p

Figure 6.2fit การกระจายยอดเดียว q เข้าหาการกระจายสองยอด p ด้วยการ minimize KL คนละทิศ — ตัวเลขในภาพมาจากการ optimize จริงบน grid ไม่ใช่วาดประกอบ

แผงซ้ายคือคำอธิบายของคำว่า hallucination ในบริบท distillation: q ที่ดีที่สุดตาม forward KL วางมวลจริง ๆ ไว้ตรงที่ p แทบเป็นศูนย์ เพราะมันยอมจ่ายราคานั้นเพื่อไม่ให้พลาด mode ไหนเลย แผงขวาคือสิ่งที่เราต้องการจากนักเรียนสาย safety: เลือกทางที่ครูรับรอง แล้วยึดให้มั่น

สิ่งที่ OPCD ซื้อให้เรา

scatter plot สามจุด ระบบไม่มี context อยู่ล่างซ้าย ระบบใส่ context เต็มอยู่บนขวา และนักเรียน OPCD อยู่บนซ้าย พร้อมลูกศรแสดงการลด prompt token 400 ต่อ requestscatter plot สามจุด ระบบไม่มี context อยู่ล่างซ้าย ระบบใส่ context เต็มอยู่บนขวา และนักเรียน OPCD อยู่บนซ้าย พร้อมลูกศรแสดงการลด prompt token 400 ต่อ request

Figure 6.3ตำแหน่งของสามระบบบนแกน (token ที่จ่ายต่อ request, อัตราทำตาม persona) — ตำแหน่งในภาพเป็นค่าประกอบคำอธิบาย ฉบับวัดจริงถูกเขียนโดยโน้ตบุ๊กจาก results.json

เป้าหมายของบทนี้เขียนเป็นเรขาคณิตได้ว่า: เลื่อนจุดสีน้ำเงินไปทางซ้าย 400 token โดยเสียความสูงให้น้อยที่สุด

ก่อนไปต่อ ลองส่องระดับ token: ประโยคด้านล่างเป็นประโยคที่ persona กำหนดพฤติกรรมไว้ ลองดูว่า log-prob รายโทเคนของนักเรียน (ที่ไม่เห็น cc) เปลี่ยนไปอย่างไรหลังเทรน — ก่อนเทรน ความน่าจะเป็นแบบนี้ต้องอาศัย cc ช่วยดัน หลังเทรนมันกลายเป็นค่า default ของโมเดลเอง:

View

Promptทักทายเป็นภาษาไทย

สวัสดีครั ผมชื่อโมเดลภาษาไทย

Hover or focus a token to see its probability and the top-5 alternatives the model considered.
Mean logprob before-1.881perplexity 6.56
Mean logprob after-0.393perplexity 1.48
Tokens improved9 / 9
Rendered clusters9from 9 tokens

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() โกหกคุณบน T4

torch รุ่นใหม่ตอบ 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 โดยไม่มี cc
  • ครู = โมเดลตัวเดิม ภายใต้ policy.disable_adapter() forward โดยมี cc นำหน้า
ผลตอบแทนจากบทที่ 2 — รอบที่สอง

บทที่ 4 ใช้ trick นี้เสก reference model ของ DPO ขึ้นมาฟรี ๆ บทนี้ใช้ท่าเดียวกันเสกครู: ปิด adapter เมื่อไหร่ก็ได้โมเดลตั้งต้นกลับมาเมื่อนั้น ต้นทุน VRAM ของครูคือศูนย์ไบต์

แถมยังมีของแถมเชิงคณิตศาสตร์ที่สวยมาก: ตอน step 0 ค่า lora_B เป็นศูนย์ นักเรียนจึงเท่ากับครูเป๊ะ ๆ ยกเว้นเรื่องเดียว — การเห็น cc ค่า 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")

นี่คือ cc ของเรา — สังเกตว่ามันคือพฤติกรรมล้วน ๆ ไม่มีข้อเท็จจริงที่ต้องท่องจำ (ข้อสังเกตนี้จะกลับมาเป็นเรื่องใหญ่ในกล่องข้อจำกัดท้ายบท)

คำถามฝึก: 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 ของตัวเอง (ไม่เห็น cc)

@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|
บั๊กเงียบอันดับหนึ่งของบทนี้: เลื่อนตำแหน่งไม่ครบ c|c|

logits ที่ทำนาย yty_t ของนักเรียนอยู่ที่ index x+t1|x|+t-1 แต่ของครูอยู่ที่ c+x+t1|c|+|x|+t-1 เพราะครูมี cc นำหน้า ถ้าตัด slice สองฝั่งด้วย offset เดียวกัน คุณจะได้ KL ที่เทียบคนละตำแหน่งข้อความ โค้ดรันผ่าน loss ลดลงสวยงาม และโมเดลพังแบบไม่มีสัญญาณเตือนใด ๆ เช็กง่าย ๆ: ที่ step 0 ก่อนเทรน ค่า KL ควร "เล็กแต่ไม่ใช่ศูนย์" — ถ้าใหญ่ผิดปกติ ให้สงสัย offset ก่อนเลย

เลขคณิตที่บังคับให้เกิด top-128 — จุดตัดสินใจเรื่องหน่วยความจำที่ควรทำให้ดูทุกครั้ง

vocab ของ Qwen3 มี 151,936 token ถ้าคำนวณ KL เต็ม vocab ตรง ๆ ใน fp32:

  • logits ครู (เห็น cc): ตำแหน่ง ~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:

  1. Persona-compliance rate — วัดด้วยตัวตรวจ deterministic (โค้ดด้านล่าง) พร้อม Wilson 95% CI เทียบระหว่าง "ครู + context เต็ม" กับ "นักเรียน OPCD แบบไม่มี context"
  2. Prompt tokens ต่อ request — วัดจริงด้วย tokenizer: ควรลดลง ~400 token ต่อ request
  3. Latency ถึง token แรก — prefill 30 token เทียบ 430 token บนเครื่องเดียวกัน
  4. 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
Promptอธิบายว่าทำไมท้องฟ้าถึงเป็นสีฟ้า แบบสั้น ๆ

base

Thai 18%41 tokens
The sky appears blue because of Rayleigh scattering. ท้องฟ้า is blue เพราะ light scatter ครับ. Shorter wavelengths scatter more than longer ones.

sft

Thai 99%78 tokens
ท้องฟ้าเป็นสีฟ้าเพราะแสงอาทิตย์กระทบกับโมเลกุลของอากาศแล้วเกิดการกระเจิงแบบเรย์ลี ซึ่งแสงสีน้ำเงินที่มีความยาวคลื่นสั้นกว่าจะกระเจิงได้มากกว่าแสงสีแดง เราจึงมองเห็นท้องฟ้าเป็นสีฟ้าครับ

Showing the built-in sample.

9. เปรียบเทียบ (Comparison)

สี่ระบบ วัดบนชุดทดสอบเดียวกันทั้งหมด — สองแถวแรกคือพื้นกับเพดาน สองแถวหลังคือคู่ชกจริง:

ระบบCompliance (95% CI)OOD compliancePrompt tokens/reqLatency ถึง 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 เทรนนักเรียนที่ไม่เห็น cc ให้เท่าครูที่เห็น cc — และในบทนี้ครูกับนักเรียนคือน้ำหนักชุดเดียวกัน ต่างกันแค่ prompt กับ adapter
  • KL ต้องเป็น reverse (πθ\pi_\theta อยู่หน้า): 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)

  1. Ye et al. (2026). On-Policy Context Distillation for Language Models — OPCD -- วิธีหลักของบทนี้
  2. Askell et al. (2021). A General Language Assistant as a Laboratory for Alignment — context distillation แบบ offline ต้นฉบับ (baseline ของหัวข้อ 9)
  3. Snell et al. (2022). Learning by Distilling Context — การกลั่น context ให้เป็นพฤติกรรมของโมเดล
  4. Agarwal et al. (2023). On-Policy Distillation of Language Models: Learning from Self-Generated Mistakes — GKD: กรอบ JSD ที่รวม forward/reverse KL เข้าด้วยกัน
  5. Gu et al. (2023). MiniLLM: On-Policy Distillation of Large Language Models — MiniLLM: เหตุผลว่าทำไมต้องใช้ reverse KL

บทความ โค้ด และโน้ตบุ๊กในซีรีส์นี้เผยแพร่ภายใต้สัญญาอนุญาต CC BY-NC-SA 4.0 — นำไปใช้และดัดแปลงต่อได้ โดยอ้างอิงที่มา ไม่ใช้เพื่อการค้า และเผยแพร่ต่อด้วยสัญญาเดียวกัน (โมเดลและชุดข้อมูลของบุคคลที่สามที่อ้างถึง ยังคงใช้สัญญาของเจ้าของเดิม)