devlos
Devlos Archive
devlos
전체 방문자
오늘
어제
10-13 00:01

최근 글

  • 분류 전체보기 (131)
    • 프로젝트 (1)
    • MSA 설계 & 도메인주도 설계 (9)
    • AI (15)
      • [LLMSO] Hands-On LLM 서빙과 최적.. (15)
    • 클라우드 컴퓨팅 & NoSQL (96)
      • [K8S Deploy] K8S 디플로이 스터디 (8)
      • [Cilium Study] 실리움 스터디 (8)
      • [KANS] 쿠버네티스 네트워크 심화 스터디 (12)
      • [T101] 테라폼 4기 스터디 (8)
      • [CICD] CICD 맛보기 스터디 (3)
      • [T101] 테라폼 기초 입문 스터디 (6)
      • [AEWS] Amazon EKS 워크숍 스터디 (7)
      • [PKOS] 쿠버네티스 실무 실습 스터디 (7)
      • Kubernetes (13)
      • Docker (7)
      • Redis (1)
      • Jenkins (3)
      • Terraform (1)
      • Ansible (4)
      • Kafka (1)
    • 프로그래밍 (7)
      • Spring Boot (5)
      • Broker (1)
    • 성능과 튜닝 (1)
    • ALM (0)
    • 기타 (2)

인기 글

태그

  • t101 4기
  • 쿠버네티스
  • 쿠버네티스 스터디
  • MSA
  • ansible
  • K8s
  • docker
  • LLM 서빙
  • LLMSO
  • CloudNet@
  • Kubernetes
  • 데브옵스
  • DevOps
  • 도커
  • kOps

티스토리

최근 댓글

hELLO · Designed By 정상우.
devlos

Devlos Archive

[7주차-LLMSO] llm-d 란 (2026.09.13)
AI/[LLMSO] Hands-On LLM 서빙과 최적화 스터디

[7주차-LLMSO] llm-d 란 (2026.09.13)

2026. 9. 20. 02:08
반응형
[7W-2] 같은 모델 파드 중 누구에게 보낼 것인가 — LLMSO 7주차
7W-2 · llm-d 들어가며 1 왜 라우터인가 2 세 부품 3 EPP ① 흐름 제어 4 EPP ② 스케줄러 5 KV 캐시 6 두 개의 라우터 7 실습 환경 8 벤치마크 9 설치와 비교 10 트레이싱 11 동작 확인 마치며
LLMSO 7주차 · [7W-2] llm-d

같은 모델 파드 여러 개 중
누구에게 보낼 것인가.

vLLM 파드를 두 개로 늘리면 쿠버네티스 Service가 알아서 나눠 줍니다. 그런데 Service는 어느 파드의 KV 캐시에 방금 그 프롬프트가 들어 있는지, 어느 파드 앞에 긴 요청이 줄 서 있는지 모릅니다.

llm-d는 그걸 아는 라우터입니다. 부품 셋과 EPP의 안쪽을 열고, GPU 한 장을 반씩 나눈 실습 클러스터에서 실제로 무엇이 달라졌는지 봅니다.

들어가며

읽는 시간 40분 대상 vLLM을 쿠버네티스에 올려 본 개발자 사전 지식 KV 캐시 개념, [7W-1] 9·11장 그림 11개

[7W-1] 11장은 EPP라는 상자 앞에서 멈췄습니다. "EPP가 파드별 큐 길이·캐시 상태를 점수화해 하나를 고른다" — 이 한 줄이 무엇을 뜻하는지가 이 페이지의 주제입니다. 원문 스터디 자료도 llm-d 공식 문서를 한 페이지씩 정리한 뒤, GPU 한 장짜리 K3s 클러스터를 새로 세워 llm-d를 올리고 직접 비교합니다.

그 비교 결과가 흥미롭습니다. 파드 2개 + 작은 모델에서는 캐시 적중률이 4.9%p 오르는 대신 TTFT가 오히려 소폭 늘었습니다. "지능형 라우팅이 무조건 빠르다"가 아니라 언제 이득이 나는가를 가르는 조건이 있다는 뜻이고, 그 조건을 그림으로 찾아보는 게 이 페이지의 목표입니다.

결론부터 세 줄
  1. llm-d Router = Proxy(Envoy 등) + EPP. Proxy는 요청을 붙잡고 ext_proc로 EPP에게 묻고, EPP는 InferencePool의 파드들을 Filter → Score → Pick으로 골라 주소를 돌려줍니다. 모델 서버는 vLLM 그대로입니다.
  2. 점수의 근거는 모델 서버가 내놓는 신호입니다. 대기열·KV 캐시 사용률 메트릭, 그리고 vLLM이 ZMQ로 흘리는 KV 블록 이벤트. 신호가 없으면(llama.cpp) 라우팅은 되지만 판단은 못 합니다.
  3. 이득은 규모가 커야 날 것으로 보입니다. 원문 실습(파드 2개, Qwen3-0.6B-FP8)에서 precise prefix 라우팅은 캐시 적중률 63.9% → 68.75%, TTFT는 59~66 → 67~69 ms. 원문은 파드 수가 많고 캐시 미스가 비쌀수록 차이가 커질 것으로 예상합니다. EPP 자체 비용은 트레이스상 요청의 0.54%였습니다.

접는 상자(▸)는 안 열어도 됩니다. 안에는 설정 YAML, 플러그인 표, 원문 명령과 출력이 있습니다.

그림은 만져보라고 넣었습니다. 특히 9장의 그림은 원문의 "파드 2개에선 이득이 작다"는 결론을 재현한 뒤 파드를 늘려 보며 반대 상황을 만들 수 있습니다.

"[7W-1] N장"·"[6W-2] N장"은 이전 주차 페이지의 장이고, 그냥 "N장"은 이 페이지의 장입니다.

먼저 말해둘 것 — 실습은 GPU가 필요합니다

원문 실습은 GPU가 반드시 필요하고, 가정용 카드가 없으면 VRAM 24 GiB 이상 AWS GPU EC2 한 대를 권합니다. GPU 없이 게이트웨이 부분만 보고 싶다면 [7W-1] 11장(CPU llama.cpp)과 원문이 추천한 멤버 글 "GPU 없이 llm-d 게이트웨이 뜯어보기"가 출발점입니다. 버전은 vLLM v0.28.0, llm-d-router EPP v0.10.0, Envoy Gateway v1.9.1, Agent Router v1.1.0, GAIE v1.6.1입니다.

먼저 질문부터 — 파드를 늘렸는데 왜 빨라지지 않는가.

01왜 LLM 전용 라우터인가

한 줄 요약: LLM 서빙에서 파드를 늘리는 건(Scaling) 절반이고, 요청을 어느 파드에 보내느냐(Routing)가 나머지 절반입니다. 같은 prefix를 이미 캐시한 파드, 줄이 짧은 파드를 고르면 같은 GPU로 더 빨라집니다.

왜 이걸 배우나
"vLLM 파드를 두 배로 늘렸는데 TTFT가 기대만큼 안 줄어든다 — 왜?"

원문이 인용한 Red Hat 발표(Beyond vLLM)는 AI 추론을 Scaling vs Routing으로 나눕니다. 백엔드가 많아질수록 LLM을 아는 부하 분산이 필요해진다는 것이 요지입니다. 일반 round robin은 첫 요청 이후에도 캐시를 모른 채 파드를 돌리니, 방금 계산해 둔 KV 캐시를 다른 파드가 처음부터 다시 계산합니다.

원문의 비교 캡처는 한 가지를 짚습니다. 최초 캐시 생성 요청 이후부터 성능 수치가 갈린다는 것. 캐시가 쌓이기 시작하면 "어디에 쌓였는지 아는 라우터"와 "모르는 라우터"의 차이가 벌어집니다.

이 장에 나오는 낯선 말
KV 캐시 · Prefix caching
모델이 앞 토큰들을 읽으며 만든 중간 계산값(Key·Value)을 GPU 메모리에 남긴 것. 다음 요청이 같은 앞부분(prefix)으로 시작하면 그 부분 계산을 건너뜁니다. vLLM에서는 --enable-prefix-caching.
TTFT · TPOT
Time To First Token(첫 토큰까지 걸린 시간)과 Time Per Output Token(토큰 하나 더 만드는 데 걸린 시간). prefix 캐시는 주로 TTFT를 줄입니다.
Prefill · Decode
프롬프트를 한 번에 읽는 단계(prefill, TTFT를 결정)와 토큰을 하나씩 만드는 단계(decode).
llm-d
쿠버네티스 네이티브 오픈소스 LLM 추론 서빙(라우팅) 스택. CNCF 샌드박스 프로젝트이고, vLLM·SGLang 같은 엔진을 대체하지 않고 감싸서 확장합니다.
Well-Lit Paths
llm-d가 제공하는 검증된 배포 레시피. Foundations(기초 기능)와 Workloads(워크로드별 패턴)로 나뉩니다.
IGW (Inference Gateway)
Gateway API Inference Extension을 구현한 게이트웨이. llm-d는 GKE Gateway, Istio, Agentgateway, Envoy AI Gateway를 지원 목록에 올립니다.

llm-d가 내세우는 것 (공식 문서 기준)

항목내용
해결하는 문제단일 GPU·노드에서 잘 도는 vLLM을 여러 노드·GPU에서 라우팅·오토스케일링·모니터링까지 되는 프로덕션 서비스로 만드는 표준 패턴을 미리 제공
하드웨어NVIDIA·AMD GPU, Google TPU, Intel XPU, x86 CPU, 그리고 Rebellions NPU까지 같은 방식으로 배포
성능 주장자체 벤치마크 기준 처리량 최대 3배, 응답 속도 2배, 토큰/초 최대 70% 개선 (llm-d 발표 수치, 이 페이지에서 검증하지 않음)
운영 기능지능형 라우팅, 라우터 고가용성, 오토스케일링, 엔드투엔드 관찰성
도구llm-d Prism — 분산 추론 성능 데이터를 모아 비교·분석하는 대시보드 웹 앱

요청 한 건마다 일어나는 일 (per-call flow)

  1. IGW의 Envoy가 요청을 받는다[7W-1] 9장의 ext_proc 필터가 붙은 그 Envoy입니다.
  2. Envoy가 EPP에게 넘긴다EPP는 KV 캐시 인식과 부하 인식을 맡습니다.
  3. Scrapers모든 추론 풀 파드에서 대기열·사용 가능한 KV 캐시·현재 부하를 읽어 옵니다.
  4. Filters → Scorers스케줄링에서 뺄 파드를 거르고, 남은 후보에 점수를 매깁니다.
  5. Envoy가 선택된 파드로 보낸다EPP가 고른 주소로 요청이 갑니다.

비유 — 반찬을 미리 해 둔 주방

체인 식당 주방 네 곳이 있습니다. 주방 B에 "김치찌개 육수"가 이미 끓고 있다면, 김치찌개 주문은 B로 보내는 게 빠릅니다. 다른 주방은 육수부터 다시 끓여야 하니까요.

그런데 김치찌개 주문이 몰려 B 앞에 줄이 20개면? 육수를 새로 끓이더라도 비어 있는 주방이 낫습니다.

round robin 배정원은 육수도 줄도 모르고 차례대로 넘깁니다. "육수만 보는" 배정원은 B를 터뜨리고, "육수와 줄을 같이 보는" 배정원이 llm-d의 EPP입니다.

작은 숫자로 한 번

# 파드 2개, 같은 system prompt(1,500 토큰) + 질문(200 토큰)으로 요청 4건 round robin 1→A(미스) 2→B(미스) 3→A(적중) 4→B(적중) 적중 토큰 = 1,500 × 2 = 3,000 / 6,800 = 44% prefix만 보기 1→A(미스) 2→A(적중) 3→A(적중) 4→A(적중) 적중 토큰 = 1,500 × 3 = 4,500 / 6,800 = 66% # 대신 A 에 4건이 몰렸다 — 요청이 초당 수십 건이면 A 의 대기열이 TTFT 를 잡아먹는다 # 이건 처음 4건일 때다. 요청이 쌓이면 round robin 도 모든 파드가 그 prefix 를 캐시해 적중률이 따라붙는다(그림 1에서 prefix 종류를 2로)
그림 1

round robin · prefix만 보기 · prefix와 대기열 같이 보기

버튼 · 슬라이더
해볼 것 — ① 기본값(파드 4, prefix 12종)에서 세 방식의 캐시 적중률과 평균 TTFT를 아래 막대로 비교하세요. ② prefix만 보기를 고르면 인기 prefix를 가진 파드 하나에 요청이 몰려 대기열(분홍)이 생깁니다. ③ 부하를 90%로 올리면 prefix만 보는 방식의 TTFT가 가장 먼저 무너집니다. ④ prefix 종류를 2로 줄이면 round robin도 금방 모든 파드에 두 가지를 다 캐시해 적중률이 거의 같아지고, TTFT는 오히려 round robin이 조금 낫습니다 — 9장 실습 결론과 같은 구도입니다. 반면 prefix만 보는 방식은 캐시를 가진 파드 두 곳에만 몰려 TTFT가 폭발합니다.
대략적인 수치입니다

prefix 1,500 토큰 + 질문 200 토큰, 캐시 안 된 토큰당 prefill 0.15 ms, 출력 100 토큰 × 15 ms, 파드당 동시 처리 8, 파드당 prefix 4종까지 캐시(LRU), 인기 prefix에 요청이 몰리는 분포로 60초를 흘린 모형입니다. 실측이 아니며 방식 간 관계를 보이기 위한 것입니다.

원문 블로그 타임라인 — 굵게 표시된 글

2025 — Announcing the llm-d community · Intelligent Inference Scheduling with llm-d · KV-Cache Wins You Can See(vLLM prefix caching에서 llm-d 분산 스케줄링까지, KVEvents로 전역 캐시 뷰) · llm-d 0.3(IGW GA)

2026 — llm-d 0.5 · Native KV Cache Offloading to Any Filesystem · Predicted-Latency Based Scheduling · KServe + llm-d + vLLM · v0.7(프로덕션 강화) · No Kubernetes? No Problem · BLIS(시뮬레이션) · 3개 GPU 벤더 이기종 서빙 · Hybrid 모델 · 분산 추론 네트워킹 · v0.8(Platform → Control Plane) · GLM-5.2 agentic 워크로드 · Kimi-VL E/PD · P2P KV 캐시 공유 · Sticky Until Saturated(token-aware routing) · End-to-End Tracing · v0.9(Hardened for Scale) · 멀티모달

마일스톤: llm-d 본체 v0.10.0은 9월 24일 예정(실습에 쓴 llm-d-router 이미지 v0.10.0과는 별개 번호), v1.0.0은 아직 계획 없음(원문 작성 시점).

1장 세 줄 요약
  1. 정의 — llm-d는 vLLM 같은 엔진 앞에서 KV 캐시와 부하를 보고 파드를 고르는 쿠버네티스 네이티브 추론 라우팅 스택입니다(CNCF 샌드박스).
  2. 성질 — round robin은 캐시를 모르고, prefix만 보면 인기 파드가 터집니다. 둘을 같이 봐야 적중률과 TTFT를 함께 잡습니다. prefix 종류가 적으면 round robin과 prefix+대기열의 차이는 작아지지만, prefix만 보는 방식은 오히려 크게 무너집니다.
  3. 쓰임 — 요청마다 Envoy → EPP(Scrape → Filter → Score → Pick) → 선택 파드. 이 흐름의 부품이 무엇인지가 다음 장입니다.

