FastAPI là framework web Python dựa trên Starlette và Pydantic, và là lựa chọn phổ biến để đưa mô hình AI thành một dịch vụ HTTP. Chạy được trong notebook mới đi được một nửa quãng đường. Nửa còn lại là những câu hỏi của môi trường thật: 32 request đến cùng lúc thì mô hình xử lý thế nào, request kẹt thì cắt sau bao lâu, hàng đợi đầy thì từ chối ra sao, và nhìn vào đâu để biết dịch vụ đang khoẻ hay đang chậm dần. Bài này dựng một dịch vụ embedding bằng FastAPI, thêm từng lớp (lifespan, gom batch, timeout, backpressure, metrics) và đo mỗi lớp bằng số thật.

Bài nối tiếp LLM multimodal: hỏi đáp trên ảnh, hoá đơn và tài liệu scan và Đánh giá LLM và guardrails, dùng lại mô hình embedding ở Embedding và semantic search. Môi trường chạy thật: Apple M4, 16 GB RAM dùng chung với nhiều tiến trình khác (load average lúc đo từ 13 đến 16), Python 3.12, fastapi==0.128.0, uvicorn==0.40.0, prometheus_client==0.23.1, torch==2.5.1, httpx==0.28.1, mô hình intfloat/multilingual-e5-small (khoảng 118 triệu tham số, vector 384 chiều). Mã ở code/s7-47; đoạn route dưới đây là bản rút gọn của app.py, bản đầy đủ còn nhánh không batching và ghi metric.

Bài toán FastAPI: một mô hình, nhiều request đồng thời

Mô hình embedding trên CPU hoặc GPU chạy hiệu quả nhất khi nhận một lô văn bản, không phải từng câu một. Nhưng client gọi từng câu một. Dịch vụ FastAPI phải đứng giữa: nhận request lẻ, gom thành lô, chạy mô hình, rồi trả kết quả về đúng chỗ. Bốn việc cần quyết định trước khi viết dịch vụ FastAPI:

  • Nạp mô hình ở đâu: một lần khi khởi động, và báo "chưa sẵn sàng" cho tới khi nạp xong.
  • Chạy mô hình ở đâu: suy luận là việc nặng, chạy thẳng trong hàm async thì cả vòng lặp sự kiện đứng yên.
  • Chờ tối đa bao lâu: mỗi request cần một hạn chót, nếu không một lần chậm sẽ kéo theo hàng trăm request chờ vô hạn.
  • Quá tải thì sao: từ chối sớm bằng 503 rẻ hơn nhận hết rồi để tất cả cùng quá hạn.

Khung dịch vụ FastAPI: lifespan và hai loại probe

Theo tài liệu FastAPI về lifespan, mã trước yield chạy một lần lúc khởi động, mã sau yield chạy lúc tắt; tài liệu nêu đúng ví dụ nạp mô hình học máy ở đây. Cách cũ @app.on_event("startup") đã bị đánh dấu deprecated, và nếu đã truyền lifespan thì các handler startup/shutdown không còn được gọi. Đừng trộn hai kiểu.

@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.model = Model()
    app.state.model.embed(["khởi động"])  # warm-up: lần chạy đầu luôn chậm hơn
    app.state.batcher = Batcher(app.state.model) if BATCHING else None
    app.state.ready = True
    yield
    app.state.ready = False
    if app.state.batcher:
        await app.state.batcher.close()
    del app.state.model

app = FastAPI(title="Dịch vụ embedding", lifespan=lifespan)

@app.get("/readyz")
def readyz(request: Request):
    if not getattr(request.app.state, "ready", False):
        raise HTTPException(503, "chưa sẵn sàng")
    return {"status": "ready", "batching": BATCHING}

