داربست — خودآموز مهندسیِ سامانه‌های هوش مصنوعی (پیشرفته)

فصل ۱ از ۹

پیشرفت ترم
۰٪

ترم ۴ · استقرار

بسته‌بندی برای اجرا

فصل ۱پیش‌نمایش رایگان
۲۰ دقیقه مطالعه فصل ۱

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

یک مدلِ کاملاً سالم را ذخیره می‌کنیم و در یک مفسرِ تازه بازش می‌کنیم. جوابی که می‌گیریم این است: AttributeError: Can't get attribute 'clean'. مدل خراب نیست — فایل فقط نصفِ چیزی است که مدل بود.

بعد همان مدل را به‌شکلِ یک artifact می‌بندیم: کد، وزن‌ها و پیکربندی در سه فایلِ جدا، با یک MANIFEST که اثرِ انگشتِ هر سه را دارد. آن‌وقت مفسرِ تازه هر ۵۰۰ پیش‌بینی را مو به مو تکرار می‌کند، و عوض کردنِ یک عدد در پیکربندی صفر بایت از وزن‌ها را دست نمی‌زند.

جعبه‌ای با سه محفظهٔ جدا و یک برگهٔ فهرست روی درش

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

  • بگویی چرا ذخیرهٔ مدل با pickle به‌تنهایی یک تحویلِ ناقص است
  • کد، وزن و پیکربندی را از هم جدا کنی و هر سه را نسخه بدهی
  • MANIFEST بنویسی که بگوید کدام تغییر کدام فایل را عوض کرده
  • بازتولیدپذیریِ یک artifact را در مفسرِ تازه اثبات کنی، نه ادعا
  • Dockerfile و فایلِ قفل را به‌عنوان artifact بنویسی و بدونِ اجرا بسنجی‌شان

قبل از شروع#

از ترمِ ۱: بسته‌ای که ماژول و تست و README تست‌شونده دارد. از ترمِ ۲: اثرِ انگشتِ محتوا و نرمال‌سازیِ متنِ فارسی. از ترمِ ۳: رجیستریِ مدل و پیوندِ مدل به داده و کد.

و دو چیزِ مشخص که ترمِ ۳ تحویلمان داد و از همین فصل به‌کار می‌آیند. اولی artifact_sha است — اثرِ انگشتِ بایت‌های مدل — که بخشِ ۳ آن را داخلِ MANIFEST کنارِ کد و پیکربندی می‌گذارد. دومی تصمیم‌نامهٔ فصلِ ۸ ترمِ ۳ است، و «شرطِ بازنگری»اش تنها چیزی است که می‌گوید این بسته اصلاً حق دارد مستقر شود یا نه. بسته‌ای که نداند از کدام تصمیم آمده، برگشت هم ندارد.

از ژرفا ترمِ ۶ فصلِ ۶: یک سرویسِ HTTP با /healthz و batching. آن فصل نشان داد چطور مدل را سرو کنی؛ این ترم از جایی شروع می‌شود که آن فصل تمام کرد — چطور همان سرویس را ماه‌ها زنده و قابلِ برگشت نگه داری.

💡 سلولِ راه‌اندازیِ ترم چه چیزی به‌ت می‌دهد: پیکرهٔ ترم ۱۶۰۰ تیکتِ پشتیبانیِ فارسی است در TRAIN (۱۱۰۰ ردیف) و TEST (۵۰۰ ردیف)؛ هر ردیف یک dict با کلیدهای ticket_id، customer_id، channel، text و category. normalise(text) «ي» و «ك» عربی را فارسی می‌کند و فاصله‌ها را جمع. fit_model(rows, tfidf=False, ngram=(1,1), C=1.0) یک دسته‌بندِ آموزش‌دیده برمی‌گرداند — موضوعِ درس نیست، فقط باید کار کند — و متن را خودش نرمال می‌کند، پس هر کسی که سرو می‌کند هم باید همین کار را بکند. WORK هم پوشهٔ کارِ ترم روی دیسک است که با هر اجرا از نو ساخته می‌شود. هر نه فصلِ این ترم روی همین‌ها سوارند و هیچ‌کدام دوباره تعریف نمی‌شوند، پس عددهایت با کتاب و با فصل‌های بعدی قابلِ مقایسه است.

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

