درس ۳ از ۱۲، بخش ۱: مدل زبانی از درون
توکن، کانتکست و نمونهبرداری: تولید متن در عمل
در این درس
در درس قبل دیدیم مدل در هر قدم یک توزیع احتمال روی کل واژگان میسازد. در این درس با یک مدل کوچک و بازِ Gemma 3 (یک میلیارد پارامتر) که روی CPU هم اجرا میشود، همین مفاهیم را با کد میبینیم: توکنها، قالب چت، و پارامترهایی که تعیین میکنند از بین آن احتمالها کدام توکن انتخاب شود.
نوتبوک این درس
01_01_transformers_tokenization_gemma-3.ipynb. روی Google Colab حتی با CPU رایگان هم اجرا میشود.
بارگذاری مدل از Hugging Face#
Hugging Face بزرگترین مخزن مدلهای باز است و کتابخانهی transformers آن برای بارگذاری و اجرای این مدلها استفاده میشود. هر مدل یک شناسه به شکل سازمان/نام-مدل دارد، مثل google/gemma-3-1b-it یا Qwen/Qwen2.5-0.5B-Instruct. پسوند it یا Instruct یعنی این نسخه دستورپذیر است (مرحلهی SFT را دیده)، نه مدل پایه.
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
model_name = "google/gemma-3-1b-it"
tokenizer = AutoTokenizer.from_pretrained(model_name, token="hf_...")
model = AutoModelForCausalLM.from_pretrained(model_name, dtype=torch.float32, token="hf_...")
device = "cuda" if torch.cuda.is_available() else "cpu"
model.to(device)
AutoTokenizerتوکنایزر مخصوص همان مدل را میآورد؛ همان چیزی که متن را به شماره تبدیل میکند و برعکس.AutoModelForCausalLMیعنی «مدل زبانی علّی». منظور همان مدل فقطدیکودری است که توکن بعدی را پیشبینی میکند.
توکن Hugging Face
بعضی مدلها، مثل Gemma و Llama، گِیتدار هستند. باید در صفحهی مدل در Hugging Face توافقنامه را بپذیرید و از تنظیمات حساب خود یک Access Token بسازید. مدلهایی مثل Qwen/Qwen2.5-0.5B-Instruct بدون گیتاند و توکن لازم ندارند.
توکنسازی فارسی و انگلیسی#
تابع زیر یک متن را توکن میکند و نسبت فشردهسازی را حساب میکند: تعداد کاراکتر تقسیم بر تعداد توکن. هر چه این عدد بزرگتر باشد، توکنایزر برای آن زبان بهینهتر است و همان متن توکن کمتری مصرف میکند.
def show_tokenization(text):
ids = tokenizer.encode(text)
tokens = tokenizer.convert_ids_to_tokens(ids)
print(len(text), "chars |", len(ids), "tokens |", round(len(text) / len(ids), 2))
print(tokens)
show_tokenization("امروز هوا خیلی خوب است و من به پارک رفتم.")
show_tokenization("Today the weather is very nice and I went to the park.")
خروجی با توکنایزر Gemma 3:
| کاراکتر | توکن | کاراکتر به ازای هر توکن | |
|---|---|---|---|
| جملهی فارسی | ۴۱ | ۱۴ | ۲٫۹۳ |
| همان جمله به انگلیسی | ۵۴ | ۱۴ | ۳٫۸۶ |
| یک پاراگراف انگلیسی | ۲۷۸ | ۵۳ | ۵٫۲۵ |
['<bos>', 'ام', 'روز', '▁هوا', '▁خیلی', '▁خوب', '▁است', '▁و', '▁من', '▁به', '▁پارک', '▁رفت', 'م', '.']
['<bos>', 'Today', '▁the', '▁weather', '▁is', '▁very', '▁nice', '▁and', '▁I', '▁went', '▁to', '▁the', '▁park', '.']
چند نکته در این خروجی:
<bos>(beginning of sequence) یک توکن ویژه است که توکنایزر خودش اول متن میگذارد.▁در Gemma یعنی «قبل از این تکه یک فاصله بوده». پس▁youباyou(بدون فاصله) دو توکن متفاوت است.- «امروز» به
ام+روزو «رفتم» بهرفت+مشکسته شده است. یک توکن لزوماً یک کلمه نیست. - Gemma فارسی را نسبتاً خوب توکن میکند. توکنایزرهای انگلیسیمحور برای فارسی خیلی بدترند و گاهی به سطح بایت خام برمیگردند و تکههای نامفهومی مثل
اÙħمیسازند. همان متن فارسی با چنین مدلی چند برابر توکن مصرف میکند، یعنی هم گرانتر است و هم زودتر پنجرهی کانتکست را پر میکند.
tokenizer.decode(ids) هم شمارهها را دوباره به متن برمیگرداند. این رفتوبرگشت باید متن را دقیقاً بازسازی کند.
قالب چت و توکنهای ویژه#
مدل دستورپذیر با قالب خاصی آموزش دیده است: کجا پیام سیستم است، کجا پیام کاربر و کجا نوبت دستیار. ما پیامها را به شکل لیستی از دیکشنری مینویسیم و apply_chat_template آن را به همان قالبی تبدیل میکند که مدل دیده است:
messages = [
{"role": "system", "content": "You are an assistant that summarizes conversations in one or two sentences."},
{"role": "user", "content": f"Summarize the following conversation:\n\n{dialogue}"},
]
prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
<bos><start_of_turn>user
You are an assistant that summarizes conversations in one or two sentences.
Summarize the following conversation: …<end_of_turn>
<start_of_turn>model
- هر مدل قالب خودش را دارد. Qwen مثلاً از
<|im_start|>systemو<|im_start|>assistantاستفاده میکند. خوشبختانهapply_chat_templateاین را از توکنایزر خود مدل میخواند. - Gemma نقش جداگانهای برای system ندارد و متن سیستم را به اول پیام کاربر میچسباند.
add_generation_prompt=Trueآخر متن<start_of_turn>modelرا اضافه میکند؛ یعنی «حالا نوبت توست».
تولید پاسخ و شرطهای توقف#
def generate(messages, **gen_kwargs):
prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
inputs = tokenizer(prompt, return_tensors="pt").to(device)
kwargs = dict(max_new_tokens=120, pad_token_id=tokenizer.eos_token_id) | gen_kwargs
with torch.no_grad():
output_ids = model.generate(**inputs, **kwargs)
new_tokens = output_ids[0][inputs["input_ids"].shape[1]:] # فقط توکنهای جدید
return tokenizer.decode(new_tokens, skip_special_tokens=True).strip()
print(generate([{"role": "user", "content": "پایتخت ایران کجاست؟"}], do_sample=False))
model.generate همان حلقهی خودبازگشتی درس قبل است. حلقه با یکی از این سه شرط تمام میشود:
- توکن پایان تولید شود (
<end_of_turn>یا<eos>). چتباتها این توکنهای ویژه را به شما نشان نمیدهند؛skip_special_tokens=Trueهمین کار را میکند. - به
max_new_tokensبرسیم. این سقف را خودتان تعیین میکنید. - پنجرهی کانتکست پر شود. مجموع توکنهای ورودی و خروجی سقفی دارد که برای مدلهای کوچک چند هزار توکن و برای مدلهایی مثل Gemini بیش از یک میلیون توکن است.
greedy یا نمونهبرداری؟#
در هر قدم دو راه داریم:
- greedy (
do_sample=False): همیشه محتملترین توکن انتخاب میشود. خروجی قطعی و تکرارپذیر است، اما ممکن است خشک، تکراری یا حتی گرفتار حلقهی تکرار شود. - نمونهبرداری (
do_sample=True): بین توکنها قرعهکشی میشود و شانس هر توکن به اندازهی احتمالش است. اگر «تهران» ۸۵٪ احتمال داشته باشد، از هر ده بار حدود هشت بار و نیم برنده میشود، اما بقیه هم شانس دارند.
نمونهبرداری به پاسخ تنوع میدهد. چتباتها برای همین در جواب «سلام» هر بار یک جور جواب میدهند: یک بار «در خدمتم»، یک بار «چطور میتوانم کمک کنم؟». از طرف دیگر، غیرقطعی بودن هم از همینجا میآید: یک سؤال یکسان دو بار جواب متفاوت میگیرد.
دما (temperature)#
دما احتمالها را پیش از قرعهکشی تغییر شکل میدهد. از نظر ریاضی، logitها قبل از softmax بر دما تقسیم میشوند:
- دمای کم (مثلاً ۰٫۲): توزیع تیز میشود. اگر یک گزینه ۷۰٪ بود، به سمت ۹۰٪ و بیشتر میرود و رفتار مدل به greedy نزدیک میشود.
- دمای ۱: همان توزیع خود مدل.
- دمای زیاد (مثلاً ۱٫۵): توزیع پهن میشود و گزینههای کماحتمال شانس بیشتری پیدا میکنند. خروجی خلاقتر و گاهی بیمعنی میشود.
top-k و top-p#
این دو پارامتر پیش از قرعهکشی دم توزیع را حذف میکنند تا توکنهای خیلی کماحتمال و بیربط هیچوقت انتخاب نشوند:
top_k: فقط k توکن پراحتمالتر در قرعهکشی میمانند.top_k=1همان greedy است.top_p(nucleus sampling): کوچکترین مجموعه از توکنهای اول که مجموع احتمالشان به p برسد. اگر مدل مطمئن باشد، این مجموعه یکی دو توکن است؛ اگر مردد باشد، توکنهای بیشتری را شامل میشود. برای همین ازtop_kانعطافپذیرتر است.
user_prompt = [{"role": "user", "content": "یک داستان کوتاه خلاقانه پیرامون باران"}]
for temp in [0.1, 0.7, 1.3]:
torch.manual_seed(42) # seed ثابت تا فقط اثر دما را ببینیم
print(temp, generate(user_prompt, do_sample=True, temperature=temp, top_k=0, top_p=1.0, max_new_tokens=60))
# ترکیب رایج در چتباتها
generate(user_prompt, do_sample=True, temperature=0.8, top_k=50, top_p=0.9)
در عمل این سه با هم استفاده میشوند: اول top_k فیلتر میکند، بعد top_p روی باقیمانده اعمال میشود و دما شکل توزیع را تعیین میکند. پارامتر repetition_penalty (بزرگتر از ۱) هم به توکنهایی که قبلاً تولید شدهاند جریمه میدهد تا مدل کمتر تکرار کند.
چه دمایی انتخاب کنم؟
برای کارهای دقیق و واقعیتمحور، مثل استخراج اطلاعات، طبقهبندی، تولید JSON یا جواب سؤال از روی سند، دمای کم (۰ تا ۰٫۳) بگذارید. برای کارهای خلاقانه، مثل ایدهپردازی، داستان یا عنوان تبلیغاتی، دمای ۰٫۷ تا ۱ مناسبتر است. در درسهای بعد تقریباً همهجا دمای ۰٫۲ میگذاریم.
یادگیری درونکانتکستی: zero-shot و few-shot#
مدل دستورپذیر را میشود بدون آموزش دوباره به کار تازهای واداشت، فقط با چیزی که در پرامپت مینویسیم. به این یادگیری درونکانتکستی (In-Context Learning) میگویند:
- zero-shot: فقط دستور میدهیم: «نظر زیر را مثبت یا منفی دستهبندی کن: …»
- one-shot / few-shot: اگر مدل منظور ما را نفهمید، یک یا چند مثال حلشده هم میآوریم:
Classify this review: I loved this movie!
Sentiment: Positive
Classify this review: I don't like this chair.
Sentiment: Negative
Classify this review: The battery died after two days.
Sentiment:
مدلهای کوچک معمولاً به مثال بیشتری نیاز دارند. مدلهای بزرگ اغلب با zero-shot هم کار را درست انجام میدهند. اما few-shot هیچوقت بیفایده نمیشود. برای کارهای واقعاً تخصصی، مثلاً نوشتن کوئریهای پیچیده به زبانی کمکاربرد مثل Cypher در پایگاهدادههای گرافی، حتی بزرگترین مدلها هم با چند مثال خیلی بهتر کار میکنند. در بخش مهندسی پرامپت دوباره به few-shot برمیگردیم.
تمرین#
temperatureرا روی ۲ بگذارید و ببینید خروجی چقدر بیمعنی میشود.top_k=1را باdo_sample=Falseمقایسه کنید. خروجی یکسان است؟ چرا؟- بهجای داستان یک سؤال دانشی بپرسید و ببینید کدام تنظیمات جواب بهتری میدهد.
- مدل را با
Qwen/Qwen2.5-0.5B-Instructعوض کنید و نسبت فشردهسازی جملهی فارسی را مقایسه کنید.