Hai route FastAPI dành cho Kubernetes hoặc balancer: /healthz (liveness) chỉ trả lời "tiến trình còn sống", /readyz (readiness) báo bản sao đã nhận được traffic hay chưa. Cần hiểu đúng vòng đời: Uvicorn chạy xong phần khởi động của lifespan (nạp mô hình, warm-up) rồi mới mở cổng nhận kết nối, nên trong lúc nạp mô hình probe nhận connection refused, không phải 503. Chạy startup_probe.sh (startup_probe_output.txt) cho thấy 28 lần thăm dò đầu, trải trên khoảng 14 giây, đều trả HTTP 000 (curl không kết nối được), lần thứ 29 mới trả HTTP 200.

Với Kubernetes, kết quả này vẫn đủ an toàn vì probe thất bại thì pod chưa vào Service; cờ ready trong code chủ yếu có ích lúc tắt (đặt False ngay sau yield để báo 503 trong lúc dọn dẹp) hoặc nếu bạn chuyển việc nạp mô hình sang tác vụ nền để cổng mở sớm. Trong dịch vụ FastAPI này, warm-up gọi một lần suy luận giả vì lần chạy đầu của PyTorch luôn chậm hơn. Tách probe riêng để một lần mô hình chậm không khiến orchestrator giết nhầm container đang khoẻ.

Đầu vào được kiểm bằng Pydantic: texts: list[str] = Field(min_length=1, max_length=8), tức mỗi request một đến tám văn bản. Kiểm tra bằng curl (smoke.sh, output thật):

{"status":"alive"}
{"status":"ready","batching":true}
{"dim":384,"embeddings":[[0.0648,-0.0486,0.0069,-0.0411],[0.0392,-0.0302,-0.0438,-0.0366]]}
texts rỗng -> HTTP 422
quá 8 văn bản -> HTTP 422

Vector trả về chỉ cắt bốn số đầu để dễ đọc; dịch vụ thật trả đủ 384 chiều. Giới hạn tám văn bản mỗi request chặn một client gửi nghìn câu trong một lần gọi.

FastAPI không chặn vòng lặp sự kiện: def, async def, to_thread

Đây là lỗi hay gặp nhất khi phục vụ mô hình bằng FastAPI. Tài liệu về async giải thích: hàm khai báo bằng def thường được FastAPI chạy trong threadpool bên ngoài rồi await kết quả, nên không chặn server. Ngược lại, async def mà bên trong gọi mã chặn (time.sleep(), driver CSDL đồng bộ, hay model(...) của PyTorch) sẽ chặn vòng lặp sự kiện cho mọi request. Suy luận mô hình là mã chặn điển hình.

Dịch vụ FastAPI này giữ route /embed là async def (vì cần await hàng đợi và asyncio.timeout) và đẩy phần tính toán sang luồng riêng bằng asyncio.to_thread. Trong luồng đó, torch.inference_mode() tắt theo dõi gradient, torch.set_num_threads(2) giới hạn số luồng tính toán để dịch vụ không chiếm hết CPU của máy dùng chung. Hàm suy luận token hoá, chạy mô hình, lấy trung bình có mặt nạ (mean pooling) rồi chuẩn hoá L2, kèm tiền tố query: như mô hình E5 yêu cầu.

Gom batch động trong FastAPI: nhận request lẻ, chạy theo lô

Trước khi viết bộ gom, hãy đo xem gom có đáng không. batch_cost.py gọi thẳng hàm suy luận, không qua HTTP, với mỗi cỡ batch lấy trung vị của 15 lần chạy:

batch | ms/lần | ms/văn bản (trung vị 15 lần)
    1 |   17.1 |   17.1
    2 |   20.2 |   10.1
    4 |   21.8 |    5.4
    8 |   40.8 |    5.1
   16 |   67.5 |    4.2
   32 |  115.8 |    3.6

Một lần suy luận một câu mất 17,1 ms; gộp 32 câu mất 115,8 ms, tức 3,6 ms mỗi câu, rẻ hơn khoảng 4,8 lần. Từ batch 1 sang batch 4 gần như miễn phí (17,1 lên 21,8 ms), sau đó chi phí tăng gần đều theo số câu. Đó là cơ hội của dynamic batching: chờ một khoảng ngắn để các request đến gần nhau cùng vào một lô. Cái giá là mỗi request chờ thêm tối đa khoảng đó.

