درس ۳ از ۱۲، بخش ۱: مدل زبانی از درون

توکن، کانتکست و نمونه‌برداری: تولید متن در عمل

متنی ۳۰ دقیقه به‌روز شده در ۱۱ آبان ۱۴۰۵

در این درس
  1. بارگذاری مدل از Hugging Face
  2. توکن‌سازی فارسی و انگلیسی
  3. قالب چت و توکن‌های ویژه
  4. تولید پاسخ و شرط‌های توقف
  5. greedy یا نمونه‌برداری؟
  6. یادگیری درون‌کانتکستی: zero-shot و few-shot
  7. تمرین

در درس قبل دیدیم مدل در هر قدم یک توزیع احتمال روی کل واژگان می‌سازد. در این درس با یک مدل کوچک و بازِ 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 همان حلقه‌ی خودبازگشتی درس قبل است. حلقه با یکی از این سه شرط تمام می‌شود:

  1. توکن پایان تولید شود (<end_of_turn> یا <eos>). چت‌بات‌ها این توکن‌های ویژه را به شما نشان نمی‌دهند؛ skip_special_tokens=True همین کار را می‌کند.
  2. به max_new_tokens برسیم. این سقف را خودتان تعیین می‌کنید.
  3. پنجره‌ی کانتکست پر شود. مجموع توکن‌های ورودی و خروجی سقفی دارد که برای مدل‌های کوچک چند هزار توکن و برای مدل‌هایی مثل Gemini بیش از یک میلیون توکن است.

greedy یا نمونه‌برداری؟#

در هر قدم دو راه داریم:

  • greedy (do_sample=False): همیشه محتمل‌ترین توکن انتخاب می‌شود. خروجی قطعی و تکرارپذیر است، اما ممکن است خشک، تکراری یا حتی گرفتار حلقه‌ی تکرار شود.
  • نمونه‌برداری (do_sample=True): بین توکن‌ها قرعه‌کشی می‌شود و شانس هر توکن به اندازه‌ی احتمالش است. اگر «تهران» ۸۵٪ احتمال داشته باشد، از هر ده بار حدود هشت بار و نیم برنده می‌شود، اما بقیه هم شانس دارند.

نمونه‌برداری به پاسخ تنوع می‌دهد. چت‌بات‌ها برای همین در جواب «سلام» هر بار یک جور جواب می‌دهند: یک بار «در خدمتم»، یک بار «چطور می‌توانم کمک کنم؟». از طرف دیگر، غیرقطعی بودن هم از همین‌جا می‌آید: یک سؤال یکسان دو بار جواب متفاوت می‌گیرد.

دما (temperature)#

دما احتمال‌ها را پیش از قرعه‌کشی تغییر شکل می‌دهد. از نظر ریاضی، logitها قبل از softmax بر دما تقسیم می‌شوند:

  • دمای کم (مثلاً ۰٫۲): توزیع تیز می‌شود. اگر یک گزینه ۷۰٪ بود، به سمت ۹۰٪ و بیشتر می‌رود و رفتار مدل به greedy نزدیک می‌شود.
  • دمای ۱: همان توزیع خود مدل.
  • دمای زیاد (مثلاً ۱٫۵): توزیع پهن می‌شود و گزینه‌های کم‌احتمال شانس بیشتری پیدا می‌کنند. خروجی خلاق‌تر و گاهی بی‌معنی می‌شود.
اثر دما بر توزیع احتمال توکن بعدیدمای کم (۰٫۵): قله‌ی تیزتهران۹۶٪شهر۳٪یک۱٪اصفهان۰٪کجا۰٪دمای ۱: توزیع خود مدلتهران۷۵٪شهر۱۲٪یک۷٪اصفهان۳٪کجا۲٪دمای زیاد (۱٫۶): توزیع پهنتهران۵۶٪شهر۱۸٪یک۱۲٪اصفهان۸٪کجا۶٪ادامه‌ی جمله‌ی «پایتخت ایران …» — هر چه دما بیشتر، شانس گزینه‌های کم‌احتمال بیشتر
همان logitها با سه دمای مختلف. با دمای کم تقریباً همیشه «تهران» انتخاب می‌شود.

top-k و top-p#

این دو پارامتر پیش از قرعه‌کشی دم توزیع را حذف می‌کنند تا توکن‌های خیلی کم‌احتمال و بی‌ربط هیچ‌وقت انتخاب نشوند:

  • top_k: فقط k توکن پراحتمال‌تر در قرعه‌کشی می‌مانند. top_k=1 همان greedy است.
  • top_p (nucleus sampling): کوچک‌ترین مجموعه از توکن‌های اول که مجموع احتمالشان به p برسد. اگر مدل مطمئن باشد، این مجموعه یکی دو توکن است؛ اگر مردد باشد، توکن‌های بیشتری را شامل می‌شود. برای همین از top_k انعطاف‌پذیرتر است.
نمونه‌برداری top-k و top-pتهران۴۲٪شهر۲۱٪یک۱۳٪اصفهان۹٪کجا۶٪بزرگ۴٪زیبا۳٪…۲٪top_k=3 ← فقط سه گزینه‌ی اولtop_p=0.9 ← تا جمع احتمال به ۹۰٪ برسد (تا «کجا»)توکن‌های خاکستری حذف می‌شوند؛ قرعه‌کشی فقط بین توکن‌های باقی‌مانده انجام می‌شودgreedy (do_sample=False) همیشه اولی را برمی‌دارد
top_k تعداد ثابتی گزینه نگه می‌دارد و top_p به اندازه‌ی جرم احتمال. توکن‌های خاکستری از قرعه‌کشی حذف می‌شوند.
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 برمی‌گردیم.

تمرین#

  1. temperature را روی ۲ بگذارید و ببینید خروجی چقدر بی‌معنی می‌شود.
  2. top_k=1 را با do_sample=False مقایسه کنید. خروجی یکسان است؟ چرا؟
  3. به‌جای داستان یک سؤال دانشی بپرسید و ببینید کدام تنظیمات جواب بهتری می‌دهد.
  4. مدل را با Qwen/Qwen2.5-0.5B-Instruct عوض کنید و نسبت فشرده‌سازی جمله‌ی فارسی را مقایسه کنید.