"Envoy가 EPP에게 묻는다"를 부품 단위로 나눠 봅니다. 공식 문서는 이걸 Router, InferencePool, Model Server 셋으로 정리합니다.

02세 부품 — Router, InferencePool, Model Server

한 줄 요약: InferencePool은 "누가 후보인가", EPP는 "그중 누구를 고를까", Proxy는 "고른 곳으로 실제로 보내기", Model Server는 "추론하고 신호를 내놓기"를 맡습니다. 결정과 전달이 분리돼 있어서 요청마다 목적지가 새로 정해집니다.

왜 이걸 배우나
"그냥 Service로 보내도 파드 두 개에 골고루 가던데, 뭐가 다르지?"

Service(kube-proxy)는 TCP 연결을 맺을 때 파드를 고릅니다. HTTP keep-alive로 연결을 재사용하면 그 연결의 요청은 계속 같은 파드로 갑니다. 원문은 이걸 "연결 단위 무작위 배정 후 keep-alive로 고착"이라고 부르고, llm-d의 구조에서는 이 문제가 구조적으로 생길 수 없다고 정리합니다.

이유는 [7W-1] 9장의 두 트릭입니다. Proxy가 요청을 붙잡고 ext_proc로 EPP에 물은 뒤, EPP가 헤더에 적어 준 파드 주소로 ORIGINAL_DST 클러스터가 직행합니다. 매 요청이 새 질문입니다.

이 장에 나오는 낯선 말
Router · Proxy · EPP
Router는 llm-d의 라우팅 부품 전체 이름이고, 그 안이 Proxy(Envoy·Istio 등 L7 프록시, 데이터 플레인)와 EPP(Endpoint Picker, 결정만 하는 두뇌)로 나뉩니다.
InferencePool
같은 모델을 서빙하는 파드를 라벨로 묶은 리소스. Router·EPP·모델 서버 사이의 source of truth(단일 정보원)입니다.
Variant
InferencePool 안의 부분 집합 개념. prefill/decode 역할이나 성능·비용별로 파드를 나눕니다.
Standalone · Gateway mode
Proxy와 EPP를 같은 파드에 사이드카로 두는 방식과, 쿠버네티스 Gateway API(Gateway·HTTPRoute)로 정식 연결하는 방식.
kube-proxy · keep-alive
kube-proxy는 Service IP로 온 연결을 iptables 등으로 파드에 나눕니다. keep-alive는 HTTP 연결 하나로 여러 요청을 보내는 것.
LoRA 어댑터
기본 모델에 얹는 작은 추가 가중치. 요청의 model 필드로 어떤 어댑터를 쓸지 고릅니다.

부품별 역할

부품맡는 일핵심
ProxyOpenAI 호환 요청을 받아 EPP가 고른 파드로 전달하고 응답을 스트리밍자체 관리형: Istio, agentgateway, Envoy AI Gateway · 클라우드 관리형: Google Cloud Application LB
EPPInferencePool 상태를 평가해 최적 파드 선택 — 결정만KV 캐시 위치성, 현재 부하(대기열), 요청 우선순위 · Filters → Scorers → Pickers
InferencePool① EPP를 위한 파드 디스커버리(selector, Ready 변화 자동 반영, targetPorts) ② 게이트웨이 연동(HTTPRoute의 backendRef, EPP 주소 제공, failureMode)보통 풀 1개 = 모델의 논리적 배포 1개 = EPP 1개. 모든 참조는 같은 네임스페이스로 제한
Model ServervLLM·SGLang·TensorRT-LLM(trtllm-serve). OpenAI 호환 API, 메트릭 노출, KV 캐시·prefix 캐시 관리, TP·DP·EP 병렬화나머지 스택과 완전히 독립 배포. 라벨로 풀에 자동 등록, /health Ready여야 트래픽 대상

EPP가 모델 서버에게 요구하는 메트릭

메트릭의미vLLM에서의 이름(예)
TotalQueuedRequests대기 중 요청 수vllm:num_requests_waiting
TotalRunningRequests처리 중 요청 수vllm:num_requests_running
KVCacheUtilizationKV 캐시 사용률(%)vllm:kv_cache_usage_perc
BlockSize · NumGPUBlocks (선택)블록당 토큰 수 · GPU 메모리의 총 블록 수—

SGLang은 sglang_num_queue_reqs·sglang_token_usage, TensorRT-LLM은 trtllm_*(v1.3.0rc12+, 성능 메트릭 옵션 필요)을 씁니다. 엔진 종류는 파드 라벨 llm-d.ai/engine-type으로 알려 줍니다.

그리고 EPP의 prefix 인식 라우팅이 의미 있으려면 모델 서버가 prefix 캐시 재사용을 지원해야 합니다(vLLM --enable-prefix-caching). [7W-1] 11장의 llama.cpp가 이 메트릭들을 안 줘서 501 로그가 났던 것입니다.

두 가지 배포 모드

StandaloneGateway (Inference Gateway)
배치Proxy와 EPP가 같은 파드에 사이드카. ext_proc가 localhost(네트워크 hop 없음)Gateway → HTTPRoute → InferencePool(또는 일반 Service)
용도테스트, 레거시 Ingress, 배치 추론, RL 파이프라인. Gateway API 인프라 불필요프로덕션. 한 Gateway가 여러 HTTPRoute, 가중치 분할·미러링·멀티클러스터
EPP 관여모든 요청InferencePool을 backendRef로 둔 경로만. 일반 Service 경로는 표준 k8s 라우팅

비유 — 인력 사무소와 현장 반장

인력 사무소 명단(InferencePool)에는 오늘 나올 수 있는 기술자가 적혀 있고, 출근·퇴근하면 명단이 바뀝니다. 현장 반장(EPP)은 명단을 보며 "이 일은 어제 비슷한 걸 한 김 기술자에게, 단 김 기술자가 너무 바쁘면 이 기술자에게"를 정합니다. 배송 기사(Proxy)는 반장이 적어 준 주소로 실어 나를 뿐입니다.

kube-proxy는 아침에 차 한 대를 기술자 한 명에게 배정하고 하루 종일 그 차로 일을 나르는 방식입니다. 차가 세 대뿐이면 한 명은 종일 놀 수도 있습니다.

작은 숫자로 한 번

# 파드 2개(A·B), keep-alive 연결 3개, 연결마다 초당 1건 kube-proxy 연결을 맺을 때 무작위 배정 → 연결1→A 연결2→A 연결3→B 60초 동안 A = 120건, B = 60건 연결이 끊기기 전까지 고정 EPP 요청마다 덜 바쁜 파드 선택 → A = 90건, B = 90건
그림 2

연결 단위 배정 vs 요청 단위 배정

버튼 · 슬라이더
해볼 것 — ① 기본값(kube-proxy, 연결 3개, 파드 2개)에서 연결선 색이 파드별로 고정되고 요청 수가 치우친 걸 보세요. ② 연결 다시 맺기를 누를 때마다 연결을 새로 추첨합니다. 같은 배정이 다시 나올 수도 있고, 여러 번 누르다 보면 한 파드가 전부 받는 경우도 나옵니다. ③ EPP로 바꾸면 같은 연결에서 나온 요청도 파드를 오가며 거의 반반이 됩니다. ④ 연결 수를 16으로 늘리면 kube-proxy도 평균적으로는 고르게 가까워집니다 — 9장 Phase 6 비교의 직접 호출 분산이 42%/58%였던 것도 연결이 여러 개였기 때문으로 보입니다.
단순화한 모형입니다

연결마다 요청 부하가 같다고 두고(1초에 1건), 60초 동안 연결이 끊기지 않는다고 가정했습니다. 실제 kube-proxy(iptables)는 연결마다 확률적으로 파드를 고르고, 클라이언트의 연결 풀 동작에 따라 연결이 다시 맺어지기도 합니다.

InferencePool 핵심 필드와 Gateway 모드 HTTPRoute 예시 (공식 문서 요약)
spec:
  selector:              # 파드 검색용 라벨 셀렉터 — 같은 네임스페이스만
  targetPorts:           # 실제 추론 트래픽 포트 (예: vLLM 8000)
  endpointPickerRef:     # EPP 서비스 참조 — Envoy ext_proc 클러스터가 가리킬 주소
  failureMode:           # EPP 실패 시 FailOpen / FailClose
# "이 경로는 InferencePool로 보낼 요청"이라고 표시해 두면 그 경로만 EPP 상담 절차를 거친다
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
spec:
  parentRefs:
    - kind: Gateway
      name: my-gateway
  rules:
    - backendRefs:
        - kind: InferencePool
          name: my-infpool
      matches:
        - path: {type: PathPrefix, value: /}

멀티 InferencePool — Prefill/Decode를 서로 다른 풀로 나눠 독립 스케일링하거나, 모델·버전별로 풀을 나눕니다. 파드·EPP 서비스·풀 사이의 참조가 같은 네임스페이스로 엄격히 제한돼 멀티테넌트 경계가 분명합니다.

LoRA — 요청의 model 필드로 어댑터를 동적으로 고르고, vllm:lora_requests_info가 max_lora·running·waiting 어댑터를 노출합니다.

2장 세 줄 요약
  1. 정의 — llm-d = Router(Proxy + EPP) + InferencePool + Model Server. InferencePool은 후보 목록, EPP는 선택, Proxy는 전달, 모델 서버는 추론과 메트릭입니다.
  2. 성질 — 결정(EPP)과 전달(Proxy)이 분리돼 요청마다 목적지가 정해집니다. kube-proxy의 연결 단위 고착이 생기지 않습니다. 대신 EPP 판단은 모델 서버 메트릭에 의존합니다.
  3. 쓰임 — 프로덕션은 Gateway 모드(Gateway → HTTPRoute → InferencePool), 테스트·배치는 Standalone. EPP 안에서 선택이 어떻게 일어나는지가 다음 두 장입니다.

EPP를 열어 봅니다. 공식 문서는 요청 처리를 6단계 파이프라인으로 그립니다 — 먼저 앞쪽 절반, 요청을 받아 줄 세우는 부분부터.

03EPP 안쪽 ① — Request Handler와 Flow Control

한 줄 요약: EPP는 요청을 파싱해 스케줄링에 필요한 데이터를 만들고(Request Handler), 모델 서버가 포화되면 바로 보내지 않고 우선순위·공정성 규칙으로 줄을 세웁니다(Flow Control). 한 번 보내면 되돌릴 수 없기 때문입니다.

왜 이걸 배우나
"배치 요약 작업을 돌렸더니 실시간 채팅이 느려졌다 — 같은 GPU 풀에서 둘을 같이 돌릴 수 있나?"

원문이 정리한 EPP의 두 목표 중 두 번째가 이것입니다. 여러 워크로드를 같은 모델 서버 풀에서 돌릴 때 우선순위를 조율하는 것.

일반적인 RPS 제한은 LLM에 안 맞습니다. 긴 컨텍스트와 자기회귀 decode 때문에 자원 소비가 요청마다 비선형적이기 때문입니다([7W-1] 8장).

Flow Control 문서가 꼽는 네 문제가 그대로 이 장의 질문입니다. Noisy neighbor(큰 프롬프트 테넌트가 KV 캐시 독점), 스케줄링 후회(디스패치 후 재라우팅 불가), 우선순위 역전(낮은 배치 작업이 중요 요청을 막음), 자원 비대칭(컨텍스트 크기 차이).

이 장에 나오는 낯선 말
Parser · DataProducer · Admitter
Request Handler의 3대 부품. 요청을 내부 구조로 파싱, 스케줄링용 상태(토큰화·캐시 매칭·지연 예측)를 생성, SLO를 못 지킬 요청을 즉시 반려합니다.
SLO
Service Level Objective. "TTFT 200 ms 이내" 같은 서비스 목표치.
FlowKey · Priority band · Fairness · Ordering
Flow Control의 3단 구조. FlowKey = FairnessID + Priority. 먼저 가장 높은 우선순위 대역을 고르고, 대역 안에서 테넌트(플로우)를 공정하게 고르고, 플로우 안에서 요청 순서를 정합니다.
Sheddable
"버려도 되는" 요청. 음수 우선순위로 표시하고, 포화 시 가장 먼저 거절됩니다. 헤더가 없으면 우선순위 0.
SaturationDetector
풀이 포화됐는지 판정하는 문지기. 실시간 텔레메트리를 보는 폐루프(utilization)와 활성 요청을 세는 개루프(concurrency) 방식이 있습니다.
TTL · graceful drain
큐에서 기다릴 수 있는 최대 시간, 그리고 종료 중인 EPP가 새 요청을 받지 않고 비우는 상태.

요청 처리 6단계 파이프라인

(llm-d 문서는 [7W-1] 9장의 ext_proc를 ext-proc으로 적습니다.) 요청 도착 → ext-proc → Request Handling → Flow Control → Request Scheduling → 요청 프록시. 별도로 백그라운드의 Data Layer가 k8s API와 메트릭을 비동기로 모읍니다(4장). Proxy ↔ EPP는 반드시 FULL_DUPLEX_STREAMED ext-proc이고, 백엔드 선택 규격은 Gateway API Inference Extension의 Endpoint Picker Protocol을 따릅니다.

Request Handler — 요청의 생명주기 관리자

부품기본 제공 플러그인하는 일
Parseropenai-parser(/chat/completions, /completions, /embeddings) · vllmgrpc-parser(Generate, Embed) · passthrough-parser원시 요청 → 내부 구조. 응답에서 usage 추출. passthrough는 본문을 안 읽어 페이로드 기반 스케줄링 불가
DataProducerpredicted-latency-producer(XGBoost로 TTFT·TPOT 예측) · inflight-load-producer · approx-prefix-cache-producer스케줄링 근거 데이터 생성
Admitterlatency-slo-admitterSLO 미충족 시 낮은 우선순위 요청 거부

확장 훅은 PreRequest(진행 중 요청 수 증가 등), ResponseHeaderProcessor, ResponseBodyProcessor(스트리밍 중·완료 시 사용량 추적)이고, 주요 메트릭은 llm_d_epp_request_total·_running·_ttft_seconds·_streaming_tpot_seconds·_input_tokens·_cached_tokens입니다.

Flow Control — 풀을 지키는 중앙 큐

  1. Tier 1 · Priority band가장 높은 우선순위 대역을 먼저 고릅니다(고정 규칙).
  2. Tier 2 · Fairness대역 안에서 테넌트를 고릅니다. round-robin-fairness-policy(활성 플로우 순회) 또는 global-strict-fairness-policy(플로우 격리 무시, 전역 순서).
  3. Tier 3 · Ordering플로우 안의 순서. fcfs(도착 순), edf(데드라인 빠른 순), slo-deadline(x-llm-d-slo-ttft-ms 헤더 기반).

