Day 15 把 vLLM 在 Kubernetes 上跑起來,一個 Pod 就是一個引擎,自己管自己的記憶體、自己的 prefix cache。後面幾天加的東西多半也還在那一台裡面,換引擎、把一台拆成 prefill 和 decode 兩段。
但一台不夠的時候要開第二台第三台,而它們互相不知道對方手上有什麼。Day 24 補上了誰來決定請求去哪一台,不過那天掛的 LWEPP 用輪詢,不看後端的任何狀態,所以看到的是分工,不是品質。
llm-d 是把這些引擎當成一組來服務的那一層。Day 2 提過它的輪廓,說它會先想過再決定請求去哪一台,依據是每台手上有哪些算好的結果、還剩多少記憶體、佇列排多長。今天把它裝起來,看它挑得不一樣在哪裡。
llm-d 是一套 Kubernetes 原生的分散式 LLM 推理堆疊,CNCF Sandbox 專案。它自己不服務請求:
| 負責什麼 | |
|---|---|
| vLLM | 載入模型、管 KV cache、算 token、提供 OpenAI 格式的 HTTP endpoint。請求最後是它處理的 |
| llm-d | 決定有幾台 vLLM、它們怎麼擺、每一發請求去哪一台 |
引擎除了 vLLM 也支援 SGLang。
它交付的是一組測過的部署配方,涵蓋一個最佳化過的基準、延遲預測路由、精確的 prefix cache 感知路由、分層 prefix cache、prefill 和 decode 分離、MoE 的 wide expert parallelism、流量控制與公平性、推理池自動擴縮。
今天從這份清單裡只取排程這一件。
一組 Pod 跑同一個模型的時候,請求要落在哪一台,這件事由一個獨立的服務決定,叫 EPP。請求進來之後 Envoy 先暫停,把請求送給它,拿到「打哪一台」的答案再送出去。
EPP 怎麼決定,由裝在它裡面的外掛決定。llm-d 把這個過程拆成三段:
| 階段 | 做什麼 |
|---|---|
| Filters | 先刪掉不該考慮的候選 |
| Scorers | 剩下的各給一個分數,可以加權 |
| Picker | 依分數挑一個 |
EPP 要挑的是哪一組 Pod、以及要問哪個 EPP,寫在一個叫 InferencePool 的物件裡。它用 label 選出成員,再指名負責挑選的那個服務。
兩台 EC2 g6e.2xlarge,各一張 L40S 46068 MiB,k3s。
kubectl get resourceslices
00000-gpu.nvidia.com-ip-172-31-26-39-9fcql ip-172-31-26-39 gpu.nvidia.com
00000-gpu.nvidia.com-ip-172-31-30-5-6bx85 ip-172-31-30-5 gpu.nvidia.com
一台一筆,裝置類別是 gpu.nvidia.com。卡的資訊登記在這裡,由 DRA 負責分配。
kubectl get nodes -o json | python3 -c "
import json,sys
for n in json.load(sys.stdin)['items']:
a=n['status']['allocatable']
print(n['metadata']['name'], {k:v for k,v in a.items() if 'nvidia' in k.lower()} or '(沒有)')
"
ip-172-31-26-39 (沒有)
ip-172-31-30-5 (沒有)
節點的可配置資源裡沒有 nvidia.com/gpu。所以要卡的 Pod 不能在 resources.limits 寫那個資源,得改成宣告一筆 claim。
llm-d 的模型伺服器樣板是對著 device plugin 和大機器寫的:
grep -A6 "resources:" guides/optimized-baseline/modelserver/gpu/vllm/base/patch-vllm.yaml
resources:
limits:
cpu: '16'
memory: 128Gi
nvidia.com/gpu: 2
| 樣板預設 | 這兩台 | |
|---|---|---|
| 卡 | 每個副本兩張,nvidia.com/gpu: 2 |
每台一張,而且要走 claim |
| 模型 | Qwen3-32B | 單張 46 GB 放不下 |
| cpu | limits 16、requests 8 | 整台 8 vCPU |
| memory | limits 128Gi、requests 96Gi | 整台 61 GiB |
replicas: 2 不用改。配上 TP1 剛好一台一個副本,而那是後面要挑副本的前提。
用 DRA 取得 GPU 的 Pod 不直接寫要多少資源,它宣告的是一筆 claim,而 claim 長什麼樣子寫在 ResourceClaimTemplate 裡。Deployment 每生一個 Pod,就照這份樣板產生一筆 claim。
cd ~/llm-d/guides/optimized-baseline/modelserver/gpu/vllm/base
cat > resource-claim-templates.yaml <<'YAML'
apiVersion: resource.k8s.io/v1
kind: ResourceClaimTemplate
metadata:
name: nvidia-decode-claim
labels:
llm-d.ai/role: decode
spec:
spec:
devices:
requests:
- name: nvidia
exactly:
deviceClassName: gpu.nvidia.com
count: 1
YAML
deviceClassName 填 gpu.nvidia.com,就是前面 resourceslices 登記的那個類別。count: 1 是一個 Pod 要一張卡。
這份 kustomization 有 namePrefix,套用的時候會把所有物件的名字加上前綴,nvidia-decode-claim 會變成 optimized-baseline-nvidia-gpu-vllm-nvidia-decode-claim。
但 Pod 裡引用樣板的那個欄位 resourceClaimTemplateName 不在 kustomize 預設會改的名單上。它會留著原本的名字,而那個名字的物件已經不存在了。
nameReference 就是告訴 kustomize 那個欄位也要跟著改:
cat > namereference.yaml <<'YAML'
nameReference:
- kind: ResourceClaimTemplate
group: resource.k8s.io
fieldSpecs:
- path: spec/template/spec/resourceClaims/resourceClaimTemplateName
kind: Deployment
YAML
兩個檔案都要掛進 kustomization.yaml,一個當資源,一個當設定:
resources:
- ../../../../../recipes/modelserver/base/single-host/default
- resource-claim-templates.yaml
namePrefix: optimized-baseline-nvidia-gpu-vllm-
configurations:
- namereference.yaml
diff patch-vllm.yaml.orig patch-vllm.yaml
14c14
< - "Qwen/Qwen3-32B"
---
> - "Qwen/Qwen2.5-7B-Instruct"
16,19c16
< - "--tensor-parallel-size=2"
< - "--enable-auto-tool-choice"
< - "--tool-call-parser=hermes"
< - "--reasoning-parser=qwen3"
---
> - "--tensor-parallel-size=1"
45,47c42,43
< cpu: '16'
< memory: 128Gi
< nvidia.com/gpu: 2
---
> cpu: '4'
> memory: 24Gi
49,51c45,48
< cpu: '8'
< memory: 96Gi
< nvidia.com/gpu: 2
---
> cpu: '2'
> memory: 16Gi
> claims:
> - name: nvidia-decode-claim
73a71,73
> resourceClaims:
> - name: nvidia-decode-claim
> resourceClaimTemplateName: nvidia-decode-claim
| 改了什麼 | 為什麼 |
|---|---|
模型換成 Qwen2.5-7B-Instruct |
單張 46 GB 放不進 32B |
--tensor-parallel-size 從 2 改成 1 |
每台只有一張卡 |
| 拿掉三個 Qwen3 專屬旗標 | Qwen2.5 不是 reasoning 模型 |
| cpu 降到 4 和 2、memory 降到 24Gi 和 16Gi | 整台只有 8 vCPU 和 61 GiB。照抄的話模型伺服器吃光一個節點,EPP 和 Envoy 會排不進去 |
拿掉 nvidia.com/gpu,Pod 層加 resourceClaims、容器層加 claims |
Pod 層宣告要這筆 claim,容器層說這個容器用它 |
有一處刻意不動:這份 kustomization 的標籤還是 llm-d.ai/model: Qwen3-32B。它帶著 includeSelectors: true,改了會連 Deployment 的 selector 和 EPP 的比對一起動到。
kubectl kustomize . | grep -nE "resourceClaimTemplateName|^ name:|deviceClassName|nvidia.com/gpu|tensor-parallel|Qwen"
42: - Qwen/Qwen2.5-7B-Instruct
44: - --tensor-parallel-size=1
109: resourceClaimTemplateName: optimized-baseline-nvidia-gpu-vllm-nvidia-decode-claim
125:kind: ResourceClaimTemplate
133: name: optimized-baseline-nvidia-gpu-vllm-nvidia-decode-claim
140: deviceClassName: gpu.nvidia.com
行 109 引用的名字和行 133 物件的名字一致,nameReference 生效了。整份輸出裡沒有 nvidia.com/gpu。
chart 的預設是 EPP 四核加 Envoy sidecar 四核,而一台只有八核,兩個加起來剛好把節點佔滿。覆寫成各一核。
Envoy 的 --concurrency 也要一起降。它決定 Envoy 開幾個工作執行緒,不設的話會照實體核心數開,跟 cgroup 給的配額對不上。
cat ~/llm-d/small-node.values.yaml
router:
epp:
resources:
requests: { cpu: "1", memory: 2Gi }
limits: { memory: 4Gi }
proxy:
resources:
requests: { cpu: "1", memory: 2Gi }
limits: { memory: 4Gi }
args:
- "--service-node"
- "envoy-sidecar"
- "--log-level"
- "warn"
- "--concurrency"
- "2"
- "--drain-strategy"
- "immediate"
- "--drain-time-s"
- "60"
- "-c"
- "/etc/envoy/envoy.yaml"
helm install optimized-baseline oci://ghcr.io/llm-d/charts/llm-d-router-standalone \
-f guides/recipes/router/base.values.yaml \
-f guides/optimized-baseline/router/optimized-baseline.values.yaml \
-f ~/llm-d/small-node.values.yaml \
-n llm-d --version v0
Pulled: ghcr.io/llm-d/charts/llm-d-router-standalone:v0
Digest: sha256:1adebe98369a77757cac0fa3b2c65a6f716fb70449cada2d822d726266bf223f
NAME: optimized-baseline STATUS: deployed REVISION: 1
kubectl -n llm-d rollout status deploy/optimized-baseline-epp --timeout=180s
deployment "optimized-baseline-epp" successfully rolled out
kubectl apply -n llm-d -k ~/llm-d/guides/optimized-baseline/modelserver/gpu/vllm/base/
serviceaccount/optimized-baseline-nvidia-gpu-vllm-sa created
deployment.apps/optimized-baseline-nvidia-gpu-vllm-decode created
resourceclaimtemplate.resource.k8s.io/optimized-baseline-nvidia-gpu-vllm-nvidia-decode-claim created
kubectl -n llm-d get resourceclaims
optimized-baseline-nvidia-gpu-vllm-decode-nvidia-decodejcd4v allocated,reserved 6s
optimized-baseline-nvidia-gpu-vllm-decode-nvidia-decodesz9kq allocated,reserved 6s
兩個 Pod 各產生一筆 claim,狀態都是 allocated,reserved,表示卡已經配下去而且被佔住了。
kubectl -n llm-d get pod -o wide
optimized-baseline-epp-7c6b55fd99-p89sl 2/2 Running 10.42.1.203 ip-172-31-30-5
optimized-baseline-nvidia-gpu-vllm-decode-65c6c44c68-bb8lg 1/1 Running 10.42.0.145 ip-172-31-26-39
optimized-baseline-nvidia-gpu-vllm-decode-65c6c44c68-pw2cx 1/1 Running 10.42.1.204 ip-172-31-30-5
兩個副本一台一個。
kubectl -n llm-d get all,inferencepool,resourceclaimtemplate
pod/optimized-baseline-epp-7c6b55fd99-p89sl 2/2 Running
pod/optimized-baseline-nvidia-gpu-vllm-decode-65c6c44c68-bb8lg 1/1 Running
pod/optimized-baseline-nvidia-gpu-vllm-decode-65c6c44c68-pw2cx 1/1 Running
service/optimized-baseline-epp ClusterIP 10.43.181.94 9002/TCP,9090/TCP,80/TCP
deployment.apps/optimized-baseline-epp 1/1
deployment.apps/optimized-baseline-nvidia-gpu-vllm-decode 2/2
inferencepool.inference.networking.k8s.io/optimized-baseline
resourceclaimtemplate.resource.k8s.io/optimized-baseline-nvidia-gpu-vllm-nvidia-decode-claim
InferencePool 在這裡出現了,是 chart 自己建的。它也可以不建,router.inferencePool.create=false 就會跳過,預設是建。
EPP 的 Pod 是 2/2,Envoy sidecar 跟 EPP 同住一個 Pod。一個 Service 開三個 port:
| port | 誰用 |
|---|---|
| 9002 | EPP 的 ext_proc gRPC,Envoy 問它要打哪一台 |
| 9090 | EPP 的指標 |
| 80 | Envoy sidecar 的 HTTP 入口,客戶端打這個 |
kubectl -n llm-d logs deploy/optimized-baseline-epp -c epp | grep "parsed config"
"body":"parsed config","scheduler-config":"{ProfileHandler: single-profile-handler,
Profiles: map[default:{
Filters: [prefix-cache-affinity-filter, utilization-detector],
Scorers: [token-load-scorer: 1.000000],
Picker: max-score-picker}]}"
裡面是它實際載入的外掛。
| 外掛 | 做什麼 |
|---|---|
prefix-cache-affinity-filter |
filter,留下可能已經有這段開頭快取的那幾台 |
utilization-detector |
filter,判斷後端是不是已經忙不過來 |
token-load-scorer |
scorer,依 token 的負載給每台打分 |
max-score-picker |
picker,取分數最高的那台 |
這份清單有兩個地方跟 values 檔對不起來。
utilization-detector 沒有寫在 values 檔裡,是解析的時候自己加進去的。
而 llm-d 還有 kv-cache-utilization-scorer 和 queue-scorer 可以用,這份部署沒有啟用,實際打分的只有 token-load-scorer 一個。
所以可以選的外掛有哪些,跟一份部署實際跑哪幾個,是兩回事。要知道跑的是哪幾個,看 EPP 的這一行,不要看 values 檔。
prefix-cache-affinity-filter 有一個參數 peakPrefillThroughput,預設值 15928。這個數字是在 H100 80 GB 上跑 Qwen3-32B、TP2 量出來的,換硬體或換模型要用 guides/recipes/router/calibration 重新量一次。
這輪是 L40S、Qwen2.5-7B、TP1,所以那個數字不適用。後面看到的挑選行為是真的,但挑得好不好,在這個環境下談不了。
kubectl -n llm-d port-forward svc/optimized-baseline-epp 8080:80 >/tmp/pf.log 2>&1 &
echo $! > /tmp/pf.pid
sleep 5
curl -si -X POST localhost:8080/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"Qwen/Qwen2.5-7B-Instruct","messages":[{"role":"user","content":"hi"}],"max_tokens":16}'
HTTP/1.1 200 OK
x-went-into-resp-headers: true
date: Fri, 09 Oct 2026 06:23:11 GMT
server: uvicorn
content-type: application/json
transfer-encoding: chunked
{"id":"chatcmpl-0cb337dd-...","model":"Qwen/Qwen2.5-7B-Instruct",
"choices":[{"message":{"role":"assistant","content":"Hello! How can I assist you today?"},
"finish_reason":"stop"}],
"system_fingerprint":"vllm-0.30.0-ad8c4a52",
"usage":{"prompt_tokens":30,"total_tokens":40,"completion_tokens":10}}
回應標頭裡沒有落點。x-went-into-resp-headers 是 Envoy 加的標記,表示回應經過它處理過,跟打到哪一台無關。
Envoy 的 access log 本來記得到落點,但這個 chart 配的格式只輸出兩個欄位:
kubectl -n llm-d logs deploy/optimized-baseline-epp -c envoy-proxy --tail=1
{"start_time":"2026-10-09T06:23:12.445Z","method":"POST",...}
那個 ... 是字面的,日誌裡就長這樣,只有 start_time 和 method,沒有 upstream_host。
可以用的訊號在 vLLM 自己那邊:
kubectl -n llm-d logs optimized-baseline-nvidia-gpu-vllm-decode-65c6c44c68-bb8lg --tail=3
(APIServer pid=1) INFO: Application startup complete.
(APIServer pid=1) INFO 10-09 06:23:12 [loggers.py:315] Engine 000: Avg prompt throughput: 3.0 tokens/s, ...
(APIServer pid=1) INFO: 10.42.1.203:45508 - "POST /v1/chat/completions HTTP/1.1" 200 OK
10.42.1.203 是 EPP Pod 的 IP,而這一行只出現在 bb8lg 的日誌裡,所以那一發是它服務的。
後面就數每個 vLLM Pod 的 POST /v1/chat/completions 行數,不用改任何設定。
cat > ~/count.sh <<'SH'
#!/bin/bash
for p in $(kubectl -n llm-d get pod -o name | grep vllm | sed 's|pod/||'); do
n=$(kubectl -n llm-d logs "$p" | grep -c 'POST /v1/chat/completions')
printf ' %-62s %s\n' "$p" "$n"
done
SH
chmod +x ~/count.sh
~/count.sh
optimized-baseline-nvidia-gpu-vllm-decode-65c6c44c68-bb8lg 1
optimized-baseline-nvidia-gpu-vllm-decode-65c6c44c68-pw2cx 0
起點不是零比零,前面那一發已經落在 bb8lg 上。
ask() {
curl -s -o /dev/null -X POST localhost:8080/v1/chat/completions \
-H 'Content-Type: application/json' \
-d "{\"model\":\"Qwen/Qwen2.5-7B-Instruct\",\"messages\":[{\"role\":\"user\",\"content\":\"$1\"}],\"max_tokens\":8}"
}
for i in 1 2 3; do ask "Explain Kubernetes DRA in one sentence."; sleep 2; done
~/count.sh
optimized-baseline-nvidia-gpu-vllm-decode-65c6c44c68-bb8lg 4
optimized-baseline-nvidia-gpu-vllm-decode-65c6c44c68-pw2cx 0
同一個 prompt 連打三發,三發都落在 bb8lg。
這排除了輪詢。兩台副本輪流發的話,三發會是兩比一。
但還有一個解釋沒排除:兩台都閒著的時候 token-load-scorer 給出平手,而 picker 在平手時固定挑同一台。那樣也會是三比零,跟 prompt 無關。要分開這兩種解釋,換掉 prompt 再打一次。
ask "What is the capital of France?"; sleep 2
ask "Write a haiku about storage classes."; sleep 2
ask "List three Linux signals."; sleep 2
~/count.sh
optimized-baseline-nvidia-gpu-vllm-decode-65c6c44c68-bb8lg 6
optimized-baseline-nvidia-gpu-vllm-decode-65c6c44c68-pw2cx 1
kill $(cat /tmp/pf.pid)
三個互不相干的 prompt,落點變成兩比一,不再全部黏在同一台。
| 落點 | 排除了什麼 | |
|---|---|---|
| 同一個 prompt 三發 | 3 比 0 | 輪詢,輪詢會是兩比一 |
| 不同的 prompt 三發 | 2 比 1 | 固定挑同一台,那樣會是三比零 |
兩組各排除一種固定的規則。剩下的解釋是落點取決於請求的內容。
這就是 prefix-cache-affinity-filter 的作用:共用開頭的請求會被帶回已經有那段 prefix cache 的那一台。
第二組的兩比一不能拿來說它會平均分配,三發的樣本講不了分佈。這一節證明的是落點會因為 prompt 而改變,不是分佈長什麼樣。
Day 15 量過引擎那一側:同一個開頭的請求可以重用已經算好的 block,省掉重算。但那只在同一台機器上成立,請求換一台就沒有那些 block。所以兩層要配合,引擎留得住,路由也得把請求送回原來那台。
llm-d 做的事是把幾台各自獨立的 vLLM 變成一個服務。請求進來有人決定去哪一台,而不是隨便挑一台有空的。
這個決定可以依據請求的內容,也可以依據後端當下的狀態。這天看到的是前者:共用開頭的請求會回到同一台,換了開頭就不會。輪詢做不到這件事,因為輪詢不看請求。
而決定的方式是設定出來的,不是寫死的。篩選、打分、挑選三段各自換外掛,就換了一套策略。
排程只是 llm-d 的其中一條配方,prefill 和 decode 分離、推理池自動擴縮、流量控制那些都在同一套東西裡。
llm-d
llm-d/llm-d
llm-d-router
Artifacts
EPP Configuration
llm-d-modelservice