رمزگشایی گویایی یعنی یک توکن در هر پاس شبکه، و کل مدل باید هر بار از حافظه خوانده شود. رمزگشایی گویایی حدسی این را عوض می‌کند: یک مدل کوچک چند توکن را پیشنهاد می‌دهد، مدل بزرگ همه را در یک پاس تأیید می‌کند و توزیع خروجی دست‌نخورده می‌ماند. در این نوشته با یک اسکریپت استاندارد و بدون نصب هیچ پکیجی ثابت می‌کنم که خروجی تغییر نمی‌کند: کای‌دو ۲٫۳۸ در برابر حد بحرانی ۱۴٫۰۷، در حالی که همان بلوک بدون تأیید ۱۵۵۵٫۵۲ می‌دهد.

مسئله: پاس شبکه، نه محاسبه

تولید متن خودکار یعنی تولید K توکن با K اجرای پشت‌سرهم مدل. Medusa این گلوگاه را دقیق توصیف می‌کند: هر گام نیاز دارد پارامترهای کامل مدل از حافظه‌ی پرسرعت به حافظه‌ی نهان پردازنده منتقل شود، و در QPS پایین همین انتقال، نه ضرب ماتریس، گلوگاه است.

برای یک مدل با عرض ۲۰۴۸ و واژگان ۳۲۰۰۰، ماتریس نگاشت لایه‌ی پنهان به واژگان به‌تنهایی ۲۵۰ مگابایت وزن fp32 است. اگر هر توکن یک پاس جدا بخواهد، همین ۲۵۰ مگابایت چهار بار خوانده می‌شود. اگر چهار توکن در یک پاس راستی‌آزمایی شوند، یک بار خوانده می‌شود و چهار برابر محاسبه انجام می‌شود. تفاوت در همان گذرگاه حافظه است.

راه‌حل باید دو کار کند: چند توکن در هر پاس بدهد، و ثابت کند همان توزیع مدل بزرگ را می‌دهد. دومی را در بخش بعد اندازه می‌گیریم.

دو مدل، یک قاعده

ایده از مقاله‌ی Leviathan و همکاران آمده که مشاهده‌ی اصلی‌اش این است: امتیازدهی موازی به چند ادامه‌ی کوتاه که یک مدل تقریبی ساخته، تقریباً به اندازه‌ی گرفتن یک توکن از مدل بزرگ طول می‌کشد. مقاله‌ی Chen و همکاران همین ایده را با یک طرح نمونه‌پذیری ردکننده‌ی اصلاح‌شده مستقل کرد.

پیاده‌سازی در سه گام است. گام پیش‌نویس، مدل کوچک G توکن را یکی‌یکی حدس می‌زند و احتمال هرکدام را q می‌دهد. گام راستی‌آزمایی، مدل بزرگ با یک پاس همه‌ی G موقعیت را امتیاز می‌دهد و احتمال واقعی را p می‌دهد. گام پذیرش، هر توکن با احتمال min(1, p/q) پذیرفته می‌شود؛ اولین رد شدن از توزیع باقیمانده‌ی (p − q)+ بازنمونه‌برداری می‌شود و بقیه‌ی بلوک دور ریخته می‌شود.

اینکه بقیه‌ی بلوک دور ریخته می‌شود همان چیزی است که روش را محدود می‌کند. در Medusa و EAGLE راه‌حل دیگری رفته‌اند: Medusa چند سر رمزگشایی به خود مدل اضافه می‌کند تا مدل پیش‌نویس جدا نداشته باشد، و EAGLE استدلال را یک لایه پایین‌تر از توکن، یعنی روی ویژگی لایه‌ی دوم‌به‌آخر، انجام می‌دهد.

آن را اندازه بگیرید: توزیع عوض می‌شود؟

ادعای مقاله این است که توزیع عوض نمی‌شود، و این تنها چیزی است که باید ثابت کنیم. کد زیر بدون نصب چیزی اجرا می‌شود: کتابخانه‌ی استاندارد پایتون، واژگان هشت‌تایی و مدل هدفی با یک سطر احتمال. برای هر کیفیت پیش‌نویس، کای‌دو را برای رمزگشایی گویایی و یک کنترل منفی حساب می‌کنیم.

