您的中介軟體可能是瓶頸
我們如何透過替換單一、簡單的中介軟體基底類別,改善 LiteLLM proxy 的延遲與吞吐量
我們的設定
LiteLLM proxy 伺服器有兩層中介軟體。第一層是 Starlette 的 CORSMiddleware(由 FastAPI 重新匯出),這是一個純 ASGI 中介軟體。然後我們還有一個名為 PrometheusAuthMiddleware 的簡單 BaseHTTPMiddleware。
PrometheusAuthMiddleware 的工作是驗證對 /metrics 端點的請求。它預設不啟用,您可以在 proxy 設定中透過旗標啟用:
Proxy 設定旗標
litellm_settings:
require_auth_for_metrics_endpoint: true
這個中介軟體會檢查兩件事:請求是否打到 /metrics,以及是否已啟用驗證?如果兩項檢查都未通過——而大多數請求都是如此——它就會直接原樣放行請求。
PrometheusAuthMiddleware 原始碼
class PrometheusAuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
if self._is_prometheus_metrics_endpoint(request):
if self._should_run_auth_on_metrics_endpoint() is True:
try:
await user_api_key_auth(request=request, api_key=...)
except Exception as e:
return JSONResponse(status_code=401, content=...)
response = await call_next(request)
return response
@staticmethod
def _is_prometheus_metrics_endpoint(request: Request):
if "/metrics" in request.url.path:
return True
return False
看起來無害。繼承 BaseHTTPMiddleware,實作 dispatch(),完成。這正是您會在 Starlette 文件1中看到的內容。
BaseHTTPMiddleware 實際做了什麼
當您撰寫 dispatch() 方法時,您可能會以為請求會直接經過您的函式再離開。實際上,情況要複雜得多。
對每個請求來說,即使是純轉送(也就是什麼都不發生),BaseHTTPMiddleware 都會建立 7 個中介物件與任務:
它會把請求包裝成一個新物件來追蹤 body 狀態,建立一個同步事件,配置一個記憶體內通道以在您的中介軟體與內層 app 之間傳遞訊息,建立一個 task group 來管理生命週期,然後在您呼叫 call_next() 時,於一個獨立的背景任務中執行實際的路由處理器。接著 response body 會透過那個記憶體內通道傳回,再重新包裝成串流 response 物件,最後才到達呼叫端。這相當多。
對於一個在我們這裡 99.9% 的請求上什麼事都不做的中介軟體來說,付出這樣的成本並不合理。
相比之下,純 ASGI 中介軟體只要檢查請求路徑並繼續往下即可。
我們的中介軟體其實只是在做一件非常簡單的事。對於絕大多數請求,它根本不需要做任何事,只要讓請求通過即可。它不需要 task group、memory stream 或 cancel scope。它只需要一次函式呼叫。
比較兩者
我們將 BaseHTTPMiddleware 子類別替換為純 ASGI 中介軟體。為了測量差異,我們使用 Apache Bench2 來比較 LiteLLM 中介軟體堆疊的兩種設定:舊設定(1 個純 ASGI + 1 個 BaseHTTPMiddleware)與新設定(2 個純 ASGI)。
一個最小化的 FastAPI app 服務 GET /health → PlainTextResponse("ok")。這個端點完全不做任何工作,以便隔離中介軟體的額外負擔:任何設定之間的差異,純粹都是中介軟體管線本身的成本。兩個中介軟體都只是呼叫下一層。相同的工作,不同的基底類別。
Apache Bench(ab)會以 1,000 個並發連線和單一 uvicorn worker 向伺服器送出請求。單一 worker 代表單一 event loop,因此這個基準測試會直接量測每種中介軟體設計如何在單一執行緒上處理並發負載。
| 設定 | 執行次數 | RPS | P50 (ms) |
|---|---|---|---|
| 之前 (1 ASGI + 1 BaseHTTP) | 1 | 3,596 | 21 |
| 之前 (1 ASGI + 1 BaseHTTP) | 2 | 3,599 | 21 |
| 之前 (1 ASGI + 1 BaseHTTP) | 3 | 4,161 | 21 |
| 之後 (2x 純 ASGI) | 1 | 6,504 | 13 |
| 之後 (2x 純 ASGI) | 2 | 6,631 | 13 |
| 之後 (2x 純 ASGI) | 3 | 6,595 | 13 |
自己試試看
將下方腳本儲存為 benchmark_middleware.py,然後執行:
# Terminal 1 — start the "before" server (1 ASGI + 1 BaseHTTPMiddleware)
python benchmark_middleware.py --middleware mixed
# Terminal 2 — benchmark it
ab -n 50000 -c 1000 http://localhost:8000/health
# Stop the server, then start the "after" server (2x pure ASGI)
python benchmark_middleware.py --middleware asgi
# Terminal 2 — benchmark again
ab -n 50000 -c 1000 http://localhost:8000/health
import argparse
import uvicorn
from fastapi import FastAPI
from fastapi.responses import PlainTextResponse
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.types import ASGIApp, Receive, Scope, Send
class NoOpBaseHTTPMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
return await call_next(request)
class NoOpPureASGIMiddleware:
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
await self.app(scope, receive, send)
def create_app(middleware_type: str | None = None, layers: int = 2) -> FastAPI:
app = FastAPI()
@app.get("/health")
async def health():
return PlainTextResponse("ok")
if middleware_type == "mixed":
app.add_middleware(NoOpBaseHTTPMiddleware)
app.add_middleware(NoOpPureASGIMiddleware)
elif middleware_type == "asgi":
for _ in range(layers):
app.add_middleware(NoOpPureASGIMiddleware)
return app
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--middleware", choices=["asgi", "mixed"], default=None)
parser.add_argument("--layers", type=int, default=2)
parser.add_argument("--port", type=int, default=8000)
args = parser.parse_args()
app = create_app(middleware_type=args.middleware, layers=args.layers)
uvicorn.run(app, host="0.0.0.0", port=args.port, workers=1, log_level="warning")
我們的變更
以下是我們將其替換成的內容:
class PrometheusAuthMiddleware:
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http" or "/metrics" not in scope.get("path", ""):
await self.app(scope, receive, send)
return
if litellm.require_auth_for_metrics_endpoint is True:
request = Request(scope, receive)
api_key = request.headers.get("Authorization") or ""
try:
await user_api_key_auth(request=request, api_key=api_key)
except Exception as e:
# send 401 directly via ASGI protocol
...
return
await self.app(scope, receive, send)
對於那 99.9% 不會打到 /metrics 的請求來說,現在這個中介軟體只需要一次 dict 查找、一次字串檢查,以及一次函式呼叫。沒有配置任何物件,也沒有產生任何任務。
隨著您的軟體成長並承擔更多責任,評估您所使用的工具是否真的適合工作內容非常重要。我們現在正加入靜態分析檢查,以防止任何新引入的中介軟體再次發生這種情況。如果我們發現某個使用情境確實有必要,那也沒問題,我們會重新評估;但就目前 LiteLLM 需要處理的一切而言,並非如此。
這次的中介軟體變更只是 LiteLLM proxy 更廣泛最佳化工作的一部分。把所有最佳化加總起來後,我們在過去兩週已測得約 30% 的 proxy 額外負擔降低。
1 Starlette 中介軟體 — BaseHTTPMiddleware

