ژرفا — خودآموز یادگیری عمیق و مهندسی هوش مصنوعی (متوسط)

فصل ۱ از ۱۰

پیشرفت ترم
۰٪

ترم ۵ · مهندسیِ LLM: سامانه‌ای که واقعاً کار می‌کند

API یک مدلِ بزرگ، از پایتون

فصل ۱پیش‌نمایش رایگان

در این فصل چه یاد می‌گیری#

چهار ترم مدل ساختی و آموزش دادی. این ترم چیزِ دیگری است: مدل را دیگری ساخته و تو باید دورش یک سامانه بسازی که واقعاً کار کند — پاسخِ نامعتبر بگیرد و نشکند، سرویس که پایین آمد جا نزند، و وقتی بد جواب داد بفهمی چقدر بد.

در این فصل اولین تماس را می‌گیری، پارامترهای تولید را دستکاری می‌کنی، و مهم‌تر از همه: کدی می‌نویسی که وقتی سرویس خطا می‌دهد از پا نمی‌افتد. آخرش با عدد نشان می‌دهیم که همان چند خط، نرخِ موفقیت را از ۱۰ از ۲۰ به ۱۹ از ۲۰ می‌رساند — و می‌گوییم بهایش چه بود.

یک تماسِ ساده که در راه به مانع می‌خورد و بارِ دوم عبور می‌کند

آخر این فصل می‌توانی:

  • پیام‌ها را با role درست بسازی و بفرستی
  • temperature و سقفِ طولِ خروجی را بشناسی و اثرشان را اندازه بگیری
  • خطاهای واقعیِ یک سرویس را دسته‌بندی کنی و برایشان retry با backoff بنویسی
  • بگویی چرا retry رایگان نیست

قبل از شروع#

از سرنخ ترمِ ۴: فراخوانیِ یک API با کلید و گذاشتنِ کلید در Colab Secrets. اینجا همان کار را می‌کنیم، ولی این بار کدِ اطرافش موضوعِ درس است، نه خودِ تماس.

از سرنخ ترمِ ۱ فصل ۸: خواندنِ traceback و گرفتنِ استثنا با try/except.

سلولِ راه‌اندازی سه چیز می‌دهد: DOCS (دوازده سندِ فارسیِ کتابخانهٔ ساختگیِ «نوردانه»)، GOLD (بیست‌وچهار پرسش با جوابِ درست، از فصلِ ۶ به بعد ستونِ فقراتِ ترم)، و دو تابعِ تماس: chat و real_chat.

📓 نوت‌بوک: نوت‌بوک این فصل را در Colab باز کن — همهٔ کدهای این فصل آماده و به‌ترتیب داخلش هست.

۱. دو تابعِ تماس، و چرا دو تا#

real_chat تماسِ واقعی است با Gemini، با کلیدِ خودت. chat بدلِ محلیِ آن است — همان ورودی، همان شکلِ خروجی، ولی بدونِ اینترنت و بدونِ کلید، و قطعی: هر بار همان جواب.

چرا این ترم روی بدل بنا شده، صریح می‌گویم:

  • هر عددی که در این کتاب می‌بینی باید برای تو هم دقیقاً همان دربیاید. با یک سرویسِ زنده این ممکن نیست؛ خروجی‌اش هر هفته عوض می‌شود.
  • درسِ این ترم مدل نیست، سامانهٔ دورِ مدل است. اعتبارسنجی، retry، بازیابی، eval، هزینه، ایمنی — هیچ‌کدام به هوشِ مدل وابسته نیستند.
  • این خودش روشِ حرفه‌ای است. هیچ تیمی تستِ خودکارش را به یک سرویسِ پولیِ غیرقطعی وصل نمی‌کند. بدل داشتن قاعده است، نه استثنا.

و مرزش را هم صریح می‌گویم: بدلِ ما فقط استخراج می‌کند — جملهٔ مرتبط را از متنی که به‌ش داده‌ای برمی‌دارد. استدلال نمی‌کند. پس هر جا در این ترم عددی می‌بینی که بدل بد گرفته، مدلِ واقعی ممکن است بهتر بگیرد — و هر جا بدل خوب گرفته، مدلِ واقعی هم می‌گیرد. این را همه‌جا یادآوری می‌کنم.

۲. یک درخواست واقعاً چیست#

import json

messages = [
    {"role": "system", "content": "تو دستیارِ کتابخانهٔ نوردانه‌ای. فقط از متنی که می‌دهم جواب بده."},
    {"role": "user", "content": DOC_BY_ID["hours"]["text"] + "\n\nپنجشنبه‌ها ساعت کار کتابخانه تا چند است؟"},
]
for m in messages:
    print(f'{m["role"]:>6} | {m["content"][:52]}…')