اجرا زمانِ تقریبی
کلِ نوت‌بوک روی CPU حدودِ یک دقیقه
GPU لازم نیست

۱. مدلی که فقط در همین نوت‌بوک زنده است#

نرمال‌سازیِ متن را — همان «ي» و «ك» عربیِ ترمِ ۲ — به‌عنوان یک مرحله داخلِ خودِ مدل می‌گذاریم. این کارِ تمیزی به‌نظر می‌رسد: مدل خودش ورودی را آماده می‌کند و هیچ‌کس نمی‌تواند فراموشش کند.

import pickle
import subprocess
import sys

from sklearn.feature_extraction.text import CountVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import FunctionTransformer


def clean(texts):
    """همان نرمال‌سازیِ ترمِ ۲، این بار به‌عنوان یک مرحله داخلِ خودِ مدل."""
    return [normalise(t) for t in texts]


naive = make_pipeline(FunctionTransformer(clean), CountVectorizer(),
                      LogisticRegression(max_iter=1000))
naive.fit([r["text"] for r in TRAIN[:800]], [r["category"] for r in TRAIN[:800]])
(WORK / "naive.pkl").write_bytes(pickle.dumps(naive))

print("در همین نوت‌بوک:", naive.predict(["رمز عبورم را فراموش کردم"])[0])
print("فایل نوشته شد:", (WORK / "naive.pkl").exists())
در همین نوت‌بوک: حساب کاربری
فایل نوشته شد: True

مدل کار می‌کند، فایل نوشته شد، هیچ خطایی هم نیامد. حالا همان فایل را در یک مفسرِ تازه باز کنیم — دقیقاً همان کاری که سرورِ تولید فردا می‌کند.

LOADER = r'''
import pickle, sys
from pathlib import Path

try:
    model = pickle.loads(Path(sys.argv[1]).read_bytes())
    print("بارگذاری شد:", model.predict(["رمز عبورم را فراموش کردم"])[0])
except Exception as exc:
    print(f"{type(exc).__name__}: {str(exc).split(' on ')[0]}")
'''
(WORK / "loader.py").write_text(LOADER, encoding="utf-8")

done = subprocess.run([sys.executable, str(WORK / "loader.py"), str(WORK / "naive.pkl")],
                      capture_output=True, text=True, encoding="utf-8")
print("مفسرِ تازه →", done.stdout.strip())
مفسرِ تازه → AttributeError: Can't get attribute 'clean'

فایل سالم است. مدل سالم است. چیزی که نیست، clean است.

pickle تابع را ذخیره نمی‌کند؛ فقط نامش و آدرسش را ذخیره می‌کند: «تابعی به نامِ clean در ماژولِ __main__». در نوت‌بوکِ تو آن آدرس معنا دارد، در مفسرِ تازه نه.

و این یک باگِ pickle نیست، یک قاعدهٔ کلی است: هر چیزی که کد است باید به‌شکلِ کد تحویل داده شود. فایلِ مدل فقط عددهاست.

🔧 اگر کار نکرد: پیامِ کاملی که پایتون می‌دهد این است: AttributeError: Can't get attribute 'clean' on <module '__main__' (built-in)>. ما فقط تکهٔ اولش را چاپ کردیم چون آن پرانتزِ آخر روی هر محیطی فرق دارد. اگر پیامِ تو ModuleNotFoundError: No module named 'sklearn' بود، مسئله چیزِ دیگری است: مفسرِ زیرفرآیند همان مفسرِ نوت‌بوک نیست — همیشه sys.executable را صدا بزن، نه رشتهٔ "python".