포화 판정은 utilization-detector(정확하지만 지연 존재) 또는 concurrency-detector(즉각적이지만 실제 메모리 압력은 모름). Flow Control이 켜져 있으면 포화 시 디스패치를 멈추고 중앙 큐에 버퍼링하고, 꺼져 있으면 음수 우선순위 요청만 즉시 429로 거부합니다. 실패는 큐 용량 초과 429, TTL 만료 503, 클라이언트 연결 끊김 503, graceful drain 503으로 매핑됩니다.

비유 — 응급실 접수

진료실(모델 서버)이 다 차면 접수(EPP)는 환자를 억지로 들여보내지 않고 대기실에 앉힙니다. 응급(높은 우선순위)부터 부르고, 같은 응급도 안에서는 병동별로 번갈아 부르고(fairness), 같은 병동 안에서는 온 순서대로 부릅니다.

일단 진료실에 들어간 환자는 다른 방으로 옮길 수 없습니다 — 그래서 들여보내는 순간을 신중하게 고르는 것이 Flow Control의 이유입니다. 대기실이 없는 접수(Flow Control 끔)는 "나중에 오셔도 되는 분"(sheddable)만 돌려보내고 나머지는 전부 들여보내 진료실을 미어터지게 합니다.

작은 숫자로 한 번

# concurrency-detector maxConcurrency: 15. 채팅(우선순위 100) 10건 + 배치(우선순위 0) 10건이 동시에 도착 Flow Control 끔 20건 모두 디스패치 → 모델 서버 동시 20 (한도 15 초과) → 모두 느려짐 Flow Control 켬 채팅 10건 먼저 → 배치 5건 → 배치 5건은 큐에서 대기 → 앞선 요청이 끝나면 차례로 배치를 우선순위 −1(sheddable)로 두고 끔 채팅 10 + 배치 5 디스패치, 나머지 배치 5건 429
그림 3

채팅과 배치를 한 풀에서 — Flow Control 켜고 끄기

버튼 · 슬라이더
해볼 것 — ① 기본값(Flow Control 끔, 배치 우선순위 0)에서 위 그래프의 동시 처리 수가 한도선(분홍 점선)을 넘는 구간과 채팅의 평균 지연을 보세요. ② Flow Control 켬으로 바꾸면 동시 처리 수가 한도에 붙고, 넘친 요청이 아래 큐에 쌓이는데 채팅은 거의 기다리지 않습니다 — 우선순위 대역 덕분입니다. ③ 끈 상태에서 배치를 sheddable(−1)로 바꾸면 포화 구간의 배치가 429로 버려집니다. ④ 배치 유입을 6건/초로 올리면 켠 상태에서 배치 대기가 TTL을 넘어 503이 생깁니다.
단순화한 모형입니다

채팅: 우선순위 100, 초당 3건, 처리 1.5초. 배치: 20~40초 구간에만 몰려 들어오고 처리 5초. 한도를 넘겨 들여보내면 처리 시간이 동시 처리 수가 한도를 넘은 비율만큼 늘어난다고 가정했습니다.

큐 TTL은 차이가 보이도록 12초로 줄였습니다(문서 예시는 60초). 모두 설명용 추정치입니다.

우선순위 요청 헤더와 Flow Control 설정 예시 (공식 문서)
# 테넌트·목표를 헤더로 지정. 헤더가 없으면 우선순위 0
curl -X POST http://${IP}:${PORT}/v1/completions \
  -H 'Content-Type: application/json' \
  -H 'x-llm-d-inference-fairness-id: tenant-a' \
  -H 'x-llm-d-inference-objective: premium-traffic' \
  -d '{"model": "default-model", "prompt": "Say hello"}'
apiVersion: llm-d.ai/v1alpha1
kind: EndpointPickerConfig
featureGates: [flowControl]            # 명시적으로 켜야 한다
plugins:
  - type: round-robin-fairness-policy
  - type: fcfs-ordering-policy
  - type: concurrency-detector
    parameters: {maxConcurrency: 15, concurrencyMode: requests}
saturationDetector:
  pluginRef: concurrency-detector
flowControl:
  maxBytes: "10Gi"                     # 전역 큐 한도
  maxRequests: "1k"
  defaultRequestTTL: "60s"             # 큐에서 기다릴 수 있는 최대 시간
  priorityBands:
    - priority: 100
      maxRequests: "500"
      fairnessPolicyRef: round-robin-fairness-policy
      orderingPolicyRef: fcfs-ordering-policy

파이프라인 위치: Request Handler(파싱·헤더 추출) → Flow Control(큐잉·포화 게이팅) → Request Scheduler(엔드포인트 선택) → Model Servers.

3장 세 줄 요약
  1. 정의 — Request Handler는 Parser·DataProducer·Admitter로 요청을 해석하고 근거 데이터를 만들며, Flow Control은 Priority band → Fairness → Ordering 3단 규칙의 중앙 큐입니다.
  2. 성질 — 포화 시 켜져 있으면 대기(TTL 넘으면 503, 큐 넘치면 429), 꺼져 있으면 sheddable만 429이고 나머지는 모델 서버로 밀려 들어갑니다. 디스패치는 되돌릴 수 없어 신중하게 합니다.
  3. 쓰임 — 실시간 채팅과 배치를 한 풀에서 섞어 돌릴 때. 헤더 x-llm-d-inference-fairness-id·objective로 테넌트와 우선순위를 표시하고, featureGates: [flowControl]로 켭니다. 원문 실습의 default-plugins.yaml에는 featureGates가 없어 꺼져 있던 것으로 보입니다(4장 접는 상자).

줄을 통과한 요청은 이제 파드 하나를 배정받습니다. 파이프라인의 뒤쪽 절반 — 스케줄러와, 그 판단 근거를 모으는 데이터 계층입니다.

04EPP 안쪽 ② — Scheduler, Data Layer, 그리고 설정 파일

한 줄 요약: 스케줄러는 후보 파드를 걸러내고(Filter), 여러 기준으로 0~1 점수를 매겨 가중합하고(Score), 하나를 고릅니다(Pick). 점수의 원재료는 Data Layer가 모델 서버에서 긁어 온 메트릭이고, 이 모든 조합은 시작할 때 한 번 읽는 YAML 하나로 정해집니다.

왜 이걸 배우나
"EPP가 이 파드를 골랐는데, 왜 저 파드가 아니었지?"

11장 트레이스에서 실제로 이 질문이 나옵니다. 두 파드가 2점 대 2점 동점이었고, 원문은 점수표를 한 줄씩 풀어 이유를 찾습니다. 그러려면 어떤 scorer가 무엇을 보고 몇 점을 주는지, 가중치가 어떻게 곱해지는지 알아야 합니다.

그리고 운영에서 가장 먼저 부딪히는 사실 하나. EPP 설정은 핫 리로드가 없습니다. 가중치 하나를 바꾸려 해도 EPP를 재시작해야 합니다.

이 장에 나오는 낯선 말
Filter · Scorer · Picker
스케줄링 3단계 플러그인. Filter는 부적합 후보 제거, Scorer는 0.0~1.0 점수, Picker는 최종 1개 선택입니다.
가중합 (weighted sum)
scorer마다 가중치를 곱해 더한 값. 예: A(가중치 2.0) 0.8점, B(가중치 1.0) 0.5점 → 각 점수에 가중치를 곱해 더하면 2.1.
SchedulingProfile · ProfileHandler
프로필 = Filter 목록 + 가중 Scorer 목록 + Picker 1개. ProfileHandler가 어떤 프로필을 돌릴지 정합니다(single-profile-handler, P/D용 disagg-profile-handler).
P/D disaggregation
한 요청의 Prefill과 Decode를 서로 다른 워커가 처리하는 구성. 라우터가 두 endpoint를 각각 고르고 KV 캐시 전송까지 조율합니다.
Data Layer
EPP의 실시간 상태 수집·저장 계층. Source → Extractor → Endpoint Attributes 순으로 파드마다 속성 맵을 채웁니다.
EndpointPickerConfig
CRD처럼 생겼지만 CRD가 아닌, EPP가 시작할 때 한 번 읽는 정적 설정 파일(--config-file 또는 --config-text).

Filter → Score → Pick

단계대표 플러그인기준
Filterprefix-cache-affinity-filter · slo-headroom-tier-filter · label-selector-filter · prefill/decode-endpoints-filterprefix 점수 높은 "sticky" 파드 우선(느리면 제외) · SLO 여유 · 라벨 · P/D 역할
Scorekv-cache-utilization-scorer · queue-depth-scorer · prefix-scorer · latency-scorer · lora-affinity-scorer · running-requests-size-scorer · token-load-scorer · session-affinity-scorer · no-hit-lru-scorerKV 사용률 낮을수록 · 대기열 짧을수록 · prefix 일치 길이 · 예측 지연과 SLO 여유 · LoRA 로드 여부 · 활성 요청 수 · 처리 중 토큰량 · 같은 세션 · 캐시 미스를 아직 안 받은 파드(prefill 균등화)
Pickmax-score-picker(기본) · random-picker · weighted-random-picker최고 점수 1개 · 무작위 · 점수를 확률로 쓰는 가중 추첨

표의 queue-depth-scorer·prefix-scorer는 원문 실습 설정에서 queue-scorer·prefix-cache-scorer라는 이름으로 나옵니다. running-requests-size-scorer도 payload-agnostic 설정에서는 active-request-scorer로 나옵니다.

P/D 분리일 때는 ProfileHandler가 Prefill 프로필(prefix 친화도·대기열 중시)과 Decode 프로필(메모리 대역폭 바운드 최적화)을 각각 돌리고, decode 파드가 Proxy의 실제 목적지가 되며 prefill 파드 주소는 헤더로 주입돼 decode 쪽 사이드카가 원격 prefill을 조율합니다. 주요 메트릭은 llm_d_epp_ready_endpoints, average_kv_cache_utilization, average_queue_size, scheduler_e2e_duration_seconds, disagg_decision_total입니다.

Data Layer — 판단 근거를 모으는 곳

소스 유형방식예
Polling설정 주기로 폴링 — 새 endpoint마다 Collector 하나metrics-data-source → core-metrics-extractor: KVCacheUsagePercent, WaitingQueueSize 등
Notificationk8s 리소스 변경 이벤트Pod·Service 변경
Endpoint Eventendpoint 생명주기 이벤트파드 추가·삭제·업데이트

Data Layer는 수집만 하고, 판단은 Scheduler와 Flow Control이 합니다. core-metrics-extractor가 엔진마다 다른 메트릭 이름(2장 표)을 표준 키로 바꿔 줍니다. 새 소스는 PollingDataSource·NotificationSource·EndpointSource 중 하나를 구현해 붙입니다.

EndpointPickerConfig — 그래프 한 장

노드는 plugins에 정의한 플러그인 인스턴스, 엣지는 schedulingProfiles·flowControl에서 pluginRef로 가리키는 연결입니다. 같은 인스턴스를 여러 프로필·대역에서 재사용합니다. name을 생략하면 type이 이름이 되고, Admitter·DataProducer는 참조 없이 자동 연결됩니다.

  1. 기본값 규칙profiles 생략 → 모든 Filter·Scorer·Picker를 담은 default 프로필 자동 생성 · plugins 빈 목록 → Picker만 주입 · Picker 미지정 → max-score-picker(1개).
  2. 검증은 시작 시점에만없는 pluginRef, 이름 중복, 프로필당 Picker 2개 이상, DataProducer 순환 의존 → 초기화·런타임 에러. 실행 중 감지·리로드 없음.
  3. 재시작이 필요한 이유"configuration is not reconciled by a controller" — 시작할 때 플러그인 그래프를 한 번 만들고 끝입니다. HA는 replicas > 1 + leader 선출로 롤링 재시작 무중단을 확보합니다.

비유 — 입사 면접 채점표

서류 탈락(Filter) 뒤 면접관 셋이 0~1점을 줍니다. 경력(prefix 일치) 가중치 3, 체력(KV 여유) 2, 일정(대기열) 2. 가중합이 가장 높은 사람을 뽑거나(max), 점수 비율대로 추첨합니다(weighted-random).

두 사람의 총점이 같으면 동점이고, 그땐 둘 중 한 명이 뽑힙니다(목록 순서인지 무작위인지는 구현에 따라 다릅니다). 채점 기준표(설정)를 바꾸려면 면접을 다시 열어야(재시작) 합니다.

작은 숫자로 한 번 — 원문 실습의 기본 가중치

# default-plugins.yaml: queue-scorer 2, kv-cache-utilization-scorer 2, prefix-cache-scorer 3 pod A: prefix 0.75 kv 여유 0.60 queue 1.0 → 0.75×3 + 0.60×2 + 1.0×2 = 5.45 pod B: prefix 0.00 kv 여유 0.90 queue 1.0 → 0.00×3 + 0.90×2 + 1.0×2 = 3.80 max-score-picker → A. weighted-random-picker → A 59% · B 41%
그림 4

채점표 — 신호를 끌어 올리고 가중치를 바꿔 보기

드래그 · 슬라이더 · 버튼
해볼 것 — ① 막대를 위아래로 끌어 파드별 신호를 바꾸세요(prefix 일치율, KV 사용률, 대기열). 아래 줄에 가중합과 선택 결과가 바로 나옵니다. ② 기본값에서 pod-1이 prefix 덕에 뽑히는데, pod-1의 대기열을 4 이상(pod-3과 같거나 더 긴 줄)으로 끌어 올리면 선택이 pod-2로 넘어갑니다. ③ prefix 가중치를 0으로 내리면 순수 부하 분산이 됩니다. ④ affinity filter를 켜면 prefix 일치율 0.7 이상인 파드만 남아, 대기열이 길어도 그 파드로 고정됩니다(sticky). ⑤ weighted-random은 점수를 확률로 바꿔 보여 줍니다.
단순화한 모형입니다

점수 변환은 설명용입니다 — KV 점수 = 1 − 사용률, 대기열 점수 = 파드들 중 최소·최대로 정규화(모두 같으면 1). 실제 플러그인의 정규화 방식은 구현에 따라 다릅니다. 이 그림의 affinity filter에는 위 표에 적은 "느리면 제외" 조건을 넣지 않아, 대기열이 길어도 계속 고정됩니다.

원문 실습 EPP의 기본 설정 (default-plugins.yaml)과 스키마 골격
apiVersion: llm-d.ai/v1alpha1
kind: EndpointPickerConfig
plugins:
- type: queue-scorer
- type: kv-cache-utilization-scorer
- type: prefix-cache-scorer
- type: metrics-data-source
  parameters: { scheme: "http", path: "/metrics", insecureSkipVerify: true }
- type: core-metrics-extractor
schedulingProfiles:
- name: default
  plugins:
  - pluginRef: queue-scorer
    weight: 2
  - pluginRef: kv-cache-utilization-scorer
    weight: 2
  - pluginRef: prefix-cache-scorer
    weight: 3