# پرونده را به نام spec_demo.py ذخیره کنید؛ نه نصب پکیجی لازم است و نه GPU
$ python3 --version
Python 3.14.7
$ python3 spec_demo.py

خود پرونده این است. تابع speculative هسته‌ی کار است و پرچم broken همان کنترل منفی را می‌سازد.

import random

V, G, TRIALS = 8, 4, 60000        # واژگان کوچک، ۴ توکن در هر پاس
random.seed(11)
TOKENS = list(range(V))

def normalise(row):
    s = sum(row)
    return [x / s for x in row]

P = [normalise([random.random() ** 2 + 0.05 for _ in range(V)]) for _ in range(V)]
START = 0

def make_draft(blur):
    # هرچه blur بزرگ‌تر، پیش‌نویس ضعیف‌تر و کمتر با هدف توافق می‌کند
    return [normalise([x + blur for x in row]) for row in P]

def speculative(Q, broken=False):
    block, prev = [], START
    for _ in range(G):
        block.append(random.choices(TOKENS, weights=Q[prev], k=1)[0])
        prev = block[-1]
    kept, prev = [], START
    for tok in block:
        p, q = P[prev][tok], Q[prev][tok]
        if broken:
            kept.append(tok)      # کنترل منفی: به پیش‌نویس اعتماد کن
            prev = tok
            continue
        if random.random() < min(1.0, p / q):
            kept.append(tok)
            prev = tok
        else:
            resid = [max(0.0, a - b) for a, b in zip(P[prev], Q[prev])]
            tot = sum(resid)
            kept.append(random.choices(TOKENS, weights=resid, k=1)[0]
                        if tot > 0 else
                        random.choices(TOKENS, weights=P[START], k=1)[0])
            break
    return kept

def chi2(counts, probs, n):
    return sum((c - probs[i] * n) ** 2 / (probs[i] * n)
               for i, c in enumerate(counts))

CRIT = 14.07                   # کای‌دو با ۷ درجه آزادی و آلفای ۰٫۰۵
naive_first = [random.choices(TOKENS, weights=P[START], k=1)[0]
               for _ in range(TRIALS)]
print("plain:", round(chi2([naive_first.count(t) for t in TOKENS],
                           P[START], TRIALS), 2))
for blur in (0.04, 0.25):
    Q = make_draft(blur)
    blocks = [speculative(Q) for _ in range(TRIALS)]
    firsts = [b[0] for b in blocks]
    bad = [speculative(Q, broken=True)[0] for _ in range(TRIALS)]
    print("spec:", round(chi2([firsts.count(t) for t in TOKENS], P[START], TRIALS), 2),
          "control:", round(chi2([bad.count(t) for t in TOKENS], P[START], TRIALS), 2))

خروجی واقعی همین اجرا روی این سرور است:

$ python3 spec_demo.py
target row for context 0: [0.0882, 0.1258, 0.3131, 0.0924, 0.1066, 0.1368, 0.0291, 0.108]
dof = 7, 0.05 critical value = 14.07

=== A) does the output distribution change? ===
plain decoding chi2 vs target: 7.28

--- draft blur 0.04 (total variation from target 0.0486) ---
  speculative chi2 vs target: 2.38 <- exactness holds
  unverified draft chi2 vs target: 1555.52 <- negative control
  mean tokens kept per target pass: 3.6292
  target passes to emit 1000 tokens:  275.5 instead of 1000.0  -> 3.63x fewer passes

--- draft blur 0.25 (total variation from target 0.1338) ---
  speculative chi2 vs target: 7.31 <- exactness holds
  unverified draft chi2 vs target: 12246.1 <- negative control
  mean tokens kept per target pass: 3.0668
  target passes to emit 1000 tokens:  326.1 instead of 1000.0  -> 3.07x fewer passes