Một câu hỏi tự nhiên là gộp batch có làm đổi kết quả không, vì câu ngắn bị đệm (padding) cho bằng câu dài nhất. padding_check.py so vector tính riêng từng câu với vector tính trong batch 16 câu có độ dài khác nhau:

cosine nhỏ nhất giữa riêng lẻ và batch: 1.000000
chênh lệch tuyệt đối lớn nhất: 8.29e-08

Chênh lệch cỡ 1e-7, tức sai số làm tròn số thực, nhờ mặt nạ chú ý (attention mask) và mean pooling có mặt nạ bỏ qua phần đệm. Với mô hình hoặc cách pooling khác phải kiểm lại.

Bộ gom là một vòng lặp đọc hàng đợi: lấy item đầu tiên (chờ vô hạn), đặt hạn chót MAX_WAIT_MS, rồi nhặt thêm cho tới khi đủ MAX_BATCH hoặc hết hạn. Mỗi item mang theo một Future để trả kết quả về đúng request:

async def run(self):
    loop = asyncio.get_running_loop()
    while True:
        items = [await self.q.get()]
        deadline = loop.time() + MAX_WAIT_MS / 1000
        while len(items) < MAX_BATCH and (left := deadline - loop.time()) > 0:
            try:
                items.append(await asyncio.wait_for(self.q.get(), left))
            except TimeoutError:
                break
        live = [(t, f) for t, f in items if not f.cancelled()]
        try:
            vecs = await asyncio.to_thread(self.model.embed, [t for t, _ in live])
            for (_, f), v in zip(live, vecs):
                f.set_result(v)
        except Exception as e:
            for _, f in live:
                f.set_exception(e)

Ba chi tiết quan trọng (bản đầy đủ trong app.py còn kiểm f.done() và cập nhật gauge). Thứ nhất, put_nowait với hàng đợi có giới hạn ném asyncio.QueueFull khi đầy, và đó chính là cơ chế backpressure (phần sau). Thứ hai, trước khi chạy mô hình, vòng lặp loại các item mà Future đã bị huỷ, nghĩa là client đã bỏ đi hoặc quá hạn, để khỏi tính toán cho người không còn chờ. Thứ ba, lỗi của mô hình được gán vào từng Future thay vì ném ra ngoài vòng lặp: nếu để ngoại lệ thoát khỏi run(), tác vụ nền chết lặng lẽ và mọi request sau đó treo cho tới khi hết hạn.

Timeout FastAPI: hạn chót cho mỗi request

Route FastAPI bọc toàn bộ công việc trong asyncio.timeout(REQUEST_TIMEOUT_S). Theo tài liệu asyncio, khi quá hạn nó huỷ tác vụ hiện tại và biến CancelledError thành TimeoutError, chỉ bắt được ở bên ngoài khối async with; từ Python 3.11, wait_for cũng ném TimeoutError thay vì asyncio.TimeoutError. Route đổi lỗi đó thành HTTP 504:

@app.post("/embed")
async def embed(body: EmbedIn, request: Request):
    try:
        async with asyncio.timeout(REQUEST_TIMEOUT_S):
            b: Batcher = request.app.state.batcher
            vecs = await asyncio.gather(*(b.submit(t) for t in body.texts))
        return {"dim": len(vecs[0]), "embeddings": [[round(x, 4) for x in v[:4]] for v in vecs]}
    except asyncio.QueueFull:
        raise HTTPException(503, "hàng đợi đầy", headers={"Retry-After": "1"})
    except TimeoutError:
        raise HTTPException(504, f"quá {REQUEST_TIMEOUT_S}s")