apiVersion: llm-d.ai/v1alpha1
kind: EndpointPickerConfig
plugins: [...]            # 노드
featureGates: [...]       # 현재 flowControl 만 명시 필요
parser: {...}             # 기본 openai-parser
flowControl: {...}        # 기본 fairness 는 global-strict-fairness-policy
saturationDetector: {...} # 미지정 시 utilization-detector 자동 생성
schedulingProfiles: [...] # 프로필당 Picker 정확히 1개
dataLayer: {...}          # metrics-data-source + core-metrics-extractor 자동 주입

같은 ConfigMap에 payload-agnostic.yaml도 있습니다. 요청 형식을 모를 때 본문을 읽지 않는 passthrough-parser와 active-request-scorer(덜 바쁜 곳)·session-affinity-scorer(세션 고정)만으로 라우팅합니다 — prefix scorer는 본문이 필요해서 빠집니다.

운영 권장: 배포 전 로컬에서 스키마·pluginRef 검증, :9090/metrics 모니터링과 upstream 대시보드, P99 지연·에러율·큐 깊이·KV 사용률 알림, 기능 롤백은 Feature Gate 비활성화로.

4장 세 줄 요약
  1. 정의 — Request Scheduler는 Filter → Score(0~1, 가중합) → Pick 플러그인 파이프라인이고, Data Layer는 Polling·Notification·Endpoint 이벤트로 파드 속성을 채우는 수집 계층입니다.
  2. 성질 — 선택은 가중치와 신호의 곱으로 정해지고, 신호가 같으면 동점이 납니다. 설정(EndpointPickerConfig)은 CRD가 아닌 정적 파일이라 바꾸면 재시작이 필요합니다.
  3. 쓰임 — 원문 실습 기본값(default-plugins.yaml)은 queue 2 · kv 2 · prefix 3입니다. 11장 트레이스는 precise-plugins.yaml로 돌아 가중치가 다르고, top_scores 역산은 11장에서 합니다. prefix 점수를 정확히 만드는 방법이 다음 장입니다.

점수표에서 가장 무거운 칸은 prefix였습니다. 그런데 EPP는 파드 안 GPU 메모리를 들여다볼 수 없습니다. 어떤 파드가 어떤 KV 블록을 가졌는지 어떻게 알까요.

05KV 캐시 관리 — 어디에 무엇이 캐시됐는지 아는 법

한 줄 요약: EPP가 파드별 캐시를 아는 방법은 둘입니다. 자기가 보낸 기록으로 추측하는 근사(Approximate), 모델 서버가 흘리는 KV 블록 이벤트로 추적하는 정밀(Precise). 점수는 "끊기지 않은 가장 긴 prefix 블록 수"이고, 캐시가 GPU에 안 들어가면 CPU·스토리지로 내립니다(Offloading).

왜 이걸 배우나
"EPP는 'pod-1에 이 prefix가 있다'고 믿고 보냈는데, pod-1은 이미 그 캐시를 지웠다면?"

GPU 메모리가 차면 vLLM은 오래된 KV 블록을 축출합니다. EPP가 자기 기록만 믿으면 없는 캐시를 보고 라우팅하게 됩니다. 원문이 근사 방식의 단점으로 정확히 이 "상태 불일치"를 꼽습니다.

9장 실습의 Phase 5가 정밀 방식을 켜는 단계입니다. vLLM에 --kv-events-config로 ZMQ 이벤트를 켜고, EPP가 그걸 구독합니다. 이 장은 그 설정 한 줄의 의미를 풉니다.

이 장에 나오는 낯선 말
KV 블록 · block key
KV 캐시는 고정 크기 토큰 묶음(예: 16토큰) 단위로 저장됩니다. 블록 키는 그 블록 내용(과 앞 블록들)의 해시입니다.
의존성 체인
블록 B2는 B0·B1의 계산 결과 위에서만 의미가 있습니다. 그래서 끊기지 않은 prefix만 재사용됩니다. B0·B1은 있는데 B2가 없으면 B3가 있어도 쓸 수 없습니다.
KVEvents · ZMQ
모델 서버가 캐시 변화를 알리는 이벤트(BlockStored, BlockRemoved, AllBlocksCleared). ZeroMQ라는 메시징 라이브러리로 발행합니다.
HBM · Offloading
HBM은 GPU의 고대역폭 메모리. Offloading은 KV 블록을 CPU DRAM·로컬 디스크·공유 스토리지로 옮겨 용량을 늘리는 것입니다.
Speculative indexing
라우팅 직후 "이 파드가 곧 이 블록을 갖게 된다"고 인덱스에 미리 적어 두는 것. 확인 이벤트가 오기 전의 공백을 메웁니다(기본 TTL 2초).
IPP (Inference Payload Processor)
라우팅 전후로 요청·응답 페이로드를 검사·수정하는 플러그형 프레임워크. 풀(pool) 레벨에서 동작합니다.

KV 캐시 관리 3계층

  1. Prefix-Cache Aware Routing (지능)Router/EPP가 캐시 인덱스를 보고 적중률이 최대가 되게 보냅니다. 근사(approx-prefix-cache-producer)와 정밀(precise-prefix-cache-producer) 두 구현.
  2. KV-Cache Indexing (관찰)풀 전체의 KV 캐시 상태를 이벤트로 지속 추적. 정밀 라우팅의 source of truth.
  3. KV Offloading (용량)HBM → CPU RAM → 로컬 디스크 다중 계층으로 캐시를 확장.

근사 vs 정밀

근사 (Approximate)정밀 (Precise)
토큰화EPP엔 토크나이저가 없어 문자-토큰 비율로 근사vLLM의 /v1/completions/render로 정확한 토큰 ID 획득(요청이 chat 형식이면 /v1/chat/completions/render)(사이드카 또는 공유 render 서비스)
인덱스"최근 어느 prefix 해시를 어느 파드로 보냈나"를 메모리 내 LRU로 자체 관리. 보낸 뒤 "이제 갖고 있다"고 가정모델 서버가 ZMQ로 흘리는 KVEvents를 KV-Cache Indexer가 구독해 파드별 블록 위치를 전역 추적
장점외부 의존성 없음, 가벼움, 토크나이저·ZMQ 불필요토큰 기반 100% 정밀, 복잡한 축출 정책도 반영, P/D 분리 네이티브 지원
단점파드가 캐시를 지워도 EPP는 모름(상태 불일치), 문자 기반이라 정밀도 낮음render 엔드포인트 + ZMQ 연결 필요, 약간의 리소스 오버헤드, 모델 서버의 이벤트 지원 필요
선택가볍게 시작하려면 근사, 정확도와 대규모 운영이 중요하면 정밀

KV-Cache Indexer — 점수는 "끊기지 않은 가장 긴 prefix"

구성은 Index(블록 키 → 파드 위치, 이벤트로 갱신) · Data Producer(요청당 한 번 토큰화, 멀티모달 피처 추출) · Scorer(파드별 가장 긴 연속 prefix 매치) 셋입니다. 원문 예시 그대로 — B0~B3를 가진 파드는 4점, B0·B1은 있지만 B2가 없는 파드는 2점. 블록은 저장 계층에 따라 가중치를 받습니다(GPU 1.0, CPU 0.8).

이벤트 전달은 중앙집중형(단일 EPP)과 파드 디스커버리(라벨로 여러 EPP active-active) 두 방식이고, 인덱스 저장소는 In-Memory(기본, 최저 지연), Cost-Aware Memory(바이트 예산), Redis/Valkey(영속, 네트워크 hop) 중에서 고릅니다. 멀티모달 해시, 블록 단위 LoRA 스코핑, 하이브리드 어텐션 레이어 그룹(진행 중)도 지원합니다.

비유 — 도서관 대출 장부

사서(EPP)가 "이 책 시리즈 1~4권은 A 분관에 있다"고 알려 줍니다. 근사 방식의 사서는 자기가 A로 보낸 기록만 믿습니다. A가 공간이 모자라 3·4권을 폐기해도 모릅니다.

정밀 방식은 분관이 입고·폐기 때마다 문자(이벤트)를 보내 장부를 고칩니다. 그리고 시리즈는 1권부터 끊기지 않고 있어야 이어 읽을 수 있습니다 — 1·2·4권만 있으면 2권까지만 쓸모 있습니다.

작은 숫자로 한 번

# prompt = B0 B1 B2 B3 B4 B5 (블록당 16토큰) pod-1: B0 B1 B2 B3 (GPU) → 1+1+1+1 = 4.0 pod-2: B0 B1 (GPU) · B3 (GPU) → 1+1, B2 없음에서 끊김 = 2.0 pod-3: B0 B1 (GPU) · B2 B3 (CPU) → 1+1+0.8+0.8 = 3.6 # pod-1 이 메모리 압박으로 B2·B3 축출 → 정밀: 2.0 으로 내려감 / 근사: 여전히 4.0 이라고 믿음
그림 5

블록 체인 점수 — 정밀 인덱스와 근사 인덱스가 갈리는 순간

클릭 · 버튼
해볼 것 — ① 칸을 클릭하면 그 파드의 블록이 없음 → GPU → CPU로 바뀝니다. 오른쪽 점수가 첫 빈칸에서 멈추는 걸 확인하세요. ② pod-1 축출을 누르면 정밀 점수는 떨어지는데 근사 점수(점선 칸)는 그대로라, 근사 방식은 캐시가 없어진 pod-1을 계속 고릅니다. ③ 초기화한 뒤 pod-3의 B4·B5를 두 번씩 눌러 CPU로 채워 보면(B2·B3은 이미 CPU입니다) GPU 1.0 · CPU 0.8 가중치 때문에 여섯 블록으로 길이가 같아도 점수가 낮습니다. ④ 초기화로 처음 상태로 돌아갑니다.
칸 읽는 법

초록 = GPU에 있음(1.0), 보라 = CPU로 내려감(0.8), 회색 = 없음. 점선 테두리는 근사 인덱스가 "있다고 믿는" 블록입니다.

KV-Cache Offloading과 IPP

Offloading은 GPU HBM 용량 제약과 인스턴스마다 캐시가 고립되는 문제를 풉니다. 두 갈래입니다. 네이티브(vLLM OffloadingConnector, --kv-offloading-backend native --kv-offloading-size <GB>)는 CPU 계층이나 공유 스토리지로 블록을 보내고, Out-of-tree 커넥터는 LMCache·Mooncake·NVIDIA KVBM 같은 외부 엔진을 KV-cache 커넥터 API로 붙입니다.

  • CPU Offloading — pinned CPU 메모리를 스테이징 버퍼로 GPU DMA 비동기 전송. 원문 기준 vLLM 0.12.0+의 연속 메모리 레이아웃으로 처리량 4~5배 향상.
  • llm-d FS Backend — POSIX 파일시스템에 블록을 파일로 저장. 인스턴스 간 공유, 재시작 후에도 유지.
  • MooncakeStoreConnector — 중앙 Master가 메타데이터·축출 관리. Embedded(프로세스 내 DRAM 풀) / Standalone-store(SSD 가능) 모드.
  • 성능 가이드 — CPU DRAM이 GPU HBM보다 크면 CPU offloading은 항상 켜라. 스토리지 offloading은 캐시가 단일 노드를 넘거나 노드 간 공유 가치가 있을 때.

IPP는 Envoy ext-proc 위에서 Plugin → Profile → Profile Picker 구조로 동작하며, 라우팅 전(헤더·본문·트레일러 검사·수정)과 응답이 돌아온 뒤의 처리를 맡습니다. 흐름은 Proxy → IPP(변경 반환) → Proxy가 적용해 InferencePool로 → EPP가 풀 안에서 파드 선택 → 모델 서버.

IPP는 독립 Deployment로 배포되고, Istio에선 EnvoyFilter로, GKE에선 GCPRoutingExtension으로 끼웁니다. 가드레일·콘텐츠 필터링·모델 인지 라우팅의 자리입니다.

5장 세 줄 요약
  1. 정의 — prefix 인식 라우팅은 근사(보낸 기록으로 추측)와 정밀(ZMQ KVEvents로 추적) 두 방식이고, 점수는 끊기지 않은 가장 긴 prefix 블록 수(GPU 1.0, CPU 0.8)입니다.
  2. 성질 — 근사는 가볍지만 축출을 모르고, 정밀은 정확하지만 render 엔드포인트·ZMQ·이벤트 지원이 필요합니다. 라우팅 직후 공백은 speculative indexing(TTL 2초)이 메웁니다.
  3. 쓰임 — 9장 Phase 5가 --enable-prefix-caching --kv-events-config로 정밀 방식을 켭니다(계획에 있던 --block-size 16은 실제 적용되지 않았습니다). 용량이 모자라면 offloading, 페이로드 정책은 IPP.

여기까지가 "풀 안에서 파드 고르기"입니다. 그런데 [7W-1]의 Agent Router도 스스로를 라우터라고 불렀습니다. 둘은 같은 걸까요.

06두 개의 라우터 — Agent Router와 llm-d Router

한 줄 요약: 이름은 둘 다 라우터지만 층이 다릅니다. Agent Router(Tier 1)는 앱 앞에서 여러 provider를 하나로 묶는 API 게이트웨이이고, llm-d Router(Tier 2)는 같은 모델을 서빙하는 vLLM 파드들 안에서 한 곳을 고르는 추론 스케줄러입니다. 경쟁이 아니라 앞뒤로 이어 씁니다.

왜 이걸 배우나
"Agent Router에도 InferencePool이 있고 llm-d에도 Envoy가 있다 — 둘 중 하나만 쓰면 되나?"

원문은 소개 구획 끝에서 이 비교를 따로 정리합니다. 원문에 실린 Two-Tier Gateway Design 그림이 답입니다. 앞단(edge)은 Agent Router, 그 뒤 vLLM 클러스터 내부 라우팅은 llm-d Router.

9장 실습 구성 역시 사실상 이 구조의 절반입니다. Envoy Gateway + Agent Router 컨트롤러를 깔았지만 AIGatewayRoute는 쓰지 않고 HTTPRoute + InferencePool 경로만 썼습니다. 원문의 설치 계획에 "Agent Router 자체 기능은 이번 테스트에서 미사용"이라고 적혀 있습니다.

이 장에 나오는 낯선 말
Tier 1 · Tier 2
요청이 지나는 앞단 게이트웨이 층과 모델 서빙 클러스터 내부 층. 3장 Flow Control의 Tier 1·2·3(우선순위 대역·공정성·순서)과는 다른 뜻입니다.
Model virtualization
앱이 부르는 모델 이름과 실제 provider·모델을 분리하는 것. 앱은 "chat-default"를 부르고, 게이트웨이가 그 이름을 어디로 보낼지 정합니다.
MCP · tool catalog
Model Context Protocol. 에이전트가 쓰는 도구 서버 규약. Agent Router는 여러 MCP 서버의 도구를 하나의 카탈로그로 모읍니다.
Homogeneous serving
같은 모델을 같은 방식으로 서빙하는 파드 묶음. llm-d Router가 최적화하는 대상입니다.
AAIF
Agentic AI Foundation. Agent Router가 소속된 Linux Foundation 산하 재단([7W-1] 9장).

나란히 놓으면

