অধ্যায় 3 · প্রোডাকশন FastAPI
Redis দিয়ে ক্যাশিং, রেট লিমিটিং ও নির্ভরযোগ্যতা
- পৃষ্ঠা 12 / 22
- 29 মিনিট পড়া
একটা AI প্রোডাক্ট মানে ধীর, দামি আর রেট-লিমিটেড কলের একটা শৃঙ্খল: আপস্ট্রিম ডেটা API, ভেক্টর ডেটাবেস, এমবেডিং মডেল, আর টোকেন ধরে বিল করা LLM। একশো জন ব্যবহারকারী যখন একই আবহাওয়ার পূর্বাভাস চায়, তখন একশোটা আপস্ট্রিম কলের টাকা দেওয়ার কথা নয়। একটা স্ক্রিপ্ট লুপ চালিয়ে আপনার /chat এন্ডপয়েন্টে একটানা রিকোয়েস্ট ছুড়ে মারলে সেটা যেন আপনার মাসিক LLM বাজেট পুড়িয়ে না দেয় বা বাকি সবাইকে ধীর না করে। আর কোনো আপস্ট্রিম সার্ভিস ভেঙে পড়লে আপনার API-র উচিত ভদ্রভাবে সামলে নেওয়া, তার সঙ্গে নিজেও ভেঙে না পড়া।
এই পাতা Redis দিয়ে একটা FastAPI সার্ভিসে এই সুরক্ষাগুলো যোগ করে: TTL ও ইনভ্যালিডেশনসহ ক্যাশিং, তিনটা রেট-লিমিটিং অ্যালগরিদম, কনকারেন্সি লিমিট, টাইমআউট ও ডেডলাইন, সার্কিট ব্রেকার, গ্রেসফুল ডিগ্রেডেশন, রিকোয়েস্ট ID আর সাধারণ মেট্রিক্স। সবকিছু Practice API ও একটা আসল Redis সার্ভারের বিরুদ্ধে চলে।
যা শিখবেন
- ক্যাশিং কখন কাজে লাগে, ইন-প্রসেস ক্যাশ বনাম Redis (এবং Valkey), কি (key), TTL ও ইনভ্যালিডেশন
- আপস্ট্রিম API-র জন্য ক্যাশ-অ্যাসাইড, এবং এক ব্যবহারকারীর উত্তর অন্যজনকে না দিয়ে LLM-এর ফলাফল ক্যাশ করা
- Redis দিয়ে রেট লিমিটিং: ফিক্সড উইন্ডো, স্লাইডিং উইন্ডো ও টোকেন বাকেট, প্রতি ব্যবহারকারী বা IP ধরে,
429ওRetry-Afterসহ - কনকারেন্সি লিমিট, টাইমআউট ও ডেডলাইন, রিট্রাই বনাম ফেইল-ফাস্ট, এবং সার্কিট ব্রেকার
- Redis বা আপস্ট্রিম ডাউন থাকলে গ্রেসফুল ডিগ্রেডেশন, রিকোয়েস্ট/কোরিলেশন ID, আর শেয়ার করা সরল মেট্রিক্স
প্রজেক্ট সেট আপ
FastAPI, Redis ক্লায়েন্ট আর httpx নিয়ে একটা নতুন প্রজেক্ট (httpx আসে fastapi[standard]-এর সঙ্গে):
mkdir -p ~/projects/bitbyte-weather && cd ~/projects/bitbyte-weather
uv init -q --bare --name bitbyte-weather
uv add -q "fastapi[standard]" redis
source .venv/bin/activate
# with pip instead of uv:
# python -m venv .venv && source .venv/bin/activate
# pip install -q "fastapi[standard]" redisএকটা Redis সার্ভারও লাগবে। যেকোনো OS-এ সবচেয়ে সহজ উপায় Docker (Docker ঠিকমতো চিনবেন Docker: আপনার API প্যাকেজ করা পাতায়)। দুটো ইমেজের যেকোনোটা চলবে; Python ক্লায়েন্ট একই:
docker run -d --name bitbyte-redis -p 6379:6379 redis:8
# or the BSD-licensed fork:
docker run -d --name bitbyte-redis -p 6379:6379 valkey/valkey:9দেখে নিন সার্ভার সাড়া দিচ্ছে কি না। (কমান্ড-লাইন টুলটা থাকলে redis-cli -u redis://localhost:6379 ping-ও একই কাজ করে ও PONG ছাপে; এখানে আমরা এইমাত্র ইনস্টল করা Python ক্লায়েন্ট ব্যবহার করছি।)
python -c "import redis; print(redis.Redis.from_url('redis://localhost:6379').ping())"TrueRedis নাকি Valkey? Redis 7.4 লাইসেন্স বদলে সোর্স-অ্যাভেইলেবল করে ফেলে, আর ২০২৪ সালের মার্চে Linux Foundation শেষ BSD-লাইসেন্সের সংস্করণ থেকে Valkey ফর্ক করে। Redis 8 (মে ২০২৫) তৃতীয় লাইসেন্স হিসেবে ওপেন-সোর্স AGPLv3 যোগ করেছে। অ্যাপ্লিকেশন ডেভেলপার হিসেবে আপনার জন্য দুটোর প্রোটোকল ও কমান্ড একই: AWS ও Google Cloud ম্যানেজড Valkey দেয়, Render-এর "Key Value" Redis-সামঞ্জস্যপূর্ণ, আর
redisPython প্যাকেজ (৮.x) সবগুলোর সঙ্গেই কথা বলে। এই পাতায় দুটোকেই "Redis" বলা হয়েছে।
কেন ক্যাশ করবেন, আর কী
ক্যাশ হলো তৈরি করতে খরচ হয়েছে এমন ফলাফলের একটা কপি, যাতে পরের একই রিকোয়েস্টের উত্তর কপি থেকে দেওয়া যায়। এতে পাওয়া যায় কম ল্যাটেন্সি, কম খরচ, আপস্ট্রিমের ওপর কম চাপ আর কিছুটা সহনশীলতা। বিনিময়ে দিতে হয় স্টেলনেস (কপি পুরোনো হতে পারে) আর জটিলতা (কপি কখন মরবে সেটা ঠিক করতে হয়)। যেকোনো মানের ক্ষেত্রে প্রশ্ন তিনটা: এটা বানাতে কত খরচ, কতবার আবার চাওয়া হয়, আর কতটা ভুল হলে চলে?
| AI ব্যাকএন্ডের মান | ক্যাশ করবেন? | সাধারণ TTL |
|---|---|---|
| আপস্ট্রিম API থেকে আবহাওয়া, বিনিময় হার, প্রোডাক্ট ক্যাটালগ | হ্যাঁ | কয়েক মিনিট |
| ডকুমেন্ট চাঙ্কের এমবেডিং (একই টেক্সট + একই মডেল = একই ভেক্টর) | হ্যাঁ, কনটেন্ট হ্যাশ ও মডেল দিয়ে কি বানিয়ে | কয়েক দিন, বা মডেল বদলানো পর্যন্ত |
| একই ফিচারের জন্য ML মডেলের প্রেডিকশন | প্রায়ই | মডেলের ভার্সন বদলানো পর্যন্ত |
| ডিটারমিনিস্টিক (একই ইনপুটে একই উত্তর), ব্যক্তিগত নয় এমন প্রম্পটে LLM-এর উত্তর (FAQ বট) | সাবধানে (নিচে দেখুন) | কয়েক ঘণ্টা |
| ব্যবহারকারীর অ্যাকাউন্ট ব্যালেন্স, পারমিশন, নিরাপত্তা-সংক্রান্ত যেকোনো কিছু | খুব কম; খুব ছোট TTL ও স্পষ্ট ইনভ্যালিডেশন | কয়েক সেকেন্ড |
সৃজনশীল LLM আউটপুট (temperature > 0), স্ট্রিমিং চ্যাট | না | — |
ইন-প্রসেস ক্যাশ
সবচেয়ে সরল ক্যাশ হলো আপনার Python প্রসেসের একটা ডিকশনারি। প্রতিটা এন্ট্রির একটা মেয়াদ লাগে; এখানে ঘড়িটা একটা প্যারামিটার, যাতে উদাহরণটা বারবার একই ফল দেয়:
import time
class TTLCache:
"""A tiny in-process cache: a dict whose entries expire."""
def __init__(self, ttl_seconds, clock=time.monotonic):
self.ttl, self.clock, self.data = ttl_seconds, clock, {}
def get(self, key):
item = self.data.get(key)
if item is None:
return None
value, expires_at = item
if self.clock() >= expires_at:
del self.data[key]
return None
return value
def set(self, key, value):
self.data[key] = (value, self.clock() + self.ttl)
now = [1000.0] # a fake clock we can move
cache = TTLCache(ttl_seconds=60, clock=lambda: now[0])
cache.set("weather:dhaka", {"temp_c": 31.5})
print(cache.get("weather:dhaka"))
now[0] += 61 # 61 seconds later
print(cache.get("weather:dhaka")){'temp_c': 31.5}
Noneইন-প্রসেস ক্যাশ সবচেয়ে দ্রুত (নেটওয়ার্ক হপ নেই) এবং সেই মানগুলোর জন্য ঠিক আছে যা সবখানে একই আর আবার হিসাব করা সস্তা, যেমন লোড করা কনফিগ বা টোকেনাইজার। functools.lru_cache এর বিল্ট-ইন সংস্করণ, তবে তাতে TTL নেই। প্রোডাকশনে এর সীমা ধরা পড়ে: প্রতিটা ওয়ার্কার প্রসেসের (fastapi run --workers 4) এবং প্রতিটা কন্টেইনারের নিজস্ব আলাদা কপি থাকে, ফলে হিট রেট কমে যায় আর ইনভ্যালিডেশন অসম্ভব হয়ে পড়ে ("ঢাকার পূর্বাভাস মুছে দাও" আটটা প্রসেসের একটাতে মোছে); প্রসেসের সঙ্গে মেমরি বাড়ে; আর রিস্টার্টে সব খালি হয়ে যায়। Redis-এর মতো শেয়ার করা ক্যাশ এই তিনটাই ঠিক করে।
Redis-এর মূল কথা: কি, ভ্যালু, TTL
Redis একটা ইন-মেমরি কি-ভ্যালু সার্ভার। ভ্যালু হতে পারে স্ট্রিং, কাউন্টার, হ্যাশ, লিস্ট, সেট ও সর্টেড সেট, আর প্রতিটা কি-র একটা টাইম টু লিভ থাকতে পারে। টেস্ট Redis শেয়ার করা বলে এই পাতার সবকিছু একটা কি প্রিফিক্সের নিচে থাকে, আর একটা হেল্পার শুধু সেই কি-গুলো মোছে (SCAN দিয়ে, যা KEYS *-এর মতো সার্ভার আটকে না দিয়ে কি-স্পেস ছোট ছোট ধাপে হাঁটে)। যে সার্ভার আরও মানুষ ব্যবহার করে সেখানে কখনো FLUSHALL চালাবেন না।
import redis
r = redis.Redis.from_url("redis://localhost:6379/0", decode_responses=True)
PREFIX = "bitbyte:p12:"
def clear_prefix(prefix=PREFIX):
keys = list(r.scan_iter(match=prefix + "*", count=500))
if keys:
r.delete(*keys)
return len(keys)
clear_prefix()
print(r.ping())
r.set(PREFIX + "demo:greeting", "hello", ex=60) # ex = TTL in seconds
print(r.get(PREFIX + "demo:greeting"))
print(r.ttl(PREFIX + "demo:greeting") <= 60)
print(r.get(PREFIX + "demo:missing")) # a miss is None
print(r.incr(PREFIX + "demo:counter"), r.incr(PREFIX + "demo:counter")) # atomic counter
print(r.set(PREFIX + "demo:lock", "worker-1", nx=True, ex=30))
print(r.set(PREFIX + "demo:lock", "worker-2", nx=True, ex=30))True
hello
True
None
1 2
True
Noneযা লক্ষ করবেন:
- কি-র নামই আপনার স্কিমা। প্রতিটা অ্যাপ্লিকেশন ও এনভায়রনমেন্টের জন্য একটা প্রিফিক্স, তারপর জিনিসটার ধরন, তারপর তার পরিচয় ব্যবহার করুন:
bitbyte:prod:weather:dhaka। পরিচয়টা নর্মালাইজ করুন (DhakaআরDHAKAনা, শুধুdhaka), নইলে একই জিনিস তিনবার ক্যাশ হয়। - প্রতিটা ক্যাশ কি-র TTL থাকতেই হবে (
ex=)। TTL ছাড়া কি কেউ মোছা পর্যন্ত থেকে যায়, আর মেমরি এমন ভ্যালুতে ভরে যায় যা আর কেউ চাইবে না। শুধু-ক্যাশ সার্ভারে নিরাপত্তা-জাল হিসেবেmaxmemoryওallkeys-lru-র মতো ইভিকশন পলিসি সেট করুন। INCRঅ্যাটমিক: দুজন ওয়ার্কার একসঙ্গে বাড়ালে দুজনেই 1 পড়ে 2 লিখতে পারে না। রেট লিমিটিংয়ের ভিত্তি এটাই।SET … NXশুধু তখনই সেট করে যখন কি-টা নেই: দ্বিতীয় ওয়ার্কার পায়None। TTL-সহ এটা একটা সরল লক হয়ে যায়, যাতে একটা ভারী রিপোর্ট শুধু একজন ওয়ার্কারই আবার বানায়।
FastAPI সার্ভিসে ক্যাশ-অ্যাসাইড
সবচেয়ে প্রচলিত প্যাটার্ন হলো ক্যাশ-অ্যাসাইড: ক্যাশে দেখুন; হিট হলে কপি ফেরত দিন; মিস হলে সোর্সে কল করুন, ফলাফল TTL দিয়ে জমা রাখুন, আর ফেরত দিন। নিচে Practice API-র আবহাওয়া এন্ডপয়েন্টের সামনে একটা ছোট সার্ভিস। এতে রেট লিমিটার, রিকোয়েস্ট ID ও মেট্রিক্সও আছে, যেগুলো পরের সেকশনগুলো ব্যাখ্যা করবে, তাই এখন একবার পড়ে নিন, পরে ফিরে আসবেন। সেটিংস আসে এনভায়রনমেন্ট ভ্যারিয়েবল থেকে, যেমন ডিপেন্ডেন্সি, লাইফস্প্যান, মিডলওয়্যার ও সেটিংস পাতায় ছিল:
# file: main.py
"""BitByte Weather: a cached, rate-limited proxy in front of the Practice API."""
import json
import logging
import os
import uuid
from contextlib import asynccontextmanager
import httpx
import redis.asyncio as aioredis
from fastapi import Depends, FastAPI, HTTPException, Request, Response
from redis.exceptions import RedisError
PRACTICE_API_URL = os.environ.get("PRACTICE_API_URL", "http://localhost:8800")
REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/0")
PREFIX = os.environ.get("KEY_PREFIX", "bitbyte:p12:")
WEATHER_TTL = 300 # seconds a cached forecast stays fresh
RATE_LIMIT = 5 # requests ...
RATE_WINDOW = 60 # ... per 60 seconds, per client
logger = logging.getLogger("weather")
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.http = httpx.AsyncClient(
base_url=PRACTICE_API_URL, timeout=httpx.Timeout(5.0, connect=2.0))
app.state.redis = aioredis.Redis.from_url(
REDIS_URL, decode_responses=True, socket_timeout=0.5, socket_connect_timeout=0.5)
yield
await app.state.http.aclose()
await app.state.redis.aclose()
app = FastAPI(title="BitByte Weather", lifespan=lifespan)
async def count(request: Request, field: str) -> None:
"""Add 1 to a shared counter. Metrics must never break a request."""
try:
await request.app.state.redis.hincrby(PREFIX + "metrics", field, 1)
except RedisError:
pass
@app.middleware("http")
async def request_id_and_metrics(request: Request, call_next):
request.state.request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex
response = await call_next(request)
response.headers["X-Request-ID"] = request.state.request_id
await count(request, f"status:{response.status_code}")
return response
def client_key(request: Request) -> str:
"""Who is calling: the API key if there is one, else the IP address."""
return request.headers.get("X-API-Key") or request.client.host
async def rate_limit(request: Request, response: Response) -> None:
"""Fixed window: RATE_LIMIT requests per RATE_WINDOW seconds per client."""
key = f"{PREFIX}rate:{client_key(request)}"
try:
async with request.app.state.redis.pipeline() as pipe:
pipe.incr(key)
pipe.expire(key, RATE_WINDOW, nx=True) # the window starts at the first request
pipe.ttl(key)
used, _, ttl = await pipe.execute()
except RedisError:
logger.warning("rate limiter unavailable, allowing the request") # fail open
return
remaining = max(0, RATE_LIMIT - used)
headers = {"X-RateLimit-Limit": str(RATE_LIMIT), "X-RateLimit-Remaining": str(remaining)}
if used > RATE_LIMIT:
raise HTTPException(429, "Rate limit exceeded", headers={**headers, "Retry-After": str(ttl)})
response.headers.update(headers)
@app.get("/health")
async def health(request: Request) -> dict:
try:
await request.app.state.redis.ping()
redis_state = "up"
except RedisError:
redis_state = "down"
return {"status": "ok", "redis": redis_state}
@app.get("/v1/weather/{city}", dependencies=[Depends(rate_limit)])
async def weather(city: str, request: Request, response: Response) -> dict:
key = f"{PREFIX}weather:{city.lower()}"
try:
cached = await request.app.state.redis.get(key)
except RedisError:
cached = None
response.headers["X-Cache"] = "BYPASS" # degrade: no cache, still answer
if cached is not None:
response.headers["X-Cache"] = "HIT"
await count(request, "cache:hit")
return json.loads(cached)
try:
upstream = await request.app.state.http.get(
"/v1/weather", params={"city": city},
headers={"X-Request-ID": request.state.request_id})
except httpx.TimeoutException:
raise HTTPException(504, "Weather service timed out")
except httpx.HTTPError:
raise HTTPException(502, "Weather service unreachable")
if upstream.status_code == 404:
raise HTTPException(404, f"Unknown city: {city}")
if upstream.status_code != 200:
raise HTTPException(502, "Weather service error")
data = upstream.json()
response.headers.setdefault("X-Cache", "MISS")
await count(request, "cache:miss")
try:
await request.app.state.redis.set(key, json.dumps(data), ex=WEATHER_TTL)
except RedisError:
pass
return data
@app.delete("/v1/weather/{city}/cache", status_code=204)
async def forget_weather(city: str, request: Request) -> None:
await request.app.state.redis.delete(f"{PREFIX}weather:{city.lower()}")
@app.get("/metrics")
async def metrics(request: Request) -> dict[str, int]:
raw = await request.app.state.redis.hgetall(PREFIX + "metrics")
return {name: int(value) for name, value in sorted(raw.items())}এটা ব্যাকগ্রাউন্ডে চালু করুন, অপেক্ষা করুন, আর ঢাকার আবহাওয়া তিনবার চান। curl -w শুধু স্ট্যাটাস কোড আর X-Cache হেডার ছাপে:
export PRACTICE_API_URL=http://localhost:8800
export REDIS_URL=redis://localhost:6379/0
fastapi run main.py --port 8000 > server.log 2>&1 &
export SERVER_PID=$!
curl -s --retry 20 --retry-connrefused --retry-delay 0 http://localhost:8000/health > /dev/null
for i in 1 2 3; do
curl -s -o /dev/null -w '%{http_code} X-Cache: %header{x-cache}\n' \
-H "X-API-Key: nadia-key" http://localhost:8000/v1/weather/Dhaka
done
curl -s -H "X-API-Key: nadia-key" http://localhost:8000/v1/weather/dhaka200 X-Cache: MISS
200 X-Cache: HIT
200 X-Cache: HIT
{"city":"Dhaka","temp_c":31.5,"humidity":78,"condition":"Humid"}প্রথম রিকোয়েস্ট ছিল MISS: সার্ভিস Practice API-কে কল করে উত্তরটা ৩০০ সেকেন্ডের জন্য জমা রেখেছে। পরের রিকোয়েস্টগুলো HIT, আপস্ট্রিমে না গিয়ে Redis থেকে এসেছে; dhaka আর Dhaka একই কি-তে পড়েছে, কারণ কি নর্মালাইজ করা। এখন Redis-এ এই কি-গুলো আছে:
python - <<'EOF'
import os, redis
r = redis.Redis.from_url(os.environ["REDIS_URL"], decode_responses=True)
for name in sorted(r.scan_iter(match="bitbyte:p12:*")):
print(name)
print(r.get("bitbyte:p12:weather:dhaka"))
EOFbitbyte:p12:demo:counter
bitbyte:p12:demo:greeting
bitbyte:p12:demo:lock
bitbyte:p12:metrics
bitbyte:p12:rate:nadia-key
bitbyte:p12:weather:dhaka
{"city": "Dhaka", "temp_c": 31.5, "humidity": 78, "condition": "Humid"}ইনভ্যালিডেশন
"কম্পিউটার সায়েন্সে কঠিন জিনিস দুটোই: ক্যাশ ইনভ্যালিডেশন আর নাম দেওয়া।" আপনার হাতে তিনটা টুল, সাধারণত একসঙ্গে ব্যবহার হয়:
- TTL-এর মেয়াদ শেষ: কিছুক্ষণ পর কপি মরে যায়। সরল, আর সবসময় শেষ ভরসা হিসেবে থাকে। TTL ঠিক করুন মানটা কতটা পুরোনো হতে পারে তা দেখে, কত ঘন ঘন বদলায় তা দেখে নয়।
- লেখার সময় স্পষ্ট ডিলিট: আপনার নিজের কোড যখন সোর্স বদলায় (একটা কোর্স এডিট হলো), একই কোড-পথে ক্যাশের কপিটা মুছে দিন। পরের রিড সেটা আবার ভরবে।
- ভার্সনযুক্ত কি: কি-তে একটা ভার্সন রাখুন (
embeddings:v3:…,model-2026-10:…)। ভার্সন বাড়ালে পুরোনো সব কি একসঙ্গে নাগালের বাইরে চলে যায়; তাদের TTL পরিষ্কার করে দেয়।
সার্ভিসে একটা ডিলিট এন্ডপয়েন্ট আছে। তারপর পরের রিড আবার মিস হয়:
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE -H "X-API-Key: tanvir-key" \
http://localhost:8000/v1/weather/Dhaka/cache
curl -s -o /dev/null -w '%{http_code} X-Cache: %header{x-cache}\n' \
-H "X-API-Key: tanvir-key" http://localhost:8000/v1/weather/Dhaka204
200 X-Cache: MISSপ্রোডাকশনের দুটো সমস্যার নাম জেনে রাখুন। একটা জনপ্রিয় কি-র মেয়াদ শেষ হলে হাজারটা রিকোয়েস্ট একসঙ্গে মিস করে সবাই আপস্ট্রিমকে কল করলে হয় ক্যাশ স্ট্যাম্পিড। সমাধান: SET NX দিয়ে ছোট একটা লক, যাতে একটা রিকোয়েস্ট ভরে আর বাকিরা অপেক্ষা করে বা পুরোনো কপি পায়; অথবা TTL-এ সামান্য র্যান্ডম জিটার, যাতে সব কি একসঙ্গে মেয়াদ না হারায়। নেগেটিভ ক্যাশিং হলো "পাওয়া যায়নি" ফলাফলটা অল্প সময়ের জন্য ক্যাশ করা, যাতে যে শহর নেই তার জন্য বারবার রিকোয়েস্ট এলে প্রতিটায় আপস্ট্রিম কল না লাগে; এর TTL ছোট রাখুন।
LLM-এর ফলাফল সাবধানে ক্যাশ করা
LLM কল আপনার স্ট্যাকের সবচেয়ে দামি জিনিস, তাই ক্যাশ করার লোভ হয়, আর কখনো সেটা ঠিকও: temperature=0 দেওয়া একটা FAQ বট দিনে একই প্রশ্ন অনেকবার পায়। কিন্তু অসাবধানে বানানো LLM ক্যাশ ডেটা ফাঁস করে আর ভুল উত্তর দেয়। নিয়মগুলো:
- কি-তে উত্তরকে প্রভাবিত করে এমন সবকিছু থাকতে হবে: প্রোভাইডার ও মডেল, সিস্টেম প্রম্পট (বা তার ভার্সন), প্রতিটা মেসেজ, আর temperature ও টুলসের মতো প্যারামিটার। প্রম্পটের ভার্সন বদলালে পুরোনো উত্তর নাগালের বাইরে চলে যেতে হবে।
- ক্যানোনিকাল রূপ হ্যাশ করুন। সর্টেড কি ও নির্দিষ্ট সেপারেটর দিয়ে সিরিয়ালাইজ করুন, তারপর SHA-256 দিয়ে হ্যাশ করুন। কি-র নামে কাঁচা প্রম্পট রাখবেন না (সেগুলো লম্বা, আর ব্যক্তিগত তথ্য থাকতে পারে)।
- ব্যক্তিগত প্রসঙ্গকে স্কোপ করুন। প্রম্পটে ব্যবহারকারীর কিছু থাকলে (নাম, ডকুমেন্ট, ইতিহাস), কি-তে ব্যবহারকারীর ID রাখুন, নয়তো ক্যাশই করবেন না। নইলে ব্যবহারকারী B পেয়ে যাবে A-র জন্য লেখা উত্তর।
- শুধু ডিটারমিনিস্টিক, সফল ফলাফল ক্যাশ করুন:
temperature> 0 হলে ক্যাশ নয়, এরর বা প্রত্যাখ্যান কখনো ক্যাশ নয়, আর এন্ট্রিতে TTL দিন। - প্রোভাইডারের দিকের প্রম্পট ক্যাশিং (OpenAI, Anthropic) আলাদা জিনিস: এটা বারবার আসা লম্বা প্রিফিক্স-কে সস্তা করে, কিন্তু মডেল তবুও নতুন উত্তর তৈরি করে। দুটোই ব্যবহার করুন।
import hashlib
import json
import httpx
PROMPT_VERSION = "faq-v3"
CHAT_URL = "http://localhost:8800/v1/chat"
HEADERS = {"Authorization": "Bearer token-nadia-001"}
def llm_cache_key(model, messages, temperature, user_scope=None):
payload = {"model": model, "prompt_version": PROMPT_VERSION, "messages": messages,
"temperature": temperature, "scope": user_scope}
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
return PREFIX + "llm:" + hashlib.sha256(canonical.encode()).hexdigest()[:32]
def cached_chat(messages, model="practice-llm-1", temperature=0.0, user_scope=None):
if temperature > 0: # creative output: never cached
return httpx.post(CHAT_URL, headers=HEADERS, timeout=10,
json={"model": model, "messages": messages}).json(), "SKIP"
key = llm_cache_key(model, messages, temperature, user_scope)
if (hit := r.get(key)) is not None:
return json.loads(hit), "HIT"
resp = httpx.post(CHAT_URL, headers=HEADERS, timeout=10,
json={"model": model, "messages": messages})
resp.raise_for_status() # errors are not cached
data = resp.json()
r.set(key, json.dumps(data), ex=3600)
return data, "MISS"
question = [{"role": "user", "content": "What is a vector database?"}]
saved = 0
for attempt in range(3):
data, status = cached_chat(question)
if status == "HIT":
saved += data["usage"]["total_tokens"]
print(status, data["message"]["content"])
print("tokens saved:", saved)
print(cached_chat([{"role": "user", "content": "what is a vector database"}])[1])
print(cached_chat(question, user_scope="user-42")[1])
print(cached_chat(question, temperature=0.7)[1])MISS This is a practice answer about: What is a vector database. A real model would say more.
HIT This is a practice answer about: What is a vector database. A real model would say more.
HIT This is a practice answer about: What is a vector database. A real model would say more.
tokens saved: 44
MISS
MISS
SKIPএকই প্রশ্ন আরও দুবার করতে কোনো খরচ হয়নি। একটু আলাদা বানানে মিস হয়েছে: এক্সাক্ট-ম্যাচ ক্যাশ শুধু হুবহু পুনরাবৃত্তিতে কাজ করে। সেমান্টিক ক্যাশ বদলে এমবেডিংয়ের সাদৃশ্য দিয়ে মেলায়, এতে হিট রেট বাড়ে, কিন্তু সদৃশ প্রশ্নের এমন উত্তর ফেরত আসতে পারে যা এই প্রশ্নের জন্য ভুল; এগুলো শুধু কড়া সাদৃশ্য-সীমা দিয়ে আর কম ঝুঁকির কনটেন্টে ব্যবহার করুন। ব্যবহারকারী-স্কোপের রিকোয়েস্টটাও মিস হয়েছে, যা হওয়াই উচিত।
রেট লিমিটিং
রেট লিমিট নির্ধারণ করে একজন ক্লায়েন্ট একটা সময়ের মধ্যে কতগুলো রিকোয়েস্ট করতে পারবে। এটা আপনার সার্ভারকে ওভারলোড ও অপব্যবহার থেকে বাঁচায়, LLM বাজেট রক্ষা করে, কাস্টমারদের মধ্যে ক্ষমতা ন্যায্যভাবে ভাগ করে, আর আপনাকে প্ল্যান বিক্রি করতে দেয় ("ফ্রি: মিনিটে ২০টা রিকোয়েস্ট")। মজবুত API ডিজাইন: পেজিনেশন, রেট লিমিট, রিট্রাই ও ভার্সনিং পাতায় আপনি ক্লায়েন্ট হিসেবে লিমিটের মুখোমুখি হয়েছিলেন; এখানে সেগুলো প্রয়োগ করছেন। তিনটা সিদ্ধান্ত নিতে হবে:
- ক্লায়েন্ট কে? যখনই থাকে, অথেন্টিকেটেড ব্যবহারকারী বা API কি; IP ঠিকানা শুধু লগইনবিহীন (anonymous) এন্ডপয়েন্টে, যেমন লগইন (আর প্রক্সির পেছনে হলে IP নিন প্রক্সির বিশ্বস্ত হেডার থেকে, ক্লায়েন্টের পাঠানো যেকোনো
X-Forwarded-Forথেকে নয়)। - কী গোনা হচ্ছে? রিকোয়েস্ট, নাকি খরচ: LLM এন্ডপয়েন্টের উচিত টোকেন গোনা, কারণ একটা ৮,০০০-টোকেনের রিকোয়েস্টের খরচ একশোটা ছোট রিকোয়েস্টের সমান।
- কাউন্টার কোথায়? Redis-এ, যাতে প্রতিটা ওয়ার্কার ও কন্টেইনার সেটা শেয়ার করে। প্রতি-প্রসেস কাউন্টারে ক্লায়েন্ট N × ওয়ার্কার সংখ্যা রিকোয়েস্ট করতে পারে।
ফিক্সড উইন্ডো
নির্দিষ্ট সময়ের খণ্ডে (12:00:00–12:00:59, 12:01:00–…) INCR দিয়ে রিকোয়েস্ট গুনুন, আর কি-টাকে উইন্ডোর সঙ্গে মেয়াদোত্তীর্ণ হতে দিন। নিচের ফাংশনগুলো now প্যারামিটার হিসেবে নেয় যাতে উদাহরণ বারবার একই ফল দেয়; প্রোডাকশনে time.time() দিন:
def fixed_window(key, limit, window, now):
bucket = f"{PREFIX}fw:{key}:{int(now // window)}"
with r.pipeline() as pipe: # MULTI/EXEC: both or neither
pipe.incr(bucket)
pipe.expire(bucket, window)
used, _ = pipe.execute()
return used <= limit
for t in [58, 58.5, 59, 59.5, 60, 60.5, 61, 61.5]:
print(f"t={t:>4}: {'allowed' if fixed_window('nadia', 3, 60, t) else 'rejected'}")t= 58: allowed
t=58.5: allowed
t= 59: allowed
t=59.5: rejected
t= 60: allowed
t=60.5: allowed
t= 61: allowed
t=61.5: rejectedসস্তা (প্রতি ক্লায়েন্টে একটা কাউন্টার) আর বোঝানো সহজ, কিন্তু কি নারাটা দেখুন: ৬০ সেকেন্ডের ঠিক আগে তিনটা আর ঠিক পরে তিনটা রিকোয়েস্ট সবই অনুমোদিত, মানে "মিনিটে ৩টা" সীমার বিপরীতে তিন সেকেন্ডে ৬টা রিকোয়েস্ট।
স্লাইডিং উইন্ডো লগ
প্রতিটা রিকোয়েস্টের সময় একটা সর্টেড সেটে রাখুন (স্কোর = টাইমস্ট্যাম্প), উইন্ডোর চেয়ে পুরোনোগুলো ফেলে দিন, আর যা বাকি থাকে তা গুনুন। উইন্ডো ঘড়ির সঙ্গে সরে, তাই কাজে লাগানোর মতো কোনো কি নারা নেই:
def sliding_window(key, limit, window, now, request_id):
zkey = f"{PREFIX}sw:{key}"
with r.pipeline() as pipe:
pipe.zremrangebyscore(zkey, 0, now - window) # forget requests outside the window
pipe.zadd(zkey, {request_id: now})
pipe.zcard(zkey)
pipe.expire(zkey, window)
_, _, used, _ = pipe.execute()
if used > limit:
r.zrem(zkey, request_id) # a rejected request does not count
return False
return True
for n, t in enumerate([58, 58.5, 59, 59.5, 60, 60.5, 61, 61.5]):
print(f"t={t:>4}: {'allowed' if sliding_window('nadia', 3, 60, t, f'req-{n}') else 'rejected'}")t= 58: allowed
t=58.5: allowed
t= 59: allowed
t=59.5: rejected
t= 60: rejected
t=60.5: rejected
t= 61: rejected
t=61.5: rejectedনিখুঁত, কিন্তু প্রতি রিকোয়েস্টে একটা এন্ট্রি জমা রাখে, তাই লিমিটের সঙ্গে মেমরি বাড়ে। একটা প্রচলিত মধ্যপথ, স্লাইডিং উইন্ডো কাউন্টার, দুটো ফিক্সড-উইন্ডো কাউন্টার রাখে এবং আগেরটাকে ওজন দেয় স্লাইডিং উইন্ডোর সঙ্গে সেটা কতটা মিলছে সেই অনুপাতে।
টোকেন বাকেট
একটা বাকেটে ক্যাপাসিটি পর্যন্ত টোকেন থাকে এবং সেটা স্থির রেটে ভরে। প্রতিটা রিকোয়েস্ট টোকেন নেয় (রিকোয়েস্ট প্রতি একটা, বা তার আনুমানিক LLM টোকেন); টোকেন কম পড়লে রিকোয়েস্ট প্রত্যাখ্যাত হয়। এতে অল্প সময়ের বার্স্ট চলে কিন্তু গড় রেট ধরে রাখা হয়, যা মানুষ আসলে চ্যাট অ্যাপ যেভাবে ব্যবহার করে তার সঙ্গে মেলে। পড়া-বদলানো-লেখা অ্যাটমিক হতে হবে, তাই এটা Redis-এর ভেতরে ছোট একটা Lua স্ক্রিপ্ট হিসেবে চলে:
TOKEN_BUCKET = r.register_script("""
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local state = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(state[1]) or capacity
local ts = tonumber(state[2]) or now
tokens = math.min(capacity, tokens + (now - ts) * rate)
local allowed = 0
if tokens >= cost then
tokens = tokens - cost
allowed = 1
end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', KEYS[1], math.ceil(capacity / rate) + 1)
return {allowed, tostring(tokens)}
""")
def token_bucket(key, capacity, rate, now, cost=1):
allowed, tokens = TOKEN_BUCKET(keys=[f"{PREFIX}tb:{key}"], args=[capacity, rate, now, cost])
return bool(allowed), float(tokens)
for t in [0, 0, 0, 0, 0, 0, 2, 2, 2]: # a burst of 6, then 2 seconds later 3 more
ok, left = token_bucket("nadia", capacity=5, rate=1.0, now=t)
print(f"t={t}: {'allowed' if ok else 'rejected'} tokens left {left:g}")
print(token_bucket("nadia", capacity=5, rate=1.0, now=10, cost=4)) # an expensive requestt=0: allowed tokens left 4
t=0: allowed tokens left 3
t=0: allowed tokens left 2
t=0: allowed tokens left 1
t=0: allowed tokens left 0
t=0: rejected tokens left 0
t=2: allowed tokens left 1
t=2: allowed tokens left 0
t=2: rejected tokens left 0
(True, 1.0)Lua স্ক্রিপ্ট Redis-এ অ্যাটমিকভাবে চলে: মাঝখানে আর কোনো কমান্ড চলে না, তাই দুজন ওয়ার্কার কখনো একই টোকেন খরচ করতে পারে না। (প্রোডাকশনে now নিন স্ক্রিপ্টের ভেতরের redis.call('TIME') থেকে, যাতে সব ওয়ার্কার একই ঘড়ি ব্যবহার করে।)
| অ্যালগরিদম | প্রতি ক্লায়েন্টে মেমরি | বার্স্ট | কোথায় ভালো |
|---|---|---|---|
| ফিক্সড উইন্ডো | ১টা কাউন্টার | উইন্ডোর কি নারায় ২ গুণ পর্যন্ত | সরল কোটা, দৈনিক লিমিট |
| স্লাইডিং উইন্ডো লগ | প্রতি রিকোয়েস্টে ১টা এন্ট্রি | নেই | নিখুঁত হওয়া জরুরি এমন ছোট লিমিট (লগইন চেষ্টা) |
| স্লাইডিং উইন্ডো কাউন্টার | ২টা কাউন্টার | ছোট, আনুমানিক | বড় পরিসরে সাধারণ API লিমিট |
| টোকেন বাকেট | ২টা সংখ্যা | ক্যাপাসিটি পর্যন্ত অনুমোদিত | ব্যবহারকারী-মুখী API, খরচ-ভিত্তিক LLM লিমিট |
সার্ভিসে লিমিটার
main.py সবচেয়ে সরল ফিক্সড উইন্ডো ব্যবহার করে, একটা বাড়তি কৌশলসহ: EXPIRE … NX শুধু প্রথম রিকোয়েস্টে TTL বসায়, তাই প্রতিটা ক্লায়েন্টের উইন্ডো শুরু হয় তার প্রথম কলে, আর বাকি TTL-ই হলো Retry-After-এর মান। নাদিয়া এ পর্যন্ত ৪টা রিকোয়েস্ট করেছে। তার পঞ্চমটা অনুমোদিত হয় কিন্তু কিছু বাকি থাকে না, আর তার পরের প্রতিটা রিকোয়েস্ট উইন্ডো শেষ না হওয়া পর্যন্ত 429 পায়; তানভীরের কাউন্টার আলাদা:
for i in 5 6 7; do
curl -s -o /dev/null -H "X-API-Key: nadia-key" http://localhost:8000/v1/weather/Dhaka \
-w '%{http_code} remaining=%header{x-ratelimit-remaining} retry-after=%header{retry-after}\n'
done | sed -E 's/retry-after=[0-9]+/retry-after=N/' # N = seconds left in her window
curl -s -H "X-API-Key: nadia-key" http://localhost:8000/v1/weather/Dhaka; echo
curl -s -o /dev/null -w 'tanvir: %{http_code}\n' -H "X-API-Key: tanvir-key" \
http://localhost:8000/v1/weather/Dhaka200 remaining=0 retry-after=
429 remaining=0 retry-after=N
429 remaining=0 retry-after=N
{"detail":"Rate limit exceeded"}
tanvir: 200ভালো একটা 429 জানায় কতক্ষণ অপেক্ষা করতে হবে (Retry-After, সেকেন্ডে) এবং কোটা কতটা বাকি। X-RateLimit-* নামগুলো বহুল ব্যবহৃত একটা রীতি; একটা IETF ড্রাফট RateLimit ও RateLimit-Policy হেডারকে স্ট্যান্ডার্ড করছে, তাই এগুলোও দেখতে পাবেন। লিমিট একাধিক স্তরে বসান: প্রক্সি বা CDN-এ উদার প্রতি-IP লিমিট, অ্যাপে প্রতি-ব্যবহারকারী বা প্রতি-কি লিমিট, আর লগইন ও পাসওয়ার্ড রিসেটে কড়া লিমিট।
কনকারেন্সি লিমিট
রেট লিমিট সময় ধরে রিকোয়েস্ট গোনে; কনকারেন্সি লিমিট ঠিক করে একই মুহূর্তে কতগুলো চলতে পারবে। ধীর কাজে এটা জরুরি: একটা LLM কলে ২০ সেকেন্ড লাগলে সামান্য রিকোয়েস্ট রেটেও প্রোভাইডারের সঙ্গে শত শত খোলা কানেকশন হয় (যাদের নিজেদের লিমিট তখন আপনাকে ফিরিয়ে দেবে), অথবা একটা GPU মডেল সার্ভারে স্লট মাত্র ৪টা। একটা প্রসেসের ভেতরে এর টুল হলো asyncio.Semaphore:
import asyncio
import httpx
async def call_upstream(client, limit, stats):
async with limit: # wait for a free slot
stats["running"] += 1
stats["peak"] = max(stats["peak"], stats["running"])
resp = await client.get("/v1/slow", params={"seconds": 0.2})
stats["running"] -= 1
return resp.status_code
async def main():
stats = {"running": 0, "peak": 0}
limit = asyncio.Semaphore(3) # at most 3 calls in flight
async with httpx.AsyncClient(base_url="http://localhost:8800", timeout=5) as client:
codes = await asyncio.gather(*(call_upstream(client, limit, stats) for _ in range(10)))
print(codes)
print("peak concurrent calls:", stats["peak"])
asyncio.run(main())[200, 200, 200, 200, 200, 200, 200, 200, 200, 200]
peak concurrent calls: 3দশটা রিকোয়েস্ট, একসঙ্গে কখনো তিনটার বেশি নয়। সেমাফোর একটা প্রসেসকে রক্ষা করে; সব ওয়ার্কার মিলিয়ে কনকারেন্সি সীমিত করতে (যেমন "প্রতি ব্যবহারকারী একসঙ্গে সর্বোচ্চ ২টা চ্যাট স্ট্রিম করতে পারবে") গণনাটা Redis-এ রাখুন: কাজ শুরুতে INCR, শেষে finally ব্লকে DECR, আর কি-তে একটা EXPIRE, যাতে ক্র্যাশ করা ওয়ার্কার চিরকালের জন্য একটা স্লট আটকে রাখতে না পারে।
টাইমআউট ও ডেডলাইন
প্রতিটা নেটওয়ার্ক কলে টাইমআউট লাগে। না থাকলে আটকে যাওয়া আপস্ট্রিম আপনার ওয়ার্কার, কানেকশন আর ব্যবহারকারীকে চিরকাল ধরে রাখে। httpx কানেক্ট করা আর ডেটার জন্য অপেক্ষা করার আলাদা সীমা দিতে দেয়:
timeout = httpx.Timeout(5.0, connect=2.0) # connect: 2 s; read, write, pool: 5 s
print(timeout)
try:
httpx.get("http://localhost:8800/v1/slow", params={"seconds": 2}, timeout=0.5)
except httpx.TimeoutException as exc:
print("gave up:", type(exc).__name__)Timeout(connect=2.0, read=5.0, write=5.0, pool=5.0)
gave up: ReadTimeoutএকটা রিকোয়েস্ট একাধিক কল করলে, বা রিট্রাই করলে, প্রতি-কল টাইমআউট যথেষ্ট নয়। ডেডলাইন হলো পুরো কাজের জন্য মোট সময়ের বাজেট; প্রতিটা ধাপ পায় কেবল যা বাকি আছে তা। আপনার API ৩ সেকেন্ডে উত্তর দেওয়ার প্রতিশ্রুতি দিলে ২.৯ সেকেন্ডে শুরু হওয়া রিট্রাইয়ের কোনো মানে নেই:
import time
def get_with_deadline(path, budget_seconds, max_attempts=4):
deadline = time.monotonic() + budget_seconds
for attempt in range(1, max_attempts + 1):
remaining = deadline - time.monotonic()
if remaining <= 0:
return f"deadline exceeded after {attempt - 1} attempt(s)"
try:
resp = httpx.get("http://localhost:8800" + path, timeout=remaining)
except httpx.TimeoutException:
return f"deadline exceeded after {attempt} attempt(s)"
if resp.status_code not in (429, 502, 503, 504):
return f"{resp.status_code} on attempt {attempt}"
time.sleep(min(0.1 * 2 ** attempt, max(0.0, deadline - time.monotonic())))
return "gave up: too many attempts"
print(get_with_deadline("/v1/flaky?key=deadline-demo&fail_times=2", budget_seconds=3))
print(get_with_deadline("/v1/slow?seconds=5", budget_seconds=1))200 on attempt 3
deadline exceeded after 1 attempt(s)async কোডে async with asyncio.timeout(remaining): (Python 3.11+) যেকোনো await-ব্লকের চারপাশে ডেডলাইন বসায়। ডেডলাইনটা ডাউনস্ট্রিম সার্ভিসগুলোকেও জানিয়ে দিন (gRPC এটা নিজে থেকেই করে; HTTP-তে X-Request-Deadline-এর মতো একটা হেডার প্রচলিত রীতি), যাতে কলার যে উত্তর ছেড়ে দিয়েছে তার জন্য কেউ কাজ চালিয়ে না যায়।
রিট্রাই, নাকি ফেইল-ফাস্ট?
| পরিস্থিতি | কী করবেন |
|---|---|
কানেকশন এরর, 502/503/504, 429 | এক্সপোনেনশিয়াল ব্যাকঅফ ও জিটার দিয়ে রিট্রাই, Retry-After মানুন, ডেডলাইনের মধ্যে |
400, 401, 403, 404, 422 | ফেইল-ফাস্ট: একই রিকোয়েস্ট আবার পাঠালে একই উত্তর আসবে |
নন-ইডেমপোটেন্ট কল (চার্জ করে বা পাঠায় এমন POST) | শুধু ইডেমপোটেন্সি কি দিয়ে রিট্রাই (দেখুন মজবুত API ডিজাইন: পেজিনেশন, রেট লিমিট, রিট্রাই ও ভার্সনিং) |
| ব্যবহারকারীকে ইতিমধ্যে টোকেন পাঠিয়ে ফেলেছে এমন LLM স্ট্রিম | চুপচাপ রিট্রাই নয়; এরর স্ট্রিমের মধ্যেই জানান |
| আপস্ট্রিম কিছুক্ষণ ধরে ব্যর্থ হচ্ছে | কিছুক্ষণ ওকে কল করা বন্ধ রাখুন: সার্কিট ব্রেকার |
রিট্রাই লোড গুণ করে: তিনটা স্তর প্রত্যেকে তিনবার রিট্রাই করলে ব্যবহারকারীর একটা ক্লিক ধুঁকতে থাকা সার্ভিসে ২৭টা কল হয়ে যায়। রিট্রাই একটা স্তরেই করুন, আর একটা রিট্রাই বাজেট রাখুন (যেমন, রিট্রাই যেন রিকোয়েস্টের সর্বোচ্চ ১০%-এর বেশি না হয়)।
সার্কিট ব্রেকার
আপস্ট্রিম ডাউন থাকলে তার প্রতিটা কল টাইমআউটের জন্য অপেক্ষা করে তারপর ব্যর্থ হয়, যা আপনার ওয়ার্কারদের আটকে রাখে আর সেরে ওঠার চেষ্টা করা সার্ভিসের ওপর বাড়তি লোড দেয়। সার্কিট ব্রেকার ব্যর্থতা নজরে রাখে এবং পরপর অনেকবার ব্যর্থ হলে "খুলে যায়" (open): কলগুলো আপস্ট্রিমে না গিয়ে সঙ্গে সঙ্গে ব্যর্থ হয়। কিছুক্ষণ ঠান্ডা হওয়ার পর সে একটা পরীক্ষামূলক কল যেতে দেয় ("হাফ-ওপেন"); সেটা সফল হলে সার্কিট আবার বন্ধ হয়।
failures >= threshold
CLOSED ─────────────────────────▶ OPEN ──── calls fail fast, upstream untouched
▲ │
│ trial call succeeds │ cool-down elapsed
│ ▼
└─────────────────────────── HALF-OPEN ── one trial call allowed
(trial fails ──▶ back to OPEN)class CircuitOpenError(Exception):
pass
class CircuitBreaker:
def __init__(self, threshold=3, cooldown=30.0, clock=time.monotonic):
self.threshold, self.cooldown, self.clock = threshold, cooldown, clock
self.failures, self.opened_at = 0, None
@property
def state(self):
if self.opened_at is None:
return "closed"
return "half-open" if self.clock() - self.opened_at >= self.cooldown else "open"
def call(self, func, *args, **kwargs):
if self.state == "open":
raise CircuitOpenError("upstream marked as down; failing fast")
try:
result = func(*args, **kwargs)
except Exception:
self.failures += 1
if self.state == "half-open" or self.failures >= self.threshold:
self.opened_at = self.clock() # (re)open
raise
self.failures, self.opened_at = 0, None # success closes the circuit
return result
def flaky_upstream():
resp = httpx.get("http://localhost:8800/v1/flaky",
params={"key": "breaker-demo", "fail_times": 3}, timeout=2)
resp.raise_for_status()
return resp.json()
now = [0.0]
breaker = CircuitBreaker(threshold=3, cooldown=30, clock=lambda: now[0])
for step in range(1, 6):
try:
print(step, breaker.state, "->", breaker.call(flaky_upstream))
except CircuitOpenError as exc:
print(step, breaker.state, "->", exc)
except httpx.HTTPStatusError as exc:
print(step, breaker.state, "->", exc.response.status_code)
now[0] += 30 # the cool-down passes
print(6, breaker.state, "->", breaker.call(flaky_upstream))
print("state now:", breaker.state)1 closed -> 503
2 closed -> 503
3 open -> 503
4 open -> upstream marked as down; failing fast
5 open -> upstream marked as down; failing fast
6 half-open -> {'ok': True, 'attempt': 4}
state now: closed১ থেকে ৩ নম্বর কল আপস্ট্রিমে পৌঁছে ব্যর্থ হয়েছে; তারপর ব্রেকার খুলেছে এবং ৪ ও ৫ নম্বর কল কোনো HTTP রিকোয়েস্ট ছাড়াই তাৎক্ষণিক ব্যর্থ হয়েছে। ঠান্ডা হওয়ার পর একটা পরীক্ষামূলক কল গিয়ে সফল হয়েছে (Practice API তিনবার ব্যর্থ হওয়ার পর সেরে ওঠে) এবং সার্কিট বন্ধ করেছে। pybreaker ও aiobreaker-এর মতো লাইব্রেরি এটা বানিয়ে রেখেছে; অবস্থা রাখুন প্রতিটা আপস্ট্রিমের জন্য আলাদা, আর একাধিক ওয়ার্কারের ডিপ্লয়মেন্টে অবস্থাটা Redis-এ রাখার কথা ভাবুন, যাতে সব ওয়ার্কার একমত থাকে।
গ্রেসফুল ডিগ্রেডেশন
আগে থেকেই ঠিক করে রাখুন প্রতিটা ডিপেন্ডেন্সি ব্যর্থ হলে আপনার সার্ভিস কী করবে, এবং সেটা টেস্ট করুন। AI ব্যাকএন্ডের সাধারণ সিদ্ধান্ত:
- ক্যাশ ডাউন: ক্যাশ ছাড়াই সার্ভ করুন (ধীর, আপস্ট্রিম কল বেশি) — ওপরের সার্ভিস এটাই করে।
- রেট লিমিটার ডাউন: সাধারণ এন্ডপয়েন্টে ফেইল ওপেন (অনুমতি দিন, জোরে লগ করুন); যেখানে অপব্যবহার ব্যয়বহুল, যেমন লগইন বা মিটার-ছাড়া LLM এন্ডপয়েন্ট, সেখানে ফেইল ক্লোজড (প্রত্যাখ্যান করুন)।
- আপস্ট্রিম ডাউন: "পুরোনো" চিহ্ন দেওয়া একটা স্টেল ক্যাশ কপি, ছোট মডেল, আগে থেকে লেখা উত্তর ("সার্চ সাময়িকভাবে বন্ধ"), অথবা
Retry-After-সহ স্পষ্ট503। - LLM প্রোভাইডার ডাউন বা ধীর: একই অ্যাডাপ্টারের পেছনে দ্বিতীয় প্রোভাইডার বা মডেলে ফলব্যাক (সেই অ্যাডাপ্টার বানাবেন স্ট্রিমিংসহ LLM ব্যাকএন্ড বানানো পাতায়)।
সার্ভিসের দ্বিতীয় কপি চালু করুন এমন একটা Redis পোর্টের দিকে তাক করে যেখানে কিছুই শোনে না, আর দেখুন সেটা তবুও কাজ করে:
REDIS_URL=redis://localhost:6390/0 fastapi run main.py --port 8001 > degraded.log 2>&1 &
export DEGRADED_PID=$!
curl -s --retry 20 --retry-connrefused --retry-delay 0 http://localhost:8001/health; echo
curl -s -o /dev/null -w '%{http_code} X-Cache: %header{x-cache}\n' http://localhost:8001/v1/weather/Sylhet
kill $DEGRADED_PID{"status":"ok","redis":"down"}
200 X-Cache: BYPASSহেলথ চেক Redis-কে ডাউন দেখাচ্ছে (একটা রেডিনেস প্রোব এটা দিয়ে অ্যালার্ট দিতে পারে), কিন্তু ব্যবহারকারীরা তবুও আবহাওয়া পাচ্ছে। এখানে main.py-এর ছোট socket_connect_timeout গুরুত্বপূর্ণ: তা না থাকলে প্রতিটা রিকোয়েস্ট Redis ছেড়ে দেওয়ার আগে অপারেটিং সিস্টেমের ডিফল্ট কানেক্ট টাইমআউট পর্যন্ত অপেক্ষা করত।
রিকোয়েস্ট ID ও সাধারণ মেট্রিক্স
রিকোয়েস্ট ID (বা কোরিলেশন ID) হলো একটা অনন্য স্ট্রিং, যা একটা রিকোয়েস্টে এবং তার ফলে ঘটা সবকিছুতে জোড়া থাকে: আপনার লগ লাইন, অন্য সার্ভিসে করা কল, ব্যবহারকারীকে দেখানো এরর। ব্যবহারকারী "ব্যর্থ হয়েছে" বললে এররে থাকা ID দিয়ে আপনি সব সার্ভিস জুড়ে সংশ্লিষ্ট প্রতিটা লগ লাইন খুঁজে পান। main.py-এর মিডলওয়্যার আসা X-Request-ID রেখে দেয় (যাতে কলার বা গেটওয়ে সেট করতে পারে), না থাকলে একটা বানায়, ফেরত দেয়, আর Practice API-তে ফরওয়ার্ড করে:
curl -s -o /dev/null -w 'X-Request-ID: %header{x-request-id}\n' \
-H "X-Request-ID: nadia-debug-001" -H "X-API-Key: farhana-key" \
http://localhost:8000/v1/weather/KhulnaX-Request-ID: nadia-debug-001মেট্রিক্সের জন্য সার্ভিস ক্যাশ হিট-মিস আর স্ট্যাটাস কোড অনুযায়ী রেসপন্স গোনে একটা Redis হ্যাশে, যা সব ওয়ার্কার শেয়ার করে:
curl -s http://localhost:8000/metrics | python -m json.tool
kill $SERVER_PID{
"cache:hit": 5,
"cache:miss": 3,
"status:200": 9,
"status:204": 1,
"status:429": 3
}এ থেকে আপনি ইতিমধ্যেই ক্যাশ হিট রেট আর এরর রেট হিসাব করতে পারেন। আসল সিস্টেম মেট্রিক্স স্ট্যান্ডার্ড ফরম্যাটে এক্সপোর্ট করে গ্রাফ আঁকে: Prometheus কাউন্টার ও হিস্টোগ্রাম, অথবা OpenTelemetry, যা FastAPI এখন নেটিভভাবে সাপোর্ট করে। প্রতিটা সার্ভিসে নজর রাখার চারটা "গোল্ডেন সিগন্যাল" হলো ল্যাটেন্সি (গড় নয়, p95-এর মতো পার্সেন্টাইলে), ট্রাফিক, এরর ও স্যাচুরেশন; AI ব্যাকএন্ডে যোগ করুন প্রতি ব্যবহারকারী ও প্রতি মডেলে টোকেন ও খরচ, ক্যাশ হিট রেট, আর লিমিট ও সার্কিট ব্রেকার কতবার ট্রিগার হলো।
বাস্তবে কোথায় কাজে লাগে
- LLM গেটওয়ে (OpenAI, Anthropic ও লোকাল মডেলের সামনে কোম্পানিগুলো যে অভ্যন্তরীণ সার্ভিস বসায়) বেশিরভাগই এই পাতার বিষয়: Redis-এ প্রতি-টিম টোকেন বাজেট, টোকেন-বাকেট লিমিট, ডিটারমিনিস্টিক কলের জন্য রেসপন্স ক্যাশিং, টাইমআউট, প্রোভাইডারদের মধ্যে ফলব্যাক আর প্রতি-রিকোয়েস্ট খরচের মেট্রিক্স।
- RAG পাইপলাইন এমবেডিং ক্যাশ করে কনটেন্ট হ্যাশ ও মডেলের নাম দিয়ে, তাই অপরিবর্তিত ডকুমেন্ট আবার ইন্ডেক্স করতে কিছুই খরচ হয় না।
- পাবলিক API (GitHub, Stripe, খোদ LLM প্রোভাইডাররা) ঠিক আপনার সার্ভিস এখন যেভাবে করছে সেভাবে
Retry-Afterও লিমিট হেডার সহ429ফেরত দেয়। - অনেক Python ব্যাকএন্ডে Redis সেশন স্টোর, জব কিউ ব্রোকার (Celery, RQ, Arq) এবং pub/sub চ্যানেলও হয়, তাই একটা ম্যানেজড Redis বা Valkey ইনস্ট্যান্স সাধারণত নিজের খরচ তুলে নেয়।
সাধারণ ভুল
- এমন ক্যাশ কি যা একটা মাত্রা বাদ দেয়। LLM-এর উত্তর শুধু প্রশ্ন দিয়ে ক্যাশ করা, তারপর সিস্টেম প্রম্পট বদলানো বা অন্য ব্যবহারকারীকে ব্যক্তিগতকৃত উত্তর দেওয়া। মডেল, প্রম্পটের ভার্সন, প্যারামিটার আর (ব্যক্তিগত প্রসঙ্গ থাকলে) ব্যবহারকারী-স্কোপ হ্যাশ করা কি-তে রাখুন।
- TTL ছাড়া কি। মেমরি ভরে যায়, বা স্টেল ডেটা চিরকাল থাকে। সবসময়
ex=দিন, আর ক্যাশ সার্ভারে ইভিকশন পলিসি সহmaxmemoryসেট করুন। - প্রতি-প্রসেস রেট লিমিট। প্রতিটা ওয়ার্কারে একটা dict কাউন্টার আসল লিমিটকে ওয়ার্কার সংখ্যা দিয়ে গুণ করে আর প্রতিটা ডিপ্লয়ে রিসেট হয়। Redis-এ গুনুন, অ্যাটমিকভাবে (
INCR, পাইপলাইন, Lua)। - রিকোয়েস্ট থেকে অন্ধভাবে নেওয়া
X-Forwarded-Forদিয়ে লিমিট করা। যেকোনো ক্লায়েন্ট ওই হেডার পাঠিয়ে প্রতি রিকোয়েস্টে নতুন লিমিট পেতে পারে। এটাকে বিশ্বাস করুন শুধু যখন আপনার নিজের প্রক্সি সেট করেছে। - টাইমআউট নেই, বা প্রতিটা স্তরে রিট্রাই। আটকে থাকা আপস্ট্রিম আপনার সার্ভিসকেও সঙ্গে নিয়ে ডুবে যায়, আর স্তরে স্তরে রিট্রাই একটা ছোট ঝামেলাকে পুরো আউটেজে পরিণত করে। কানেক্ট ও রিড টাইমআউট দিন, ডেডলাইন ব্যবহার করুন, একটা জায়গায় রিট্রাই করুন, আর অস্থির ডিপেন্ডেন্সির জন্য সার্কিট ব্রেকার যোগ করুন।
নিজে চেষ্টা করুন
- সহজ:
weather_cached(city)লিখুন, যা সিঙ্কrক্লায়েন্ট আর সরাসরি Practice API ব্যবহার করে:PREFIX + "ex:weather:"-এর নিচে ১২০ সেকেন্ড TTL-সহ ক্যাশ-অ্যাসাইড, আর(data, "HIT"|"MISS")ফেরত দেয়।"Rajshahi"-এর জন্য দুবার কল করুন। - মাঝারি: নেগেটিভ ক্যাশিং যোগ করুন: Practice API
404দিলে ৩০ সেকেন্ডের জন্য"__none__"মার্কারটা জমা রাখুন ও(None, "MISS")ফেরত দিন; পরের কলে HTTP রিকোয়েস্ট ছাড়াই(None, "HIT")ফেরত দিন।"Atlantis"দিয়ে দেখান। - কঠিন: LLM এন্ডপয়েন্টের জন্য টোকেন বাকেটকে খরচ-ভিত্তিক করুন: প্রতিটা ব্যবহারকারীর ক্যাপাসিটি ১,০০০ টোকেন, সেকেন্ডে ১০ টোকেন করে ভরে, আর প্রতিটা কলের খরচ Practice API-র
/v1/chatusage-এরtotal_tokens। কলের আগে একটা আনুমানিক মান দিয়ে বাকেট পরীক্ষা করুন (প্রম্পটের শব্দ × ২), আরnow=0-তে পাঁচটা কলের জন্য allowed/rejected ছাপুন, যেখানে প্রতিটা প্রম্পট ২০০ শব্দের।
উত্তর
# 1. Easy
def weather_cached(city):
key = PREFIX + "ex:weather:" + city.lower()
if (hit := r.get(key)) is not None:
return json.loads(hit), "HIT"
resp = httpx.get("http://localhost:8800/v1/weather", params={"city": city}, timeout=5)
resp.raise_for_status()
r.set(key, resp.text, ex=120)
return resp.json(), "MISS"
print(weather_cached("Rajshahi"))
print(weather_cached("Rajshahi")[1])({'city': 'Rajshahi', 'temp_c': 33.6, 'humidity': 58, 'condition': 'Sunny'}, 'MISS')
HIT# 2. Medium
def weather_cached(city):
key = PREFIX + "ex:weather:" + city.lower()
if (hit := r.get(key)) is not None:
return (None if hit == "__none__" else json.loads(hit)), "HIT"
resp = httpx.get("http://localhost:8800/v1/weather", params={"city": city}, timeout=5)
if resp.status_code == 404:
r.set(key, "__none__", ex=30) # short TTL for "not found"
return None, "MISS"
resp.raise_for_status()
r.set(key, resp.text, ex=120)
return resp.json(), "MISS"
print(weather_cached("Atlantis"))
print(weather_cached("Atlantis"))(None, 'MISS')
(None, 'HIT')# 3. Hard
def llm_call_with_budget(user, prompt, now):
estimate = len(prompt.split()) * 2
ok, left = token_bucket(f"llm:{user}", capacity=1000, rate=10, now=now, cost=estimate)
if not ok:
return f"rejected (needs ~{estimate}, has {left:g})"
resp = httpx.post(CHAT_URL, headers=HEADERS, timeout=10,
json={"messages": [{"role": "user", "content": prompt}]})
used = resp.json()["usage"]["total_tokens"]
return f"allowed (estimated {estimate}, used {used}, {left:g} left)"
prompt = " ".join(["word"] * 200)
for call in range(1, 6):
print(call, llm_call_with_budget("nadia", prompt, now=0))
print("cleaned up keys:", clear_prefix() > 0)1 allowed (estimated 400, used 412, 600 left)
2 allowed (estimated 400, used 412, 200 left)
3 rejected (needs ~400, has 200)
4 rejected (needs ~400, has 200)
5 rejected (needs ~400, has 200)
cleaned up keys: Trueপ্রোডাকশন সংস্করণ কলের পরেও বাকেট সংশোধন করবে আনুমানিক ও আসল টোকেনের পার্থক্য দিয়ে (বাড়তিটা খরচ করে, বা অব্যবহৃতটা ফেরত দিয়ে)।
সারসংক্ষেপ
- যা দামি, বারবার চাওয়া হয় আর সামান্য পুরোনো হলে ক্ষতি নেই তা ক্যাশ করুন; প্রতিটা কি-তে প্রিফিক্স, নর্মালাইজ করা পরিচয় ও TTL দিন; TTL, লেখার সময় ডিলিট আর ভার্সনযুক্ত কি দিয়ে ইনভ্যালিডেট করুন।
- ওয়ার্কারদের মধ্যে শেয়ার হয় এমন সবকিছুর জন্য Redis (বা Valkey, একই ক্লায়েন্ট) ব্যবহার করুন: ক্যাশ, কাউন্টার, লিমিট, লক, মেট্রিক্স।
- LLM-এর ফলাফল শুধু ডিটারমিনিস্টিক কলের জন্য ক্যাশ করুন, মডেল, প্রম্পটের ভার্সন, প্যারামিটার ও ব্যবহারকারী-স্কোপ হ্যাশ করা কি-তে রেখে; এরর কখনো ক্যাশ করবেন না।
- প্রতি ব্যবহারকারী বা কি ধরে Redis-এ রেট লিমিট করুন: সরলতার জন্য ফিক্সড উইন্ডো, নিখুঁততার জন্য স্লাইডিং উইন্ডো, বার্স্ট ও টোকেন খরচের জন্য টোকেন বাকেট; উত্তর দিন
Retry-After-সহ429। - কনকারেন্সি সীমিত করুন, টাইমআউট ও ডেডলাইন দিন, শুধু রিট্রাইযোগ্য জিনিস একটা স্তরে রিট্রাই করুন, আর প্রতিটা ডিপেন্ডেন্সির জন্য সার্কিট ব্রেকার ও পরিকল্পিত ডিগ্রেডেশন রাখুন।
- প্রতিটা রিকোয়েস্টে ID লাগান এবং হিট, মিস, এরর ও খরচ গুনুন, যাতে আপনার লিমিট ও ক্যাশ কী করছে তা দেখতে পান।
এরপর: পেশাদার API টেস্ট করা দেখায় এসব যে কাজ করছে তা কীভাবে প্রমাণ করবেন: ফিক্সচার, ডিপেন্ডেন্সি ওভাররাইড, মক করা HTTP ও LLM প্রোভাইডার, রোলব্যাক হওয়া ডেটাবেস টেস্ট, আর অথ, লিমিট, রিট্রাই ও স্ট্রিমিংয়ের টেস্ট।