system | تو دستیارِ کتابخانهٔ نوردانه‌ای. فقط از متنی که می‌د…
  user | کتابخانه شنبه تا چهارشنبه از ساعت ۸ تا ۲۰ باز است. پ…

یک فهرست از دیکشنری. همین. هر چیزِ دیگری که دربارهٔ «چت با هوش مصنوعی» شنیده‌ای، رویِ همین ساختار سوار است.

سه role رایج است:

  • system — دستورِ پایدار: تو کی هستی، چه کاری بکن، چه کاری نکن.
  • user — چیزی که کاربر می‌گوید، به‌علاوهٔ هر متنی که تو به‌ش می‌چسبانی.
  • assistant — پاسخ‌های قبلیِ خودِ مدل. حافظهٔ گفت‌وگو همین است و بس: مدل چیزی به‌خاطر نمی‌سپارد؛ تو تاریخچه را هر بار دوباره می‌فرستی.

آن جملهٔ آخر را دست‌کم نگیر. مدل بینِ دو تماس هیچ حالتی نگه نمی‌دارد. اگر گفت‌وگوی ده‌مرحله‌ای می‌خواهی، در مرحلهٔ دهم داری هر ده پیام را دوباره می‌فرستی — و برای هر ده‌تا دوباره پول می‌دهی. فصلِ ۸ همین را اندازه می‌گیرد.

و متنِ سند را کجا گذاشتیم؟ داخلِ پیامِ user. فعلاً با دست. فصلِ ۴ همین کار را خودکار می‌کند و اسمش RAG است.

۳. اولین تماس#

reply = chat(messages)
print("متن  :", reply["text"])
print("پایان:", reply["finish"])
print("مصرف :", reply["usage"])
متن  : پنجشنبه‌ها ساعت کار از ۸ تا ۱۴ است.
پایان: stop
مصرف : {'input_chars': 316, 'output_chars': 35}

چک کن: اگر جوابی گرفتی که ربطی به پنجشنبه ندارد، احتمالاً پرسش را اولِ پیام گذاشته‌ای. قرارِ این ترم این است که پرسش آخرین سطرِ پیامِ کاربر باشد و سند بالایش. این فقط قاعدهٔ بدلِ ما نیست؛ توصیهٔ عملیِ کارِ با مدلِ واقعی هم هست — دستورِ نزدیک به انتهای متن بیشتر رعایت می‌شود.

سه فیلد را نگاه کن:

  • text — چیزی که می‌خواستی.
  • finishچرا تولید تمام شد. stop یعنی مدل خودش تمام کرد. مقدارِ دیگری هم دارد که در بخشِ ۵ می‌بینی.
  • usage — مصرف. سرویسِ واقعی اینجا توکن می‌دهد؛ بدلِ ما نویسه می‌شمارد چون توکنایزرِ آن مدل را ندارد. در فصلِ ۸ با توکنایزرِ واقعی دقیق می‌شماریم.

۴. همان تماس، این بار واقعی#

try:
    real = real_chat(messages)
    print("پاسخِ مدلِ واقعی:", real["text"])
    print("مصرفِ واقعی:", real["usage"])
except LLMError as error:
    print("تماسِ واقعی انجام نشد →", error)
تماسِ واقعی انجام نشد → کلید پیدا نشد: GOOGLE_API_KEY را در Colab Secrets بگذار و دوباره اجرا کن.

اگر کلید گذاشته‌ای، این سلول جوابِ واقعی می‌دهد و طبیعتاً با متنِ بالا فرق دارد. بقیهٔ فصل‌ها روی بدل کار می‌کنند تا عددهایت با کتاب یکی باشد.

برای گرفتن و گذاشتنِ کلید:

  • ۱) از aistudio.google.com یک کلیدِ رایگان بساز.
  • ۲) در Colab، از نوارِ کناری 🔑 را باز کن، نامش را GOOGLE_API_KEY بگذار و مقدارش را بچسبان، و «دسترسیِ نوت‌بوک» را روشن کن.
  • ۳) در نوت‌بوک این دو خط را اجرا کن:
from google.colab import userdata
os.environ["GOOGLE_API_KEY"] = userdata.get("GOOGLE_API_KEY")

🔧 اگر کار نکرد: SecretNotFoundError یعنی نامِ رمز با چیزی که صدا می‌زنی یکی نیست — نام حساس به بزرگی و کوچکیِ حروف است. NotebookAccessError یعنی رمز هست ولی دسترسیِ همین نوت‌بوک را روشن نکرده‌ای. و هرگز کلید را مستقیم داخلِ سلول ننویس — نوت‌بوک را که به اشتراک بگذاری، کلیدت هم می‌رود.