Với request FastAPI nhiều văn bản, mã thật gom các submit thành tác vụ riêng và huỷ những tác vụ anh em khi một submit ném QueueFull; nếu dùng asyncio.gather trần, lỗi thoát ra nhưng các item đã xếp hàng vẫn bị tính toán cho request đã nhận 503. Chạy multi_cancel_check.sh (một request 8 văn bản vào hàng đợi cỡ 2) cho HTTP 503 và chỉ 1 lô 1 văn bản (chính là warm-up) trong metric, nghĩa là không có suy luận nào cho request bị từ chối.

Cần hiểu một giới hạn: huỷ một await trên to_thread chỉ dừng việc chờ, còn hàm đang chạy trong luồng thì không thể bị dừng cưỡng bức, nó chạy tới khi trả về. Đây là hành vi chung của luồng trong Python, tài liệu asyncio không nêu. Vì vậy timeout bảo vệ client (nhận 504 đúng hạn) và bảo vệ hàng đợi (item đã huỷ bị bỏ qua), nhưng không thu hồi được CPU của lô đang chạy. Muốn dừng thật thì phải dùng tiến trình riêng hoặc mô hình hỗ trợ ngắt.

Kiểm chứng bằng cách ép mô hình chậm giả lập 400 ms (biến FAKE_DELAY_MS, chỉ dùng để thử) với hạn chót 0,2 s; 20 request, 4 đồng thời (bench_timeout_output.txt):

n=20 conc=4 tổng=1.06s thông lượng=18.8 req/s p50=203ms p95=240ms p99=240ms mã={504: 20}
  mã 504: p50=203ms max=240ms
embed_requests_total{status="504"} 20.0
embed_batch_size_count 3.0
embed_batch_size_sum 9.0

Cả 20 request nhận 504 sau khoảng 203 ms, đúng hạn dù mô hình cần 400 ms. Số liệu cuối cho thấy hiện tượng vừa nói: mô hình vẫn chạy xong 2 lô (8 văn bản) cho các request đã bị cắt; số đếm 3 lô và 9 văn bản ở trên gồm cả lần warm-up 1 văn bản lúc khởi động.

Backpressure FastAPI: từ chối sớm khi quá tải

Hàng đợi không giới hạn che giấu quá tải: độ trễ tăng vô hạn, bộ nhớ tăng, và khi mô hình xử lý tới request thì client đã bỏ đi từ lâu. Hàng đợi có giới hạn buộc dịch vụ nói thật: QUEUE_SIZE chỗ, quá thì trả 503 kèm Retry-After để client biết thử lại. Thử với hàng đợi chỉ 8 chỗ, lô tối đa 4, 300 request ở mức 100 đồng thời (bench_backpressure_output.txt):

n=300 conc=100 tổng=5.25s thông lượng=57.1 req/s p50=880ms p95=4873ms p99=5110ms mã={503: 111, 200: 189}
  mã 200: p50=1789ms max=5150ms
  mã 503: p50=213ms max=5116ms

111 request bị từ chối bằng 503 và 189 request được phục vụ (số liệu mỗi lần chạy lệch nhau chút vì máy dùng chung). Đuôi trễ dài (p95 gần 5 giây) vì 100 kết nối đồng thời chạy trên máy dùng chung đang bận, hàng đợi chỉ giới hạn item, không giới hạn số kết nối. Đây là lý do các lớp bảo vệ nằm ở nhiều tầng: ngoài hàng đợi trong ứng dụng, Uvicorn có tham số --limit-concurrency, theo tài liệu Uvicorn là "số kết nối hoặc tác vụ đồng thời tối đa, trước khi trả HTTP 503", và --backlog là hàng chờ chấp nhận kết nối TCP; --timeout-graceful-shutdown đặt thời gian chờ tối đa khi tắt để request đang chạy kịp xong. Ngoài cùng, Nginx reverse proxy cũng có timeout riêng.

Giám sát FastAPI bằng Prometheus metrics

Dịch vụ FastAPI phơi bày metrics ở /metrics bằng prometheus_client, dùng một CollectorRegistry riêng để không lẫn với metric mặc định của tiến trình. Năm metric đủ trả lời hầu hết câu hỏi vận hành:

