LLM 추론 서버를 여러 대 띄웠을 때, 들어온 요청을 어느 서버로 보낼 것인가.
이 뻔해 보이는 질문이 LLM 에서는 왜 어려운지, 그리고 llm-d 가 이를 머신러닝으로 예측해서 푸는 방식을 확인해봤습니다.
1. 문제: 왜 평범한 로드밸런싱이 안 통하는가
웹 서버라면 로드밸런싱은 단순합니다. 요청 하나하나가 비슷한 비용이니 라운드로빈(순서대로 돌아가며 배분)이나 least-connection(연결이 가장 적은 서버 선택)이면 충분합니다.
LLM 추론은 다릅니다. 요청마다 비용이 수십 배씩 차이 납니다.
요청 A: "안녕" 입력 2토큰, 출력 10토큰
요청 B: 200페이지 문서 요약해줘 입력 50,000토큰, 출력 2,000토큰로드밸런서 입장에서 두 요청은 똑같이 "연결 1개"입니다. 그래서 연결 수나 대기열(큐) 길이로 판단하면, 큐가 짧아 보이는 서버가 사실은 거대한 요청 하나를 처리하느라 몇 초씩 막혀 있는 상황이 자주 생깁니다.
LLM 추론의 두 단계
두 용어를 먼저 잡고 가겠습니다. 글 전체에서 계속 나옵니다.
| 단계 | 하는 일 | 지표 |
|---|---|---|
| Prefill (사전 채움) | 입력 프롬프트 전체를 한 번에 읽어 첫 토큰을 만든다 | TTFT (Time To First Token) — 요청부터 첫 글자가 나오기까지 걸린 시간 |
| Decode (생성) | 그 뒤로 토큰을 하나씩 순차 생성한다 | TPOT (Time Per Output Token) — 토큰 하나당 평균 시간 |
Prefill 은 입력 길이에, Decode 는 출력 길이에 비례합니다. 즉 "이 요청이 서버를 얼마나 오래 붙잡을지"는 입력·출력 길이에 달려 있는데, 큐 길이라는 숫자에는 그 정보가 담겨 있지 않습니다.
캐시라는 두 번째 변수
여기에 KV 캐시가 붙습니다. 모델이 프롬프트를 읽으며 만든 중간 계산 결과를 서버가 메모리에 들고 있는 것입니다. 앞부분이 똑같은 프롬프트가 같은 서버로 다시 오면, 그 부분의 prefill 을 통째로 건너뛸 수 있습니다. 이렇게 "앞부분이 일치하는지"를 보고 재사용하는 것을 프리픽스(prefix) 캐시라고 부릅니다.
그래서 "덜 바쁜 서버"보다 "내 프롬프트의 캐시를 이미 갖고 있는 서버"가 훨씬 빠를 수 있습니다.
정리하면 이렇습니다. 부하 지표(큐 길이)와 캐시 적중은 서로 다른 서버를 가리킬 때가 많습니다. 큐가 짧은 A 와 캐시를 가진 B 중 어디로 보낼지 정하려면 둘에 가중치를 매겨야 하는데, 그 가중치를 사람이 손으로 정하면 트래픽 패턴이 바뀔 때마다 다시 맞춰야 합니다.
2. llm-d 는 무엇인가
llm-d 는 쿠버네티스에서 LLM 추론을 분산 서빙하기 위한 스택입니다. 세 조각으로 이해하면 됩니다.
Envoy 프록시
| "이 요청 어디로 보낼까?" 라고 물어봄 (ext-proc 규약)
v
EPP (Endpoint Picker) <-- llm-d 의 스케줄러
| "3번 파드로 보내"
v
vLLM 파드들 (실제 모델 서버)- vLLM — 실제 모델을 돌리는 추론 엔진
- Gateway API Inference Extension (GAIE) — 쿠버네티스 표준 규약. 프록시가 라우팅 결정을 스스로 하지 않고 외부 컴포넌트에 물어보는 방식을 정의합니다. 이 "물어보는 통신 규약"을 ext-proc(external processing)이라고 합니다
- EPP (Endpoint Picker) — 그 질문에 답하는 llm-d 의 스케줄러. 이 글에서 다루는 대상입니다
3. Predicted Latency-Based Scheduling: 개념
기존 스케줄링은 관측된 상태(큐 깊이, KV 캐시 사용률)에 사람이 정한 가중치를 곱해 점수를 냈습니다. Predicted Latency 방식은 질문 자체를 바꿉니다.
기존 : "어느 서버가 덜 바빠 보이나?"
변경 : "이 요청을 각 서버에 보내면 실제로 몇 ms 걸릴까?"XGBoost(의사결정나무 기반의 머신러닝 알고리즘) 모델로 그 시간을 예측합니다. 특징은 온라인 학습이라는 점입니다. 미리 준비한 학습 데이터셋을 쓰는 것이 아니라, 지금 이 클러스터에서 처리되는 요청의 실제 결과를 계속 학습에 되먹입니다.
구성 요소
예측에 필요한 서버들이 EPP 파드 안에 사이드카로 함께 뜹니다. 사이드카란 같은 파드 안에서 주 컨테이너를 보조하는 별도 컨테이너를 말합니다. 실제로 설치하면 이렇게 보입니다.
$ kubectl get pod <epp-pod> -o jsonpath='{.spec.containers[*].name}'
envoy-proxy epp training-server prediction-server-1
| 컨테이너 | 역할 |
|---|---|
envoy-proxy |
요청을 받고, EPP 에 "어디로 보낼까" 질의 |
epp |
스케줄러 본체. 플러그인 파이프라인 실행 |
training-server |
완료된 요청을 모아 XGBoost 모델을 주기적으로 재학습 |
prediction-server-1 |
학습된 모델로 TTFT/TPOT 예측. 모든 요청이 지나가는 경로에 있어 여기가 느리면 전체가 느려집니다 |
요청 한 건의 흐름
1. 요청 도착, Envoy 가 받아서 EPP 에 질의
2. EPP 가 prediction-server 에 물어봄
"이 요청을 파드 1/2/3에 보내면 각각 TTFT·TPOT 몇 ms?"
3. prediction-server 가 파드별 예측값 반환
4. EPP 의 latency-scorer 가 점수를 매겨 파드 선택
5. 선택된 vLLM 파드가 응답
6. 실제로 걸린 시간을 training-server 로 전송하여 모델 갱신6번이 이 구조의 핵심입니다. 예측이 틀리면 그 오차가 다음 학습에 반영되므로, 트래픽 패턴이 바뀌어도 모델이 따라갑니다.
실제로 어떤 값을 보고 예측하나
학습 서버 로그에서 모델이 쓰는 입력값(피처) 목록을 그대로 확인할 수 있었습니다. 11개입니다.
is_queued, kv_cache_percentage, input_token_length,
num_request_waiting, num_request_running,
prefill_tokens_in_flight, decode_tokens_in_flight,
prefix_cache_score, effective_input_tokens,
prefill_score_bucket, pod_type_cat여기서 눈여겨볼 점은 큐 길이(num_request_waiting)가 11개 중 하나에 불과하다는 것입니다. 입력 토큰 수, KV 캐시 사용률, 프리픽스 캐시 점수, 현재 처리 중인 prefill/decode 토큰 수가 함께 들어갑니다. 1절에서 말한 "큐 길이에는 요청 비용 정보가 없다"는 문제를, 나머지 10개 피처로 메우는 구조입니다.
4. 실습 환경
| 항목 | 값 |
|---|---|
| 쿠버네티스 | v1.36.1 |
| GPU | NVIDIA RTX PRO 6000 Blackwell Server Edition, 96GB, 1장 |
| 드라이버 / CUDA | 580.105.08 / 13.0 |
| GPU 분할 | HAMi hami-core 로 5분할 (nvidia.com/gpu: 5) |
| GPU 노드 | 16 vCPU / 64GB RAM |
| 모델 | Qwen/Qwen3-0.6B (인증 불필요), 복제본 3개 |
| vLLM | v0.26.0 |
| llm-d 라우터 차트 | oci://ghcr.io/llm-d/charts/llm-d-router-standalone:v0 |
HAMi 는 물리 GPU 1장을 여러 파드가 나눠 쓰게 해주는 도구입니다. 여기서는 96GB 카드 하나를 5개 슬롯으로 쪼개 썼습니다.
전제 두 가지
1. 모델 서버가 2대 이상이어야 합니다.
스케줄링은 후보 중에서 고르는 일이라, 파드가 1개면 고를 것이 없어 관찰할 수 없습니다. GPU 1장을 HAMi 로 나눠 복제본 3개를 띄웠습니다.
2. 모델 서버들의 구성이 서로 같아야 합니다.
같은 모델·같은 설정의 복제본 묶음을 풀(pool) 이라고 부릅니다. 공식 문서는 GPU 종류나 모델 변형, 서빙 설정이 섞인 풀에서는 예측이 부정확해진다고 명시적으로 경고합니다.
5. 설치
5-1. 리포지토리와 환경변수
git clone https://github.com/llm-d/llm-d.git && cd llm-d
export REPO_ROOT=$(realpath $(git rev-parse --show-toplevel))
source ${REPO_ROOT}/guides/env.sh
export GUIDE_NAME="predicted-latency-routing"
export NAMESPACE=llm-d-predicted-latency
export MODEL_NAME="Qwen/Qwen3-0.6B"
env.sh 가 아래 변수를 채워 줍니다. 직접 정의할 필요 없습니다.
ROUTER_STANDALONE_CHART="oci://ghcr.io/llm-d/charts/llm-d-router-standalone"
ROUTER_CHART_VERSION="v0"
GAIE_URL="releases/latest/download"
5-2. Gateway API Inference Extension CRD 설치
CRD(Custom Resource Definition)는 쿠버네티스에 새로운 리소스 종류를 추가하는 정의입니다. llm-d 의 EPP 는 GAIE 규약 위에서 동작하므로, 그 규약이 정의한 리소스를 클러스터가 먼저 알고 있어야 합니다.
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/${GAIE_URL}/v1-manifests.yaml
설치되면 다음 리소스가 생깁니다.
$ kubectl get crd | grep -i inference
inferencepools.inference.networking.k8s.io 2026-09-16T11:41:44Z
5-3. 네임스페이스와 토큰
kubectl create namespace ${NAMESPACE}
# 인증이 필요 없는 모델이면 값이 비어 있어도 됩니다.
# 다만 매니페스트가 이 시크릿을 참조하므로 "존재" 자체는 필요합니다.
kubectl create secret generic llm-d-hf-token \
--from-literal="HF_TOKEN=" \
--namespace "${NAMESPACE}" \
--dry-run=client -o yaml | kubectl apply -f -
HuggingFace 의 일부 모델은 사용 동의를 받아야 내려받을 수 있고(이를 gated 모델이라 합니다), 그런 모델은 실제 토큰이 필요합니다. Qwen 계열은 gated 가 아니라 빈 토큰으로도 받아집니다.
5-4. 모델 서버 배포 — 가이드 기본값은 그대로 쓸 수 없습니다
공식 가이드가 제공하는 설정은 이렇습니다.
replicas: 10
args:
- "Qwen/Qwen3-32B"
- "--tensor-parallel-size=2"
- "--max-model-len=131072"
resources:
limits: { cpu: "16", memory: 128Gi, nvidia.com/gpu: 2 }
복제본 10개가 각각 GPU 2장을 쓰므로 GPU 20장이 필요합니다. 대형 클러스터 기준이라, 실습 환경에서는 설정을 직접 덮어써야 합니다.
쿠버네티스에서 기존 매니페스트의 일부만 바꿔 쓰는 방식을 kustomize 오버레이라고 합니다. 원본은 그대로 두고 바꿀 부분만 따로 적는 것입니다.
overlay/kustomization.yaml — 무엇을 기반으로 무엇을 덮어쓸지 선언합니다.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- <llm-d>/guides/optimized-baseline/modelserver/gpu/vllm/base
patches:
- path: patch-vllm.yaml
overlay/patch-vllm.yaml — 실제로 바꿀 내용입니다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: optimized-baseline-nvidia-gpu-vllm-decode
spec:
replicas: 3
template:
spec:
containers:
- name: modelserver
args:
- "Qwen/Qwen3-0.6B"
- "--disable-access-log-for-endpoints=/health,/metrics,/v1/models"
- "--tensor-parallel-size=1"
- "--max-model-len=8192"
- "--gpu-memory-utilization=0.85"
resources:
limits:
cpu: "4"
memory: 12Gi
nvidia.com/gpu: 1
nvidia.com/gpumem: 12000
requests:
cpu: "2"
memory: 8Gi
nvidia.com/gpu: 1
nvidia.com/gpumem: 12000
주의: --gpu-memory-utilization 을 반드시 낮춰야 합니다.
vLLM 은 기본적으로 이 값이 0.9 인데, 이는 "자기에게 보이는 GPU 메모리의 90%를 미리 잡는다"는 뜻입니다. 슬라이스 제한이 없으면 첫 파드가 96GB 중 86GB 를 선점해 나머지 2개가 뜨지 못합니다.
그래서 두 가지를 같이 걸었습니다. HAMi 의 nvidia.com/gpumem: 12000(단위 MB)으로 파드마다 12GB 만 보이게 자르고, 그 안에서 85%를 쓰게 했습니다. 3개 × 12GB = 36GB 로 96GB 안에 여유 있게 들어갑니다.
kubectl apply -n ${NAMESPACE} -k ./overlay
5-5. 라우터(EPP) 배포
helm install ${GUIDE_NAME} ${ROUTER_STANDALONE_CHART} \
-f ${REPO_ROOT}/guides/recipes/router/base.values.yaml \
-f ${REPO_ROOT}/guides/${GUIDE_NAME}/router/predicted-latency.values.yaml \
-n ${NAMESPACE} --version ${ROUTER_CHART_VERSION}
이 기능을 켜는 설정 자체는 단 두 줄입니다.
router:
latencyPredictor:
enabled: true # 이 한 줄이 training/prediction 사이드카를 띄운다
그리고 EPP 가 요청을 처리할 때 거치는 플러그인 순서가 함께 정의됩니다.
plugins:
- type: approx-prefix-cache-producer
- type: predicted-latency-producer # 예측 서버 호출
- type: prefix-cache-affinity-filter # 캐시 보유 파드 우선 (임계값 0.8)
- type: latency-scorer # 예측 지연으로 점수화
- type: weighted-random-picker # 최종 선택
6. 설치하다 막힌 지점 세 가지
한 번에 되지 않았습니다. 같은 데서 막히실 분들을 위해 그대로 적습니다.
막힌 지점 1. Helm 차트 내려받기가 403 거부
Error: INSTALLATION FAILED: failed to perform "FetchReference" on source:
GET "https://ghcr.io/v2/llm-d/charts/llm-d-router-standalone/manifests/v0":
response status code 403: denied: deniedcurl 로 직접 시도하면 익명 접근 토큰이 잘 발급되는데 helm 만 실패했습니다. 원인은 ~/.config/helm/registry/config.json 에 남아 있던 만료된 인증 정보였습니다. helm 이 그 자격증명을 우선 사용했고, 만료된 자격증명이라 거절당한 것입니다.
# 빈 registry 설정으로 우회
mkdir -p /tmp/helmreg && echo '{}' > /tmp/helmreg/config.json
export HELM_REGISTRY_CONFIG=/tmp/helmreg/config.json
교훈: 익명 접근이 되는 저장소인데 403 이 난다면, 인증 정보가 없어서가 아니라 잘못된 것이 있어서일 수 있습니다.
막힌 지점 2. EPP 파드가 Pending — 기본 리소스 요청이 18 vCPU
0/2 nodes are available: 1 Insufficient memory, 2 Insufficient cpuEPP 파드 하나가 요구하는 자원의 합계입니다.
| 컨테이너 | CPU | 메모리 |
|---|---|---|
| envoy-proxy | 4 | 8Gi |
| epp | 8 | 8Gi |
| training-server | 2 | 4Gi |
| prediction-server-1 | 8 | 4Gi |
| 합계 | 18 | 24Gi |
16 vCPU 짜리 노드에는 들어갈 수 없는 크기입니다. 실습용으로 줄였습니다.
막힌 지점 3. prediction-server 가 OOMKilled — 이번엔 너무 줄였다
위를 해결하려고 메모리를 2Gi 로 줄였더니 이번에는 컨테이너가 반복해서 죽었습니다. OOMKilled 는 컨테이너가 할당된 메모리 한도를 넘겨 커널에 강제 종료된 상태를 말합니다(종료 코드 137).
lastState.terminated: reason=OOMKilled exitCode=137
로그:
"Uvicorn running on http://0.0.0.0:8001"
"Started parent process [1]" <- 여기서 멈춤, 워커 로그가 없음부모 프로세스만 뜨고 워커가 전혀 시작되지 못한 모습입니다. 웹 서버(uvicorn)가 노드의 CPU 개수(16)만큼 워커를 띄우려다 2Gi 한도 안에서 메모리가 부족해진 것으로 보입니다. 정확한 원인은 더 파보지 않았고, 메모리를 올리니 해결됐습니다.
최종 실습용 설정값:
router:
proxy:
resources:
requests: { cpu: "300m", memory: 512Mi }
limits: { memory: 2Gi }
epp:
resources:
requests: { cpu: "500m", memory: 1Gi }
limits: { memory: 4Gi }
latencyPredictor:
trainingServer:
resources:
requests: { cpu: "500m", memory: 2Gi }
limits: { cpu: "2", memory: 4Gi }
volumeSize: "5Gi"
predictionServers:
resources:
requests: { cpu: "1", memory: 4Gi } # 4Gi 미만으로 내리면 OOM 발생
limits: { cpu: "4", memory: 8Gi }
기동 완료
$ kubectl -n llm-d-predicted-latency get po
optimized-baseline-nvidia-gpu-vllm-decode-...-8mtld 1/1 Running
optimized-baseline-nvidia-gpu-vllm-decode-...-csbws 1/1 Running
optimized-baseline-nvidia-gpu-vllm-decode-...-ztn5d 1/1 Running
predicted-latency-routing-epp-...-vq6lm 4/4 RunningvLLM 파드가 요청을 받을 수 있는 상태가 되기까지 약 9분 걸렸습니다. 대부분 컨테이너 이미지 내려받기와 CUDA 그래프 준비에 쓰인 시간입니다.
7. 요청 보내기
export IP=$(kubectl get service ${GUIDE_NAME}-epp -n ${NAMESPACE} -o jsonpath='{.spec.clusterIP}')
kubectl -n ${NAMESPACE} run curlbox --image=cfmanteiga/alpine-bash-curl-jq \
--restart=Never -- sleep 900
EPP 서비스는 포트가 세 개입니다. 추론 요청은 80번으로 보냅니다.
port 9002 -> 9002 ext-proc gRPC (Envoy 가 EPP 에 질의하는 통로)
port 9090 -> 9090 메트릭
port 80 -> 8081 추론 API <- 요청은 여기로kubectl -n ${NAMESPACE} exec curlbox -- curl -s -X POST http://${IP}:80/v1/completions \
-H 'Content-Type: application/json' \
-d '{
"model": "Qwen/Qwen3-0.6B",
"prompt": "Explain the difference between prefill and decode.",
"max_tokens": 64,
"temperature": 0,
"stream": true,
"stream_options": {"include_usage": true}
}'
응답:
data: {"id":"cmpl-ee2d838c...","object":"text_completion","model":"Qwen/Qwen3-0.6B",
"usage":{"prompt_tokens":10,"total_tokens":74,"completion_tokens":64}}
data: [DONE]"stream": true 는 빼면 안 됩니다. 이 구성은 토큰이 하나씩 도착하는 간격을 재서 TPOT 을 학습하기 때문에, 스트리밍이 아니면 학습에 쓸 샘플이 제대로 쌓이지 않습니다.
부하 생성
예측 모델은 트래픽이 있어야 학습됩니다. 그리고 1절에서 본 것처럼 이 기능은 요청 크기가 제각각일 때 의미가 있으므로, 길이를 세 종류로 섞어 150건을 보냈습니다.
짧은 프롬프트(약 3자) / max_tokens 32
중간 프롬프트(600자) / max_tokens 64
긴 프롬프트(4000자) / max_tokens 128
세 종류를 무작위로, 동시 5건씩, 총 150건8. 분석 1: 예측 모델이 정말 학습했는가
학습 서버의 로그가 가장 직접적인 증거입니다.
kubectl -n ${NAMESPACE} logs <epp-pod> -c training-server --tail=200 | grep -i "model trained"
부하를 넣기 전에는 학습할 것이 없다고 나옵니다.
Skipping training: only 0 samples (< 10).부하를 넣은 뒤 — 10초 간격으로 재학습하는 모습이 그대로 보입니다.
11:57:56 TTFT model trained on 64 samples. MAE = 18.1990, RMSE = 28.8657
11:58:06 TTFT model trained on 132 samples. MAE = 20.3510, RMSE = 28.3740
11:58:16 TTFT model trained on 132 samples. MAE = 20.3510, RMSE = 28.3740
11:58:16 TTFT XGBoost trees saved to /models/ttft_trees.json
11:58:16 TPOT XGBoost trees saved to /models/tpot_trees.jsonMAE 와 RMSE 읽는 법. 둘 다 예측이 실제와 얼마나 어긋났는지를 나타내는 오차 지표이고, 작을수록 좋습니다.
- MAE(평균 절대 오차) — 오차의 크기를 단순 평균한 값. "평균적으로 이만큼 틀렸다"
- RMSE(제곱근 평균 제곱 오차) — 큰 오차에 더 큰 벌점을 주는 방식. MAE 보다 크게 나오면 가끔 크게 틀리는 경우가 있다는 뜻
여기서 읽어낼 수 있는 것:
- p90 을 목표로 학습합니다. 로그에
quantile 0.9로 찍히는데, 평균이 아니라 상위 10% 느린 요청까지 맞추겠다는 뜻입니다. 사용자 체감을 좌우하는 것은 평균이 아니라 느린 쪽이기 때문입니다 - 재학습 주기는 10초입니다(
LATENCY_RETRAINING_INTERVAL_SEC: "10"), 학습을 시작하는 최소 샘플 수는 기본값 100 입니다 - 64샘플일 때 MAE 18.20, 132샘플일 때 20.35. 샘플이 늘었는데 오차가 커졌습니다. 나빠진 것으로 보이지만, 같은 시점의 RMSE 는 28.87 에서 28.37 로 오히려 줄었습니다. 처음 64건은 비슷한 요청이었다가 이후 긴 프롬프트가 섞이면서 다뤄야 할 범위가 넓어진 것으로 보입니다
Insufficient samples for ensemble split, using single model only— 샘플이 부족해 여러 모델을 조합하지 않고 단일 모델만 씁니다. 150건은 이 모델이 제 성능을 내기에는 한참 모자란 양입니다
초반 수치만으로 이 기능의 정확도를 판단해서는 안 됩니다. 온라인 학습이라 트래픽이 쌓여야 정확해지고, 공식 문서는 충분히 수렴했을 때 평균 오차율 약 5% 라고 밝히고 있습니다.
9. 분석 2: 스케줄러가 실제로 서버를 "고르고" 있는가
이 실습에서 가장 흥미로웠던 부분입니다. 150건이 3개 파드에 어떻게 나뉘었는지 vLLM 자체 메트릭으로 확인했습니다.
kubectl -n ${NAMESPACE} exec curlbox -- curl -s http://<pod-ip>:8000/metrics \
| grep -E 'vllm:request_success_total|vllm:prefix_cache_(hits|queries)_total'
| 파드 | 처리한 요청 | 비율 | 프리픽스 캐시 적중률 |
|---|---|---|---|
...8mtld |
82건 | 54% | 96.7% (122,592 / 126,729) |
...ztn5d |
36건 | 24% | 94.2% (14,688 / 15,585) |
...csbws |
33건 | 22% | 0% (0 / 66) |
| 합계 | 151건 |
라운드로빈이었다면 50 / 50 / 50 이 나와야 합니다. 실제로는 한 파드에 54%가 몰렸습니다. 스케줄러가 균등 분배가 아니라 선택을 하고 있다는 직접적인 증거입니다.
그리고 가장 많이 받은 파드는 프리픽스 캐시 적중률이 가장 높은 파드였습니다. 캐시가 있으면 prefill 을 건너뛰어 TTFT 가 낮아지고, 예측 모델이 그 상태를 입력으로 받아 낮은 지연을 예측하고, 그래서 더 많은 요청이 그리로 갑니다. 요청이 갈수록 캐시는 더 쌓입니다.
특히 눈에 띄는 것은 csbws 의 캐시 적중률이 0% 라는 점입니다. 캐시를 조회한 횟수 자체가 66건뿐입니다. 스케줄러가 이 파드를 "캐시가 없어 느릴 것"으로 판단해 덜 보냈고, 덜 보냈기 때문에 캐시가 쌓이지 않아 계속 불리해지는 스스로 강화되는 순환이 나타난 것으로 보입니다.
이 동작에는 양면이 있습니다.
- 얻는 것: 캐시를 가진 서버로 요청을 모아 전체 지연을 낮춥니다
- 잃는 것: 특정 파드에 부하가 쏠려 자원이 고르게 쓰이지 않습니다. 실제로 22%만 받은 파드가 있습니다
prefix-cache-affinity-filter 의 affinityThreshold: 0.8 이 이 쏠림의 강도를 정하는 값입니다. 캐시 지역성과 균등 분배 중 어디에 무게를 둘지 여기서 조절하게 됩니다.
10. 분석 3: 메트릭 확인 — 여기서는 실패했습니다
공식 문서가 안내하는 확인 방법은 EPP 가 노출하는 Prometheus 메트릭입니다.
| 메트릭 | 의미 |
|---|---|
llm_d_epp_request_ttft_seconds |
실제 TTFT |
llm_d_epp_request_predicted_ttft_seconds |
예측 TTFT |
llm_d_epp_request_ttft_prediction_duration_seconds |
예측 계산 자체에 걸린 시간 |
llm_d_epp_request_streaming_tpot_seconds |
실제 TPOT |
llm_d_epp_request_predicted_tpot_seconds |
예측 TPOT |
llm_d_epp_request_slo_violation_total |
SLO 위반 누적 횟수 |
예측 정확도는 예측값과 실제값을 같은 구간에서 비교하면 됩니다.
histogram_quantile(0.90, sum by (le) (rate(llm_d_epp_request_predicted_ttft_seconds_bucket[5m])))
histogram_quantile(0.90, sum by (le) (rate(llm_d_epp_request_ttft_seconds_bucket[5m])))
다만 이번 실습에서는 이 엔드포인트에 접근하지 못했습니다. 9090 포트가 인증을 요구합니다.
$ curl http://<epp-pod-ip>:9090/metrics
HTTP 401
# ServiceAccount 토큰과 /metrics 접근 권한을 부여한 뒤에도
HTTP 500 "Authentication failed"별도의 인증 방식이 필요한 것으로 보이나 원인을 끝까지 규명하지 못했습니다. 그래서 정확도 관련 수치는 8절의 학습 서버 로그(MAE/RMSE)로 대체했습니다. 모니터링 스택(monitoring.values.yaml)을 함께 설치하면 ServiceMonitor 를 통해 Prometheus 가 수집하므로, 그 경로라면 이 문제를 겪지 않을 가능성이 높습니다.
SLO 헤더는 이번 구성에서 동작하지 않습니다
x-llm-d-slo-ttft-ms 와 x-llm-d-slo-tpot-ms 헤더로 요청마다 지연 목표를 지정할 수 있고, 어떤 파드도 그 목표를 맞추지 못한다고 예측되면 느린 곳에 억지로 보내는 대신 요청을 거절합니다.
다만 이 동작은 predicted-latency-slo.values.yaml 을 써야 켜집니다. 이번에 사용한 기본 구성(predicted-latency.values.yaml)에서는 헤더를 보내도 무시됩니다. 설정 파일 주석에도 그렇게 적혀 있어, 이번 실습에서는 검증하지 못했습니다.
11. 정리하며 배운 것
1. 스케줄링이 실제로 일어납니다. 54% / 24% / 22% 라는 분배는 라운드로빈에서는 나올 수 없는 숫자입니다. 그리고 그 쏠림이 프리픽스 캐시 적중률 순서와 정확히 일치했습니다.
2. 큐 길이는 11개 입력값 중 하나일 뿐입니다. input_token_length, kv_cache_percentage, prefix_cache_score, prefill_tokens_in_flight 같은 값이 함께 들어갑니다. 요청 비용을 직접 나타내는 값들이 포함된다는 점이 기존 방식과의 실질적인 차이입니다.
3. 학습 목표가 평균이 아니라 p90 입니다. 느린 쪽 요청을 줄이는 데 맞춰진 설계입니다.
4. 작은 실습으로 정확도를 논하기는 어렵습니다. 150건은 로그에 Insufficient samples 가 찍히는 수준입니다. 의미 있는 정확도를 보려면 훨씬 많은 트래픽과 긴 관측 기간이 필요합니다.
5. 기본값은 대형 클러스터 기준입니다. 모델 서버는 GPU 20장을, EPP 는 18 vCPU 를 요구합니다. 실습하려면 자원을 직접 줄여야 하는데, 너무 줄이면 이번처럼 OOM 으로 되돌아옵니다.
언제 도입할 만한가
아래는 워크로드가 좋은지 나쁜지를 가르는 기준이 아니라, 이 스케줄링 기능을 켤지 말지를 정하는 기준입니다.
도입하면 이득이 큰 경우
- 요청 크기의 편차가 큰 트래픽. 짧은 챗봇 질의와 긴 문서 요약이 한 엔드포인트에 섞여 들어오는 경우가 전형적입니다. 큐 길이가 실제 부하를 대변하지 못하는 바로 그 상황이라, 예측이 값어치를 합니다
- 대화형 요청과 배치 요청을 같은 GPU 풀에서 처리해야 하고, 요청마다 지연 목표를 다르게 걸고 싶은 경우 (SLO 구성이 별도로 필요합니다)
도입해도 이득이 적은 경우 — 켜지 않아도 됩니다
- 요청 크기가 고른 트래픽. 이런 워크로드가 나쁘다는 뜻이 전혀 아닙니다. 요청 비용이 비슷하면 큐 길이만으로도 이미 판단이 잘 맞기 때문에, 예측을 얹어도 개선폭이 작다는 의미입니다
- 비용도 함께 보셔야 합니다. 이 기능을 켜면 EPP 파드에 사이드카 2개(training, prediction)가 추가되고, 차트 기본값 기준으로 그 둘만 CPU 10개와 메모리 8Gi 를 요구합니다. 여기에 요청마다 예측 호출이 한 번씩 더 붙습니다. 그 지연은
llm_d_epp_request_ttft_prediction_duration_seconds로 잴 수 있지만, 이번 실습에서는 메트릭 접근에 실패해 값을 확인하지 못했습니다
도입하면 안 되는 경우 — 앞의 둘과 성격이 다릅니다
- GPU 종류나 모델 설정이 섞인 풀. 이것은 "효과가 없다"가 아니라 예측 자체가 틀린다는 문제입니다. 공식 문서가 "will produce inaccurate predictions" 라고 명시적으로 경고합니다. 풀이 이질적이라면 먼저 같은 구성끼리 풀을 나누고 나서 적용해야 합니다
결국 판단은 자기 트래픽의 요청 크기 분포를 먼저 확인하는 것에서 시작하는 것이 맞겠습니다. 편차가 작다면 굳이 얹을 이유가 없고, 크다면 얹을 값어치가 있습니다.
참고 자료
'LLM Serving and Optimization Study' 카테고리의 다른 글
| [LLMSO 6주차] AI 칩 아키텍처 (0) | 2026.09.13 |
|---|---|
| [LLMSO 5주차] vLLM 두 대 앞에 LiteLLM을 세우고, 네 가지를 실제로 재봤다 (0) | 2026.09.04 |
| [LLMSO 4주차] 쿠버네티스 위 TensorRT-LLM 실습기 — 설치부터 FP8 양자화, 그리고 vLLM 대조 (0) | 2026.08.30 |
| [LLM 3.5주차] 양자화 실습 (0) | 2026.08.23 |
| [LLMSO 3주차] K8s 기반 vLLM 서빙 파라미터 실측 튜닝기 (0) | 2026.08.23 |