داخلِ real_chat یک نکتهٔ مهندسی هست که ارزشِ دیدن دارد: قالبِ پیامِ Gemini با قالبِ role/content که نوشتیم یکی نیست — آن contents و parts می‌خواهد و دستورِ سامانه را جدا می‌گیرد. پس real_chat مترجم است. این الگو را نگه دار: کدت را با قالبِ خودت بنویس و ترجمه به هر سرویس را در یک تابع حبس کن. آن روز که سرویس را عوض می‌کنی، فقط همان یک تابع عوض می‌شود.

۵. temperature: چقدر جسور#

reset_calls()
cold = {chat(messages, temperature=0.0)["text"] for _ in range(8)}
reset_calls()
warm = {chat(messages, temperature=1.0, seed=3)["text"] for _ in range(8)}

print("temperature=0.0 →", len(cold), "پاسخِ متمایز در ۸ تماس")
print("temperature=1.0 →", len(warm), "پاسخِ متمایز در ۸ تماس")
for text in sorted(warm):
    print("   ", text[:54])
temperature=0.0 → 1 پاسخِ متمایز در ۸ تماس
temperature=1.0 → 4 پاسخِ متمایز در ۸ تماس
    باجهٔ امانت نیم‌ساعت زودتر از پایانِ ساعتِ کار بسته می
    جمعه‌ها کتابخانه تعطیل است.
    پنجشنبه‌ها ساعت کار از ۸ تا ۱۴ است.
    کتابخانه شنبه تا چهارشنبه از ساعت ۸ تا ۲۰ باز است.

در ترمِ ۴ فصلِ ۷ temperature را روی مدلِ خودت دیدی: تقسیمِ logits بر یک عدد، قبل از softmax. همان است، فقط این بار داخلِ سرویسِ دیگری.

و نتیجه‌اش اینجا هم همان: ۰ یعنی همیشه محتمل‌ترین، بالاتر یعنی گاهی گزینه‌های بعدی. سه پاسخِ اضافه‌ای که بالا می‌بینی همگی از همان سند آمده‌اند و همگی غلط‌اند — چون پرسش دربارهٔ پنجشنبه بود.

قاعدهٔ عملی: هر جا خروجی باید درست باشد — استخراج، دسته‌بندی، JSON، جوابِ روی سند — temperature=0. هر جا تنوع ارزش دارد — طوفانِ فکری، چند عنوانِ جایگزین — بالاتر ببر. پیش‌فرضِ اکثرِ سرویس‌ها صفر نیست، پس اگر خروجیِ برنامه‌ات بی‌دلیل بی‌ثبات است، اول اینجا را نگاه کن.

📏 اندازه بگیر: «صفر یعنی قطعی» یک تقریبِ مفید است، نه یک تضمین. روی سرویسِ واقعی حتی با temperature=0 هم گاهی خروجی فرق می‌کند (سخت‌افزار، به‌روزرسانیِ مدل، batch شدنِ درخواست‌ها). پس اگر برنامه‌ات به یکسان بودنِ خروجی وابسته است، وابستگی را بردار — به temperature تکیه نکن.

۶. سقفِ طولِ خروجی#

short = chat(messages, max_output_tokens=4)
print("متن  :", short["text"])
print("پایان:", short["finish"])
متن  : پنجشنبه‌ها ساعت 
پایان: length

finish شد length و جمله وسطِ راه بریده شد.

این بی‌سروصداترین خرابیِ کارِ با مدل است. خروجی می‌آید، خطایی نمی‌گیری، برنامه ادامه می‌دهد — و متن ناقص است. اگر خروجی JSON بوده باشد، قالبش می‌شکند و فصلِ بعد دقیقاً همین را نشان می‌دهد.

قاعده: همیشه finish را چک کن، نه فقط text را. یک if reply["finish"] != "stop" سه‌خطی، ساعت‌ها اشکال‌یابی را می‌خرد.

۷. وقتی سرویس جواب نمی‌دهد#

FAILURES["rate"] = 0.4
reset_calls()


def try_once():
    try:
        chat(messages)
        return "ok"
    except LLMError as error:
        return type(error).__name__


results = [try_once() for _ in range(20)]
print("موفق:", results.count("ok"), "از ۲۰")
print("شکست‌ها:", {k: results.count(k) for k in sorted(set(results)) if k != "ok"})
موفق: 10 از ۲۰
شکست‌ها: {'LLMTimeout': 1, 'RateLimitError': 5, 'ServerError': 4}