MetricLoạiTrả lời câu hỏi
embed_requests_total{status}CounterBao nhiêu request thành công, bị 503, bị 504?
embed_request_secondsHistogramĐộ trễ người dùng thấy, p50 và p95?
embed_batch_sizeHistogramBatching có thực sự gom được không?
embed_inference_secondsHistogramMô hình tốn bao lâu, tách khỏi thời gian chờ hàng đợi?
embed_queue_depthGaugeHàng đợi đang đầy tới đâu?

Theo tài liệu client_python, Histogram xuất ba chuỗi _bucket{le=...}, _sum, _count, có thể truyền buckets= tuỳ chỉnh (luôn tăng dần, +Inf tự thêm), và tên nhãn le bị dành riêng. Ở đây bucket đặt theo thang của dịch vụ: 10 ms đến 5 s cho request, 5 ms đến 1 s cho suy luận. Tài liệu Prometheus về histogram nhắc hai điều đáng nhớ: phân vị được tính ở phía server bằng histogram_quantile(), và sai số bị giới hạn bởi bề rộng bucket chứa phân vị đó, nên bucket thưa quanh ngưỡng SLO sẽ cho p95 thiếu chính xác.

Chỉ status làm nhãn, một tập nhỏ và cố định. Nhãn kiểu user_id hay nội dung văn bản tạo vô số chuỗi thời gian và làm sập Prometheus. Cặp _sum và _count của embed_batch_size cho kích thước lô trung bình ngay lập tức: ở lần chạy có batching, 601 / 209 bằng khoảng 2,9 văn bản mỗi lô. Cách dựng dashboard có ở Giám sát Kubernetes với kube-prometheus và Grafana.

Đo tải thật FastAPI: batching có thắng không?

loadtest.py dùng httpx bắn vào dịch vụ FastAPI 600 request, mỗi request một câu, 32 đồng thời; run_bench.sh khởi động dịch vụ ở từng cấu hình, đợi /readyz, chạy tải, in metric rồi tắt. Số liệu từ /metrics gồm một lần warm-up nên đếm 601. Kết quả của lần chạy sạch nhất:

== khong_batching (BATCHING=0)
n=600 conc=32 tổng=4.01s thông lượng=149.8 req/s p50=191ms p95=338ms p99=428ms mã={200: 600}
embed_inference_seconds_count 601.0
embed_inference_seconds_sum 54.83518085199648
== co_batching (BATCHING=1)
n=600 conc=32 tổng=5.57s thông lượng=107.7 req/s p50=223ms p95=760ms p99=1286ms mã={200: 600}
embed_batch_size_count 209.0
embed_batch_size_sum 601.0
embed_inference_seconds_sum 3.748447908998969

Kết quả này không cho thấy batching thắng về thông lượng. Máy dùng chung nên số đo dao động mạnh. Chạy lặp ba vòng hai chế độ (bench_lap_lai_output.txt), thông lượng không batching là 79,0; 90,2; 74,1 req/s, có batching là 68,0; 105,1; 61,2 req/s, và p95 có batching luôn cao hơn (824 đến 1495 ms so với 606 đến 789 ms). Kết luận: ở quy mô nhỏ trên CPU, hai chế độ có thông lượng cùng cỡ, khác biệt nằm trong nhiễu đo.

Điều ổn định giữa các lần chạy là tổng thời gian suy luận: không batching cần 54,8 đến 111,7 giây cộng dồn trên 601 lần gọi, có batching chỉ 3,7 đến 7,7 giây trên 167 đến 216 lần gọi. Con số không-batching lớn vì nhiều luồng PyTorch cùng tranh vài nhân CPU đang bận. Batching đổi sự tranh chấp đó thành một lần suy luận lớn mỗi lúc, nên dùng CPU ít hơn cho cùng khối lượng việc. Lợi ích thông lượng có thể rõ hơn trên GPU, nhưng đoạn này cần GPU, chưa chạy trong bài.