۲. سه چیزی که همیشه با هم قاطی می‌شوند#

یک مدلِ مستقرشده از سه جنسِ کاملاً متفاوت ساخته شده:

  • ۱) کد — نرمال‌سازی، منطقِ آستانه، شکلِ خروجی. با git نسخه می‌خورد و باید خوانده شود.
  • ۲) وزن‌ها — نتیجهٔ آموزش. بزرگ، دودویی، و به git نمی‌رود (ترمِ ۲ فصلِ ۵).
  • ۳) پیکربندی — آستانه، نامِ برچسبِ پشتیبان، سقفِ طولِ ورودی. کوچک، متنی، و بدونِ آموزشِ دوباره عوض می‌شود.

artifact چیزی جز «این سه تا، کنارِ هم، با شماره» نیست.

import hashlib
import json
import platform

import sklearn

ART = WORK / "artifact-1.0.0"
ART.mkdir()

model = fit_model(TRAIN[:800])                       # همان مدل، بدونِ مرحلهٔ کدیِ داخلش
(ART / "model.pkl").write_bytes(pickle.dumps(model))

(ART / "config.json").write_text(json.dumps({
    "version": "1.0.0",
    "normalise": True,
    "min_confidence": 0.0,
    "fallback_label": "نامشخص",
}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")

(ART / "serve.py").write_text(r'''import json
import pickle
import sys
from pathlib import Path

HERE = Path(__file__).resolve().parent
CONFIG = json.loads((HERE / "config.json").read_text(encoding="utf-8"))


def normalise(text):
    return " ".join(text.replace("ي", "ی").replace("ك", "ک").split())


def load():
    return pickle.loads((HERE / "model.pkl").read_bytes())


def predict(model, texts):
    if CONFIG["normalise"]:
        texts = [normalise(t) for t in texts]
    proba = model.predict_proba(texts)
    labels = list(model.classes_)
    out = []
    for row in proba:
        best = int(row.argmax())
        out.append(labels[best] if row[best] >= CONFIG["min_confidence"]
                   else CONFIG["fallback_label"])
    return out


if __name__ == "__main__":
    texts = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
    answers = predict(load(), texts)
    Path(sys.argv[2]).write_text(json.dumps(answers, ensure_ascii=False), encoding="utf-8")
    print(f"نسخه={CONFIG['version']} تعداد={len(answers)}")
''', encoding="utf-8")

print(sorted(p.name for p in ART.iterdir()))
['config.json', 'model.pkl', 'serve.py']

به serve.py دقت کن: normalise این بار داخلِ فایلِ کد است، نه داخلِ pickle. همان تابع، همان رفتار — ولی حالا چیزی است که مفسرِ تازه می‌تواند بخواند.

و config.json سه چیزِ متفاوت را نگه می‌دارد: normalise یک قرارداد است (مدل با متنِ نرمال‌شده آموزش دیده، پس سرو هم باید نرمال کند)، min_confidence یک اهرم است، و version هویتِ بسته.

۳. MANIFEST — اثرِ انگشتِ هر چیزی که عوض می‌شود#

def sha(path):
    return hashlib.sha256(path.read_bytes()).hexdigest()


def write_manifest():
    files = sorted(p.name for p in ART.iterdir()
                   if p.is_file() and p.name != "MANIFEST.json")
    manifest = {
        "version": json.loads((ART / "config.json").read_text(encoding="utf-8"))["version"],
        "files": {name: sha(ART / name)[:12] for name in files},
        "built_with": {"python": platform.python_version(),
                       "scikit-learn": sklearn.__version__},
    }
    (ART / "MANIFEST.json").write_text(
        json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    return manifest


first = write_manifest()
print("نسخه       :", first["version"])
print("فایل‌ها     :", sorted(first["files"]))
print("هشِ serve.py:", first["files"]["serve.py"])
نسخه       : 1.0.0
فایل‌ها     : ['config.json', 'model.pkl', 'serve.py']
هشِ serve.py: f14e6250ae23

سه هش، سه فایل. هشِ model.pkl را عمداً چاپ نکردیم، چون به نسخهٔ کتابخانه‌ای که مدل را ساخته بستگی دارد و روی ماشینِ تو عددِ دیگری است. serve.py یک فایلِ متنی است و همه‌جا همان هش را می‌دهد.

و همان هشِ چاپ‌نشده، دقیقاً artifact_shaی رجیستریِ ترمِ ۳ فصلِ ۷ است. آنجا نامِ فایل در انبار بود؛ اینجا یک سطر از MANIFEST. یک عدد، دو جا — و همین است که اجازه می‌دهد بگویی بسته‌ای که روی سرور نشسته، همان نسخه‌ای است که در رجیستری production علامت خورده.

سؤالی که MANIFEST جوابش را می‌دهد این نیست که «هش چند است»؛ این است که «چه چیزی عوض شد».

۴. آزمونِ واقعی: مفسرِ تازه، همان ۵۰۰ جواب#

texts = [r["text"] for r in TEST]
truth = [r["category"] for r in TEST]
(WORK / "in.json").write_text(json.dumps(texts, ensure_ascii=False), encoding="utf-8")


def call_artifact():
    """artifact را از بیرون صدا می‌زند — همان‌طور که سرورِ تولید صدایش می‌زند."""
    done = subprocess.run([sys.executable, str(ART / "serve.py"),
                           str(WORK / "in.json"), str(WORK / "out.json")],
                          capture_output=True, text=True, encoding="utf-8")
    return done.stdout.strip(), json.loads((WORK / "out.json").read_text(encoding="utf-8"))


def accuracy(answers):
    return sum(a == t for a, t in zip(answers, truth)) / len(truth)


here = list(model.predict([normalise(t) for t in texts]))
banner, there = call_artifact()
print("خروجیِ مفسرِ تازه:", banner)
print(f"دقت در نوت‌بوک  : {accuracy(here):.4f}")
print(f"دقت از artifact : {accuracy(there):.4f}")
print("۵۰۰ پیش‌بینی مو به مو یکی؟", here == there)
خروجیِ مفسرِ تازه: نسخه=1.0.0 تعداد=500
دقت در نوت‌بوک  : 0.8660
دقت از artifact : 0.8660
۵۰۰ پیش‌بینی مو به مو یکی؟ True

همان بسته، دو مفسرِ متفاوت، پانصد جوابِ یکسان. این همان چیزی است که بخشِ ۱ نداشت.

چک کن: اگر «مو به مو یکی» برایت False شد، همان لحظه بایست و دنبالِ چیزی بگرد که فقط در حافظهٔ نوت‌بوک زندگی می‌کند. رایج‌ترین سه مورد: یک seed که در سلولِ بالاتر نشسته، یک متغیرِ سراسری که serve.py از آن بی‌خبر است، و ترتیبِ ورودی‌ها. این تفاوت هیچ‌وقت خودبه‌خود ناپدید نمی‌شود؛ فقط تا روزِ بدی صبر می‌کند.

۵. پیکربندی یک اهرم است، نه بخشی از وزن‌ها#

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

def rebuild_config(**changes):
    cfg = json.loads((ART / "config.json").read_text(encoding="utf-8"))
    cfg.update(changes)
    (ART / "config.json").write_text(
        json.dumps(cfg, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    return write_manifest()


def report(threshold):
    _, answers = call_artifact()
    unknown = sum(a == "نامشخص" for a in answers)
    answered = [(a, t) for a, t in zip(answers, truth) if a != "نامشخص"]
    hit = sum(a == t for a, t in answered) / len(answered)
    print(f"min_confidence={threshold:.2f} · نامشخص {unknown:>3}"
          f" · دقت روی جواب‌داده‌ها {hit:.4f}")


report(0.0)
after = rebuild_config(min_confidence=0.80)
report(0.80)
print("وزن‌ها عوض شد؟   ", first["files"]["model.pkl"] != after["files"]["model.pkl"])
print("پیکربندی عوض شد؟", first["files"]["config.json"] != after["files"]["config.json"])
print("کد عوض شد؟      ", first["files"]["serve.py"] != after["files"]["serve.py"])
min_confidence=0.00 · نامشخص   0 · دقت روی جواب‌داده‌ها 0.8660
min_confidence=0.80 · نامشخص  92 · دقت روی جواب‌داده‌ها 0.9485
وزن‌ها عوض شد؟    False
پیکربندی عوض شد؟ True
کد عوض شد؟       False

۹۲ تیکت به آدم رفت و دقت روی بقیه از ۰٫۸۶۶۰ به ۰٫۹۴۸۵ رسید — بدونِ یک بایت تغییر در وزن‌ها.

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

📏 اندازه بگیر: با چه چیزی مقایسه شد؟ با همان artifact و همان وزن‌ها، فقط با یک عددِ متفاوت در config.json. روی کدام داده؟ هر ۵۰۰ ردیفِ آزمون، در هر دو حالت. با چند seed؟ یکی — و اینجا کافی است، چون ادعا «بهتر شد» نیست؛ ادعا این است که همان مدل با آستانهٔ متفاوت این دو عدد را می‌دهد، و آن یک محاسبهٔ قطعی روی خروجیِ ثابتِ مدل است، نه یک آزمایشِ تصادفی.

۶. سنجاقِ نسخه — و هشداری که کسی نادیده‌اش نگیرد#

MANIFEST نسخهٔ محیطِ ساخت را ثبت کرده. حالا بگذار بارگذاری خودش بررسی کند.

def compatibility(manifest):
    live = {"python": platform.python_version(), "scikit-learn": sklearn.__version__}
    return sorted(k for k, v in manifest["built_with"].items() if live[k] != v)


print("با همان محیط    :", compatibility(after))
faked = json.loads(json.dumps(after))
faked["built_with"]["scikit-learn"] = "0.0.0"
print("با محیطِ متفاوت  :", compatibility(faked))
با همان محیط    : []
با محیطِ متفاوت  : ['scikit-learn']

یک فهرستِ خالی یعنی «همان محیط». فهرستِ ناخالی یعنی بایست و تصمیم بگیر — نه اینکه لود کن و امیدوار باش.

چرا هشدار و نه خطا؟ چون گاهی واقعاً باید با نسخهٔ تازه‌تر اجرا کنی و راهی جز آن نداری. ولی تفاوت باید در سیاهه دیده شود، وگرنه سه ماه بعد که جوابی عجیب می‌شود، هیچ‌کس نمی‌داند محیط عوض شده بود.

import importlib.metadata

PINNED = ["scikit-learn", "numpy", "scipy"]
lines = [f"{name}=={importlib.metadata.version(name)}" for name in PINNED]
(ART / "requirements.txt").write_text("\n".join(lines) + "\n", encoding="utf-8")
print((ART / "requirements.txt").read_text(encoding="utf-8").strip())
scikit-learn==1.7.2
numpy==2.4.6
scipy==1.17.1

نسخه‌ها را از محیطِ زنده می‌خوانیم، نه از حافظه — همان قاعدهٔ ترمِ ۱ فصلِ ۹. عددهای بالا روی ماشینِ تو فرق می‌کنند و نباید با کتاب یکی باشند.

۷. چه چیزی داخلِ artifact نیست#

داده. یک artifact هرگز دادهٔ آموزش را با خودش حمل نمی‌کند — بزرگ است، خصوصی است، و نسخه‌بندی‌اش کارِ ترمِ ۲ بود. آنچه حمل می‌شود اثرِ انگشتِ داده است: یک رشته که می‌گوید «این وزن‌ها از آن نسخهٔ داده آمده‌اند».

سؤال هنگامِ استقرار کجای artifact جوابش است
این کدام مدل است؟ version در config.json و MANIFEST
چطور صدایش بزنم؟ serve.py — کد، نه توضیح
با چه محیطی ساخته شده؟ built_with + requirements.txt
چه چیزی از دیروز عوض شده؟ مقایسهٔ هشِ MANIFEST
آستانه را کجا عوض کنم؟ config.json، بدونِ آموزشِ دوباره
از کدام داده آمده؟ اثرِ انگشتِ داده (ترمِ ۲ فصلِ ۵)

۸. چیزی که requirements.txt نمی‌دهد#

آن فایل سه خط دارد و سه چیز را نمی‌گوید: با کدام نسخهٔ پایتون، روی کدام سیستم‌عامل، و با کدام وابستگیِ وابستگی‌ها. pip install -r requirements.txt امروز و شش ماهِ دیگر دو محیطِ متفاوت می‌سازد، چون هر بستهٔ غیرسنجاق‌شده‌ای که زیرِ این سه‌تا بنشیند آزاد است تازه‌تر شود.

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

import re


def closure(names):
    """کلِ درختِ وابستگی: چیزی که واقعاً نصب شده، نه چیزی که خواسته‌ای."""
    seen, stack = {}, list(names)
    while stack:
        name = stack.pop()
        key = name.lower().replace("_", "-")
        if key in seen:
            continue
        try:
            seen[key] = importlib.metadata.version(name)
        except importlib.metadata.PackageNotFoundError:
            continue
        for req in importlib.metadata.requires(name) or []:
            if "extra ==" in req:                      # وابستگیِ اختیاری نصب نشده
                continue
            stack.append(re.split(r"[<>=!;\[ ]", req, maxsplit=1)[0])
    return dict(sorted(seen.items()))


LOCK = closure(PINNED)
(ART / "requirements.lock").write_text(
    "".join(f"{name}=={version}\n" for name, version in LOCK.items()),
    encoding="utf-8")
DOCKERFILE = "\n".join([
    f"FROM python:{platform.python_version()}-slim",   # نسخه از محیطِ زنده، نه دستی
    "WORKDIR /app",
    "COPY requirements.lock .",
    "RUN pip install --no-deps -r requirements.lock",
    "COPY . /app/artifact",
    'CMD ["python", "/app/artifact/serve.py"]',
]) + "\n"
(ART / "Dockerfile").write_text(DOCKERFILE, encoding="utf-8")
print("مستقیم خواسته‌ایم:", len(PINNED), "بسته · قفل شد:", len(LOCK), "بسته")
print(DOCKERFILE.rstrip())
مستقیم خواسته‌ایم: 3 بسته · قفل شد: 5 بسته
FROM python:3.13.2-slim
WORKDIR /app
COPY requirements.lock .
RUN pip install --no-deps -r requirements.lock
COPY . /app/artifact
CMD ["python", "/app/artifact/serve.py"]

سه بسته خواستیم و پنج‌تا قفل شد — و همان دوتای اضافه‌اند که یک روز بی‌خبر عوض می‌شوند و جوابِ مدل را جابه‌جا می‌کنند. عددها روی ماشینِ تو فرق می‌کنند و نباید با کتاب یکی باشند؛ چیزی که همه‌جا برقرار است بزرگ‌تر بودنِ قفل از خواسته است.

و حالا صادق‌ترین بخشِ این فصل: نمی‌توانیم این Dockerfile را اجرا کنیم. ساختِ image به یک daemonِ Docker نیاز دارد که در runtimeِ Colab وجود ندارد. پس کاری می‌کنیم که همیشه شدنی است: خودِ فایل را به‌عنوان artifact می‌سنجیم — دقیقاً همان کاری که یک دروازهٔ بیلد قبل از هر docker build انجام می‌دهد.

def audit_runtime(art):
    docker = (art / "Dockerfile").read_text(encoding="utf-8").splitlines()
    lock = (art / "requirements.lock").read_text(encoding="utf-8").split()
    direct = {line.split("==")[0]
              for line in (art / "requirements.txt").read_text(encoding="utf-8").split()}
    base = next(line for line in docker if line.startswith("FROM "))
    return [
        ("نسخهٔ پایه latest نیست", ":latest" not in base and ":" in base),
        ("نسخهٔ پایه با digest سنجاق شده", "@sha256:" in base),
        ("نصب از فایلِ قفل است، نه requirements", "requirements.lock" in " ".join(docker)),
        ("وابستگیِ ناخواسته اضافه نمی‌شود", "--no-deps" in " ".join(docker)),
        ("هر خطِ قفل نسخهٔ دقیق دارد", all("==" in line for line in lock)),
        ("هر بستهٔ مستقیم در قفل هست", direct <= {line.split("==")[0] for line in lock}),
    ]


rows = audit_runtime(ART)
for name, passed in rows:
    print(f"  {'سبز ' if passed else 'قرمز'} {name}")
print(f"{sum(p for _, p in rows)} از {len(rows)} بند سبز")
  سبز  نسخهٔ پایه latest نیست
  قرمز نسخهٔ پایه با digest سنجاق شده
  سبز  نصب از فایلِ قفل است، نه requirements
  سبز  وابستگیِ ناخواسته اضافه نمی‌شود
  سبز  هر خطِ قفل نسخهٔ دقیق دارد
  سبز  هر بستهٔ مستقیم در قفل هست
5 از 6 بند سبز

آن یک بندِ قرمز عمدی است و مهم‌ترین بندِ جدول. python:3.13-slim یک برچسب است و برچسب‌ها جابه‌جا می‌شوند: همان اسم، ماهِ بعد، یک image دیگر. سنجاقِ واقعی @sha256:... است — و آن digest را فقط از خروجیِ docker pull روی ماشینِ خودت می‌گیری، پس اینجا نمی‌شود ساختش. جدولی که بندِ قرمزِ خودش را نشان بدهد از جدولِ همه‌سبز مفیدتر است.

⚠️ مواظب باش: این بخش دو فایل نوشت و هیچ‌کدام را اجرا نکرد. یک Dockerfile که هرگز ساخته نشده باشد، دقیقاً همان‌قدر ادعا است که یک README تست‌نشدهٔ ترمِ ۱. این تنها جای دوره است که نمی‌شود ادعا را همان‌جا با عدد بست — و تمرینِ اجباریِ همین فصل کارش را به ماشینِ خودت می‌سپارد.

🤖 از دستیارت بپرس: «چرا فرمت‌هایی مثلِ ONNX و safetensors ساخته شدند و چه چیزی را از pickle می‌گیرند؟» جوابِ درست شاملِ اجرای کدِ دلخواه هنگامِ بارگذاری است — یک فایلِ pickle از یک منبعِ ناشناس دقیقاً به همان اندازهٔ یک اسکریپتِ ناشناس خطرناک است. بعد این را بپرس: «اگر artifact من فقط وزن باشد و کدش جای دیگری نسخه بخورد، چطور مطمئن شوم این دو با هم جفت‌اند؟» — جوابش همان MANIFEST است و فصلِ بعد رویش حساب می‌کند.

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

کلمه تلفظ به حروف فارسی یعنی چه
artifact آرتیفکت بستهٔ تحویلیِ یک مدل: کد، وزن، پیکربندی، با شماره
manifest مانیفست فهرستِ محتویاتِ بسته با اثرِ انگشتِ هر فایل
pinning پینینگ ثبتِ نسخهٔ دقیقِ وابستگی‌ها
serialization سریالایزیشن تبدیلِ یک شیء در حافظه به بایت‌های قابلِ ذخیره
runtime ران‌تایم محیطی که کد واقعاً در آن اجرا می‌شود
confidence threshold کانفیدنس ترشهولد کمینهٔ اطمینانی که زیرش سرویس جواب نمی‌دهد

تمرین‌ها

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

در فصل بعد#

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

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

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