عدد ۲٫۳۸ و ۷٫۳۱ هر دو زیر ۱۴٫۰۷ هستند، پس توزیع خروجی از نظر آماری با رمزگشایی معمولی تفاوتی ندارد. عدد ۱۵۵۵٫۵۲ و ۱۲۲۴۶٫۱ همان بلوک‌های حدسی‌اند که بدون راستی‌آزمایی برگردانده شده‌اند؛ اختلاف آن‌ها با هدف آن‌قدر بزرگ است که هر آزمونی آن را می‌گیرد. بدون این کنترل منفی، عدد ۲٫۳۸ به‌تنهایی هیچ چیزی را ثابت نمی‌کرد، چون پیش‌نویس اولیه آن‌قدر به هدف نزدیک بود که حتی بدون راستی‌آزمایی هم آزمون را رد نمی‌کرد.

هزینه: هر رد کردن، بقیه‌ی بلوک را می‌برد

دقیق‌ترین عدد این اجرا میانگین توکن‌های حفظ‌شده در هر پاس هدف است، نه نسبت شتاب. با چهار توکن پیشنهادی، این میانگین بین ۳٫۰۷ و ۳٫۶۳ است، یعنی هر پاس هدف به‌جای یک توکن، حدود سه تا چهار توکن بیرون می‌دهد.

توزیع طول بلوک نشان می‌دهد چرا میانگین زیر پنج می‌ماند. در حالت پیش‌نویس خوب، از ۶۰۰۰۰ بلوک فقط ۲۹۳۶ بلوک در همان توکن اول رد شدند و ۴۸۲۴۵ بلوک هر چهار توکن را کامل پذیرفتند. در حالت پیش‌نویس ضعیف‌تر، ردهای زودهنگام از ۲۹۳۶ به ۸۲۴۷ رفت و بلوک‌های کامل از ۴۸۲۴۵ به ۳۱۷۸۶ کم شد. پس پیش‌نویس ضعیف‌تر گران‌تر است، اما توزیع را خراب نمی‌کند.

نقطه‌ی سربه‌سر هم حساب ساده‌ای دارد. اگر یک پاس پیش‌نویس کسری از پاس هدف باشد، هزینه‌ی خالص هر چهار توکن پیشنهادی برابر 4 × نسبت + 1 پاس معادل هدف است. با میانگین ۳٫۶۳ توکن حفظ‌شده، نسبت ۲ درصدی سود خالص ۳٫۳۶ برابر می‌دهد، نسبت ۵ درصدی ۳٫۰۲ برابر، نسبت ۱۰ درصدی ۲٫۵۹ برابر و نسبت ۲۵ درصدی ۱٫۸۱ برابر.

نسبت هزینه‌ی پیش‌نویسپاس معادل هدفشتاب خالص
۲ درصد۱٫۰۸۳٫۳۶ برابر
۵ درصد۱٫۲۰۳٫۰۲ برابر
۱۰ درصد۱٫۴۰۲٫۵۹ برابر
۲۵ درصد۲٫۰۰۱٫۸۱ برابر

یعنی پیش‌نویسی که یک‌چهارم هزینه‌ی هدف را دارد، هنوز برای سرعت‌دادن ارزش دارد. پیش‌نویسی که به هدف نزدیک شود، دیگر ندارد.

در عمل: یک فرمان برای vLLM

کد بالا فقط سازوکار را نشان می‌دهد. برای استفاده‌ی واقعی، مستندات vLLM همان الگوریتم را آماده دارد و همه‌ی تنظیم‌هایش در یک شیء JSON به نام speculative_config جمع شده‌اند. نمونه‌ی رسمی مستندات، مدل ۸ میلیاردی هدف را با یک مدل ۰٫۶ میلیاردی پیش‌نویس و پنج توکن حدس در هر پاس راه می‌اندازد.

from vllm import LLM, SamplingParams

prompts = ["The future of AI is"]
sampling_params = SamplingParams(temperature=0.8, top_p=0.95)