Tier 1 · Agent Router (구 Envoy AI Gateway)Tier 2 · llm-d Router
질문"이 요청을 어느 provider·모델로?""이 모델을 서빙하는 N개 파드 중 어디로?"
구조설정 계층(Agent Router: provider·자격증명·규칙·도구·한도) + 트래픽 계층(Envoy). "Agent Router가 설정하고 Envoy가 처리한다"Proxy(데이터 플레인, 요청을 park하고 ext-proc 콜백) + EPP(Filter → Scorer → Picker)
핵심 기능16개 provider 통합(OpenAI 호환 → Anthropic·Bedrock·Vertex·Azure·자체 vLLM 등), fallback·failover, 모델명 가상화, 팀·앱·모델별 토큰 한도, 키 중앙 관리·로테이션, MCP 도구 통합, OpenTelemetry GenAI 관측KV·prefix 캐시 인지, 부하 인지, 요청 우선순위·flow control — 동종 모델 서빙 인프라 내부 최적화
배포로컬 aigw run 한 줄 → Kubernetes까지 설정 변경 없이경량: Envoy가 EPP와 같은 파드 · 프로덕션 권장: Gateway API(InferencePool backend + 공유 Gateway의 HTTPRoute)
연결점Agent Router가 self-hosted 모델에 대해 InferencePool을 지원 → 그 풀의 endpoint 선택을 llm-d EPP가 맡는다([7W-1] 11장에서 본 llm-d-router-endpoint-picker 이미지)

원문 기준 연혁: Agent Router는 2026년 6월 v1.0, 8월 v1.1을 내고 Envoy 프로젝트에서 분리돼 AAIF 소속이 되었습니다.

비유 — 공항 환승 안내와 게이트 배정

공항 안내 데스크(Tier 1)는 "런던행이면 A 항공사, 파리행이면 B 항공사, 국내선이면 우리 자체 셔틀"을 정합니다. 여권 확인(인증), 수하물 한도(토큰 한도), 결항 시 대체편(failover)도 여기서 합니다.

자체 셔틀(self-hosted)을 고른 승객에게는 셔틀 터미널의 배차원(Tier 2)이 어느 차가 비었고 누가 이미 같은 방향 짐을 실었는지 보고 차를 고릅니다. 안내 데스크는 차 한 대 한 대를 모르고, 배차원은 항공사를 모릅니다.

작은 숫자로 한 번

# 앱이 보낸 요청 3건 — Two-Tier 에서 누가 무엇을 결정하나 model=gpt-4o-mini Tier1: 키 교체 · 토큰 한도 차감 · OpenAI 형식 그대로 → api.openai.com Tier2: 관여 없음 model=claude-sonnet Tier1: OpenAI → Anthropic 형식 변환 · 인증 교체 → Anthropic Tier2: 관여 없음 model=Qwen3-0.6B-FP8 Tier1: 토큰 한도 차감 · InferencePool 로 → Tier2: EPP 가 pod-1/pod-2 중 선택
그림 6

요청 한 건이 두 층을 지나는 길

버튼
해볼 것 — ① model 버튼을 바꿔 가며 Tier 2까지 내려가는 요청은 self-hosted 모델뿐이라는 걸 보세요. ② 각 층 상자 아래의 이 요청에 작동한 기능 목록이 바뀝니다. ③ Tier 2 없이를 켜면 self-hosted 요청이 일반 Service로 가서, 파드 선택이 연결 단위 무작위(2장)로 돌아갑니다. ④ OpenAI 장애를 켜고 gpt-4o-mini를 보내면 Tier 1이 다른 provider로 넘깁니다 — 이 결정에 Tier 2는 전혀 관여하지 않습니다.
6장 세 줄 요약
  1. 정의 — Agent Router는 provider 통합·키·한도·failover·MCP를 맡는 앞단 게이트웨이, llm-d Router는 동종 vLLM 파드 중 하나를 고르는 클러스터 내부 스케줄러입니다.
  2. 성질 — 층이 달라 겹치지 않고, InferencePool을 매개로 이어집니다. Tier 1은 파드를 모르고, Tier 2는 provider를 모릅니다.
  3. 쓰임 — 외부 provider와 자체 모델을 함께 쓰면 Two-Tier. 원문 실습은 Tier 2 경로(HTTPRoute + InferencePool + llm-d EPP)를 검증했습니다(9장).

개념 정리는 여기까지입니다. 이제 실제 GPU 한 장짜리 클러스터를 세우고, llm-d를 붙이기 전의 기준선을 만듭니다.

07실습 환경 — GPU 한 장, vLLM 파드 둘

한 줄 요약: K3s 단일 노드에 모니터링을 깔고, NVIDIA device plugin 대신 HAMi를 올려 GPU 한 장을 코어·메모리 50%씩 나눕니다. 모델은 MinIO(S3)에 올려 두고 vLLM이 스트리밍 로더로 바로 읽습니다. 이 "파드 2개"가 9장의 비교 대상입니다.

왜 이걸 배우나
"라우터를 시험하려면 파드가 여러 개 필요한데, GPU는 한 장뿐이다."

원문 실습의 해법은 [6W-2] 9장에서 본 HAMi입니다. nvidia.com/gpucores: "50", nvidia.com/gpumem-percentage: "50"으로 한 장을 둘로 나눠 vLLM 파드 2개를 띄웁니다. 작은 모델(Qwen3-0.6B-FP8)을 쓰는 것도 같은 이유입니다.

다만 이 선택이 9장 결론에 그대로 영향을 줍니다. 파드가 2개뿐이고 모델이 작아 토큰 생성(TPOT 약 15 ms)이 빠르다는 조건이 "precise 라우팅의 이득이 작았다"는 결과의 배경입니다. 환경을 알아야 결과를 제대로 읽습니다.

이 장에 나오는 낯선 말
K3s
가벼운 쿠버네티스 배포판. 실습은 기본 Ingress(traefik)와 LB(servicelb)를 끄고 MetalLB를 따로 씁니다.
HAMi · hami-scheduler
GPU를 코어 비율·메모리 비율로 쪼개 여러 파드에 나눠 주는 가상화 미들웨어와 그 스케줄러. 파드에 schedulerName: hami-scheduler를 적습니다.
gpucores · gpumem-percentage
HAMi 리소스 이름. 파드가 쓸 GPU 연산 비율과 메모리 비율(%).
runai model streamer
vLLM이 S3 같은 오브젝트 스토리지에서 가중치를 스트리밍으로 바로 로드하는 로더(--load-format runai_streamer). FUSE 마운트가 필요 없습니다.
gpu-memory-utilization
vLLM이 보이는 GPU 메모리 중 몇 %를 쓸지(가중치 + 작업 공간 + KV 캐시). 실습 값 0.8.
DCGM Exporter
NVIDIA GPU 지표를 Prometheus 형식으로 내보내는 exporter([6W-2] 6장).

쌓는 순서

  1. K3s + NVIDIA device plugin + MetalLB노드 gpupc(v1.36.4+k3s1, Ubuntu 24.04.4). device plugin은 runtimeClassName=nvidia. MetalLB는 Helm 0.16.1.
  2. kube-prometheus-stack + DCGM ExporterPrometheus NodePort 30001, Grafana 30002. DCGM도 NVML 조회를 위해 runtimeClassName=nvidia. 대시보드 12239·23382.
  3. HAMi 2.10.0 (device plugin 제거)HAMi도 nvidia.com/gpu를 관리하므로 기존 nvdp를 uninstall. 노드에 gpu=on 라벨, monitor 메트릭 9394, HAMi vGPU 대시보드 추가.
  4. MinIO + model-loader JobinitContainer가 HF에서 Qwen/Qwen3-0.6B-FP8 10개 파일을 익명 다운로드 → mc mirror로 1.00 GiB를 3초(288 MiB/s)에 업로드. Job 41초.
  5. vLLM v0.28.0 Deploymentvllm serve s3://models/Qwen3-0.6B-FP8 --load-format runai_streamer --max-model-len 8192 --enforce-eager --kv-cache-dtype fp8, 컴파일 캐시를 hostPath(/var/cache/vllm)에 영속화(원문 매니페스트 그대로입니다), NodePort 30004.
  6. replicas: 2 + HAMi 50/50--gpu-memory-utilization 0.8, nvidia.com/gpu: 1 · gpucores: 50 · gpumem-percentage: 50, strategy: Recreate. per-Pod vLLM 대시보드를 추가해 파드별로 봅니다.

호출 확인: /v1/models에 "id": "Qwen3-0.6B-FP8", "root": "s3://models/Qwen3-0.6B-FP8". 한국의 수도를 물으면 <think>로 시작하는 답이 옵니다(Qwen3의 reasoning 출력).

원문 매니페스트를 옮길 때 — 자격 증명은 바꾸세요

원문 MinIO Secret에는 예시용 계정·비밀번호가 평문으로 들어 있습니다. 이 페이지에는 옮기지 않았습니다. 실제 환경에 쓸 때는 반드시 다른 값으로 바꾸고, 공개 저장소에 올리지 않는 편이 안전합니다.

비유 — 원룸을 가벽으로 나누기

GPU 한 장은 원룸 하나입니다. HAMi는 가벽을 세워 두 사람이 반씩 쓰게 합니다. 각자는 자기 쪽이 방 전체인 줄 압니다(vLLM은 절반 메모리를 "GPU 전체"로 봅니다).

그 절반에서 vLLM은 다시 80%만 쓰고, 그중 가구(가중치)와 작업대(활성화 텐서·연산 버퍼)를 놓고 남은 바닥이 KV 캐시입니다. 바닥이 좁으면 동시에 펼칠 수 있는 대화(시퀀스)가 줄어듭니다.

작은 숫자로 한 번 — 24 GiB 카드라고 가정

# 원문은 카드 모델을 적지 않았다. 권장 사양(VRAM 24 GiB)으로 가정한 계산 파드가 보는 메모리 24 GiB × 50% = 12 GiB vLLM 이 쓰는 몫 12 GiB × 0.8 = 9.6 GiB 가중치 ≈ 1.0 GiB, 작업 공간 ≈ 1.2 GiB(추정) → KV 캐시 ≈ 7.4 GiB 토큰당 KV (Qwen3-0.6B: 28층 × KV헤드 8 × 128차원 × K·V 2 × 1바이트 — --kv-cache-dtype fp8) = 57,344 바이트 ≈ 56 KiB 7.4 GiB ÷ 56 KiB ≈ 약 138,000 토큰 → 8,192 토큰짜리 대화 약 16개를 동시에
그림 7

GPU 한 장을 나누면 KV 캐시는 얼마나 남나

버튼 · 슬라이더
해볼 것 — ① 기본값(24 GiB, 50% × 2개, 0.8)에서 파드 하나의 초록(KV 캐시) 크기와 오른쪽의 동시 8K 대화 수를 보세요. ② 파드를 3개로 늘리면 50% × 3 = 150%라 한 파드는 스케줄되지 않습니다(HAMi가 메모리를 못 줌). 비율을 33%로 내리면 3개가 뜹니다. ③ 카드 16 GiB · 비율 20% · utilization 0.5로 두면 가중치와 작업 공간만으로 몫이 차서 KV 캐시 자리가 없어지고 vLLM이 기동에 실패합니다. ④ gpu-memory-utilization을 0.95로 올리면 KV가 늘지만, 같은 GPU를 쓰는 다른 프로세스와 부딪힐 여유가 줄어듭니다.
대략적인 수치입니다

원문에는 GPU 모델과 vLLM의 KV 캐시 할당 로그가 없어 카드 크기를 선택지로 두었습니다. 가중치 1.0 GiB(원문의 model.safetensors 1.06 GB), 작업 공간 1.2 GiB는 추정이고, KV는 원문 설정(--kv-cache-dtype fp8)대로 값당 1바이트로 계산했습니다. 실제 값은 vLLM 기동 로그의 KV cache 크기로 확인하세요.

원문 매니페스트 발췌 — HAMi values, vLLM 2 replica
# hami-values.yaml
devicePlugin:
  runtimeClassName: nvidia
  monitor:
    extraArgs: ["-v=4", "--metrics-bind-address=:9394"]
prometheus:
  enabled: true                 # GPU Utilization Metrics
scheduler:
  kubeScheduler:
    imageTag: v1.36.4

helm uninstall nvdp -n nvidia-device-plugin
kubectl label nodes ${NODE_NAME} gpu=on
helm install hami hami-charts/hami -n kube-system --version 2.10.0 -f hami-values.yaml
# vLLM Deployment (발췌)
spec:
  replicas: 2
  strategy: { type: Recreate }
  template:
    spec:
      schedulerName: hami-scheduler
      runtimeClassName: nvidia
      containers:
        - name: vllm
          image: docker.io/vllm/vllm-openai:v0.28.0
          args:
            - |
              python3 -c "import runai_model_streamer" 2>/dev/null || pip install --no-cache-dir vllm[runai]
              exec vllm serve s3://models/Qwen3-0.6B-FP8 \
                --served-model-name Qwen3-0.6B-FP8 \
                --load-format runai_streamer \
                --host 0.0.0.0 --port 8000 \
                --max-model-len 8192 \
                --enforce-eager \
                --gpu-memory-utilization 0.8 \
                --kv-cache-dtype fp8
          env:
            - { name: AWS_ENDPOINT_URL, value: "http://minio.vllm.svc.cluster.local:9000" }
            - { name: VLLM_CACHE_ROOT, value: /vllm-cache }     # torch.compile 캐시 영속화
            # AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY 는 Secret 참조
          resources:
            limits:
              nvidia.com/gpu: "1"
              nvidia.com/gpucores: "50"
              nvidia.com/gpumem-percentage: "50"
7장 세 줄 요약
  1. 정의 — 실습 클러스터는 K3s 단일 노드 + 모니터링 + HAMi(GPU 50/50) + MinIO + vLLM v0.28.0 2 replica(Qwen3-0.6B-FP8, S3 스트리밍 로드)입니다.
  2. 성질 — 파드는 절반 메모리를 GPU 전체로 보고, 그중 gpu-memory-utilization만큼에서 가중치·작업 공간을 뺀 나머지가 KV 캐시입니다. 비율 합이 100%를 넘으면 파드가 뜨지 않습니다.
  3. 쓰임 — GPU 한 장으로 "파드 여럿" 라우팅 실험을 가능하게 합니다. 대신 파드 2개·작은 모델이라는 조건이 9장의 결과 해석에 따라붙습니다.

파드 둘이 떴습니다. llm-d를 붙이기 전에, 이 상태가 얼마나 빠른지부터 반복해서 잴 수 있어야 비교가 됩니다.

08벤치마크 반복 — 같은 부하를 여러 번, 그리고 seed의 함정

한 줄 요약: vllm bench serve를 CronJob으로 만들어 2분마다 같은 조건으로 돌립니다. 그런데 seed를 고정하면 매번 똑같은 프롬프트가 나가 prefix 캐시가 "공짜로" 적중해 버립니다. 원문은 seed를 실행 시각으로 바꿔 매번 다른 요청을 보냅니다.

왜 이걸 배우나
"두 번째 벤치마크에서 캐시 적중률이 확 올랐다 — 서버가 좋아진 걸까, 같은 문제를 다시 푼 걸까?"

