הגשת LLM בייצור
vLLM, ניהול זיכרון GPU וזיהוי סחיפה
מודל שעובד יפה במחברת נופל תחת עשרים בקשות במקביל. המדריך הזה עוסק במה שקורה אחרי הפריסה: איך vLLM מנצל את ה-GPU פי כמה מהרצה נאיבית, למה OOM מגיע דווקא בשעת עומס, ואיך מזהים שהמערכת התחילה לקבל קלט שונה ממה שנבדק.
למה הרצה נאיבית מתמוטטת
הקוד המוכר — model.generate() בלולאה — מבזבז את רוב ה-GPU. שתי סיבות:
- אצווה סטטית — אוספים בקשות, מריצים יחד, ומחכים לארוכה ביותר. בקשה שסיימה אחרי 10 טוקנים תופסת מקום עד שהשכנה מסיימת 500.
- הקצאה לפי המקסימום — מקצים זיכרון KV לפי אורך ההקשר המרבי לכל בקשה, גם כשבפועל היא צורכת חמישית מזה. רוב הזיכרון מוקצה ואינו בשימוש.
vLLM פותר את שניהם: continuous batching מכניס בקשה חדשה ברגע שאחרת סיימה, ו-PagedAttention מקצה KV בעמודים קטנים לפי צורך — בדיוק כמו זיכרון וירטואלי במערכת הפעלה. התוצאה בעומס היא בדרך כלל שיפור של פי כמה ב-throughput על אותה חומרה.
הבנת זיכרון ה-GPU
זיכרון ה-GPU מתחלק לשלושה, ורק אחד מהם משתנה בזמן ריצה:
משקלי המודל קבוע 8B ב-FP16 ≈ 16GB, ב-AWQ/INT4 ≈ 5GB
KV cache משתנה גדל עם (בקשות במקביל × אורך הקשר)
הפעלות קטן חוצץ זמני לחישוב
זיכרון KV לכל טוקן ≈ 2 × שכבות × ראשי_KV × מימד × 2 בתים
Llama 3.1 8B: ~128KB לכל טוקן
→ 4,096 טוקנים ≈ 0.5GB לבקשה
→ 32 בקשות במקביל ≈ 16GB רק ל-KV
כאן מסתתר ה-OOM. המשקלים קבועים, אז בבדיקה הכול נראה תקין. ה-KV גדל ליניארית במקביליות ובאורך ההקשר — ולכן הקריסה מגיעה תמיד בשעת העומס, ולא בבדיקות. אם אתה מגדיר max_model_len גבוה "ליתר ביטחון", אתה קובע תקרה שתפגוש אותך בייצור.
vLLM — הפעלה נכונה
pip install vllm
python -m vllm.entrypoints.openai.api_server \
--model meta-llama/Llama-3.1-8B-Instruct \
--gpu-memory-utilization 0.90 \ # כמה מה-GPU להקצות
--max-model-len 8192 \ # תקרת הקשר — משפיע ישירות על KV
--max-num-seqs 64 \ # תקרת בקשות במקביל
--quantization awq \ # פחות זיכרון למשקלים = יותר ל-KV
--port 8000
ארבעת הפרמטרים האלה הם כמעט כל הכוונון. ההיגיון שמאחוריהם:
gpu-memory-utilization— 0.90 סביר. 0.95 ומעלה מסוכן: אין מרווח לקפיצות, וקריסה בייצור עולה יותר ממה שהרווחת.max-model-len— קבע לפי השימוש האמיתי, לא לפי יכולת המודל. הורדה מ-32K ל-8K משחררת זיכרון KV שמתורגם ישירות ליותר בקשות במקביל.max-num-seqs— מגביל מקביליות. עדיף שבקשה תמתין בתור מאשר שהשרת ייפול.quantization— AWQ או GPTQ מקטינים משקלים ומשחררים מקום ל-KV. ראה מדריך הרצה מקומית.
הממשק תואם OpenAI, כך שהמעבר מקוד קיים הוא שינוי כתובת:
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="none")
resp = client.chat.completions.create(
model="meta-llama/Llama-3.1-8B-Instruct",
messages=[{"role": "user", "content": "סכם את הדוח"}],
max_tokens=512,
)
מה למדוד בהגשה
ארבעה מדדים, וכל אחד עונה על שאלה אחרת:
TTFT Time To First Token מה שהמשתמש חווה כ"מהירות". קריטי בסטרימינג.
TPOT Time Per Output Token קצב הזרימה אחרי ההתחלה.
Throughput טוקנים/שנייה יעילות כלכלית. זה מה ש-vLLM משפר.
Queue depth בקשות ממתינות מדד ההתראה. עלייה מתמדת = חסר קיבולת.
vLLM חושף מטריקות Prometheus מובנות:
curl http://localhost:8000/metrics | grep -E "vllm:(num_requests|gpu_cache)"
# vllm:num_requests_running בעיבוד כרגע
# vllm:num_requests_waiting בתור ← זה מה שמתריעים עליו
# vllm:gpu_cache_usage_perc תפוסת KV ← מתקרב ל-1.0 = לפני OOM
ההתראה החשובה ביותר היא gpu_cache_usage_perc. כשהיא מתקרבת ל-1.0 באופן מתמשך, vLLM מתחיל להעדיף בקשות ולדחות אחרות — ה-latency מזנק לפני שמשהו קורס. התראה על 0.85 נותנת לך זמן לפעול; התראה על קריסה מגיעה מאוחר מדי.
סחיפה — הכישלון שאף אחד לא מדווח עליו
שרת שנופל מייצר התראה. מודל שהתחיל לענות פחות טוב פשוט ממשיך לעבוד, והמשתמשים נוטשים בשקט. שלושה סוגי סחיפה:
- סחיפת קלט — המשתמשים שואלים דברים אחרים. השקת פיצ'ר, עונתיות, קהל חדש.
- סחיפת פלט — התשובות משתנות באורך, בטון או בשיעור הסירובים. לרוב אחרי שינוי פרומפט או גרסת מודל.
- סחיפת מושג — העולם השתנה והידע במודל או ב-RAG התיישן. תשובות נכונות מבחינה צורנית ושגויות לגופן.
ניטור מעשי מבוסס embeddings: משווים את התפלגות הקלט של היום מול קו בסיס, בלי צורך בתוויות אנושיות.
import numpy as np
from scipy.stats import ks_2samp
class DriftMonitor:
"""משווה התפלגות של קלט נכנס מול קו בסיס מתקופה תקינה."""
def __init__(self, baseline_embeddings: np.ndarray):
self.baseline = baseline_embeddings
self.centroid = baseline_embeddings.mean(axis=0)
# פיזור סביב המרכז — ההתפלגות שאנחנו משווים אליה
self.base_dists = np.linalg.norm(baseline_embeddings - self.centroid, axis=1)
def check(self, recent: np.ndarray, alpha: float = 0.01) -> dict:
dists = np.linalg.norm(recent - self.centroid, axis=1)
stat, p = ks_2samp(self.base_dists, dists) # מבחן קולמוגורוב-סמירנוב
# שיעור הבקשות שרחוקות מכל מה שראינו בבסיס
threshold = np.percentile(self.base_dists, 99)
outliers = float((dists > threshold).mean())
return {
"drift_detected": bool(p < alpha),
"p_value": float(p),
"outlier_rate": outliers, # בבסיס ≈ 1% מעצם ההגדרה
"mean_shift": float(dists.mean() - self.base_dists.mean()),
}
ולצד הקלט — מדדי פלט זולים לחישוב, שתופסים רגרסיות מהר:
from collections import deque
class OutputMonitor:
def __init__(self, window: int = 1000):
self.lengths = deque(maxlen=window)
self.refusals = deque(maxlen=window)
self.latency = deque(maxlen=window)
def record(self, text: str, ms: float):
self.lengths.append(len(text))
self.refusals.append(any(p in text for p in
("אני לא יכול", "לא אוכל לסייע", "I cannot", "I'm unable")))
self.latency.append(ms)
def snapshot(self) -> dict:
n = len(self.lengths) or 1
return {
"mean_length": sum(self.lengths) / n,
"refusal_rate": sum(self.refusals) / n, # קפיצה = פרומפט הודק מדי
"p95_latency": sorted(self.latency)[int(n * 0.95) - 1] if self.latency else 0,
}
קפיצה ב-refusal_rate היא הסימן המוקדם השימושי ביותר: היא כמעט תמיד אומרת ששינוי פרומפט או guardrail חוסם עכשיו גם בקשות לגיטימיות. זהו בדיוק ה-FRR ממדריך Red Teaming, נמדד בייצור במקום בבדיקה.
פריסה בטוחה
שינוי פרומפט הוא שינוי התנהגותי, ומגיע לו אותו יחס ששינוי קוד מקבל. פריסה הדרגתית עם שער אוטומטי:
async def route(request, canary_pct: int = 5):
"""אחוז קטן לגרסה החדשה; שאר התעבורה יציבה."""
use_canary = hash(request.session_id) % 100 < canary_pct
version = "candidate" if use_canary else "stable"
result = await generate(request, prompt_version=version)
metrics.record(version, result) # נמדדים בנפרד
return result
def promote_or_rollback(m):
"""שער אוטומטי. אין החלטה ידנית תחת לחץ."""
if m["candidate"]["refusal_rate"] > m["stable"]["refusal_rate"] * 1.5:
return "rollback" # חוסם משתמשים לגיטימיים
if m["candidate"]["p95_latency"] > m["stable"]["p95_latency"] * 1.3:
return "rollback"
if m["candidate"]["samples"] < 500:
return "wait" # עוד אין מובהקות
return "promote"
הפחתת עלות בלי לפגוע באיכות
- ניתוב לפי מורכבות — רוב הבקשות אינן דורשות את המודל הגדול. סיווג זול מראש שולח את הפשוטות למודל קטן.
- Prefix caching — כשה-system prompt זהה בכל הבקשות, vLLM יכול לשמור את חישובו. הפעלה עם
--enable-prefix-caching. - הגבלת
max_tokens— לא לפי המקסימום האפשרי אלא לפי מה שהתשובה באמת צריכה. זה משפיע גם על תפוסת ה-KV. - אצווה למה שאינו אינטראקטיבי — עיבוד לילי בסבלנות גבוהה מנצל את ה-GPU הרבה יותר טוב.
להעמקה בצד הכלכלי ראה מדריך עלויות LLM וCaching.
סיכום מעשי
- KV cache הוא מה שקובע כמה בקשות אתה מסוגל לשרת. הוא, לא המשקלים.
max-model-lenלפי השימוש האמיתי — זה הכוונון בעל ההשפעה הגדולה ביותר.- התרע על
gpu_cache_usage_percועל עומק התור, לא על קריסות. - נטר סחיפת קלט וגם פלט; refusal rate תופס רגרסיות מוקדם.
- פרוס שינויי פרומפט כ-canary עם שער אוטומטי, כמו כל שינוי קוד.
המשך ב-MLOps
הגשה היא שכבה אחת. סביבה בוגרת מוסיפה עליה tracing, evals וניהול גרסאות.