অধ্যায় 4 · AI সার্ভিস ও নিরাপত্তা
একটা ML মডেল সার্ভ করা
- পৃষ্ঠা 14 / 22
- 20 মিনিট পড়া
নোটবুকে পড়ে থাকা মডেল কোনো প্রোডাক্ট নয়। ব্যবহারকারী, অন্য সার্ভিস আর আপনার নিজের ফ্রন্টএন্ড মডেলে পৌঁছাতে পারে শুধু একটা API দিয়ে, আর সেটা করলেই এমন কিছু প্রশ্ন সামনে আসে যা অ্যাকুরেসি স্কোর কখনো জিজ্ঞেস করেনি: মডেল প্রতি রিকোয়েস্টে না লোড করে একবার লোড করবেন কীভাবে? নেগেটিভ পাপড়ি-দৈর্ঘ্যের একটা রিকোয়েস্ট মডেল পর্যন্ত পৌঁছানো ঠেকাবে কে? এই উত্তরটা কোন ভার্সন দিল? মডেল ধীর হলে কী হবে, আর সেটা আপনি জানবেনই বা কীভাবে? এই পাতায় FastAPI আর scikit-learn দিয়ে একটা ছোট কিন্তু বাস্তবসম্মত প্রেডিকশন সার্ভিস বানানো হবে, যা এই সব প্রশ্নের উত্তর দেয়। এখানে ডিপেন্ডেন্সি, লাইফস্প্যান, মিডলওয়্যার ও সেটিংস পাতার lifespan, সেটিংস আর থ্রেড-পুলের ধারণাগুলো কাজে লাগবে।
মডেলটা ইচ্ছা করেই খুব ছোট (আইরিস ফুল: চারটা সংখ্যা ইনপুট, আউটপুটে একটা প্রজাতি), যাতে সবকিছু কয়েক সেকেন্ডে চালানো যায়। মডেলের চারপাশের সার্ভিসটা কিন্তু ফ্রড মডেল, রেকমেন্ডার বা টেক্সট ক্লাসিফায়ারের জন্যও হুবহু একই থাকে।
যা শিখবেন
- ট্রেনিং আর ইনফারেন্সের পার্থক্য, এবং কেন সার্ভিস শুধু দ্বিতীয়টা করে।
- প্রিপ্রসেসিংসহ মডেলের একটা পাইপলাইন ট্রেন করে
joblibদিয়ে ভার্সন করা আর্টিফ্যাক্ট হিসেবে সেভ করা, এবং ওয়ার্ম-আপসহlifespan-এ প্রতিটা ভার্সন একবার লোড করা। - Pydantic ফিচার ভ্যালিডেশনসহ প্রেডিকশন এন্ডপয়েন্ট, একক ও ব্যাচ রিকোয়েস্ট, এবং প্রতিটা রেসপন্সে মডেল ভার্সন।
- টাইমআউট, কনকারেন্সি লিমিট আর থ্রেডে সরিয়ে দেওয়া, যাতে ধীর মডেল কখনো ইভেন্ট লুপ জমিয়ে না দেয়; সঙ্গে CPU বনাম GPU-র নোট।
- প্রেডিকশন লগিং, লেটেন্সি মেট্রিক, এরর হ্যান্ডলিং, এবং ছোট মডেল দিয়ে টেস্ট।
ট্রেনিং বনাম ইনফারেন্স
| ট্রেনিং | ইনফারেন্স (সার্ভিং) | |
|---|---|---|
| কাজ | লেবেলসহ ডেটা থেকে প্যারামিটার শেখা | শেখা প্যারামিটার নতুন ইনপুটে প্রয়োগ করা |
| কখন চলে | মাঝে মাঝে, অফলাইনে, মিনিট থেকে দিন | অবিরাম, প্রতি রিকোয়েস্টে, মিলিসেকেন্ডে |
| কীসের চিন্তা | অ্যাকুরেসি, এক্সপেরিমেন্ট, বড় ডেটা, GPU | লেটেন্সি, থ্রুপুট, সঠিকতা, আপটাইম |
| ব্যর্থতা দেখতে যেমন | খারাপ একটা মেট্রিক, যা আবার চালানো যায় | ব্যবহারকারীর সামনে এরর, অথবা নীরবে ভুল উত্তর |
| থাকে কোথায় | একটা স্ক্রিপ্ট বা নোটবুকে (train.py) | API প্রসেসে (app/) |
দুইয়ের মধ্যে সেতু হলো একটা আর্টিফ্যাক্ট (artifact): ট্রেনিং জব যে ফাইল লেখে, সার্ভিস সেটা পড়ে। এই পাতার বাকি সবকিছু এই ফাইলটাকে দায়িত্বের সঙ্গে সামলানো নিয়ে।
প্রজেক্ট সাজান
mkdir -p ~/projects/iris-api
cd ~/projects/iris-api
uv init --bare -q
uv add -q "fastapi[standard]" pydantic-settings scikit-learn joblib
uv add -q --dev pytest httpx2
# with pip: python -m venv .venv && source .venv/bin/activate && pip install -q "fastapi[standard]" pydantic-settings scikit-learn joblib pytest httpx2
mkdir -p app tests
touch app/__init__.py
cat >> pyproject.toml <<'EOF'
[tool.fastapi]
entrypoint = "app.main:app"
[tool.pytest.ini_options]
pythonpath = ["."]
EOFমডেল ট্রেন করে সেভ করুন
ট্রেনিং কোড app/ প্যাকেজের বাইরে থাকে: সার্ভিস কখনো এর ওপর নির্ভর করবে না। স্ক্রিপ্টটা ক্লাসিক আইরিস ডেটাসেটে (scikit-learn-এর সঙ্গেই আসে, ডাউনলোড লাগে না) দুটো ক্যান্ডিডেট মডেল ট্রেন করে এবং প্রতিটাকে ভার্সন করা ফাইল হিসেবে সেভ করে। প্রতিটা র্যান্ডম সিদ্ধান্তে random_state ঠিক করা, তাই প্রতিবার একই সংখ্যা পাবেন।
# file: train.py
"""Train two small classifiers and save them as versioned artifacts."""
import sys
from pathlib import Path
import joblib
import sklearn
from sklearn.datasets import load_iris
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
FEATURES = ["sepal_length", "sepal_width", "petal_length", "petal_width"]
CLASSES = ["setosa", "versicolor", "virginica"]
def build_bundle(version, estimator, X, y):
"""Fit scaler + estimator as ONE pipeline, so preprocessing can never be forgotten."""
pipeline = make_pipeline(StandardScaler(), estimator)
pipeline.fit(X, y)
return {"version": version, "features": FEATURES, "classes": CLASSES,
"sklearn": sklearn.__version__, "pipeline": pipeline}
def main(out_dir="models"):
iris = load_iris()
X_train, X_test, y_train, y_test = train_test_split(
iris.data, iris.target, test_size=0.25, random_state=42, stratify=iris.target)
candidates = {
"v1": LogisticRegression(max_iter=200),
"v2": RandomForestClassifier(n_estimators=50, random_state=42),
}
Path(out_dir).mkdir(exist_ok=True)
for version, estimator in candidates.items():
bundle = build_bundle(version, estimator, X_train, y_train)
accuracy = bundle["pipeline"].score(X_test, y_test)
joblib.dump(bundle, Path(out_dir) / f"iris-{version}.joblib")
print(f"{version}: {type(estimator).__name__}, test accuracy {accuracy:.3f}")
if __name__ == "__main__":
main(*sys.argv[1:])python train.py
ls modelsv1: LogisticRegression, test accuracy 0.921
v2: RandomForestClassifier, test accuracy 0.921
iris-v1.joblib
iris-v2.joblib- পাইপলাইন (pipeline)
StandardScaler(প্রিপ্রসেসিং) আর এস্টিমেটরকে একসঙ্গে ধরে রাখে। ক্লাসিক বাগ হলো স্কেল করা ডেটায় ট্রেন করে স্কেল না করা ডেটা সার্ভ করা; একটাই সেভ করা অবজেক্ট থাকলে এটা হতেই পারে না। সার্ভিস প্রিপ্রসেসিং কখনো নতুন করে লেখে না। - আর্টিফ্যাক্টটা শুধু একটা মডেল নয়, একটা ডিকশনারি: এতে আছে
version, মডেল যে ফিচারের ক্রম আশা করে সেটা, ক্লাসের নাম এবং যে scikit-learn ভার্সনে ট্রেন হয়েছে সেটা। এই প্রসঙ্গ ছাড়া একটা মডেল ফাইল ছয় মাস পর রহস্য হয়ে যায়। - টেস্ট অ্যাকুরেসি মাপা হয়েছে এমন সারিতে, যা মডেল আগে কখনো দেখেনি। আর্টিফ্যাক্টের সঙ্গে এটা জানিয়ে রাখুন; "v2 ভালো" কথাটা হতে হবে একটা সংখ্যা, অনুভূতি নয়। এখানে এই ছোট স্প্লিটে দুটো মডেলের স্কোর একই, যেটা নিজেই একটা শিক্ষা: শুধু একটা সংখ্যা দেখে নয়, একাধিক বিষয় (লেটেন্সি, সাইজ, কঠিন কেসে আচরণ) দেখে বেছে নিন।
joblib.loadpickle ব্যবহার করে, যা লোড করার সময় যেকোনো কোড চালিয়ে দিতে পারে। শুধু আপনার নিজের পাইপলাইনের বানানো আর্টিফ্যাক্টই লোড করুন, কখনো ব্যবহারকারীর আপলোড করা বা অচেনা উৎস থেকে নামানো ফাইল নয়। যে scikit-learn ভার্সনে ফাইল সেভ হয়েছে, লোডও করুন সেই ভার্সনে; অমিল হলে ওয়ার্নিং আসতে বা কাজ ভাঙতে পারে। যে ফাইল বিশ্বাসের সীমানা পেরিয়ে আসে, তার জন্য নিরাপদ ফরম্যাট (ONNX,skops) আছে।
সার্ভিসের অংশগুলো
সেটিংস আসে এনভায়রনমেন্ট থেকে, আগের পাতার মতোই:
# file: app/settings.py
from pathlib import Path
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="IRIS_", extra="ignore")
model_dir: Path = Path("models")
default_version: str = "v1"
predict_timeout_s: float = Field(default=2.0, gt=0) # give up on one prediction after this
max_concurrent: int = Field(default=4, ge=1) # predictions running at onceস্কিমাগুলো কলারদের সঙ্গে চুক্তি। Pydantic দরজার প্রহরী: মডেল শুধু চারটা সসীম, যুক্তিসঙ্গত সংখ্যাই দেখে।
# file: app/schemas.py
from typing import Annotated
from pydantic import BaseModel, ConfigDict, Field
MAX_BATCH = 100
Centimetres = Annotated[float, Field(gt=0, le=20, allow_inf_nan=False)]
class Flower(BaseModel):
model_config = ConfigDict(extra="forbid") # a misspelt feature is an error, not ignored
sepal_length: Centimetres
sepal_width: Centimetres
petal_length: Centimetres
petal_width: Centimetres
class BatchIn(BaseModel):
items: list[Flower] = Field(min_length=1, max_length=MAX_BATCH)
class Result(BaseModel):
label: str
probabilities: dict[str, float]
class Prediction(Result):
model_version: str
class BatchOut(BaseModel):
model_version: str
results: list[Result]এবার রেজিস্ট্রি: এটা প্রতিটা আর্টিফ্যাক্ট খুঁজে বের করে, লোড করে, API-র চুক্তির সঙ্গে মিলিয়ে দেখে এবং ওয়ার্ম-আপ করে। আসল মডেলের প্রথম প্রেডিকশনে প্রায়ই একবারের খরচ থাকে (লেজি ইমপোর্ট, মেমরি বরাদ্দ, GPU কার্নেল কম্পাইল); স্টার্টআপে একটা ফেলনা প্রেডিকশন চালালে সেই খরচ প্রথম ব্যবহারকারীর রিকোয়েস্ট থেকে সরে যায়।
# file: app/registry.py
from pathlib import Path
import joblib
import numpy as np
from app.schemas import Flower
WARMUP_ROW = [[5.1, 3.5, 1.4, 0.2]]
def predict_rows(bundle: dict, rows: list[list[float]]) -> list[dict]:
"""Plain blocking function: one call for any number of rows."""
proba = bundle["pipeline"].predict_proba(np.array(rows))
results = []
for row in proba:
best = int(row.argmax())
results.append({
"label": bundle["classes"][best],
"probabilities": {name: round(float(p), 3) for name, p in zip(bundle["classes"], row)},
})
return results
def load_registry(model_dir: Path) -> dict[str, dict]:
"""Load every iris-*.joblib file, keyed by version. Fail loudly, at startup."""
registry = {}
for path in sorted(model_dir.glob("iris-*.joblib")):
bundle = joblib.load(path)
if bundle["features"] != list(Flower.model_fields):
raise RuntimeError(f"{path.name}: features {bundle['features']} do not match the API schema")
predict_rows(bundle, WARMUP_ROW) # warm-up: result thrown away
registry[bundle["version"]] = bundle
if not registry:
raise RuntimeError(f"No model files found in {model_dir}")
return registryসবশেষে অ্যাপ। ওপর থেকে নিচে পড়ুন: lifespan সবকিছু একবার লোড করে, একটা ডিপেন্ডেন্সি মডেল ভার্সন বেছে নেয়, আর infer হলো সেই একমাত্র জায়গা যেখানে প্রেডিকশন চলে, মাপা হয়, সীমিত হয়, লগ হয় ও গোনা হয়।
# file: app/main.py
import asyncio
import json
import logging
import time
from contextlib import asynccontextmanager
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, Query, Request
from app.registry import load_registry, predict_rows
from app.schemas import BatchIn, BatchOut, Flower, Prediction, Result
from app.settings import Settings
log = logging.getLogger("iris_api.predictions")
class Metrics:
"""In-memory counters. In production export these to Prometheus, Datadog or your platform."""
def __init__(self) -> None:
self.requests = self.errors = self.slow = 0
self.total_ms = self.max_ms = 0.0
def record(self, ms: float, *, ok: bool) -> None:
self.requests += 1
self.errors += 0 if ok else 1
self.slow += 1 if ms > 1000 else 0
self.total_ms += ms
self.max_ms = max(self.max_ms, ms)
def snapshot(self) -> dict:
average = self.total_ms / self.requests if self.requests else 0.0
return {"requests": self.requests, "errors": self.errors, "slow_over_1s": self.slow,
"avg_ms": round(average, 2), "max_ms": round(self.max_ms, 2)}
def create_app(settings: Settings | None = None) -> FastAPI:
settings = settings or Settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
logging.basicConfig(level="INFO", format="%(asctime)s %(levelname)s %(name)s %(message)s")
app.state.settings = settings
app.state.registry = load_registry(settings.model_dir) # blocks startup, not requests
if settings.default_version not in app.state.registry:
raise RuntimeError(f"Default model {settings.default_version!r} was not loaded")
app.state.predict = predict_rows
app.state.metrics = Metrics()
app.state.slots = asyncio.Semaphore(settings.max_concurrent)
yield
app.state.registry.clear() # release the memory
app = FastAPI(title="BitByte Iris API", lifespan=lifespan)
def get_bundle(request: Request, version: Annotated[str | None, Query(pattern=r"^[a-z0-9.-]{1,20}$")] = None) -> dict:
registry = request.app.state.registry
wanted = version or request.app.state.settings.default_version
if wanted not in registry:
raise HTTPException(404, f"Unknown model version {wanted!r}; available: {sorted(registry)}")
return registry[wanted]
BundleDep = Annotated[dict, Depends(get_bundle)]
async def infer(request: Request, bundle: dict, flowers: list[Flower]) -> list[dict]:
state = request.app.state
rows = [[getattr(flower, name) for name in bundle["features"]] for flower in flowers]
started = time.perf_counter()
ok = False
try:
async with state.slots: # at most N predictions at once
work = asyncio.to_thread(state.predict, bundle, rows) # off the event loop
results = await asyncio.wait_for(work, state.settings.predict_timeout_s)
ok = True
except TimeoutError:
raise HTTPException(504, "The model took too long to answer") from None
except Exception:
log.exception("inference failed") # details stay in the logs
raise HTTPException(500, "Prediction failed") from None
finally:
duration_ms = (time.perf_counter() - started) * 1000
state.metrics.record(duration_ms, ok=ok)
fields = {"model_version": bundle["version"], "n_items": len(rows), "ok": ok,
"labels": [r["label"] for r in results], "duration_ms": round(duration_ms, 1)}
log.info("prediction %s", json.dumps(fields))
return results
@app.get("/health")
def health() -> dict:
return {"status": "ok", "versions": sorted(app.state.registry)}
@app.get("/v1/model")
def model_info(bundle: BundleDep) -> dict:
return {"version": bundle["version"], "features": bundle["features"],
"classes": bundle["classes"], "estimator": type(bundle["pipeline"][-1]).__name__}
@app.post("/v1/predict")
async def predict(flower: Flower, request: Request, bundle: BundleDep) -> Prediction:
(result,) = await infer(request, bundle, [flower])
return Prediction(model_version=bundle["version"], **result)
@app.post("/v1/predict/batch")
async def predict_batch(data: BatchIn, request: Request, bundle: BundleDep) -> BatchOut:
results = await infer(request, bundle, data.items)
return BatchOut(model_version=bundle["version"], results=[Result(**r) for r in results])
@app.get("/v1/metrics")
def metrics() -> dict:
return app.state.metrics.snapshot()
return app
app = create_app()চালিয়ে ডাক দিন
fastapi run --port 8000 > server.log 2>&1 &
export SERVER_PID=$!
export BASE=http://localhost:8000
curl -s --retry 20 --retry-connrefused --retry-delay 0 $BASE/health
echo
curl -s $BASE/v1/model{"status":"ok","versions":["v1","v2"]}
{"version":"v1","features":["sepal_length","sepal_width","petal_length","petal_width"],"classes":["setosa","versicolor","virginica"],"estimator":"LogisticRegression"}দুটো মডেল লোড ও ওয়ার্ম-আপ হওয়ার পরই সার্ভার চালু হয়েছে, তাই প্রথম রিকোয়েস্ট বিশেষ কিছু নয়। এবার একটা প্রেডিকশন, তারপর একই ফুলে অন্য মডেল ভার্সন:
curl -s $BASE/v1/predict -H "Content-Type: application/json" \
-d '{"sepal_length": 6.3, "sepal_width": 2.9, "petal_length": 5.6, "petal_width": 1.8}'
echo
curl -s "$BASE/v1/predict?version=v2" -H "Content-Type: application/json" \
-d '{"sepal_length": 6.3, "sepal_width": 2.9, "petal_length": 5.6, "petal_width": 1.8}'
echo
curl -s "$BASE/v1/predict?version=v9" -H "Content-Type: application/json" \
-d '{"sepal_length": 6.3, "sepal_width": 2.9, "petal_length": 5.6, "petal_width": 1.8}'{"label":"virginica","probabilities":{"setosa":0.001,"versicolor":0.195,"virginica":0.804},"model_version":"v1"}
{"label":"virginica","probabilities":{"setosa":0.0,"versicolor":0.0,"virginica":1.0},"model_version":"v2"}
{"detail":"Unknown model version 'v9'; available: ['v1', 'v2']"}প্রতিটা রেসপন্স বলে দেয় কোন model_version সেটা বানিয়েছে। আগামী মাসে কোনো ব্যবহারকারী উত্তর নিয়ে অভিযোগ করলে এই ফিল্ড বলে দেয় কোন আর্টিফ্যাক্ট দিয়ে সেটা আবার বানিয়ে দেখবেন; আর এতে ধাপে ধাপে রোলআউটও (১০% কলার v2-তে) মাপা যায়। যে মডেল উত্তর দেয় কিন্তু বলে না কোন ভার্সন সেটা বানিয়েছে, তাকে ডিবাগ করা যায় না।
ভ্যালিডেশন মডেলকে রক্ষা করে
মডেল কখনো আপত্তি করে না: নেগেটিভ পাপড়ি পেলেও সে খুশি মনে একটা প্রজাতি বলে দেয়। ভুল ইনপুট দিলে বেরোয় আত্মবিশ্বাসী ভুল আউটপুট। Pydantic খারাপ ইনপুট মডেলের কাছে পৌঁছানোর আগেই ফিরিয়ে দেয়:
import os
from fastapi.testclient import TestClient
from app.main import create_app
from app.settings import Settings
app = create_app(Settings())
client = TestClient(app).__enter__() # runs the lifespan, like a real startup
good = {"sepal_length": 6.3, "sepal_width": 2.9, "petal_length": 5.6, "petal_width": 1.8}
bad_inputs = {
"negative": {**good, "petal_length": -1},
"missing": {k: v for k, v in good.items() if k != "petal_width"},
"text": {**good, "sepal_width": "wide"},
"misspelt": {**good, "petal_lenght": 5.6},
"huge": {**good, "sepal_length": 9999},
}
for name, body in bad_inputs.items():
response = client.post("/v1/predict", json=body)
problem = response.json()["detail"][0]
print(f"{name:9} {response.status_code} {problem['loc'][-1]}: {problem['msg']}")negative 422 petal_length: Input should be greater than 0
missing 422 petal_width: Field required
text 422 sepal_width: Input should be a valid number, unable to parse string as a number
misspelt 422 petal_lenght: Extra inputs are not permitted
huge 422 sepal_length: Input should be less than or equal to 20এগুলোর কোনোটার জন্যই মডেল ডাকা হয় না। সীমা বেছে নিন আপনার ট্রেনিং ডেটা আর ডোমেন থেকে (পাপড়ি কখনো ৯৯৯৯ সেমি হয় না), Pydantic যা প্রকাশ করতে পারে তা থেকে নয়; আর মনে রাখুন একটা মান বৈধ হয়েও মডেলের দেখা সবকিছু থেকে অনেক দূরে হতে পারে। এই "ড্রিফট" ধরার জায়গা মনিটরিং (নিচে), ভ্যালিডেটর নয়।
ব্যাচ বনাম একক
মডেল ভেক্টরাইজড: ১০০টা সারি এক কলে প্রেডিক্ট করতে একটা সারির চেয়ে সামান্যই বেশি লাগে, কিন্তু ১০০টা আলাদা কলে প্রতি-কলের ওভারহেড ১০০ বার দিতে হয় (আর প্রতিটা রিকোয়েস্ট হলে ১০০ বার HTTP যাওয়া-আসা)। যে কলারের অনেক আইটেম আছে তার জন্য ব্যাচ এন্ডপয়েন্টই ঠিক টুল; তবে এতে একটা সীমাও লাগে (এখানে MAX_BATCH = 100), যাতে একটা রিকোয়েস্ট মিনিটের পর মিনিট একটা ওয়ার্কার আটকে না রাখে।
import time
import numpy as np
from app.registry import predict_rows
bundle = app.state.registry["v1"]
rows = np.tile([[6.3, 2.9, 5.6, 1.8]], (100, 1)).tolist()
start = time.perf_counter()
for row in rows:
predict_rows(bundle, [row])
one_by_one = time.perf_counter() - start
start = time.perf_counter()
results = predict_rows(bundle, rows)
as_batch = time.perf_counter() - start
print(len(results), "results; batch faster than 100 single calls:", as_batch < one_by_one)
response = client.post("/v1/predict/batch", json={"items": [good, {**good, "petal_length": 1.4, "petal_width": 0.2}]})
print(response.json())
print(client.post("/v1/predict/batch", json={"items": []}).status_code,
client.post("/v1/predict/batch", json={"items": [good] * 101}).status_code)100 results; batch faster than 100 single calls: True
{'model_version': 'v1', 'results': [{'label': 'virginica', 'probabilities': {'setosa': 0.001, 'versicolor': 0.195, 'virginica': 0.804}}, {'label': 'versicolor', 'probabilities': {'setosa': 0.373, 'versicolor': 0.627, 'virginica': 0.0}}]}
422 422ইভেন্ট লুপ আটকাবেন না
আগের পাতায় দেখেছেন async def-এর ভেতরে ব্লকিং কোড প্রতিটা রিকোয়েস্ট জমিয়ে দেয়। মডেল কল ঠিক তা-ই: CPU-নির্ভর আর সিঙ্ক্রোনাস। তাই infer ফাংশন এর চারপাশে তিনটা কাজ করে:
- থ্রেডে সরায়
asyncio.to_threadদিয়ে, যাতে মডেল হিসাব করার সময়ও ইভেন্ট লুপ/healthও অন্য রিকোয়েস্টের উত্তর দিতে পারে। (অনেক সংখ্যাতাত্ত্বিক লাইব্রেরি হিসাবের সময় Python-এর GIL ছেড়ে দেয়, তাই থ্রেড সত্যিই একসঙ্গে চলে। খাঁটি Python মডেলে তা হয় না; তখন আরও ওয়ার্কার প্রসেস লাগে।) - কনকারেন্সি সীমিত করে একটা সেমাফোর দিয়ে: চার CPU কোরে একসঙ্গে দশটা প্রেডিকশন সবার জন্য একবারে চারটা চালানোর চেয়ে ধীর। বাড়তি রিকোয়েস্ট ঠেলাঠেলি না করে পালা ধরে অপেক্ষা করে।
- টাইমআউট দেয়
asyncio.wait_forদিয়ে, যাতে আটকে যাওয়া বা অস্বাভাবিক ইনপুট ঝুলে থাকা কানেকশনের বদলে একটা পরিষ্কার 504 হয়ে যায়।
টাইমআউট কাজ করছে এভাবে: প্রেডিকশন ফাংশনের জায়গায় ইচ্ছা করে ধীর একটা বদলি বসানো হয়েছে (app.state.predict রাখার নকশার উদ্দেশ্যই হলো টেস্ট যেন এটা করতে পারে):
def slow_predict(bundle, rows):
time.sleep(0.5)
return predict_rows(bundle, rows)
tight = create_app(Settings(predict_timeout_s=0.1))
with TestClient(tight) as tight_client:
tight.state.predict = slow_predict
response = tight_client.post("/v1/predict", json=good)
print(response.status_code, response.json())
tight.state.predict = predict_rows
print(tight_client.post("/v1/predict", json=good).status_code)
print(tight_client.get("/v1/metrics").json()["errors"], "error counted")504 {'detail': 'The model took too long to answer'}
200
1 error countedটাইমআউট আসলে কী করে, সেটা স্পষ্ট জেনে রাখুন: এটা কলারকে মুক্তি দেয়, কিন্তু ওয়ার্কার থ্রেডকে বন্ধ করা যায় না, সে নিজের হিসাব শেষ করেই ছাড়ে। এজন্যই সেমাফোর জরুরি, আর এজন্যই সত্যিই সীমাহীন কাজে (বিশাল ইনপুট, অনিয়ন্ত্রিত জেনারেশন) ইনপুট সাইজের কড়া সীমা এবং বন্ধ করা যায় এমন আলাদা ওয়ার্কার প্রসেস লাগে।
CPU বনাম GPU
| CPU সার্ভিং | GPU সার্ভিং | |
|---|---|---|
| ভালো কোথায় | ক্লাসিক্যাল ML (scikit-learn, XGBoost), ছোট নিউরাল নেট, কম ট্রাফিক | বড় নিউরাল নেট, LLM, ছবি, অডিও |
| স্কেলিং | আরও ওয়ার্কার প্রসেস (fastapi run --workers N); প্রতিটা নিজের কপি লোড করে, তাই মেমরি মডেলের সাইজের N গুণ | সাধারণত প্রতি GPU-তে একটা প্রসেস; বাধা GPU, পাইথন নয় |
| ব্যাচিং | সামান্য কাজে লাগে | অপরিহার্য: GPU ব্যাচ পেলেই দক্ষ, তাই সার্ভার কয়েক মিলিসেকেন্ড রিকোয়েস্ট জমায় ("ডায়নামিক ব্যাচিং") |
| স্টার্টআপ | কয়েক সেকেন্ড | ওয়েট লোড ও ওয়ার্ম-আপে মিনিট লাগতে পারে: ওয়ার্ম-আপ আর রেডিনেস চেক এখানে সবচেয়ে গুরুত্বপূর্ণ |
| খরচ | সস্তা, সর্বত্র পাওয়া যায় | দামি; অলস ফেলে রাখবেন না |
শুনতে গুরুগম্ভীর বলে GPU ধরবেন না: লজিস্টিক রিগ্রেশন GPU-তে CPU-র চেয়ে ধীর। PyTorch-এ আপনি অতিরিক্ত model.eval() ডাকবেন, torch.inference_mode()-এর ভেতরে চালাবেন এবং ইনপুট মডেলের ডিভাইসে পাঠাবেন; কিন্তু এই পাতার lifespan, ভ্যালিডেশন, টাইমআউট ও ভার্সনিংয়ের কাঠামো একই থাকে। হাতে লেখা এন্ডপয়েন্ট ছাড়িয়ে গেলে বিশেষ সার্ভার (vLLM, Triton, BentoML, TorchServe) ডায়নামিক ব্যাচিং আপনার হয়ে করে দেয়।
প্রেডিকশন লগিং ও লেটেন্সি মনিটরিং
দুই ধরনের প্রশ্নের জন্য দুই ধরনের টুল। লগ বলে "এই রিকোয়েস্টে কী ঘটল?"; মেট্রিক বলে "সার্ভিস মোটের ওপর কেমন চলছে?"। infer দুটোই বের করে: প্রতি প্রেডিকশনে একটা লগ লাইন (মডেল ভার্সন, আইটেম সংখ্যা, প্রেডিক্ট করা লেবেল, সময়, সফল কি না) এবং কাউন্টার (রিকোয়েস্ট, এরর, ধীর রিকোয়েস্ট, গড় ও সর্বোচ্চ লেটেন্সি)।
import json
import logging
lines = []
class Capture(logging.Handler):
def emit(self, record):
lines.append(record.getMessage())
logging.getLogger("iris_api").addHandler(Capture())
logging.getLogger("iris_api").setLevel(logging.INFO)
client.post("/v1/predict?version=v2", json=good)
entry = json.loads(lines[-1].removeprefix("prediction "))
print({key: value for key, value in entry.items() if key != "duration_ms"})
print("duration under 1 s:", entry["duration_ms"] < 1000)
stats = client.get("/v1/metrics").json()
print(stats["requests"] > 0, stats["errors"], stats["slow_over_1s"], "average under 1 s:", stats["avg_ms"] < 1000){'model_version': 'v2', 'n_items': 1, 'ok': True, 'labels': ['virginica']}
duration under 1 s: True
True 0 0 average under 1 s: True- কী লগ করবেন: ভার্সন, সাইজ, আউটপুট, সময়, রিকোয়েস্ট আইডি। কাঁচা ইনপুট লগ করুন শুধু কারণ আর অধিকার থাকলে: ফিচার ব্যক্তিগত তথ্য হতে পারে। সাধারণ সমঝোতা হলো স্যাম্পলিং, হ্যাশ করা, অথবা শুধু সমষ্টি লগ করা। প্রেডিকশন লগ না থাকলে অভিযোগ তদন্ত করা, পরের ট্রেনিংয়ের জন্য ডেটাসেট বানানো, বা ড্রিফট (লাইভ ইনপুট ধীরে ধীরে ট্রেনিং ডেটার মতো না থাকা) ধরা যায় না।
- কীসে অ্যালার্ট দেবেন: এরর রেট, এবং লেটেন্সির উঁচু পার্সেন্টাইল (p95 বা p99), শুধু গড় নয়, কারণ গড় ধীর লেজ লুকিয়ে রাখে। ওপরের ইন-মেমরি কাউন্টার শেখার জন্য: রিস্টার্টে মুছে যায় আর প্রসেস-প্রতি। আসল সার্ভিস হিস্টোগ্রাম মনিটরিং সিস্টেমে পাঠায়।
- হেলথ:
/healthলোড হওয়া ভার্সন জানায়; যে সার্ভিসের মডেল লোড হয়নি তার চালু হওয়াই উচিত নয়, তাইload_registryপ্রথম রিকোয়েস্টে ব্যর্থ না হয়ে স্টার্টআপেই এরর তোলে।
এরর হ্যান্ডলিং
| পরিস্থিতি | রেসপন্স | কোথায় সামলানো হয় |
|---|---|---|
| অবৈধ বা সীমার বাইরের ফিচার | ফিল্ড-ভিত্তিক বার্তাসহ 422 | Pydantic, মডেলের আগে |
| অজানা মডেল ভার্সন | পাওয়া যায় এমন ভার্সনের তালিকাসহ 404 | get_bundle |
| মডেল খুব ধীর | 504 | infer-এ wait_for |
| মডেল এক্সেপশন তোলে | সাধারণ বার্তাসহ 500; স্ট্যাক ট্রেস শুধু লগে | infer |
| আর্টিফ্যাক্ট নেই বা সামঞ্জস্যহীন | সার্ভিস চালুই হয় না | load_registry |
মডেলের ভেতরের এক্সেপশনের লেখা কখনো কলারকে ফেরত দেবেন না: এতে পাথ, লাইব্রেরি ভার্সন ও ডেটা ফাঁস হতে পারে। আসল প্রজেক্টে এগুলো সবই FastAPI আর্কিটেকচার: রাউটার, স্কিমা ও এরর হ্যান্ডলিং পাতার একটাই এরর ফরম্যাটের ভেতর দিয়ে পাঠান। টেস্টের আগে ডেমো সার্ভার বন্ধ করুন:
kill $SERVER_PID
wait $SERVER_PID 2>/dev/null
echo stoppedstoppedছোট মডেল দিয়ে টেস্ট
টেস্ট যেন গত মঙ্গলবার ট্রেন করা মডেলের ওপর নির্ভর না করে। তারা একটা অস্থায়ী ফোল্ডারে ছোট মডেল বানায় (কয়েকটা সারি, ফিট হতে সেকেন্ড, প্রতিবার একই ফল) এবং অ্যাপকে সেখানে নির্দেশ করে। আসল build_bundle-ও তারা ব্যবহার করে, তাই ট্রেনিং আর সার্ভিং সামঞ্জস্যপূর্ণ থাকে।
# file: tests/conftest.py
import joblib
import pytest
from fastapi.testclient import TestClient
from sklearn.datasets import load_iris
from sklearn.linear_model import LogisticRegression
from app.main import create_app
from app.settings import Settings
from train import build_bundle
GOOD = {"sepal_length": 5.1, "sepal_width": 3.5, "petal_length": 1.4, "petal_width": 0.2} # a setosa
@pytest.fixture
def client(tmp_path):
iris = load_iris()
X, y = iris.data[::5], iris.target[::5] # 30 rows: a tiny model
joblib.dump(build_bundle("test", LogisticRegression(max_iter=200), X, y), tmp_path / "iris-test.joblib")
app = create_app(Settings(model_dir=tmp_path, default_version="test", predict_timeout_s=0.2))
with TestClient(app) as test_client:
yield test_client# file: tests/test_api.py
import time
import pytest
from app.registry import predict_rows
from tests.conftest import GOOD
def test_prediction_shape_and_version(client):
body = client.post("/v1/predict", json=GOOD).json()
assert body["model_version"] == "test"
assert body["label"] == "setosa"
assert abs(sum(body["probabilities"].values()) - 1) < 0.01
def test_batch_matches_single(client):
single = client.post("/v1/predict", json=GOOD).json()["label"]
batch = client.post("/v1/predict/batch", json={"items": [GOOD, GOOD]}).json()
assert [r["label"] for r in batch["results"]] == [single, single]
@pytest.mark.parametrize("field,value", [("petal_length", -1), ("sepal_width", "wide"), ("sepal_length", 9999)])
def test_invalid_features_are_rejected(client, field, value):
assert client.post("/v1/predict", json={**GOOD, field: value}).status_code == 422
def test_unknown_version_is_404(client):
assert client.post("/v1/predict?version=nope", json=GOOD).status_code == 404
def test_slow_model_times_out_and_service_recovers(client):
def slow(bundle, rows):
time.sleep(0.5)
return predict_rows(bundle, rows)
client.app.state.predict = slow
assert client.post("/v1/predict", json=GOOD).status_code == 504
client.app.state.predict = predict_rows
assert client.post("/v1/predict", json=GOOD).status_code == 200
def test_model_crash_is_a_generic_500(client):
def broken(bundle, rows):
raise ValueError("secret internal detail")
client.app.state.predict = broken
response = client.post("/v1/predict", json=GOOD)
assert response.status_code == 500
assert "secret" not in response.textpytest -q........ [100%]
8 passed in 0.97sটেস্টগুলো আচরণ যাচাই করে (গঠন, ভার্সন, এরর, টাইমআউট), মডেলের মান নয়: মডেলের মান মাপা হয় ট্রেনিং পাইপলাইনে আলাদা করে রাখা (held-out) ডেটার ইভ্যালুয়েশন দিয়ে, আর প্রোডাকশনে মনিটরিং দিয়ে। এই দুই দায়িত্ব আলাদা রাখুন।
বাস্তবে কোথায় কাজে লাগে
- ফ্রড, চার্ন, রেকমেন্ডেশন ও র্যাঙ্কিং মডেল ঠিক এভাবেই সার্ভ হয়: ভ্যালিডেট করা ফিচার পেলোড ঢোকে, একটা স্কোর আর মডেল ভার্সন বেরোয়, লেটেন্সি বাজেট কয়েক দশ মিলিসেকেন্ড।
- টিমগুলো আর্টিফ্যাক্ট রাখে মডেল রেজিস্ট্রিতে (MLflow, Hugging Face Hub, ভার্সন ফোল্ডারসহ S3 বাকেট), লোকাল
models/ডিরেক্টরিতে নয়; তখন lifespan একটা পিন করা ভার্সন নামিয়ে আনে। বড় ফাইল কেন Git-এ কমিট করবেন না, তা জানতে দেখুন AI প্রজেক্টে Git: ডেটা, মডেল, LFS, DVC ও ফাঁস হওয়া সিক্রেট। - রোলআউটে ভার্সন ফিল্ড কাজে লাগে: কিছু কলারকে
v2দিন, এরর রেট ও বিজনেস মেট্রিক তুলনা করুন, তারপর ডিফল্ট বদলান। - LLM ব্যাকএন্ড একই কাঠামোর ওপর স্ট্রিমিং আর প্রোভাইডার কল যোগ করে; সেটা পরের পাতায়।
সাধারণ ভুল
- এন্ডপয়েন্টের ভেতরে মডেল লোড করা। প্রতি রিকোয়েস্টে ডিস্ক পড়া আর ডিসিরিয়ালাইজেশনে সেকেন্ড যায়।
lifespan-এ একবার লোড করুন। - সেভ করা পাইপলাইনের বাইরে প্রিপ্রসেসিং। সার্ভিসে নতুন করে লেখা স্কেলিং বা এনকোডিং ট্রেনিং থেকে সরে যায় আর নীরবে উত্তরের মান কমায়। পুরো পাইপলাইন সেভ করুন।
async def-এ সরাসরি মডেল ডাকা। একটা ধীর প্রেডিকশন পুরো সার্ভার আটকে দেয়। থ্রেড (বা সাধারণdefরুট), কনকারেন্সি লিমিট ও টাইমআউট ব্যবহার করুন।- ভ্যালিডেশন নেই, রেসপন্সে ভার্সনও নেই। আপনি আত্মবিশ্বাসের সঙ্গে অর্থহীন উত্তর সার্ভ করেন আর বলতে পারেন না কোন মডেল বলেছে। প্রতিটা ফিচার ভ্যালিডেট করুন;
model_versionফেরত দিন। - সিড ছাড়া, আবার ঘটানো যায় না এমন ট্রেনিং আর অবিশ্বস্ত pickle।
random_stateঠিক করুন, লাইব্রেরি ভার্সন লিখে রাখুন, আর শুধু নিজের বানানো ফাইলইjoblib.loadকরুন।
নিজে চেষ্টা করুন
- সহজ: পাইথনে
models/iris-v1.joblibলোড করে ফুল[5.1, 3.5, 1.4, 0.2]-এর প্রেডিক্ট করা লেবেল ও সম্ভাবনা প্রিন্ট করুন। - মাঝারি:
/v1/predict/batch-এ ১০০টা সারি পাঠিয়ে ১০০টা ফলাফল ফেরত আসে কি না যাচাই করুন, তারপর দেখুন এন্ডপয়েন্ট ১০১টা সারি প্রত্যাখ্যান করে কি না; এমন একটা টেস্ট লিখুন। - কঠিন:
Result-এconfidence(সবচেয়ে বেশি সম্ভাবনা) ফিল্ড যোগ করুন, আর একটা গার্ড: মডেলের কনফিডেন্স ০.৬-এর কম হলে একক এন্ডপয়েন্ট তবু 200 দেবে কিন্তু"low_confidence": trueযোগ করবে। দুই প্রজাতির মাঝামাঝি একটা ফুল দিয়ে দেখান।
উত্তর
# 1. Easy
import joblib
from app.registry import predict_rows
loaded = joblib.load("models/iris-v1.joblib")
print(predict_rows(loaded, [[5.1, 3.5, 1.4, 0.2]]))
# 2. Medium (run it as a pytest test: add it to tests/test_api.py)
def test_batch_limit(client):
from tests.conftest import GOOD
assert len(client.post("/v1/predict/batch", json={"items": [GOOD] * 100}).json()["results"]) == 100
assert client.post("/v1/predict/batch", json={"items": [GOOD] * 101}).status_code == 422
# 3. Hard: the check can live in one small function that works on a result
def with_confidence(result: dict) -> dict:
confidence = max(result["probabilities"].values())
return {**result, "confidence": confidence, "low_confidence": confidence < 0.6}
between = predict_rows(loaded, [[6.0, 2.7, 4.9, 1.7]])[0]
print(between["probabilities"])
print(with_confidence(between)["low_confidence"], with_confidence(predict_rows(loaded, [[5.1, 3.5, 1.4, 0.2]])[0])["low_confidence"])[{'label': 'setosa', 'probabilities': {'setosa': 0.981, 'versicolor': 0.019, 'virginica': 0.0}}]
{'setosa': 0.003, 'versicolor': 0.455, 'virginica': 0.542}
True Falseসারসংক্ষেপ
- ট্রেনিং অফলাইন এবং একটা আর্টিফ্যাক্ট বানায়; সার্ভিং সেটা লোড করে শুধু প্রেডিক্ট করে। প্রিপ্রসেসিং ও মডেল একটাই পাইপলাইন হিসেবে সেভ করুন, সঙ্গে ভার্সন, ফিচার ক্রম ও লাইব্রেরি ভার্সন।
- সব মডেল ভার্সন
lifespan-এ একবার লোড করুন, API স্কিমার সঙ্গে মিলিয়ে দেখুন, ওয়ার্ম-আপ করুন, আর কিছু ভুল হলে চালু হতে অস্বীকার করুন।joblibফাইলকে কোডের মতো দেখুন: শুধু বিশ্বস্তটাই লোড করুন। - ফিচার মডেলে পৌঁছানোর আগে Pydantic দিয়ে ভ্যালিডেট করুন, একক ও সীমা-দেওয়া ব্যাচ রিকোয়েস্ট সমর্থন করুন, এবং প্রতি রেসপন্সে
model_versionফেরত দিন। - ইভেন্ট লুপ মুক্ত রাখুন: মডেল থ্রেডে চালান, কনকারেন্সি সীমিত করুন, টাইমআউট দিন; টাইমআউট কলারকে মুক্তি দেয়, থ্রেডকে নয়। যে মডেলের দরকার শুধু তার জন্যই GPU নিন, আর নিলে ব্যাচ করুন।
- ডেটা ফাঁস না করে প্রতি প্রেডিকশন লগ করুন, এরর রেট ও লেজের লেটেন্সি দেখুন, আর অস্থায়ী ফোল্ডারে বানানো ছোট মডেল দিয়ে সার্ভিস টেস্ট করুন।
এরপর: স্ট্রিমিংসহ LLM ব্যাকএন্ড বানানো পাতায় এই কাঠামো (সেটিংস, lifespan, ভ্যালিডেশন, টাইমআউট, লগিং) থাকবে, শুধু লোকাল মডেলের বদলে বসবে একটা ল্যাঙ্গুয়েজ-মডেল প্রোভাইডার, আর উত্তর তৈরি হওয়ার সঙ্গে সঙ্গে ক্লায়েন্টে স্ট্রিম হবে।