Còn chi phí của batching ở tải thấp thì đo được rõ. Một client gọi tuần tự 100 request (bench_tai_thap_*_output.txt):

tai_thap_co_batching:    p50=25ms p95=51ms p99=105ms
tai_thap_khong_batching: p50=18ms p95=30ms p99=38ms

Batching thêm khoảng 7 ms ở p50, gần bằng MAX_WAIT_MS=8: mỗi request lẻ đều chờ trọn cửa sổ gom dù không có ai khác đến. Đó là đánh đổi cốt lõi: MAX_WAIT_MS là độ trễ bạn trả để đổi lấy hiệu quả khi tải cao. Dịch vụ có tải thấp và độ trễ quan trọng nên giữ cửa sổ rất ngắn hoặc tắt batching.

Khi nào không nên tự viết bộ gom batch trong FastAPI

  • Mô hình sinh văn bản (LLM): độ dài đầu ra khác nhau nên gom lô tĩnh lãng phí; dùng máy chủ có continuous batching như vLLM (cần GPU, chưa chạy trong bài).
  • Nhiều tiến trình FastAPI: mỗi tiến trình có hàng đợi riêng, --workers N nhân bản cả mô hình trong RAM, và batching chỉ gom trong từng tiến trình.
  • Mô hình đủ nhanh: nếu một lần gọi 17 ms đã đạt mục tiêu, bộ gom chỉ thêm độ phức tạp cho dịch vụ FastAPI.

Lỗi thường gặp khi phục vụ mô hình bằng FastAPI

  1. Suy luận trong async def mà không đẩy ra luồng: cả server đứng, kể cả /healthz, rồi orchestrator giết container.
  2. Nạp mô hình trong mỗi request hoặc ở cấp module: chậm, tốn RAM, làm test khó chạy; dùng lifespan.
  3. Không có warm-up: người dùng đầu tiên gánh độ trễ khởi tạo.
  4. Hàng đợi không giới hạn: quá tải biến thành độ trễ vô hạn thay vì lỗi 503 rõ ràng.
  5. Ngoại lệ thoát khỏi vòng lặp bộ gom: tác vụ nền chết, mọi request sau treo tới hết hạn; gán lỗi vào Future.
  6. Đặt nhãn metric theo giá trị không giới hạn: số chuỗi thời gian bùng nổ.
  7. Kết luận từ một lần đo: số liệu trên máy dùng chung dao động mạnh.

Checklist đưa mô hình lên FastAPI

  • Dịch vụ FastAPI nạp mô hình trong lifespan, cổng chỉ mở sau khi nạp và warm-up xong (probe thấy connection refused trước đó); /readyz chuyển 503 lúc tắt; /healthz tách riêng.
  • Tính toán nặng chạy ngoài vòng lặp sự kiện (to_thread, hoặc route def), giới hạn số luồng của thư viện.
  • Mỗi request FastAPI có asyncio.timeout, trả 504; item đã huỷ không tính tiếp.
  • Hàng đợi có giới hạn, trả 503 kèm Retry-After; thêm --limit-concurrency và timeout ở proxy phía trước.
  • Metrics: counter theo mã trạng thái, histogram độ trễ, kích thước lô, thời gian suy luận, gauge độ sâu hàng đợi; bucket đặt theo thang thực tế.
  • Đo lặp nhiều lần, ghi rõ cấu hình máy.

Cài đặt dịch vụ FastAPI: python3.12 -m venv .venv && .venv/bin/pip install -r requirements.txt, rồi chạy ./smoke.sh và ./run_bench.sh trong code/s7-47; các file *_output.txt là output đã dán ở trên. Tham khảo thêm các khái niệm triển khai của FastAPI.

Bài tiếp theo

Tối ưu suy luận: export ONNX, quantization và chạy CPU. Gom batch giúp dùng một lần suy luận hiệu quả hơn; bước kế tiếp là làm chính lần suy luận đó nhanh và nhẹ hơn bằng ONNX Runtime và lượng tử hoá.