FAILURES["rate"] شیرِ خرابیِ بدل است: با ۰٫۴ حدودِ چهل درصدِ تماس‌ها شکست می‌خورند — قطعی و تکرارپذیر، تا بتوانی راه‌حلت را واقعاً بسنجی.

سه خطا سه معنیِ کاملاً متفاوت دارند و یکسان با آن‌ها رفتار کردن اشتباه است:

خطا کدِ HTTP یعنی کارِ درست
RateLimitError ۴۲۹ از سهمیه گذشتی صبر کن و دوباره بزن
ServerError ۵۰۰ و بالاتر مشکل سمتِ آن‌هاست صبر کن و دوباره بزن
LLMTimeout پاسخ به‌موقع نرسید دوباره بزن، ولی حواست به تماسِ نیمه‌کاره باشد
خطای کلید ۴۰۱ و ۴۰۳ کلید غلط یا بی‌اجازه است هرگز دوباره نزن — تا کلید عوض نشود همان جواب می‌آید
ورودیِ نامعتبر ۴۰۰ درخواستت خراب است هرگز دوباره نزن — خودت را درست کن

دو ردیفِ آخر را جدی بگیر. retry روی خطای ۴۰۰ و ۴۰۱ فقط سهمیه‌ات را می‌سوزاند و ممکن است حسابت را موقتاً ببندد. فقط روی خطاهای گذرا دوباره تلاش کن.

۸. retry با backoff#

import time

SLEEP_SCALE = 0.0          # در Colab بگذار 1.0 تا صبرِ واقعی را حس کنی


def with_retry(call, attempts=4, base=0.5, verbose=False):
    for i in range(attempts):
        try:
            return call()
        except (RateLimitError, ServerError, LLMTimeout) as error:
            if i == attempts - 1:
                raise
            wait = base * (2 ** i)
            if verbose:
                print(f"  تلاشِ {i + 1}: {type(error).__name__} — {wait:.1f} ثانیه صبر")
            time.sleep(wait * SLEEP_SCALE)
    raise LLMError("unreachable")


reset_calls()
print("یک تماس با گزارشِ کامل:")
with_retry(lambda: chat(messages), verbose=True)
print("  گرفتیم.")
یک تماس با گزارشِ کامل:
  تلاشِ 1: RateLimitError — 0.5 ثانیه صبر
  تلاشِ 2: ServerError — 1.0 ثانیه صبر
  گرفتیم.

دو بار شکست، بارِ سوم گرفت. و مهم‌تر از خودِ تکرار، فاصلهٔ بینِ تلاش‌هاست: نیم ثانیه، یک ثانیه، دو ثانیه — هر بار دو برابر. اسمش exponential backoff است.

چرا دو برابر و نه ثابت؟ چون خطای ۴۲۹ یعنی سرویس شلوغ است. اگر همه بلافاصله دوباره بزنند، شلوغی بیشتر می‌شود — و این دقیقاً همان چیزی است که یک اتفاقِ کوچک را به یک قطعیِ بزرگ تبدیل می‌کند. عقب‌نشینیِ نمایی به سرویس فرصتِ نفس کشیدن می‌دهد.

💡 نکته: در کارِ واقعی یک تکهٔ تصادفیِ کوچک هم به wait اضافه می‌کنند (jitter). دلیلش این است که اگر هزار برنامه هم‌زمان خطا بگیرند، همه دقیقاً نیم ثانیهٔ بعد با هم برمی‌گردند و موجِ دوم را می‌سازند. یک random.uniform(0, wait) این موج را پخش می‌کند.

۹. حالا اندازه بگیریم#

reset_calls()
ok = 0
for _ in range(20):
    try:
        with_retry(lambda: chat(messages))
        ok += 1
    except LLMError:
        pass
print("با with_retry:", ok, "از ۲۰ تماس به جواب رسید")
print("شمارِ کلِ تماس‌های واقعیِ زده‌شده:", _CALLS["n"])
FAILURES["rate"] = 0.0
با with_retry: 19 از ۲۰ تماس به جواب رسید
شمارِ کلِ تماس‌های واقعیِ زده‌شده: 40

۱۰ از ۲۰ شد ۱۹ از ۲۰ — با ده خط کد.

📏 اندازه بگیر: و بهایش را هم بخوان: ۴۰ تماسِ واقعی برای ۲۰ درخواست. یعنی هزینه و بارِ روی سرویس دو برابر شد. retry رایگان نیست؛ قابلیتِ اطمینان را با پول و تأخیر می‌خری. و آن یک موردی که با چهار تلاش هم نگرفت، یادآوریِ این است که همیشه باید یک مسیرِ شکستِ محترمانه داشته باشی — پیامی به کاربر، نه یک traceback.