원문 표현을 그대로 빌리면, ShareGPT 샘플링은 seed로 초기화된 의사난수로 94,145개 대화를 섞은 뒤 앞에서부터 num_prompts개를 뽑는 방식입니다. seed가 같으면 언제 실행하든 같은 대화가 같은 순서로 나갑니다.

vLLM은 prefix 캐시가 기본으로 켜져 있어(9장 Phase 5의 --enable-prefix-caching은 명시적으로 못 박는 것입니다), 같은 프롬프트를 다시 보내면 캐시 적중이 치솟습니다. 9장에서 "라우팅 방식에 따른 캐시 적중률"을 비교하려면 이 착시를 먼저 걷어내야 합니다. 그래서 원문은 --seed $(date +%s)로 매번 다른 요청을 보냅니다.

이 장에 나오는 낯선 말
vllm bench serve
vLLM 이미지에 들어 있는 온라인 서빙 벤치마크 CLI. 처리량·TTFT·TPOT·ITL·E2E 지연을 잽니다. GPU가 필요 없는 순수 HTTP 부하 생성기입니다.
ShareGPT · prefix_repetition
ShareGPT는 실제 대화 모음 데이터셋(약 642 MB). prefix_repetition은 공통 prefix 몇 개에 서로 다른 suffix를 붙인 합성 프롬프트를 만들어 prefix 캐시 효과를 재는 데이터셋입니다.
seed
의사난수의 출발값. 같으면 매번 같은 "무작위" 결과가 나옵니다.
ITL · E2EL
Inter-Token Latency(토큰 사이 간격)와 End-to-End Latency(요청 전체 소요).
CronJob · suspend
정해진 주기로 Job을 만드는 쿠버네티스 리소스. suspend: true면 멈춰 두고 kubectl create job --from=cronjob/…으로 수동 실행합니다.
OOMKilled
컨테이너가 메모리 limit을 넘어 커널에 의해 종료된 상태.

원문이 만든 벤치마크 구조

  1. CronJob 템플릿매번 파드를 손으로 띄우지 않도록. 처음엔 suspend: true로 두고 수동 실행, 검증 뒤 */2 * * * *(2분마다) + startingDeadlineSeconds: 60(밀린 스케줄 무한 누적 방지).
  2. 데이터셋·결과를 노드에 캐시642 MB ShareGPT를 hostPath에 한 번만 받고, 두 번째 실행부터 initContainer가 다운로드를 건너뜁니다. 결과 JSON·타임라인 HTML·데이터셋 통계 PNG도 hostPath에.
  3. 파라미터--backend vllm --endpoint /v1/completions --host qwen3-0-6b-fp8.vllm.svc.cluster.local --model Qwen3-0.6B-FP8 --tokenizer Qwen/Qwen3-0.6B-FP8 --dataset-name sharegpt --num-prompts 50 --max-concurrency 8. 기본값(1000개, 동시성 무제한)은 GPU 50% 공유 환경엔 과합니다.
  4. 트러블슈팅첫 실행이 memory limit 2 Gi에서 OOMKilled — vLLM CLI 임포트 + 642 MB JSON 파싱. requests 2 Gi / limits 6 Gi로 올려 해결.
  5. seed를 실행 시각으로--seed $(date +%s). 두 CronJob 모두 워커마다 RUN_SEED_BASE + i.

기준선 결과 (원문, llm-d 붙이기 전 · 직접 Service 호출)

지표값지표값
성공 요청50 / 50Mean TTFT49.56 ms (P99 72.43)
Benchmark duration26.44 sMean TPOT15.18 ms (P99 15.35)
입력 / 생성 토큰12,298 / 11,676Mean ITL15.19 ms (P99 16.59)
Request throughput1.89 req/sMean E2EL3,581 ms (P99 12,028)
Output token throughput441.66 tok/s (peak 536)Peak concurrent14

두 번 반복해도 1.88 · 1.90 req/s, TTFT 50.26 · 49.23 ms로 재현됐고, Grafana 파드별 대시보드에서 두 파드가 트래픽을 나눠 처리하는 것도 확인했습니다. 이 두 번은 seed 고정(42)이었지만 TTFT 차이는 1 ms 안팎이었습니다 — seed 효과는 지연보다 캐시 적중률에서 봐야 합니다.

prefix caching 벤치마크는 --dataset-name prefix_repetition, 기본 num_prefixes 5 · prefix_len 512 · suffix_len 128 · output_len 128 · NUM_PROMPTS 100을 병렬 워커로 나눠 돌립니다. 원문은 seed 고정/변경에 따른 대시보드 변화를 캡처로 남겼습니다(수치 표는 없음).

비유 — 같은 모의고사를 다시 보기

모의고사 성적이 두 번째에 확 오르면 실력이 는 걸까요, 같은 시험지를 받은 걸까요. seed 고정은 같은 시험지입니다.

캐시는 "지난번에 푼 문제"를 기억하는 능력이라, 같은 시험지면 점수가 부풀려집니다. 라우터의 실력(어느 파드로 보내야 캐시를 잘 쓰는가)을 재려면 매번 새 시험지를 줘야 합니다.

작은 숫자로 한 번

# ShareGPT 50개(평균 입력 약 246 토큰), 파드 2개, 캐시 용량은 충분하다고 가정 seed 고정 1회차: 거의 미스 2회차: 같은 50개 → 이전에 그 파드로 갔던 것은 적중 5회차쯤: 대부분 적중 seed 변경 매 회차: 새 50개 → 대화 템플릿 같은 짧은 공통 부분만 적중 → 적중률이 낮게 평평 # 파드가 2개라 같은 프롬프트도 다른 파드로 가면 미스 — "어느 파드로 보내느냐"가 여기서 갈린다
그림 8

반복 실행 10회 — seed 고정과 변경의 캐시 적중률

버튼 · 슬라이더
해볼 것 — ① 기본값(ShareGPT, seed 고정)에서 회차가 갈수록 막대가 올라가는 걸 보세요. 서버가 빨라진 게 아니라 같은 프롬프트를 다시 보낸 효과입니다. ② seed 매번 변경으로 바꾸면 막대가 바닥에 붙습니다 — 이게 라우팅 비교에 쓸 기준선입니다. ③ prefix_repetition으로 바꾸면 seed를 바꿔도 적중률이 높습니다. 한 회차 안에서 공통 prefix 5개가 반복되기 때문입니다. ④ 파드당 캐시 용량을 최소로 줄이면 오래된 블록이 밀려나 seed 고정 효과도 줄어듭니다.
대략적인 수치입니다

원문에는 이 비교의 수치 표가 없고 대시보드 캡처만 있어, 모형으로 다시 만들었습니다. 16토큰 블록, 파드 2개에 요청을 무작위 배정, 파드마다 LRU 블록 캐시. ShareGPT는 공통 템플릿 1블록 + 대화별 고유 블록(평균 약 15블록), prefix_repetition은 원문 기본값(prefix 512 · suffix 128 · prefix 5종 · 100개)으로 흉내 냈습니다.

파드당 KV 용량은 7장 계산값(약 138k 토큰)보다 좁은 범위로 두어 축출이 보이게 했습니다.

원문 실행 명령과 결과 파일
# CronJob 적용 (최초 1회) → 반복 실행은 이 세 줄
kubectl apply -f /root/manifests/vllm-bench/vllm-bench-cronjob.yaml
JOB_NAME="vllm-bench-manual-$(date +%s)"
kubectl create job --from=cronjob/vllm-bench-sharegpt "$JOB_NAME" -n vllm
kubectl logs -f job/"$JOB_NAME" -n vllm -c bench

# 조건 바꾸기
kubectl set env cronjob/vllm-bench-sharegpt -n vllm NUM_PROMPTS=200 MAX_CONCURRENCY=16

# 결과 (hostPath)
ls -la /var/cache/vllm-bench/results/
#  vllm-bench-infqps-concurrency8-Qwen3-0.6B-FP8-20260913-013518.json
#  ...timeline.html   (--plot-timeline)
#  ...dataset_stats.png (--plot-dataset-stats)

# 2분 주기 자동 실행으로 전환
#  schedule: "0 0 31 2 *"(존재하지 않는 날짜) → "*/2 * * * *",  suspend: true → false
kubectl get cronjobs.batch -n vllm
8장 세 줄 요약
  1. 정의 — 기준선은 vllm bench serve를 CronJob으로 반복 실행해 만듭니다. ShareGPT 50개·동시성 8에서 1.89 req/s, TTFT 49.56 ms, TPOT 15.18 ms였습니다(원문).
  2. 성질 — seed가 같으면 매번 같은 프롬프트라 prefix 캐시 적중이 부풀려집니다. prefix_repetition은 한 회차 안에서도 공통 prefix가 반복돼 seed와 무관하게 적중이 높습니다.
  3. 쓰임 — 라우팅 방식을 비교하려면 seed를 매번 바꾸고, 같은 파라미터로 짝지어 실행합니다. 9장 Phase 6이 정확히 그렇게 비교합니다.

기준선이 준비됐습니다. 이제 같은 파드 둘 앞에 llm-d를 세우고, 같은 벤치마크를 게이트웨이 경유로 다시 돌립니다.

098단계 설치와 A/B 비교 — 파드 2개에서 얻은 것과 잃은 것

한 줄 요약: Envoy Gateway → Agent Router → Inference Extension → InferencePool·llm-d EPP → 정밀 prefix 라우팅(ZMQ) → 비교 → 메트릭 → 대시보드 순으로 올렸습니다. 같은 파드 둘에 게이트웨이를 거치자 캐시 적중률은 63.9% → 68.75%로 올랐지만 TTFT는 59~66 → 67~69 ms로 소폭 늘었습니다.

왜 이걸 배우나
"지능형 라우팅을 붙였는데 오히려 느려졌다 — 잘못 설치한 걸까?"

잘못 설치한 게 아닙니다. 원문의 결론은 정직합니다. "파드 2개 + 작은 모델" 규모에서는 precise 라우팅의 캐시 이득이 라우팅 오버헤드를 상쇄할 만큼 크지 않을 수 있다.

그렇게 보이는 이유는 이렇습니다. Qwen3-0.6B의 TPOT가 이미 약 15 ms로 빨라서, Envoy → EPP gRPC 스코어링 한 번의 고정 비용이 상대적으로 커 보였다는 해석입니다.

그리고 원문은 반대 조건도 짚습니다. precise 라우팅의 진짜 이점은 파드 수가 많아 라운드로빈이 우연히 캐시를 못 맞출 확률이 높고, 큰 모델이라 캐시 미스(재계산)가 비싼 환경(예: replica 8개 이상)에서 더 뚜렷할 것으로 예상합니다. 이 장의 그림이 그 경계를 찾아봅니다.

이 장에 나오는 낯선 말
Gateway API 표준 CRD
GatewayClass·Gateway·HTTPRoute·GRPCRoute·ReferenceGrant. 실습은 v1.3.0.
GAIE
Gateway API Inference Extension. InferencePool CRD(실습 v1.6.1)와 Envoy Gateway의 InferencePool 지원을 켭니다.
llm-d-router-gateway Helm 차트
InferencePool·EPP·EPP Service·RBAC·HTTPRoute를 한 번에 만드는 공식 차트(v0.10.0).
--kv-events-config
vLLM이 KV 블록 이벤트를 ZMQ로 발행하게 하는 인자. 5장의 정밀 인덱스가 이걸 구독합니다.
token-producer · render
EPP의 정밀 토큰화 플러그인. 별도 토크나이저 사이드카 없이 vLLM의 /v1/completions/render(요청이 chat 형식이면 /v1/chat/completions/render)를 재사용했습니다.
PodMonitor · ServiceMonitor
Prometheus Operator에게 어떤 파드·서비스를 스크랩할지 알려 주는 리소스.

설치 아키텍처

Client (curl / bench)
   │
   ▼
Envoy Gateway (데이터 플레인, envoy-gateway-system) ── Agent Router 가 설정을 얹음 (envoy-ai-gateway-system)
   │  InferencePool 기능 활성화
   │  ext-proc (gRPC)
   ▼
EPP (llm-d 커스텀 이미지 + 플러그인) ── InferencePool ── [Qwen3-0.6B-FP8 pod A]
   │  prefix-cache indexer (KV 이벤트 ZMQ 구독)          [Qwen3-0.6B-FP8 pod B]
   ▼
선택된 vLLM 파드로 포워딩

여덟 단계

  1. Phase 1 · Envoy GatewayGateway API v1.3.0 CRD, Envoy Gateway v1.9.1 Helm → envoy-gateway 1/1.
  2. Phase 2 · Agent Router CRD + Controllerv1.1.0. CRD: aigatewayroutes · aiservicebackends · backendsecuritypolicies · gatewayconfigs · mcproutes · quotapolicies.
  3. Phase 3 · GAIE 활성화InferencePool CRD(v1.6.1 — 이 버전엔 InferenceObjective가 릴리스에서 빠져 설치하지 않음), agent-router 예제 config로 InferencePool watch를 켜고 envoy-gateway 재기동.
  4. Phase 4 · 기존 vLLM 파드 연동새로 배포하지 않고 app: qwen3-0-6b-fp8 라벨을 selector로 재사용. llm-d-router-gateway 차트 v0.10.0으로 InferencePool(failureMode: FailOpen) + EPP(llm-d-router-endpoint-picker:v0.10.0) + HTTPRoute. GAIE 기본 EPP는 prefix 플러그인이 없어 llm-d EPP로 교체. MetalLB가 Gateway에 192.168.254.240.
  5. Phase 5 · 정밀 prefix 라우팅vLLM에 --enable-prefix-caching --kv-events-config(ZMQ publisher, 포트 5557, topic kv@$(POD_NAME)@Qwen3-0.6B-FP8). EPP를 precise 모드(token-producer → precise-prefix-cache-producer)로. EPP 로그에 두 파드 모두 Connected subscriber socket: tcp://<PodIP>:5557. 같은 긴 prompt 6회 → 100% 같은 파드.
  6. Phase 6 · Prefix-Aware Scheduling 비교기존 prefix caching CronJob(직접 Service)과 같은 파라미터로 게이트웨이 경유 Job을 짝지어 실행, 캐시 메트릭 스냅샷으로 비교.
  7. Phase 7 · 메트릭 수집EPP ServiceMonitor의 router.monitoring.* 위치 버그를 router.monitoring.provider.name: prometheusoperator로 수정. vLLM은 기존 ServiceMonitor와 중복 스크랩되던 새 PodMonitor를 지워 일원화(더블 카운팅 방지).
  8. Phase 8 · Grafana공식 llm-d 대시보드 3종(vllm-overview, performance-kv-cache, inference-gateway). KV Cache Hit Rate 패널 65.1%. 요청 단위 EPP 메트릭·flow control 패널은 해당 기능이 꺼져 있어 "No data".

Phase 6 비교 결과 (원문 표)

지표직접 호출 (baseline, kube-proxy)게이트웨이 경유 (precise prefix-cache)
Prefix cache 적중률63.9% (40,896 / 64,005)68.75% (44,000 / 64,005)
파드 간 쿼리 분산42% / 58% (거의 균등)62% / 38% (캐시 어피니티로 한쪽에 더 몰림)
Mean TTFT59~66 ms67~69 ms
P99 TTFT113~151 ms148~173 ms
Throughput약 3.9~4.1 req/s약 4.0~4.1 req/s (동일)