# مدل هدف ۸ میلیاردی، مدل پیش‌نویس ۰٫۶ میلیاردی، ۵ توکن حدس
llm = LLM(
    model="Qwen/Qwen3-8B",
    tensor_parallel_size=1,
    speculative_config={
        "model": "Qwen/Qwen3-0.6B",
        "num_speculative_tokens": 5,
        "method": "draft_model",
    },
)
outputs = llm.generate(prompts, sampling_params)
print(outputs[0].outputs[0].text)

همان تنظیم روی خط فرمان هم هست و برای یک سرور در حال اجرا کار می‌کند، بدون آنکه کد کلاینت تغییر کند:

$ vllm serve Qwen/Qwen3-4B-Thinking-2507 \
    --host 0.0.0.0 --port 8000 --seed 42 \
    -tp 1 --max-model-len 2048 --gpu-memory-utilization 0.8 \
    --speculative-config '{"model": "Qwen/Qwen3-0.6B", "num_speculative_tokens": 5, "method": "draft_model"}'

دو نکته که مستندات به آن‌ها اشاره می‌کند و در عمل تعیین‌کننده‌اند. اول اینکه روش‌های نرم‌افزاری مثل ngram یا suffix مدل پیش‌نویس جدا نمی‌خواهند و برای متن تکراری مثل کد، سود کمتری می‌دهند؛ جدول انتخاب روش در همین صفحه، سود پایین تا متوسط را برای آن‌ها و سود بالا را برای eagle و mtp نشان می‌دهد. دوم اینکه transformers هر سه پرچم پیش‌فرض خودش را دارد و پیش از دست زدن به سرور، همان دقیقاً روشی است که vllm serve انجام می‌دهد.

کِی به کار بیاید

پس سود واقعی را از کجا بسنجیم؟ همان نسبت ۳٫۶ در برابر ۱٫۰ بالا، بهترین دستگیره‌ی این بخش است: تعداد پاس‌های لازم برای یک هزار توکن، پیش و پس از فعال‌کردن روش. عدد ۲۷۵٫۵ در برابر ۱۰۰۰ یعنی شتاب روی تعداد پاس، و تنها چیزی که باید کم شود همان بازخوانی وزن‌ها از حافظه است. این با بودجه‌ی کانتکست ایجنت‌ها فرق دارد: آنجا بحث بر سر پول توکن‌های ورودی بود و اینجا سر تعداد دفعات خواندن وزن‌ها.

روش در جایی بی‌فایده است که سه شرط با هم برقرار نباشند. اگر پیش‌نویس گران باشد، سربه‌سر از دست می‌رود. اگر متن ورودی تکراری نباشد، توکن‌های حدسی مرتب رد می‌شوند و بلوک‌ها کوتاه می‌شوند. اگر بار کاری پرتراکم باشد، انتقال حافظه دیگر گلوگاه نیست و روش چیزی برای بهبود ندارد؛ مستندات vLLM هم دامنه‌ی توصیه‌شده را مناسب کم‌بودن ترافیک و کار حافظه‌محور اعلام می‌کند.

عدد ۲٫۳۸ کای‌دو در برابر حد ۱۴٫۰۷ بالا، معیار عملی این نوشته است. اگر پیاده‌سازی شما این عدد را بدهد، توزیع را نگه داشته‌اید. اگر همین عدد را بدهد ولی میانگین توکن حفظ‌شده‌اش زیر دو بماند، شتاب نخواهید گرفت.

منابع

  1. Fast Inference from Transformers via Speculative Decoding — arXiv:2211.17192
  2. Accelerating Large Language Model Decoding with Speculative Sampling — arXiv:2302.01318
  3. EAGLE: Speculative Sampling Requires Rethinking Feature Uncertainty — arXiv:2401.15077
  4. Medusa: Simple LLM Inference Acceleration Framework with Multiple Decoding Heads — arXiv:2401.10774
  5. Speculative Decoding — vLLM documentation
  6. Draft Models — vLLM documentation
  7. Ngram decoding — vLLM documentation
  8. Generation strategies — Hugging Face Transformers