۱۰. streaming: پاسخ تکه‌تکه#

def real_stream(messages, model=None, timeout=60):
    """همان تماس، ولی پاسخ تکه‌تکه می‌رسد. با کلیدِ خودت اجرایش کن."""
    import os
    import requests

    key = os.environ.get("GOOGLE_API_KEY")
    if not key:
        raise LLMError("کلید پیدا نشد: GOOGLE_API_KEY را در Colab Secrets بگذار.")
    url = GEMINI_URL.format(model=model or GEMINI_MODEL).replace(
        ":generateContent", ":streamGenerateContent") + "?alt=sse"
    payload = {"contents": [{"role": "user", "parts": [{"text": m["content"]}]}
                            for m in messages if m["role"] == "user"]}
    with requests.post(url, headers={"x-goog-api-key": key}, json=payload,
                       stream=True, timeout=timeout) as resp:
        resp.raise_for_status()
        for line in resp.iter_lines(decode_unicode=True):
            if line.startswith("data: "):
                chunk = json.loads(line[len("data: "):])
                for part in chunk["candidates"][0]["content"].get("parts", []):
                    yield part.get("text", "")


print("real_stream تعریف شد — با کلیدِ خودت این‌طور صدایش بزن:")
print('  for piece in real_stream(messages): print(piece, end="")')
real_stream تعریف شد — با کلیدِ خودت این‌طور صدایش بزن:
  for piece in real_stream(messages): print(piece, end="")

streaming یعنی به‌جای یک پاسخِ کامل، تکه‌تکه بگیری — همان چیزی که در رابط‌های گفت‌وگو می‌بینی و حروف یکی‌یکی ظاهر می‌شوند.

و دقیقاً بدان چه چیزی را عوض می‌کند و چه چیزی را نه:

  • تأخیرِ تا اولین نویسه به‌شدت کم می‌شود. کاربر زودتر چیزی می‌بیند.
  • زمانِ کلِ تولید تقریباً همان است. سریع‌تر نشد، فقط زودتر شروع شد.
  • هزینه مو نمی‌زند. همان توکن‌ها، همان پول.
  • و کارِ تو سخت‌تر می‌شود: تا تکهٔ آخر نرسیده، خروجیِ کامل را نداری. پس هر جا باید JSON را اعتبارسنجی کنی یا ابزاری را صدا بزنی، streaming به دردت نمی‌خورد — اول همه‌اش را بگیر.

واژه‌های تازهٔ این فصل#

کلمه تلفظ به حروف فارسی یعنی چه
role رول نقشِ هر پیام: system، user یا assistant
temperature تمپرچر میزانِ جسارتِ مدل در انتخابِ توکنِ بعدی
retry ری‌ترای تلاشِ دوباره بعد از یک خطای گذرا
exponential backoff اکسپوننشال بک‌آف دو برابر شدنِ زمانِ صبر در هر تلاش
jitter جیتر تصادفی‌سازیِ کوچکِ زمانِ صبر تا همه با هم برنگردند
rate limit ریت لیمیت سقفِ تعدادِ درخواست در واحدِ زمان
streaming استریمینگ گرفتنِ پاسخ به‌صورتِ تکه‌تکه

🤖 از دستیارت بپرس: «تفاوتِ 429 با 529 چیست و برای هر کدام چه رفتاری درست است؟» بعد این را بپرس: «اگر سرویس در هدرِ پاسخ Retry-After بفرستد، backoff نمایی را باید کنار بگذارم؟» — جوابِ درست «بله» است و دلیلش این است که سرویس بهتر از تو می‌داند کِی آماده می‌شود.

تمرین‌ها

اول خودت فکر کن یا امتحان کن — بعد اینجا را باز کن.

در فصل بعد#

تا اینجا خروجی متن بود و خودت خواندی‌اش. ولی برنامه‌ات نمی‌تواند متن بخواند — به dict نیاز دارد، با کلیدهای مشخص و مقدارهای از نوعِ درست. فصلِ بعد از مدل JSON می‌خواهد، می‌بیند که در ۱۶ بار از ۴۰ بار چیزی می‌دهد که json.loads رویش می‌شکند — و یک حلقهٔ تصحیح می‌سازد که این عدد را به صفر می‌رساند.

به آخر این فصل رسیدی!

اگر ساختی و جواب داد، این دکمه مال توست.