원문 해석 세 줄: 캐시 적중률은 명확히 개선(+약 4.9%p, 파드 분산이 42/58에서 62/38로 치우친 것 자체가 캐시 어피니티의 증거) · TTFT는 소폭 증가(Envoy → EPP 홉의 고정 오버헤드, 작은 모델이라 상대적으로 두드러짐) · 작은 규모에선 이득이 오버헤드를 상쇄하지 못할 수 있음. 비교 후엔 prefix caching CronJob의 대상을 192.168.254.240:80(게이트웨이)으로 바꿔 이후 실행이 llm-d를 거치게 했습니다.

비유 — 동네 편의점 두 곳에 배달 앱 붙이기

편의점이 두 곳뿐이고 둘 다 인기 상품을 늘 갖춰 두면, 아무 데나 가도 대부분 있습니다.

여기에 "재고를 조회해 알맞은 곳으로 안내"하는 앱을 붙이면 조회 시간만큼 조금 늦어집니다. 편의점이 스무 곳이고 상품이 수백 가지라면 이야기가 다릅니다. 아무 데나 가면 허탕이 잦고, 안내 앱의 몇 초가 싸게 느껴집니다.

작은 숫자로 한 번

# prefix 5종, 요청마다 무작위 prefix, 캐시는 넉넉하다고 할 때 "처음 보는 prefix" 미스 수 파드 2개 · 무작위 배정 각 파드가 5종을 한 번씩 처음 볼 때만 미스 → 최대 2 × 5 = 10번 미스 후 전부 적중 파드 2개 · precise prefix마다 처음 1번만 미스 → 5번 # 요청이 수천 건이면 10번과 5번의 차이는 묻힌다 → 적중률 차이가 작다 파드 16개 · prefix 200종 · 캐시에 파드당 20종만 무작위: 거의 매번 미스 / precise: 가진 파드를 찾아감 → 차이가 크다
그림 9

파드 수와 prefix 종류에 따라 precise 라우팅이 이득이 되는 지점

슬라이더
해볼 것 — ① 기본값(파드 8, prefix 40종, 캐시 여유 파드당 8종)에서 오른쪽 선 그래프의 두 선이 벌어지는 모습을 보세요. 파드가 늘수록 precise의 TTFT는 내려가는데 무작위 배정은 캐시를 못 맞춰 높은 채로 머뭅니다. ② 파드를 2, prefix를 5로 두면 원문 실습과 같은 구도 — 적중률은 비슷하고 precise가 오버헤드만큼 느립니다. ③ 라우팅 오버헤드를 0 ms로 내리면 작은 규모에서도 precise가 밀리지 않습니다. ④ 캐시 여유를 40종으로 늘리면 무작위 배정도 결국 다 캐시해서 차이가 줄어듭니다.
대략적인 수치입니다

prefix 512 + suffix 128 토큰, 요청 800건, TTFT는 기본 지연 45 ms에 캐시 안 된 토큰 수에 비례한 시간(토큰당 0.06 ms)을 더하고, precise면 오버헤드를 얹은 모형입니다. 파라미터는 원문 기준선(TTFT 60 ms 안팎, 오버헤드 수 ms)과 비슷한 크기가 되도록 정했을 뿐 실측이 아닙니다. precise는 prefix를 가진 파드 중 덜 바쁜 곳, 너무 몰리면 다른 파드로 보냅니다.

원문 Phase 4·5·7 설정 발췌
# kubectl get inferencepools -n vllm -o yaml (발췌)
spec:
  appProtocol: http
  endpointPickerRef:
    failureMode: FailOpen          # EPP 가 죽어도 요청은 흘린다
    kind: Service
    name: qwen3-router-epp
    port: { number: 9002 }
  selector:
    matchLabels: { app: qwen3-0-6b-fp8 }
  targetPorts:
  - number: 8000
# endpoints/qwen3-0-6b-fp8   10.42.0.15:8000,10.42.0.16:8000

# EPP 컨테이너 (발췌)
image: ghcr.io/llm-d/llm-d-router-endpoint-picker:v0.10.0
ports: 9002 (grpc), 9003 (grpc-health), 9090 (metrics)
args: --pool-name qwen3-router --pool-namespace vllm --pool-group inference.networking.k8s.io
      --config-file /config/default-plugins.yaml --grpc-health-port 9003 --tracing=false
# Phase 5 — vLLM 인자 추가
--enable-prefix-caching          # 계획엔 --block-size 16도 있었으나 실제 describe 출력엔 없음
--kv-events-config '{"enable_kv_cache_events": true, "publisher": "zmq", "endpoint": "tcp://*:5557", "topic": "kv@$(POD_NAME)@Qwen3-0.6B-FP8"}'
# EPP 설정 파일은 이후 /config/precise-plugins.yaml 로 전환
# 재기동 전 prefix caching CronJob 을 suspend → patch → 재기동 확인 → resume
# 경로 비교 — 같은 파드, 다른 입구
직접 호출   Client → k8s Service(ClusterIP) → 파드              kube-proxy(iptables), TCP 연결 단위 무작위
게이트웨이  Client → Envoy Gateway → EPP(ext-proc gRPC 스코어링) → 파드   요청마다 prefix/kv-util/queue 점수
9장 세 줄 요약
  1. 정의 — 설치는 Envoy Gateway → Agent Router → GAIE → InferencePool + llm-d EPP → 정밀 prefix(ZMQ KV 이벤트) → 비교 → 메트릭·대시보드 8단계였고, 기존 vLLM 파드를 라벨로 그대로 재사용했습니다.
  2. 성질 — 원문 비교에서 적중률 +4.9%p, TTFT +수 ms, 처리량 동일. 캐시 이득은 파드 수·prefix 다양성·미스 비용이 클수록 커지고, EPP 홉의 고정 비용은 작은 모델에서 두드러집니다.
  3. 쓰임 — 도입 효과를 판단할 때 "적중률"과 "TTFT"를 같이 보고, 규모를 키운 조건에서 다시 재야 합니다. EPP 오버헤드가 정말 몇 ms인지는 다음 장의 트레이스가 답합니다.

"게이트웨이 홉 때문에 느려졌다"는 해석이 맞는지, 요청 한 건을 시간 단위로 쪼개 확인해 봅니다.

10트레이싱으로 본 450 ms — EPP는 정말 비쌌나

한 줄 요약: EPP와 vLLM에 OpenTelemetry를 켜서 Jaeger로 보니, 요청 한 건 450.44 ms 중 vLLM 추론이 448.01 ms(99.46%)였고 EPP 쪽 스팬은 모두 합쳐 약 2.43 ms(0.54%), 그중 루트 스팬 self는 1.28 ms였습니다. 라우팅 단계는 토큰화 → prefix 캐시 조회 → 스코어러 셋 → 선택으로 전부 한 트레이스에 이어져 보였습니다.

왜 이걸 배우나
"9장에서 TTFT가 몇 ms 늘었다 — 정말 EPP 탓인가, 어디서 늘었나?"

평균 지표만으로는 "어느 구간"인지 모릅니다. 원문은 선택 단계로 분산 트레이싱(Jaeger + OTel Collector)을 붙여 한 요청을 스팬 단위로 쪼갭니다. 결과는 9장의 해석을 정량적으로 받쳐 줍니다 — EPP 내부 처리는 마이크로초~밀리초 단위였습니다.

예상 밖의 발견도 하나 있었습니다. Envoy Gateway에는 트레이싱을 따로 켜지 않아 EPP 스팬과 vLLM 스팬이 따로 떨어질 줄 알았는데, Envoy가 trace context 헤더를 그대로 통과시켜 하나의 traceID로 이어졌습니다.

이 장에 나오는 낯선 말
Trace · Span
Trace는 요청 한 건의 전체 여정, Span은 그 안의 작업 한 구간(시작·끝·태그). 스팬은 부모-자식 트리를 이룹니다.
Self · Total
스팬의 자기 자신만의 시간과 자식 스팬까지 포함한 누적 시간.
OTLP · OTel Collector · Jaeger
OTLP는 OpenTelemetry 전송 프로토콜(gRPC 4317), Collector는 받은 스팬을 거르고 묶어 내보내는 중계기, Jaeger는 트레이스 저장·조회 UI(16686)입니다.
traceparent
서비스 사이에 trace ID를 이어 주는 HTTP 헤더(W3C Trace Context). 중간 프록시가 이걸 지우지 않으면 트레이스가 이어집니다.
Flamegraph · Waterfall
스팬을 시간 폭만큼 가로로 쌓은 그림과, 시작 시각 순서로 계단식으로 늘어놓은 그림.
gen_ai.* 속성
OpenTelemetry GenAI 시맨틱 컨벤션. 모델명, e2e 지연, decode 시간 같은 LLM 전용 태그 이름 규약입니다.

구성

vLLM (vllm-decode) ────┐
EPP  (llm-d-router)  ──┼── OTLP/gRPC :4317 ──▶ OTel Collector ──▶ Jaeger (all-in-one, in-memory)
(P/D Routing Proxy 는     │                    /metrics 스크랩 스팬 제거     UI :16686 → NodePort 30005
 이번 구성에 없음)        ┘                    batch 처리
  1. Jaeger 2.15.0 + OTel Collector contrib 0.120.0vllm 네임스페이스. Collector 파이프라인: otlp 수신 → /metrics 관련 스팬 필터 → batch → Jaeger.
  2. EPPhelm values router.tracing.enabled: true, otelExporterEndpoint, sampler parentbased_traceidratio · 1.0(실습 트래픽이 적어 100%, 운영은 0.1 권장). EPP가 --tracing=true로 재기동.
  3. vLLM--otlp-traces-endpoint, --collect-detailed-traces all, OTEL_SERVICE_NAME=vllm-decode 등 env 4종. Phase 5와 같은 패턴(CronJob suspend → patch → 재기동 → resume).

원문 트레이스 한 건 해부

스팬서비스시간읽을 점
gateway.requestllm-d-router/eppTotal 450.44 ms · Self 1.28 ms루트. 자기 몫(self)은 1.28 ms이고, 하위 스팬 orchestration과 vLLM 대기가 나머지
tokenize_renderepp1.04 msvLLM /render로 정확한 토큰화 — EPP 안에서 가장 긴 단계
produce_precise_prefix_cacheepp0.02 ms5장의 정밀 인덱스 조회
scorer 3종 (prefix-cache · kv-cache-utilization · queue)epp각 <0.01 ms4장의 가중합
vllm-decode: llm_requestvllm-decode448.01 ms (99.46%)gen_ai.latency.e2e=0.4478s, time_in_model_decode=0.4161s, time_in_model_inference=0.4463s

그래프 뷰에선 gateway.request → gateway.request_orchestration과 llm_request로 갈라지고, orchestration 아래로 tokenize_render · produce_precise_prefix_cache · run_scheduler_profile → filter_endpoints · llm_d.epp.scoring(스코어러 3개) · pick_endpoints가 이어집니다. 원문 캡처 기준 서비스 2개, 스팬 13개입니다(아래 그림과 이 문단에는 이름이 확인되는 12개만 옮겼습니다). pick_endpoints 태그에는 gen_ai.request.model=Qwen3-0.6B-FP8, llm_d.epp.picker.candidate_endpoints=2가 찍힙니다.

다만 이 트레이스가 답한 건 절반입니다. 2.43 ms는 EPP가 소유한 스팬의 합이고, 그 안의 루트 self 1.28 ms에는 Envoy를 거쳐 vLLM 응답을 기다린 시간도 섞여 있습니다.

9장에서 늘어난 5 ms 남짓 중 나머지는 트레이싱을 켜지 않은 Envoy 홉과 네트워크 구간으로 보이지만, 이번 구성에서는 재지 못했습니다.

비유 — 택시 호출 앱의 배차 시간

강남에서 인천공항까지 택시로 45분. 그중 앱이 기사를 배차하는 데 쓴 시간이 15초라면 비중은 약 0.56%입니다 — 이번 트레이스의 EPP(0.54%)와 거의 같은 비중입니다.

다만 가까운 편의점까지 3분 거리라면 같은 15초가 8%가 됩니다 — 9장의 "작은 모델에서 오버헤드가 두드러진다"는 해석이 이 구도입니다.

작은 숫자로 한 번

# 원문 트레이스 값 전체 450.44 ms vLLM 448.01 ms → 448.01 / 450.44 = 99.46 % EPP 전체 450.44 − 448.01 = 2.43 ms → 0.54 % (그중 루트 self 1.28 ms = 0.28 %) orchestration ≈ 450.44 − 448.01 − 1.28 = 1.15 ms (루트 self 정의에서 역산) 그중 tokenize_render 1.04 ms · prefix 조회 0.02 ms · 나머지(스케줄러) ≈ 0.09 ms
그림 10

flamegraph — 확대해야 보이는 EPP

슬라이더 · 클릭
해볼 것 — ① 기본 배율(×1)에서 초록 llm_request가 거의 전부를 차지하고 루트 gateway.request를 뺀 EPP 스팬은 왼쪽 끝 얇은 세로선 몇 개로만 보이는 걸 보세요. ② 확대를 ×178까지 올리면 0~2.53 ms 구간이 펼쳐집니다. 이름이 붙은 단계 중에는 tokenize_render(1.04 ms)가 가장 길고, 그 오른쪽의 빈 구간이 루트 스팬의 self 시간(1.28 ms)입니다. 오른쪽 끝(2.43 ms)에서 llm_request가 시작하고, ×200 이상이면 화면 밖으로 밀려납니다. ③ 막대를 클릭하면 스팬의 Self·Total과 비율이 오른쪽에 나옵니다. ④ 토큰화 방식을 근사로 바꾸면 tokenize_render가 사라진 가상의 트레이스가 됩니다 — 5장의 근사 방식이 가벼운 이유입니다.
원문 수치와 추정의 경계

원문 값: 전체 450.44, llm_request 448.01, EPP self 1.28, tokenize_render 1.04, prefix 조회 0.02, 스코어러 각 <0.01 ms. 추정: orchestration 1.15 ms(역산), run_scheduler_profile·filter·pick의 개별 시간과 모든 스팬의 시작 시각. "근사" 버튼은 render 호출을 뺐을 때를 가정한 가상 트레이스입니다.

원문 설정 발췌 — EPP helm values와 vLLM 인자
router:
  tracing:
    enabled: true
    otelExporterEndpoint: "http://otel-collector.vllm.svc.cluster.local:4317"
    sampling:
      sampler: "parentbased_traceidratio"
      samplerArg: "1.0"          # 실습은 100%, 운영은 0.1 권장

# vLLM
--otlp-traces-endpoint http://otel-collector.vllm.svc.cluster.local:4317
--collect-detailed-traces all
env:
- { name: OTEL_SERVICE_NAME,           value: "vllm-decode" }
- { name: OTEL_EXPORTER_OTLP_ENDPOINT, value: "http://otel-collector.vllm.svc.cluster.local:4317" }
- { name: OTEL_TRACES_SAMPLER,         value: "parentbased_traceidratio" }
- { name: OTEL_TRACES_SAMPLER_ARG,     value: "1.0" }

# 검증
curl http://<node>:30005/api/services     # vllm-decode, llm-d-router/epp
10장 세 줄 요약
  1. 정의 — EPP(router.tracing)와 vLLM(--otlp-traces-endpoint)이 OTLP로 Collector → Jaeger에 스팬을 보내고, Envoy가 trace 헤더를 통과시켜 한 traceID로 이어집니다.
  2. 성질 — 원문 트레이스에서 EPP 쪽 합계 약 2.43 ms(0.54%, 루트 self 1.28 ms), vLLM 448.01 ms(99.46%). EPP 안에서는 정밀 토큰화(tokenize_render 1.04 ms)가 가장 크고, 스코어링은 마이크로초 단위입니다.
  3. 쓰임 — "라우터 오버헤드"를 숫자로 확인하고, 어떤 단계(토큰화·조회·스코어링)를 줄일지 고릅니다. pick_endpoints 태그로 후보 수와 선택 근거를 봅니다(11장).

마지막으로 설치된 것들을 하나씩 열어 확인합니다. 로그 두 줄이 서로 다른 파드에 찍힌 이유, 그리고 2점 대 2점 동점의 정체.

11동작 확인 — 로그 두 줄과 2점 대 2점

한 줄 요약: 설치된 Router(Envoy 파드)·HTTPRoute·InferencePool·EPP·모델 서버를 차례로 열어 봅니다. vLLM 로그에 요청마다 두 줄(EPP의 토큰화 호출, Envoy의 실제 추론 호출)이 찍히고, 처음 보는 prompt는 두 파드가 동점이라 목록 첫 번째로 갔을 가능성이 높고, 같은 prompt를 반복하면 prefix 점수가 붙어 거의 한 파드로 몰립니다.

왜 이걸 배우나
"요청은 한 번 보냈는데 vLLM 로그가 서로 다른 두 파드에 찍혔다 — 중복 처리된 걸까?"

원문이 실제로 멈춰서 따져 본 장면입니다. 결론은 서로 다른 두 흐름입니다. 하나는 EPP가 정밀 prefix 스코어링을 위해 vLLM Service에 보낸 /render(토큰화), 다른 하나는 Envoy가 EPP가 고른 파드로 보낸 진짜 추론입니다.

이 장은 1~10장의 부품을 실물 리소스로 확인하는 점검표이자, "왜 이 파드로 갔나"를 로그·트레이스 태그로 역산하는 연습입니다.

이 장에 나오는 낯선 말
access log 필드
Envoy가 요청마다 남기는 JSON 한 줄. downstream_local_address(Envoy가 받은 주소), upstream_host(실제로 보낸 파드), response_flags(재시도·타임아웃 등 이상 표시).
/v1/chat/completions/render
vLLM이 프롬프트를 추론 없이 토큰화만 해 주는 엔드포인트. 정밀 prefix 스코어링의 재료입니다.
top_endpoints · top_scores
pick_endpoints 스팬 태그. 최고 점수를 받은 파드 목록과 점수. 여러 개면 동점입니다.
kubectl tail · get-all
kubectl tail은 여러 파드 로그를 한 번에 따라가는 도구, kubectl get-all은 네임스페이스의 모든 리소스를 한 번에 보여 주는 도구입니다. 둘 다 kubectl 플러그인 관리자 krew로 설치합니다.

부품별 점검표 (원문에서 확인한 것)

부품확인 결과눈여겨볼 점
Router — Envoy 파드envoy-vllm-inference-gateway-42784ead, 2/2(envoy + shutdown-manager), IP 10.42.0.225, LB IP 192.168.254.240[7W-1] 10장의 3/3과 달리 extproc 컨테이너가 없습니다 — AIGatewayRoute를 쓰지 않고 HTTPRoute + InferencePool 경로만 써서, ext_proc 대상이 별도 파드의 EPP이기 때문으로 보입니다
access logdownstream_local_address 10.42.0.225:10080, x-envoy-origin-path /v1/completions, user-agent aiohttp(벤치 CronJob), upstream_host가 10.42.0.19 ↔ 10.42.0.20으로 번갈아10080은 비루트 컨테이너라서(서비스에선 80). response_code_details: via_upstream, response_flags: - — 정상
HTTPRoute qwen3-routerparentRef Gateway inference-gateway, path /, backendRef InferencePool, timeouts.request: 300sLLM 생성이 길어질 수 있어 넉넉하게(차트 기본값). Phase 4에서 차트가 자동 생성
InferencePool + EPPInferencePool qwen3-router → endpointPickerRef EPP Service :9002. EPP 10.42.0.17, --config-file /config/precise-plugins.yaml트래픽 순서: 클라이언트 → Envoy(별도 파드) → EPP(gRPC) → Envoy → vLLM. EPP 로그: "EPP received request", "EPP sent response body back to proxy"
Model Serverqwen3-0-6b-fp8 2/2, vLLM v0.28.0, NodePort 30004, 로그에 prompt·generation throughput과 GPU KV cache usage요청마다 /render(출발지 EPP)와 실제 추론 호출(출발지 Envoy — 벤치는 /v1/completions, 수동 curl은 /v1/chat/completions) 두 줄

로그 두 줄의 정체

vkk2k  10.42.0.17:33942  - "POST /v1/chat/completions/render HTTP/1.1" 200   ← EPP 파드 (token-producer 토큰화, vLLM Service 경유)
zhsb7  10.42.0.225:39450 - "POST /v1/chat/completions HTTP/1.1" 200          ← Envoy 파드 (EPP가 고른 파드로 실제 추론)

토큰화는 어느 파드든 같은 토크나이저라 결과가 같으니 다른 파드에 떨어져도 문제없고, 실제 추론 파드는 EPP의 prefix·kv·queue 점수가 정합니다. 원문은 이어서 같은 prompt를 반복 호출해 "거의 대부분 한쪽 파드(vkk2k)로 전달"되는 것을 확인합니다.

2점 대 2점 — pick_endpoints 트레이스

태그값의미
llm_d.epp.picker.candidate_endpoints2풀의 두 파드(vkk2k, zhsb7)가 모두 후보
llm_d.epp.picker.top_endpoints[…vkk2k-rank-0, …zhsb7-rank-0]최고 점수 파드가 둘 — 동점
llm_d.epp.picker.top_scores[2, 2]각 최종 점수

원문 해석: 처음 보는 prompt라 prefix-cache-scorer는 두 파드 모두 0, 두 파드가 유휴라 kv-cache-utilization·queue가 각각 1 → 0 + 1 + 1 = 2 동점. 즉 "캐시가 변별력을 못 낸 순수 부하 분산 상황"이고, max-score-picker는 목록 첫 번째(vkk2k)를 골랐을 가능성이 높습니다. 스팬 시간은 prefix-cache-scorer 84 µs, kv-cache-utilization-scorer 75 µs, queue-scorer 1 µs, pick_endpoints 5 µs였습니다. (10장 트레이스의 "스코어러 각 <0.01 ms"와 달라 다른 요청의 트레이스로 보이지만, 원문에 trace ID가 없어 확인하지 못했습니다.)

비유 — 처음 온 손님과 단골

처음 온 손님은 어느 직원에게 가도 똑같습니다(동점). 매니저는 명단 맨 위 직원에게 보냅니다. 그 직원이 손님 취향을 기억하게 되면(prefix 캐시), 다음부터는 그 직원 점수만 올라가 자연스럽게 단골 담당이 됩니다.

작은 숫자로 한 번

# 원문 트레이스 해석 기준 가중치: prefix 2 · kv 1 · queue 1 (precise-plugins.yaml 본문은 원문에 없음) 새 prompt vkk2k: 0×2 + 1 + 1 = 2 zhsb7: 0×2 + 1 + 1 = 2 → 동점 → 목록 첫 번째 vkk2k 같은 prompt vkk2k: 1×2 + 1 + 1 = 4 zhsb7: 2 → vkk2k (sticky)
그림 11

같은 prompt를 반복하면 동점이 깨진다

버튼
해볼 것 — ① 새 prompt 보내기를 누르면 두 파드가 2점 동점이고 목록 첫 번째로 갑니다. 아래 로그에 /render와 /chat/completions가 다른 파드에 찍힐 수 있습니다. ② 이어서 같은 prompt 보내기를 몇 번 누르면 선택된 파드의 prefix 점수가 올라 4 대 2로 고정됩니다. ③ 동점 규칙을 무작위로 바꾸고 새 prompt를 여러 번 보내면 가끔 첫 배정이 다른 파드로 갑니다(연달아 같은 파드가 나올 수도 있습니다). ④ 같은 prompt 보내기로 한 파드에 prefix 점수가 붙은 상태에서(초기화를 눌렀다면 이것부터), pod 부하 올리기를 두 번 누르고 다시 같은 prompt 보내기를 눌러 보세요. queue 점수가 0으로 떨어져도(합계 3 대 2) prefix 가중치가 더 커서 파드가 바뀌지 않습니다.
단순화한 모형입니다

가중치(prefix 2 · kv 1 · queue 1)는 원문 해석 문장("가중치 2.0", "1+1=2")을 따른 값입니다. [2, 2] 동점으로 확인되는 건 kv·queue 가중치 합이 2라는 것뿐이고, prefix 가중치는 precise-plugins.yaml이 없어 확인하지 못했습니다(4장의 3·2·2였다면 [4, 4]).

kv 사용률은 두 파드 모두 거의 0으로 고정했고, /render는 vLLM Service를 거쳐 무작위 파드로 간다고 두었습니다. 부하는 queue 점수를 0.5씩 깎는 것으로 흉내 냈습니다.

원문 점검 명령 모음
# Router
kubectl get deploy,pod -n envoy-gateway-system -l app.kubernetes.io/component=proxy -owide
kubectl logs -n envoy-gateway-system -l app.kubernetes.io/component=proxy -c envoy -f

# HTTPRoute → InferencePool
kubectl get httproute -n vllm qwen3-router -oyaml
kubectl get inferencepool -n vllm qwen3-router -oyaml

# EPP
kubectl logs -n vllm -l llm-d-router-gateway=qwen3-router-epp -f

# Model Server — 두 파드 로그를 한 번에
kubectl tail -n vllm -l app=qwen3-0-6b-fp8 --since=3m | grep -vE 'metrics|health'

# 반복 호출 (부하 CronJob 은 먼저 멈춘다)
kubectl patch cronjob -n vllm vllm-bench-prefix-caching -p '{"spec":{"suspend":true}}'
curl -s -X POST http://192.168.254.240/v1/chat/completions -H 'Content-Type: application/json' \
  -d '{"model":"Qwen3-0.6B-FP8","messages":[{"role":"user","content":"What can you do?"}],"max_tokens":100}' | jq
11장 세 줄 요약
  1. 정의 — 동작 확인은 Router(Envoy 2/2) → HTTPRoute(backend InferencePool, 300s) → InferencePool·EPP(precise-plugins) → Model Server 순으로, 로그와 트레이스 태그로 합니다.
  2. 성질 — vLLM 로그의 두 줄은 EPP의 /render와 Envoy의 실제 추론으로 서로 다른 흐름입니다. 새 prompt는 prefix 0점이라 부하 점수만으로 동점이 나고, 반복되면 prefix 점수로 한 파드에 고정됩니다.
  3. 쓰임 — "왜 이 파드로 갔나"는 top_endpoints·top_scores와 가중치로 역산합니다. 원문의 다음 과제는 Load-aware·Predicted Latency·Tiered Prefix Cache·P/D·Flow Control·Autoscaling을 직접 켜 보는 것입니다.

EPP의 상자를 열고, 파드 둘에 붙여 재고, 트레이스로 쪼갰습니다. 정리합니다.

마치며

처음 질문으로 돌아갑니다. 같은 모델 파드 여러 개 중 누구에게 보낼 것인가.

llm-d의 답은 "InferencePool이 후보를 대고, EPP가 Filter → Score → Pick으로 고르고, Proxy가 그 주소로 보낸다"였습니다. 점수의 기준은 모델 서버의 대기열·KV 사용률 메트릭과, vLLM이 ZMQ로 흘리는 KV 블록 이벤트였습니다.

즉, 같은 모델 파드 여러 개 중 누구에게 보낼지는 InferencePool이 후보를 정하고, EPP가 점수로 고른 뒤 Proxy가 그 주소로 보내는 구조로 결정된다고 할 수 있습니다.

이 구조 덕분에 llm-d는 모델 서버의 상태와 vLLM의 캐시 상황을 반영하여 효율적으로 요청을 분배할 수 있다는 것을 알게 되었습니다.

이로써 7주차 LLMSO 스터디의 마지막 주차가 끝났네요, 후반부의 실습 내용은 사정으로 인해 직접 해보지 못했지만, 가시다님의 설명을 통해 개념적으로 이해할 수 있었습니다. 연말에 직접 실습을 해보며 경험을 쌓아, 회사에서 실제로 활용할 수 있기를 기대합니다.

어려운 내용을 최대한 쉽게 나누어 이해될 수 있게 스터디를 준비해 주신 CloudNet@에 감사드립니다.

이상으로 7주차 LLMSO 스터디의 llm-d 학습 자료 정리를 마칩니다. 감사합니다.

제보 부탁드립니다

이 페이지는 [7W-2] llm-d 스터디 자료의 정리본입니다. 참고한 것은 llm-d 공식 문서(Architecture · Core · Advanced)와 블로그, 원문에 링크된 Red Hat 발표(Beyond vLLM), Gateway API Inference Extension, 그리고 멤버 추천 글(GPU 없이 llm-d 게이트웨이 뜯어보기, PagedAttention에서 llm-d까지, vLLM 라우팅 6주차 정리, llm-d 기반 EKS 분산 추론 가이드)입니다.

설명이 틀렸거나 최신 버전에서 다르게 동작하는 부분이 있으면 알려주세요.

[7W-2] llm-d — LLMSO 스터디 7주차 학습자료
반응형

'AI > [LLMSO] Hands-On LLM 서빙과 최적화 스터디' 카테고리의 다른 글

[7주차-LLMSO] Envoy: Agent Router (2026.09.13)  (0) 2026.09.20
[6주차-LLMSO] Scaling LLM Inference with vLLM and AWS Trainium (2026.09.06)  (1) 2026.09.13
[5주차-LLMSO]CH10. Advancements in LLM Serving (2026.08.30)  (1) 2026.09.05
[5주차-LLMSO]CH9. LLM Optimization in Practice (2026.08.30)  (0) 2026.09.05
[4주차-LLMSO]CH8. LLM Serving Frameworks (2026.08.23)  (0) 2026.08.30
    devlos
    devlos
    안녕하세요, Devlos 입니다. 새로 공부 중인 지식들을 공유하고, 명확히 이해하고자 블로그를 개설했습니다 :) 여러 DEVELOPER 분들과 자유롭게 지식을 공유하고 싶어요! 방문해 주셔서 감사합니다 😀 - DEVLOS -

    티스토리툴바