| 편 | 제목 | 다루는 것 |
|---|---|---|
| 0 | 개요와 용어 사전 | 이 문서 |
| 1 | 에이전틱 서빙 | Knowledge Agent, RAG, CAG |
| 2 | 엔터프라이즈 계층형 아키텍처 | 7개 레이어와 각 레이어의 난제 |
| 3 | 오픈소스 스택 | Kubernetes + Ray Serve + KubeRay 실습 |
| 4 | 클라우드 벤더 6단계 | SageMaker 스펙트럼, Build vs Buy |
| 5 | 성능 지표 | Latency, Throughput, 측정 모범 사례 |
| 6 | 마지막 3문제 답변 | 학습 질문 풀이 |
세 줄 요약
1. 2~3장은 "모델을 어떻게 실행하는가"였고, 4장은 "실제 서비스를 어떻게 운영하는가"다.
2. 결정적 전환점은 모델이 요청당 한 번이 아니라 제어 루프 안에서 반복 호출된다는 것.
3. 그 순간 서빙은 추론 문제가 아니라 시스템 아키텍처 문제가 된다.
[그림 0-1] 전제의 전환
2~3장: 요청 1건 ──▶ 추론 1회 ──▶ 응답 1건
(모델 = 독립적인 예측 엔진)
4장: 요청 1건 ──┬──▶ LLM 호출 (계획 수립)
├──▶ embedding 호출 (검색)
├──▶ LLM 호출 (1차 생성)
└──▶ LLM 호출 (2차 생성)
(모델 = 제어 루프 안의 부품)
| 용어 | 뜻 |
|---|---|
| agent | 목표를 이해하고, 방법을 추론하고, 도구를 골라 호출하고, 중간 결과에 적응해 반복하며, 사람 개입 없이 결과를 내놓는 자율 LLM 시스템 |
| control loop | 최종 답이 나올 때까지 "생각 → 행동 → 관찰"을 반복하는 순환 구조 |
| action | 에이전트가 호출할 수 있는 이산적(discrete) 작업 단위. 연속적이지 않고 서로 명확히 구분되는 유한 개수 |
| planning | 사용자 질문을 어떤 action을 어떤 순서로 실행할지로 분해하는 단계 |
| tool calling | LLM이 도구를 고르고 구조화된 입력(보통 JSON)을 생성하면, 에이전트가 실제로 실행하고 결과를 다시 LLM에 넣는 4단계 패턴 |
| MCP | Model Context Protocol. 도구 발견·호출·결과 반영을 표준화한 프로토콜. 도구 정의를 에이전트 핵심 로직에서 분리 |
| context chaining | 앞 action의 출력을 다음 action의 입력 context로 넘기는 것 |
| 용어 | 뜻 |
|---|---|
| RAG | Retrieval-Augmented Generation. 질의 시점에 외부 문서를 검색해 prompt에 붙여 넣는 방식 |
| CAG | Cache-Augmented Generation. 지식을 미리 KV cache에 올려두고 검색 없이 바로 답하는 방식 |
| embedding | 텍스트를 의미를 담은 숫자 벡터로 변환한 것 |
| chunking | 문서를 검색 단위로 쪼개는 것. 보통 1,000 token 내외 |
| vector database | embedding 벡터를 저장하고 유사도 검색을 제공하는 저장소 |
| cosine similarity | 두 벡터가 이루는 각도로 재는 유사도. 방향이 같으면 1 |
| context window | LLM이 한 번에 처리할 수 있는 최대 token 수(입력 + 출력) |
| KV cache | 이미 계산한 Key/Value 중간값 저장소. 앞부분 재계산을 생략하게 해줌 |
| 용어 | 뜻 |
|---|---|
| prefill | 입력 프롬프트 전체를 한 번에 forward 시키는 첫 단계. compute-bound |
| decode | prefill 이후 token을 1개씩 이어 만드는 단계. memory-bound |
| speculative decoding | 빠르고 싼 draft model이 여러 token을 미리 만들고, 느리지만 정확한 target model이 한꺼번에 검증하는 기법 |
| tensor parallel (TP) | 하나의 layer 연산을 여러 GPU로 쪼개는 병렬화. 같은 노드 안이 기본 |
| pipeline parallel (PP) | layer를 그룹으로 나눠 여러 노드에 배치하는 병렬화. 노드 간 |
| multiplexing | 하나의 서비스 인스턴스가 여러 모델을 번갈아 서빙하는 것 |
| canary | 새 버전으로 트래픽 일부만 흘려보내 검증하는 배포 방식 |
| tenant | 하나의 플랫폼을 나눠 쓰는 고객/조직 단위 |
| HPA | Horizontal Pod Autoscaler. 지표를 보고 Kubernetes pod 수를 늘리고 줄이는 컨트롤러 |
| CRD | Custom Resource Definition. Kubernetes에 새 리소스 타입을 추가하는 방법 |
| DLC | Deep Learning Container. AWS가 프레임워크별로 미리 빌드해 둔 서빙용 Docker 이미지 |
| LMI | Large Model Inference. AWS의 대형 모델 추론 컨테이너. 내부에 vLLM 등을 탑재 |
| 용어 | 뜻 |
|---|---|
| E2E latency | 요청 수신부터 전체 응답 완료까지의 시간. 큐 대기·네트워크·라우팅까지 포함 가능 |
| TTFT | Time To First Token. 첫 token이 나올 때까지. prefill 단계에 대응 |
| ITL / TPOT | Inter-Token Latency / Time Per Output Token. 첫 token 이후 token 하나당 간격. decode 단계에 대응 |
| RPS / RPM | 초당 / 분당 요청 수 |
| TPS | Tokens Per Second. 출력 token만 셈. 입력이나 입출력 합산이 아님 |
| SLO / SLA | Service Level Objective / Agreement. 서비스 수준 목표 / 협약 |
에이전트가 기본 패턴이 되면서, 사용자 요청 하나가 여러 번의 LLM 호출·검색·도구 실행을 촉발한다.
그래서 서빙은 모델을 효율적으로 실행하는 일을 넘어 오케스트레이션·메모리 관리·시스템 레벨 조정까지 감당해야 한다.
세 줄 요약
1. 모델이 요청당 한 번이 아니라 제어 루프 안에서 반복 호출된다.
2. 그래서 사용자 상호작용 1회가 다중 LLM 호출 + 긴 context + 검색(RAG) + 캐시 재사용(CAG)을 촉발한다.
3. 결과는 셋 — token 사용량 증가, 체인 호출에서 tail latency 증폭, 동적인 트래픽 패턴.
| 전통적 (규칙 기반 챗봇) | LLM 에이전트 | |
|---|---|---|
| 워크플로우 | 개발자가 미리 짜둠 | 스스로 계획 |
| 지시 해석 | 정해진 패턴 매칭 | 자연어 해석 |
| 도구 | 하드코딩된 호출 | 스스로 선택·사용 |
| 적응 | 없음 | 피드백에 적응, 반복 |
차이의 본질은 자율성입니다 — "무엇을 어떻게 할지"까지 시스템이 판단합니다.
실무에서는 에이전트 하나로 끝나지 않고, 여러 에이전트가 서로 쌓이거나 협업하거나 기능별로 특화된 네트워크/플랫폼 형태가 됩니다. 자율성은 커지지만 서빙 관점의 난도도 그만큼 올라갑니다.
세 줄 요약
1. PDF 폴더를 지식 베이스로 삼는 RAG 에이전트. 로컬 모델·DB 없이 OpenAI API + 인메모리.
2. 모델 2개가 역할 분담 — embedding용과 추론·생성용.
3. LLM이 혼자 다 하는 게 아니다. LLM은 생각, RAG는 자료 찾기, Planner는 순서 결정, Agent는 조정.
| 모델 | 역할 | 로그에 찍히는 것 |
|---|---|---|
text-embedding-3-small | 텍스트 → 의미 벡터. 시맨틱 검색용 | POST /v1/embeddings |
gpt-4.1-nano | 추론·계획·자연어 생성 | POST /v1/chat/completions |
[그림 1-1] Knowledge Agent 구조
사용자 질문 → Agent.process_query()
├─ Planner (gpt-4.1-nano) ────────▶ 실행 계획 수립
└─ ActionExecutor 순차 실행
├─ RAGSystem (text-embedding-3-small) → 관련 문서 검색
└─ LLMManager (gpt-4.1-nano) ────────▶ 각 action별 응답 생성
| 컴포넌트 | 하는 일 |
|---|---|
| Agent (orchestrator) | 전체 조율. process_query()가 계획 → 실행 → 최종 응답까지 지휘 |
| RAGSystem | PDF 처리(청크 분할), embedding 생성, 벡터 검색 |
| Planner | LLM으로 실행 계획 수립 |
| ActionExecutor | 질의/요약/분석 등 구체적 작업 실행. 앞 결과를 다음 context로 체이닝 |
| LLMManager | OpenAI API 호출과 token 관리 |
query_rag_with_context — RAG 검색 + 답변generate_profile_based_response — 사용자 프로필 반영 답변generate_summary — 요약generate_analysis — 심층 분석각 action은 "LLM 호출 + 특화된 프롬프트 템플릿"으로 구현됩니다. 예를 들어 분석용 프롬프트는 질문과 context를 결합해 (1) 종합 분석 (2) 핵심 인사이트 (3) context에서 뽑은 예시 (4) 정보의 한계 — 네 가지를 요구하는 구조입니다.
주의: 이 샘플은 의도적으로 action을 전부 LLM 호출로만 만들었습니다. 실제 에이전트의 action은 여기 국한되지 않습니다 — 웹 검색, API 질의, DB 명령, 파일 관리, 외부 서비스 호출, 그리고 사용자에게 노출되지 않는 중간 추론 단계까지 포함됩니다.
세 줄 요약
1. 질문 1개 → API 호출 4회 (chat 3 + embedding 1).
2. LLM planner가 키워드 기반 fallback 규칙과 다른 판단을 내렸다 — 규칙보다 똑똑했다.
3. 2단계 action에서 embedding 호출이 없다 — context chaining이 실제로 일어났다는 증거.
[그림 1-2] 실제 호출 순서 (조작 포인트: 계획 단계 수를 늘려보면 호출 수가 어떻게 늘어나는지)
기동 시 PDF 4개 → 42 chunk → [POST /v1/embeddings] ← 인덱스 빌드
질문 입력
│
├─ ① Planner.create_plan() [POST /v1/chat/completions]
│ → plan: ['generate_summary', 'generate_analysis']
│
├─ ② action 1/2: generate_summary
│ ├─ context 비어있음 → RAG 검색 [POST /v1/embeddings]
│ │ → 관련 문서 5개 확보
│ └─ 요약 생성 [POST /v1/chat/completions]
│
└─ ③ action 2/2: generate_analysis
└─ 분석 생성 [POST /v1/chat/completions]
↑ embedding 호출이 없다! = 앞 요약을 context로 재사용
| 호출 | 횟수 |
|---|---|
chat/completions (플래닝) | 1 |
embeddings (쿼리 검색) | 1 |
chat/completions (요약) | 1 |
chat/completions (분석) | 1 |
| 합계 | 4회 |
① LLM 플래너가 규칙 기반보다 나은 판단을 했다.
_create_fallback_plan()의 키워드 규칙대로면 "what"/"how"에 매칭돼 ["query_rag_with_context"] 1스텝이었어야 합니다. 하지만 LLM 플래너가 살아있었기 때문에 generate_summary + generate_analysis 2스텝이라는 독자적 판단을 내렸습니다. "LLM 기반 플래닝이 규칙 기반보다 유연하다"를 로그로 확인한 셈입니다.
② context chaining이 실제로 작동했다.
2단계 직전에 embeddings 호출이 없습니다. 앞 action의 결과(50자 초과 조건 충족)가 context로 재사용되면서, generate_analysis는 원본 문서를 재검색하지 않고 방금 만든 요약문 자체를 입력으로 삼았습니다.
③ 사용자는 최종 결과만 봤다.
process_query는 results[-1]만 final_response로 반환합니다. 1단계 요약의 실제 텍스트는 화면에 전혀 안 나왔고, 내부적으로 2단계의 입력 재료로만 소비됐습니다. 겉보기엔 1번 답변이지만 실제로는 2단계 추론이 뒤에서 돌았습니다.
| 패턴 | 내용 |
|---|---|
| fallback plan | LLM 응답 파싱 실패 시 키워드 매칭으로 대체 계획 생성 → 플래닝이 실패해도 멈추지 않음 |
| action 단위 try/except | 한 스텝이 실패해도 에러 문자열만 남기고 나머지 진행 |
| 최상위 안전망 | process_query 전체가 try/except → 어떤 예외든 success: False dict로 변환. 프로세스가 죽지 않음 |
| ⚠️ 조용한 실패 | validate_action_prerequisites()가 len(documents) > 0만 확인. build_knowledge_base()를 안 부르면 모든 action이 에러 없이 스킵되고 success: True + "No response generated"가 반환됨 |
마지막 항목이 실무에서 제일 위험한 유형입니다. 실패가 실패처럼 안 보이는 실패입니다.
세 줄 요약
1. 질문이 복잡해지면 계획이 3단계로 늘어난다.
2. RAG → 분석 → 요약 순으로, 각 단계가 앞 단계 출력을 먹는다.
3. 그림의 "LLM 서빙"은 같은 모델일 수도, 다른 모델일 수도 있다.
질문 예시: "데이터베이스 쿼리 최적화와 데이터 구조 최적화 간의 상세 비교를 작성해 주세요."
{
"plan": ["query_rag_with_context", "generate_analysis", "generate_summary"],
"reasoning": "First, retrieving relevant contextual information ensures ...",
"estimated_steps": 3
}
[그림 1-3] 9단계 (조작 포인트: 각 화살표가 곧 API 왕복 1회)
① 사용자 질문
↓
② Agent → Planner 호출
↓
③ Planner → LLM 호출 ──────────▶ 3단계 실행 계획 반환
↓
④ ActionExecutor 순차 실행 시작
↓
⑤ [1단계] query_rag_with_context
├─ RAG 시맨틱 검색 → 관련 문서
└─ 문서를 context로 LLM 답변 생성
↓
⑥ [2단계] generate_analysis ← ⑤의 결과를 context로
↓
⑦ LLM이 심층 비교 분석 생성
↓
⑧ [3단계] generate_summary ← ⑦의 결과를 요약
↓
⑨ Agent가 종합해 최종 결과 반환
세 줄 요약
1. LLM 입장에서 RAG는 prompt 텍스트가 길어진 것일 뿐이다. 행렬 주입이 아니다.
2. 워크플로우는 두 개 — 오프라인 인덱스 빌드, 온라인 질의/검색.
3. chunk 크기가 검색 정밀도와 문맥 풍부함 사이의 트레이드오프를 결정한다.
RAG를 처음 접하면 "검색된 문서가 LLM 내부의 Q 행렬에 들어간다"거나 "RAG의 query와 attention의 Q가 같은 것"이라는 직관을 갖기 쉽습니다. 틀렸습니다.
[그림 1-4] RAG의 실제 데이터 흐름 (조작 포인트: top-k를 늘리면 X의 row가 늘어날 뿐)
사용자 query
↓
[embedding model — RAG 전용, LLM과 별개]
↓
query 벡터 (예: 1536차원)
↓
[Vector DB — cosine similarity 검색]
↓
top-k 문서 텍스트
↓
─────────────────────────────────────────
여기부터는 그냥 "텍스트 합치기" — 행렬 주입이 아님
prompt = f"""
{doc_1}
{doc_2}
...
Question: {query}
"""
─────────────────────────────────────────
↓
[LLM] → 토큰화 → X (입력 임베딩 행렬, row가 그만큼 늘어남)
↓
평소와 똑같은 self-attention
↓
응답
외부에서 검색했든 사람이 직접 붙여넣었든, LLM은 구분하지 못합니다.
비유하면 RAG는 오픈북 시험입니다. 매 시험(요청)마다 책(외부 DB)에서 찾아 답안(prompt)에 첨부합니다.
반대 방향이 fine-tuning — 암기, 즉 가중치에 지식을 내재화하는 방식입니다.
[그림 1-5] RAG의 두 워크플로우
[A] Index-building — 오프라인, 주기적 실행
원시 문서(HTML/PDF) → 정제·파싱 → 일반 텍스트
→ chunking (~1,000 token)
→ embedding 벌크 계산 (오프라인 배치 추론)
→ Vector DB에 인덱싱
[B] Query/retrieval — 온라인, 실시간
사용자 query → embedding
→ cosine similarity로 최근접 벡터 검색
→ 관련 chunk 반환
→ query + chunk를 LLM에 전달 → 응답
chunking은 단순 전처리가 아닙니다. 두 가지를 동시에 결정합니다.
1. 검색의 세분화 정도 — 시스템이 한 번에 고려할 수 있는 텍스트 양
2. 오프라인 배치 추론이 일어나는 지점 — 매번 문서 전체를 임베딩하는 대신 chunk별로 미리 계산
Knowledge Agent는 이걸 단순화해서 매번 기동할 때마다 인메모리로 다시 빌드합니다. 테스트를 4번 돌리면 42개 chunk를 4번 재임베딩합니다. 실무에서는 이 부분이 주기적 배치 파이프라인으로 분리됩니다.
[그림 1-6] Fine vs Coarse chunking (조작 포인트: chunk 크기를 200 ↔ 2000 token으로)
작은 chunk: ■ ■ ■ ■ ■ ■ ■ ■ 검색 정밀도 ↑ / 문맥 손실 위험
큰 chunk: ■■■■ ■■■■ 문맥 풍부 / 관련 없는 내용 섞여 정밀도 ↓ (dilution)
최적값은 도메인과 LLM의 context window에 따라 달라집니다. 정답이 없습니다.
LLM은 입력과 출력을 합산한 token 수에 상한이 있습니다. 이 공간이 context window입니다. 그래서 문서 전체가 아니라 관련성 높은 chunk 몇 개만 골라 보내야 합니다.
세 줄 요약
1. RAG의 3가지 대가 — 검색 지연, 선택 오류, 시스템 복잡도.
2. context window가 100만 token까지 커지면서 "몇 개만 골라 넣는다"는 전제 자체가 흔들렸다.
3. CAG는 지식을 미리 KV cache에 올려두고 검색 없이 답한다. 대신 메모리/연산 요구량이 늘어난다.
| 한계 | 내용 |
|---|---|
| 추가 지연시간 | LLM 호출 전에 "query embedding → 유사도 검색" 단계가 끼어듦 |
| 선택 오류 위험 | 제한된 token 안에서 "가장 관련 있는" 문서만 골라야 하는데, 잘못 고를 수 있음 |
| 시스템 복잡도 | embedding·인덱스·벡터 DB를 구축·유지해야 함 |
[그림 1-7] 두 방식의 구조 차이 (조작 포인트: 쿼리 수를 1 → 100으로 늘려보기)
RAG: query 1 ─▶ [검색] ─▶ LLM ← 매 쿼리마다 검색 발생
query 2 ─▶ [검색] ─▶ LLM
query 3 ─▶ [검색] ─▶ LLM
CAG: [지식 문서를 KV cache에 1회 로드]
query 1 ─────────────▶ LLM ← 검색 없음
query 2 ─────────────▶ LLM
query 3 ─────────────▶ LLM
CAG는 에이전트 설계 자체를 크게 단순화합니다 — embedding model, vector DB, 검색 로직이 통째로 사라집니다.
큰 context window + 캐시 관리는 메모리와 연산 요구량을 늘립니다. 지식이 커질수록 KV cache가 커지고, 그만큼 동시 처리 가능한 요청 수가 줄어듭니다. 이걸 최적화하는 기법이 7장 주제입니다.
| 강점 | 개선하는 축 | |
|---|---|---|
| RAG | 동적 검색, 최신성, 지식 갱신이 쉬움 (vector DB만 갱신) | 답변 품질 (지식 접지·최신성) |
| CAG | 반복 query·고정 지식셋에서 latency 절감 | 서빙 효율 (지연시간·처리량·비용) |
RAG로 입력을 보강하고, CAG로 실행을 최적화하는 조합이 현실적인 답입니다.
세 줄 요약
1. 에이전트는 LLM 하나가 아니라 여러 모델과 도구를 조율한다.
2. tool calling은 4단계 루프이고, 그 루프가 곧 반복 호출의 원천이다.
3. 이 모든 게 HTTP/gRPC API로 온디맨드 호출되므로, 저지연·저비용 서빙이 성공 조건이 된다.
| 종류 | 용도 |
|---|---|
| LLM | 추론, 계획, 대화 생성 |
| embedding model | 검색, 유사도 매칭 |
| 비전/음성 모델 | 멀티모달 인식 |
| 태스크 전용 모델 | 코드 생성, 분류, 요약 |
| 외부 도구 | API, DB, 서비스 |
[그림 1-8] tool calling 루프 (조작 포인트: 이 루프가 몇 바퀴 도는지가 곧 서빙 부하)
① LLM이 요청 + 사용 가능한 도구를 놓고 추론
↓
② 도구 호출용 구조화 출력(JSON) 생성
↓
③ 에이전트가 도구를 실제로 실행
↓
④ 결과를 다시 LLM에 투입 ──┐
↑ │
└───── 작업이 끝날 때까지 반복 ─┘
MCP(Model Context Protocol)는 이 패턴을 표준화합니다 — 도구 발견, 구조화된 호출, 결과 반영을 일관된 인터페이스로 제공하고, 도구 정의를 에이전트 핵심 로직에서 분리해 ad-hoc 프롬프트 엔지니어링의 취약성을 줄입니다.
세 줄 요약
1. 2~4장 앞부분에서 만든 것은 전부 "모델을 호스팅하고 실행한다"는 좁은 의미의 서빙이었다.
2. 실제 프로바이더는 그 위에 인증·과금·리소스·네트워킹·최적화·실험·관측성·온콜을 더 얹어야 한다.
3. 그리고 이 아키텍처가 어려운 진짜 이유는 기술이 아니라 조직이다.
책이 명시적으로 짚는 부분입니다.
즉 서빙 아키텍처 설계는 "레이턴시를 줄이는 기술" 문제가 아니라,
"어떤 레이어를 누가 소유하고, 경계를 어디에 그어야 팀들이 서로 발목을 안 잡는가"라는 조직 설계 문제이기도 합니다.
[그림 2-1] 7개 레이어 (조작 포인트: 위에서 아래로 갈수록 추상화가 벗겨진다)
┌─────────────────────────────────────────────────┐
│ 1. Public API │ 인증·과금·rate limit·라우팅
├─────────────────────────────────────────────────┤
│ 2. Resource Management │ GPU/CPU/메모리/네트워킹 배분
├─────────────────────────────────────────────────┤
│ 3. Model Selection & Orchestration │ 어떤 모델을 쓸지 결정
├─────────────────────────────────────────────────┤
│ 4. Distributed Serving │ 멀티GPU/노드 + 분산 캐싱
├─────────────────────────────────────────────────┤
│ 5. Core Inference │ vLLM/Triton + CUDA 커널
├─────────────────────────────────────────────────┤
│ 6. Model Optimization │ 양자화·speculative decoding
├─────────────────────────────────────────────────┤
│ 7. Model │ 실제 학습된 모델 공급·버전 관리
└─────────────────────────────────────────────────┘
세 줄 요약
1. 고객·개발자·내부 서비스가 만나는 외부 인터페이스.
2. 인증 → rate limit → tenant 식별 → 과금 → 라우팅 순으로 관문 역할.
3. 여기서 나온 tenant 값 하나에 이후 모든 레이어의 정책이 의존한다.
[그림 2-2] Public API 파이프라인
Internet
↓
Authentication ← JWT 또는 API key
↓
Rate Limit
↓
Tenant 식별 ← ★ 이후 모든 정책의 기준점
↓
Billing
↓
Routing
↓
LLM
| 핵심 난제 | 내용 |
|---|---|
| High concurrency | 수백만 동시 연결 처리 |
| Fair usage & monetization | quota 강제, 어뷰징 방지, 정확한 과금 |
| Low-latency global access | 지역 라우팅·캐싱으로 최근접 리전 서빙 |
| Security | 인증, tenant 격리, 네트워크 공격 방어 |
세 줄 요약
1. CPU/GPU/메모리/디스크/네트워킹을 여러 리전에 걸쳐 배분한다.
2. 예산 검토와 비용 배분이 여기서 통합된다.
3. 질문은 단순하다 — 누구에게 GPU를 줄 것인가, 지금 GPU가 놀고 있지 않은가.
[그림 2-3] GPU 풀 관리 (조작 포인트: 이기종 GPU 비율을 바꿔보기)
GPU Cluster
├─ H100 × N
├─ H200 × N
├─ B200 × N
└─ L40S × N
질문:
- 누구에게 GPU를 줄 것인가?
- 몇 대가 필요한가?
- GPU가 놀고 있지는 않은가?
- 중요 고객 요청을 우선 처리할 것인가?
| 핵심 난제 | 내용 |
|---|---|
| Capacity planning | 수요 예측, 과다 프로비저닝 방지 |
| GPU utilization | 데이터센터 전반의 이기종 GPU 풀 고가동률 유지 |
| Customer prioritization | 중요 워크로드에 quota/예약을 강제, 저우선순위는 선점 가능하게 |
세 줄 요약
1. 요청마다 어떤 모델을 쓸지 결정. 정확도·지연·비용의 균형점을 찾는 레이어.
2. 단일 모델 선택을 넘어 여러 모델을 동시에 조율한다 (speculative decoding, 모델 패밀리 간 라우팅).
3. "1+1?"에 거대한 reasoning model을 쓸 이유는 없다.
[그림 2-4] 라우팅 판단 (조작 포인트: 분류 임계값을 조정하면 비용/품질 곡선이 움직인다)
단순 질문 ──▶ Small Model (싸고 빠름)
복잡한 추론 ──▶ Large Model (비싸고 정확)
Quality
△
╱ ╲
╱ ╲
Cost ───── Latency ← 셋 중 둘만 가져갈 수 있다
| 핵심 난제 | 내용 |
|---|---|
| Cost–quality trade-off | 모든 작업에 최대 모델이 필요하진 않음. 쿼리를 이해해서 triage해야 함 |
| Load balancing | 모델 풀 전반에 트래픽 분산 |
| Latency-sensitive use case | 더 작고 빠른 모델 또는 speculative decoding 적용 |
세 줄 요약
1. 두 갈래 — 대형 모델을 위한 분산 호스팅, 중복 연산을 줄이는 분산 캐싱.
2. 모델이 GPU 한 장에 안 들어가면 쪼개야 한다.
3. cache-aware routing이 이 레이어의 숨은 핵심이다.
[그림 2-5] 모델이 GPU 하나에 안 들어갈 때
Model Weight = 160 GB
GPU VRAM = 80 GB
↓
GPU 0 ─┬─ GPU 1 같은 노드 내 → Tensor Parallel (TP)
│
Node A ─── Node B 노드 간 → Pipeline Parallel (PP)
예: GPU 8장 노드 1대 → TP 8
GPU 8장 노드 2대 → TP 8, PP 2
관리 대상: Multi-GPU, Multi-Node, KV Cache, Prompt Cache, Cache-aware Routing
| 핵심 난제 | 내용 |
|---|---|
| Hardware limitations | 모델 크기가 단일 GPU 메모리를 초과 |
| Multi-GPU/node coordination | 요청 context를 효율적으로 공유하며 SLA 유지 |
| Caching for efficiency | KV-cache 인식 라우팅으로 중복 추론 감소 |
세 줄 요약
1. 모델 계산이 실제로 일어나는 곳.
2. 서빙 프레임워크 + 최적화 커널을 웹 엔드포인트로 노출한다.
3. 3장에서 상세히 다룬 영역이다.
[그림 2-6] Core Inference 스택
Application
↓
Orchestration
↓
vLLM / Triton / TensorRT-LLM / SGLang ← 서빙 프레임워크
↓
FlashAttention / GEMM / PagedAttention ← 최적화 커널
↓
CUDA Kernel
↓
GPU
| 핵심 난제 | 내용 |
|---|---|
| 모델별 dependency와 framework가 제각각 | |
| model load/unload가 latency spike를 만든다 | cold start |
| hot model과 cold model의 트래픽 차이가 크다 | hot model scaling |
| 모델 cache eviction 정책이 성능과 비용에 직결 | |
| 보안·격리·observability가 단일 모델보다 복잡 |
세 줄 요약
1. 레이어 6은 재학습 없이 성능·효율을 높이는 기법을 적용한다.
2. 레이어 7은 학습된 모델을 서빙 시스템에 공급하고 분류·추적·버전 관리한다.
3. 새 모델과 기술이 계속 나와도, 이 계층 분리 패턴 자체는 유지된다.
레이어 6 — Model Optimization
양자화(6장), speculative decoding·LMCache(7장), 종단간 최적화(9장) 등이 여기에 해당합니다.
레이어 7 — Model
책이 명시적으로 다루지는 않지만, 실무에서 반드시 계산해야 하는 항목입니다.
레이어를 나눌 때마다 네트워크 홉이 하나씩 늘어납니다.
[그림 2-7] 레이어 분리의 비용 (조작 포인트: 홉 수를 늘려보며 latency budget 소진 확인)
Public API ──1ms──▶ Orchestration ──1ms──▶ Router ──1ms──▶ Serving ──1ms──▶ Engine
총 ~4ms 오버헤드
TTFT 목표가 200ms면 4ms는 2% — 무시 가능
TTFT 목표가 30ms면 4ms는 13% — 무시 불가
즉 계층형 아키텍처는 조직 확장성을 사기 위해 latency budget을 지불하는 거래입니다. 대규모 조직이 아니라면 레이어를 다 만들 필요가 없고, 만들지 않는 게 낫습니다. "레퍼런스 아키텍처"는 도달 목표지 시작점이 아닙니다.
세 줄 요약
1. 이유는 두 가지 — 핵심 기능(배포/스케일링/관리 자동화)과 거대한 생태계.
2. Kubernetes 한 층이 레이어 1의 라우팅/네트워킹 부분과 레이어 2 전체를 실제 구현으로 채워준다.
3. 목표는 단일 정답 제시가 아니라, 직접 플랫폼을 만들 수 있는 출발점 제공이다.
Kubernetes + 생태계가 담당하는 것:
[그림 3-1] 레이어 대응 관계
레이어 1 Public API → FastAPI + Ingress(nginx) + JWT/API key
레이어 2 Resource Mgmt → Kubernetes (HPA, node pool, scheduler)
레이어 3 Model Selection → 미들웨어 서비스 또는 API 안의 라우팅 로직
레이어 4 Distributed → Ray Serve
레이어 5 Core Inference → vLLM / Triton
세 줄 요약
1. 인증은 JWT와 API key 두 경로가 있지만, 둘 다 tenant 식별이라는 같은 목적지로 수렴한다.
2. rate limit이 두 곳에 이중으로 걸린다 — Ingress(거친 방어선)와 애플리케이션(정교한 quota).
3. HPA는 CPU 기준으로 파드를 3~15개로 자동 확장한다.
@app.post("/v1/chat/completions")
async def chat(req: ChatReq, idp=Depends(require_auth)):
await rate_limit(idp["tenant"])
# tenant 정보로 모델 선택 / 트래픽 라우팅
FastAPI의 Depends()로 인증 로직을 선언적으로 주입해서, 라우트 핸들러는 비즈니스 로직에만 집중합니다.
async def require_auth(api_key=Depends(verify_api_key), claims=Depends(verify_jwt)):
if not api_key and not claims:
raise HTTPException(401, "Missing API key or JWT")
tenant = claims.get("tenant") if claims else await rds.hget(f"keys:{api_key}", "tenant")
if not tenant:
raise HTTPException(403, "Unknown tenant")
return {"tenant": tenant, "claims": claims, "api_key": api_key}
[그림 3-2] 인증 두 경로
JWT 경로: Authorization: Bearer ...
→ RSA 공개키(JWK)로 서명 검증 + audience/만료 확인
→ 토큰 claim에서 바로 tenant 추출
↘
tenant ← 여기로 수렴
↗
API key 경로: key → Redis 조회(rds.hget) → tenant 역참조
이후 단계(quota 강제, 라우팅, 모델 선택)가 전부 이 tenant 값 하나에 의존합니다.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
spec:
scaleTargetRef: {kind: Deployment, name: enterprise-model-api}
minReplicas: 3
maxReplicas: 15
metrics:
- type: Resource
resource: {name: cpu, target: {type: Utilization, averageUtilization: 70}}
여기서 짚어둘 것: 이 HPA는 API 서버용이라 CPU 기준이 맞습니다. 하지만 GPU 추론 replica에 이 방식을 그대로 쓰면 안 됩니다. GPU 워크로드에서 CPU 사용률은 부하를 반영하지 못하고, GPU는 CPU처럼 오버서브스크립션이 안 됩니다. 추론 replica는 큐 길이나
target_ongoing_requests같은 요청 기반 지표로 스케일해야 합니다.
annotations:
nginx.ingress.kubernetes.io/limit-rps: "50"
nginx.ingress.kubernetes.io/limit-burst-multiplier: "5"
nginx.ingress.kubernetes.io/proxy-body-size: "8m"
초당 50건, 버스트는 5배(순간 최대 250건)까지 허용.
[그림 3-3] 이중 rate limit = defense in depth
Internet
↓
[Ingress limit-rps: 50] ← 거친 방어선. 앱 도달 전에 DDoS성 트래픽을 값싸게 차단
↓
[app: rate_limit(tenant)] ← 정교한 방어선. tenant별 계약(quota)을 정확히 강제
↓
LLM
세 줄 요약
1. 모델 선택 로직은 Public API 안, 별도 미들웨어, 정적 라우팅 설정 중 어디에나 둘 수 있다.
2. 이 예제는 출력 길이(1024 token) 로 speculative decoding 여부를 분기한다.
3. canary 판정이 tenant override보다 먼저 실행되는 순서에 의도가 있다.
ep = choose_endpoint(req.model, idp["tenant"])
if req.model.draft_enabled and req.max_new_tokens > 1024:
draft_ep = config.get_draft_endpoint(req.model)
gen = speculative_decode(req, endpoint_draft=draft_ep, endpoint_target=ep)
else:
gen = passthrough(ep)
임계값 1024의 의미: speculative decoding은 두 모델을 동시에 돌리는 오버헤드가 있습니다. 생성 token이 많을수록 draft model의 속도 이득이 그 오버헤드를 상쇄하고도 남습니다. 짧은 응답에는 오히려 손해라서, 출력 길이로 분기하는 게 합리적인 휴리스틱입니다.
def choose_endpoint(model: str, tenant: str):
cfg = load_routes()
route = cfg["models"].get(model) or cfg["aliases"].get(model)
if not route: raise HTTPException(404, f"Unknown model {model}")
if "canary" in route and random() < float(route["canary"]["weight"]):
return route["canary"]["url"] # ← tenant보다 먼저!
route_over = (route.get("tenants") or {}).get(tenant)
if route_over: return route_over["url"]
return route["url"]
[그림 3-4] 라우팅 우선순위 (조작 포인트: canary weight를 0 → 1로)
① 모델/alias 조회 없으면 404
↓
② canary 가중치 판정 random() < weight → 신규 버전으로
↓ ★ 여기가 tenant override보다 먼저인 이유:
↓ 전체 트래픽에서 무작위 샘플링해야
↓ 특정 고객군에 편향되지 않은 대표성 확보
③ tenant override 전용 파인튜닝 모델/전용 용량
↓
④ 기본 라우트
세 줄 요약
1. 3장에서 손으로 만든 것들을 Ray Serve는 데코레이터 한 줄로 대체한다.
2. 단일 모델은@serve.deployment, 멀티 모델은@serve.multiplexed.
3. 우리가 짠ModelManager의 LRU 캐시가 프레임워크 기능으로 내장돼 있다.
@serve.deployment(
name="qwen_vllm",
num_replicas=3,
ray_actor_options={"num_cpus": 0.5, "num_gpus": 1},
)
class QwenVLLM:
def __init__(self):
args = AsyncEngineArgs(
model="Qwen3-Thinking-2507",
tensor_parallel_size=1,
max_num_batched_tokens=4096,
gpu_memory_utilization=0.9,
)
self.engine = AsyncLLMEngine.from_engine_args(args)
async def __call__(self, http_request):
...
[그림 3-5] Ray Serve 단일 모델 (조작 포인트: num_replicas를 1↔3으로)
HTTP Request
↓
Ray Serve
├─ Replica 1 → GPU 1
├─ Replica 2 → GPU 2
└─ Replica 3 → GPU 3
↓
vLLM AsyncLLMEngine
↓
GPU
3장의 ModelWorker와 개념적으로 같은 패턴입니다 — 모델을 한 번 로드하고 요청을 처리. 다만 수동 배칭/큐잉을 직접 구현했던 부분을 AsyncLLMEngine에 통째로 위임합니다. 그리고 HPA YAML(minReplicas/maxReplicas/CPU 임계값)이 하던 일을 데코레이터 한 줄이 대신합니다.
@serve.deployment(num_replicas=2, ray_actor_options={"num_cpus": 1})
class MultiModelService:
async def __call__(self, request):
model_id = serve.get_multiplexed_model_id()
tokenizer, model = await self.load_model(model_id)
...
@serve.multiplexed(max_num_models_per_replica=2)
async def load_model(self, model_id: str):
repo = MODEL_REGISTRY[model_id]
...
호출은 HTTP 헤더로:
curl -X POST "http://127.0.0.1:8000/SentimentService" \
-H 'ray_serve_multiplexed_model_id: distilbert-sst2' \
-d '{"text":"I absolutely loved this!"}'
직접 만든 것 (ch03/multi_model_serving) | Ray Serve 대응 기능 |
|---|---|
ModelManager (OrderedDict LRU, max_models=2) | @serve.multiplexed(max_num_models_per_replica=2) |
ModelEngine.create_worker() — 요청 시점 로드 | await self.load_model(model_id) |
config/models.json (model_id → 모델명) | MODEL_REGISTRY dict |
server.py의 model_id 필드로 라우팅 | HTTP 헤더 ray_serve_multiplexed_model_id |
핵심 차이: 단일 모델은 __init__에서 미리 로드하지만, 멀티 모델은 요청마다 다른 모델이 올 수 있어 실시간 로드를 시도합니다. 그 지연을 완화하는 게 @serve.multiplexed의 LRU 캐싱이고, max_num_models_per_replica를 넘으면 자동 축출합니다.
세 줄 요약
1. Ray Cluster = Head Node 1개 + Worker Node N개.
2. Head Node만 클러스터 관리 싱글톤(Autoscaler, GCS, 드라이버)을 돌린다.
3. Ray Serve LLM은 그 위에 OpenAiIngress + LLMServer 두 컴포넌트를 얹는다.
| 컴포넌트 | 역할 |
|---|---|
| Ray Core | 기반 레이어. @ray.remote로 함수(Task)/클래스(Actor)를 분산 실행 |
| Ray Data | 대규모 병렬 데이터 처리/ETL |
| Ray Train | 분산 학습 자동화 |
| Ray Tune | 병렬 하이퍼파라미터 튜닝 |
| Ray Serve | 모델을 HTTP 엔드포인트로 프로덕션 서빙 |
| Ray RLlib | 강화학습 |
[그림 3-6] Ray Cluster 구조
┌──────────────── Head Node ────────────────┐
│ Worker Node와 동일한 프로세스 │
│ + Autoscaler (리소스 요청 감지, 노드 증감) │
│ + GCS (클러스터 메타데이터) │
│ + Ray Driver (사용자 스크립트 실행) │
└───────────────────┬───────────────────────┘
│
┌─────────────┼─────────────┐
▼ ▼ ▼
Worker Node Worker Node Worker Node
(Task/Actor 실행만, 클러스터 관리 기능 없음)
| 개념 | 뜻 |
|---|---|
| Deployment | 기본 단위. @serve.deployment로 정의, 여러 replica(각각 Ray Actor)로 확장 |
| Application | 하나 이상의 Deployment로 구성된 업그레이드/롤백 단위 |
| Ingress Deployment | serve.run()에 전달되는 최상위 Deployment. HTTP 진입점 |
| DeploymentHandle | Deployment 간 통신용 Python 네이티브 API |
연결: 각 Deployment를 .bind()로 연결 → serve.run(ingress.bind(...))로 실행.
[그림 3-7] Ray Serve LLM 요청 흐름
Client
↓
OpenAiIngress FastAPI 기반. OpenAI 호환 엔드포인트,
↓ 라우팅, 모델 multiplexing(LoRA 어댑터 포함)
DeploymentHandle (RPC)
↓
LLMServer Replica ← 가능하면 같은 노드 우선 배치
↓ (크로스노드 오버헤드 최소화)
vLLM 엔진 (GPU)
↓
Response
LLMServer의 3가지 운영 모드
추가 기능
target_ongoing_requests로 균형 유지세 줄 요약
1. Ray head/worker를 Kubernetes pod으로 관리하는 공식 Operator.
2. CRD 4개 — RayCluster, RayJob, RayService, RayCronJob.
3. 프로덕션 모델 서빙에는 RayService가 정답이다. 고를 이유가 명확하다.
| CRD | 용도 |
|---|---|
| RayCluster | Head/Worker pod으로 구성된 Ray 클러스터 자체의 생명주기 관리 |
| RayJob | RayCluster를 만들어 단일 Job 실행 후 정리 (배치 작업용) |
| RayService | RayCluster 위에 Ray Serve 앱을 얹어 운영. 무중단 업그레이드, 헬스체크 기반 HA |
| RayCronJob | RayJob을 크론 스케줄로 반복 실행 |
| 항목 | RayCluster | RayService |
|---|---|---|
| 역할 범위 | Ray 클러스터만 | 클러스터 + Serve 앱까지 |
| 배포 | RayCluster CR | 단일 매니페스트에 rayClusterConfig + serveConfigV2 |
| 접속 | head pod에 exec, dashboard port-forward 후 ray job submit | Serve용 Service(-serve-svc)로 바로 HTTP |
| Serve 라이프사이클 | 없음 — serve run으로 수동 배포·갱신 | 자동. 설정만 바꾸면 무중단 in-place 업데이트 |
| 헬스체크/HA | Ray 자체 기능만, k8s가 Serve 상태 모름 | /-/routes 기반 헬스체크로 k8s가 Serve 상태까지 파악 |
| 적합 용도 | 배치 작업, 대화형 실험, Data/Train/Tune | 프로덕션 모델 서빙 |
| GPU 워커 설정 | workerGroupSpec에 직접 기술 | 동일 |
RayService를 고르는 이유 3가지
1. RayCluster만 쓰면 Serve 앱을 직접 serve run으로 올리고 pod이 죽으면 수동 재배포해야 함
2. RayService는 무중단 업데이트/헬스체크를 컨트롤러가 대신 — 모델 교체나 replica 변경이 kubectl apply 한 번
3. GPU 워커 스펙 작성 난이도가 동일하므로 RayCluster를 고를 이유가 없음
세 줄 요약
1. Qwen2.5-7B-Instruct-AWQ를 GPU 1장에 올려 OpenAI 호환 엔드포인트로 노출.
2. 부하 테스트 결과 p50~p99 편차가 거의 없었다 — 하지만 그 이유를 오해하면 안 된다.
3. GPU가 병목으로 예측 가능하게 동작한다는 것까지가 이 테스트가 증명한 범위다.
helm repo add kuberay https://ray-project.github.io/kuberay-helm/
helm repo update kuberay
kubectl create namespace kuberay-system
helm install kuberay-operator kuberay/kuberay-operator --version 1.6.0 -n kuberay-system
CRD는 helm install에 포함되어 자동 설치됩니다. 별도 kubectl apply -f crds/ 불필요.
kubectl get crd | grep ray.io
# rayclusters.ray.io / raycronjobs.ray.io / rayjobs.ray.io / rayservices.ray.io
| 항목 | 공식 예제 | 조정값 | 이유 |
|---|---|---|---|
| 모델 | Qwen2.5-7B-Instruct | -AWQ + quantization: awq | 가중치 4~5GB로 축소 |
gpu_memory_utilization | — | 0.85 | AWQ라 가중치가 작아 KV cache에 넉넉히 배분 |
max_model_len | 1024 | 4096 | VRAM 여유 확보로 실용적 길이 상향 |
| GPU 수 | 4 | 1 | 물리 GPU 1장 |
max_replicas | 4 | 1 | 동일 |
| worker CPU/메모리 | 32 / 32Gi | 8 / 24Gi | 모니터링 스택과 공존 |
핵심 설정만:
serveConfigV2: |
applications:
- name: llms
import_path: ray.serve.llm:build_openai_app
args:
llm_configs:
- model_loading_config:
model_id: qwen2.5-7b-instruct-awq
model_source: Qwen/Qwen2.5-7B-Instruct-AWQ
engine_kwargs:
quantization: awq
max_model_len: 4096
gpu_memory_utilization: 0.85
deployment_config:
autoscaling_config:
min_replicas: 1
max_replicas: 1
target_ongoing_requests: 16
max_ongoing_requests: 32
kubectl get rayservice -n kuberay
# NAME SERVICE STATUS NUM SERVE ENDPOINTS
# vllm-service Running 2
kubectl describe로 확인할 것:
LLMServer:qwen2_5-7b-instruct-awq → HEALTHYOpenAiIngress → HEALTHY로그에서 Using executor class: RayDistributedExecutor → Started vLLM engine. 확인.
curl -s http://<node-ip>:30005/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"qwen2.5-7b-instruct-awq","messages":[{"role":"user","content":"안녕"}]}'
파라미터: 동시 8, 5분, max_tokens=64, 고정 프롬프트, closed-loop
| 지표 | 값 |
|---|---|
| 총 요청 | 1,177건 |
| 성공/실패 | 1,177 / 0 (100%) |
| 평균 지연시간 | 2.05 s |
| p50 / p95 / p99 | 2.02 s / 2.10 s / 2.10 s |
| 처리량 | 3.92 req/s |
| GPU (부하 중) | 사용률 99%, 201.5 W, 66 °C |
| GPU (종료 후) | 즉시 idle (0%, 52 W) |
산술 검증: 동시성 8 ÷ 평균 2.05s = 3.90 req/s ≈ 측정값 3.92. 일치합니다.
p50~p99 편차가 0.08초밖에 안 되는 걸 보고 "시스템이 매우 안정적이다"로 읽으면 절반만 맞습니다.
[그림 3-8] closed-loop vs open-loop (조작 포인트: 부하 방식을 바꿔보기)
closed-loop (이 테스트):
워커 8개가 각자 "응답 오면 즉시 다음 요청"
→ 동시성이 절대 8을 넘지 못함
→ 시스템이 느려지면 요청 발사 속도도 같이 느려짐
→ 큐가 쌓일 수가 없음 ⇒ 큐잉 지연을 측정할 수 없음
open-loop (포화점 측정용):
응답과 무관하게 "초당 N건" 고정 발사
→ 처리 속도 < 발사 속도면 큐가 무한히 쌓임
→ 여기서 비로소 p99가 폭발하고 포화점이 보인다
즉 이 테스트가 증명한 것은 "동시성 8에서는 여유롭다"이지, "시스템의 한계가 어디인지"가 아닙니다. max_ongoing_requests: 32 설정이 실제로 어떻게 동작하는지는 동시성을 32 이상으로 올리거나 open-loop로 바꿔야 보입니다.
이건 5편의 "성능 측정 모범 사례"와 직결되는 지점이라 거기서 다시 다룹니다.
| 구분 | 내용 | 다운타임 |
|---|---|---|
| A. Prometheus 스크레이프 | PodMonitor 2개(head/worker) + operator ServiceMonitor | 없음 |
| B. Grafana 대시보드 | kuberay 공식 5종을 ConfigMap으로 자동 로드 | 없음 |
| C. Grafana embedding | allow_embedding + 익명 Viewer 허용 | Grafana pod 재시작(수 초) |
| D. Ray Dashboard 연동 | head에 RAY_GRAFANA_HOST 등 env 추가 | vLLM 서빙 수 분 중단 |
D 단계는 head pod 재생성을 유발하므로 서빙이 끊깁니다. 무중단이 필요하면 처음 배포할 때 env를 함께 넣어야 합니다.
보안 주의: C의 익명 Viewer 허용은 해당 네트워크에 접근 가능한 누구나 로그인 없이 Grafana를 조회할 수 있게 만드는 정책 변화입니다. 사설망이라도 명시적으로 판단하고 넘어가야 합니다.
| 역할 | 도구 |
|---|---|
| 인프라 관리 | Kubernetes |
| 모델 호스팅 | Ray Serve 또는 Triton Inference Server |
| 분산 LLM 서빙 + 최적화 | vLLM |
| 나머지 | 가벼운 커스텀 미들웨어 |
여기 나온 라이브러리들은 전부 수년간 프로덕션에서 검증됐고, 참고할 구현체도 많습니다. 움직이는 부품이 많아 보이지만 각각은 이미 성숙한 도구입니다.
세 줄 요약
1. AWS SageMaker를 예로 6단계. 완전 관리형 → 완전 직접 구축 순.
2. 목적은 SageMaker 사용법 튜토리얼이 아니라 벤더가 서빙 옵션을 설계하는 논리를 이해하는 것.
3. 이 논리를 알면 Vertex AI, Azure ML의 유사 스펙트럼도 스스로 판단할 수 있다.
| 단계 | 방식 | 자유도 | 운영 부담 |
|---|---|---|---|
| 1 | Bedrock | 낮음 | 매우 낮음 |
| 2 | SageMaker JumpStart | 조금 높음 | 낮음 |
| 3 | Bring Your Own Model | 중간 | 중간 |
| 4 | Bring Your Own Code | 높음 | 높음 |
| 5 | Bring Your Own Serving Image | 매우 높음 | 매우 높음 |
| 6 | Build Your Own Infrastructure | 최고 | 최고 |
[그림 4-1] 자동차 비유 (조작 포인트: 아래로 갈수록 내가 직접 만지는 부품이 늘어난다)
1. Bedrock 🚕 택시 목적지만 말한다
2. JumpStart 🚗 렌터카 차종은 고르지만 준비된 것 중에서
3. BYO Model 🔧 엔진만 내 것, 차량 플랫폼은 AWS
4. BYO Code ⚙️ 컨테이너는 AWS, 추론 코드는 내가
5. BYO Image 🏭 Docker 이미지까지 직접
6. BYO Infra 🏗️ EKS부터 전부 직접
세 줄 요약
1. 서버가 없다. 요청/token 단위 과금.
2. 3단계면 끝 — API key 생성, 모델 선택, 클라이언트 초기화.
3. 대신 못 하는 게 3가지 명확하다.
os.environ["AWS_BEARER_TOKEN_BEDROCK"] = "..."
client = boto3.client(service_name="bedrock-runtime", region_name="us-west-2")
response = client.converse(
modelId="amazon.nova-lite-v1:0",
messages=[{"role": "user", "content": [{"text": "Hello!"}]}],
)
AWS 인프라가 눈에 안 보이게 처리하는 것: GPU 인스턴스 프로비저닝 → 모델 가중치 로딩 → 추론 실행 → 결과 반환
못 하는 것 3가지
1. 인스턴스 타입 선택 불가
2. 컨테이너/코드 커스터마이징 불가
3. 새 모델을 Bedrock에 배포하는 것 자체가 불가 — 자체 독점 모델이나 남들이 안 올려둔 파인튜닝 버전은 아예 못 씀
| 적합 | 부적합 | |
|---|---|---|
| Bedrock | 챗봇/생성기를 인프라 관리 없이 빠르게 프로토타입, 사전학습 모델로 충분 | 자체 커스텀 모델 배포, 정교한 추론 로직 필요 |
세 줄 요약
1. Bedrock과 결정적 차이 — 내 AWS 계정에 실제 엔드포인트가 뜬다.
2. 그래서 과금이 token당이 아니라 인스턴스 시간당으로 바뀐다.
3. 인스턴스는 내가 고르지만, 그 안에서 뭘 하는지는 여전히 AWS가 정한 대로다.
| Bedrock | JumpStart | |
|---|---|---|
| 모델이 도는 곳 | AWS가 숨긴 인프라 | 내 SageMaker 계정 |
| 과금 | 요청/token당 (서버 없음) | 시간당 인스턴스 요금 |
| 인프라 관리 | 거의 없음 | 인스턴스 타입 직접 선택 |
model = JumpStartModel(
model_id="huggingface-llm-mistral-7b-instruct",
instance_type="ml.g5.2xlarge",
env={"SAGEMAKER_MODEL_SERVER_WORKERS": "1", "SAGEMAKER_CONTAINER_LOG_LEVEL": "20"},
)
predictor = model.deploy(initial_instance_count=1, endpoint_name="my-mistral-endpoint")
response = predictor.predict({"inputs": "Hello, world!"})
deploy()라는 단계가 새로 생겼습니다. 인프라 설정을 지정하고, 그 결과 나온 predictor로 추론하는 2단계 구조입니다.
한계 6가지
세 줄 요약
1. "bring-your-own-model"이지 "bring-your-own-code"는 아니다. 서빙 코드는 안 짠다.
2. JumpStart는 컨테이너를 자동 선택해줬지만, DLC는 어떤 컨테이너를 쓸지 직접 지정한다.
3. 모든 DLC는 포트 8080에서/invocations(POST)와/ping(GET)을 구현해야 한다.
baseimage = sagemaker.image_uris.retrieve(
framework="pytorch", py_version="py310", image_scope="inference",
version="2.0.1", instance_type="ml.g4dn.16xlarge",
)
model = Model(model_data=f"{output_path}/mnist.tar.gz", image_uri=baseimage, name="mnist")
predictor = model.deploy(instance_type="ml.g4dn.16xlarge", initial_instance_count=1, ...)
JumpStart에서 자동이던 이미지 검색 단계가 여기선 명시적입니다.
model = DJLModel(
model_id="meta-llama/Meta-Llama-3.1-8B-Instruct",
env={
"OPTION_TENSOR_PARALLEL_DEGREE": "4",
"OPTION_SERVING_LOADER": "vllm",
"OPTION_MAX_ROLLING_BATCH_SIZE": "128",
}
)
predictor = model.deploy(instance_type="ml.g5.12xlarge", initial_instance_count=1, ...)
[그림 4-2] 3장에서 직접 만진 것 ↔ LMI 설정 대응
OPTION_SERVING_LOADER=vllm → /generate_vllm 엔드포인트로 통합했던 그 vLLM
OPTION_MAX_ROLLING_BATCH_SIZE=128 → 로그로 관찰했던 continuous batching
OPTION_TENSOR_PARALLEL_DEGREE=4 → tensor_parallel_size (우리는 1로 GPU 1장)
AWS의 관리형 LMI 컨테이너 뒤에서 실제로 도는 엔진이 우리가 손으로 통합해본 것과 똑같은 vLLM입니다. 다르는 건 인프라 프로비저닝을 AWS가 해준다는 것뿐입니다.
| 한계 | 내용 |
|---|---|
| 컨테이너 내부 통제 불가 | 프레임워크/드라이버/CUDA/Python/OS가 전부 이미지 태그에 고정 (예: TF 2.19 + CUDA 12.2 + Ubuntu 22.04) |
| 고정 포트/HTTP 계약 | 8080 포트, /invocations(POST) + /ping(GET), 기본 타임아웃(예: 60초) |
| 입출력 포맷 고정 | 커스텀 전/후처리가 필요하면 Option 4로 |
이 HTTP 계약은 사실상 SageMaker가 강제하는 미니 Public API 레이어입니다. 2편의 레이어 1이 벤더 쪽에서는 이런 형태로 나타납니다.
SageMaker 서비스를 안 켜고 ECR에서 이미지만 받아 로컬 GPU에서 재현할 수 있습니다.
| 항목 | 값 |
|---|---|
| DLC 이미지 | pytorch-inference:2.6.0-gpu-py312-cu124-ubuntu22.04-sagemaker-v1.84 |
| TorchServe | 0.12.0 (컨테이너 내장, 토큰 인증 기본 활성화) |
| 호스트 GPU 드라이버 | 595.84 (CUDA 13.2) → 이미지 CUDA 12.4와 호환 |
| 테스트 모델 | ResNet18 (TorchScript 변환 후 .mar 패키징) |
docker pull 763104351884.dkr.ecr.us-west-2.amazonaws.com/pytorch-inference:2.6.0-gpu-py312-cu124-ubuntu22.04-sagemaker-v1.84
docker run -d --name sm-pytorch-local --gpus=1 -p 8080:8080 -p 8081:8081 <이미지>
curl -X POST http://localhost:8080/predictions/resnet18 -T cat1.jpg
# {"282": 0.318, "281": 0.287, "285": 0.171, ...}
# TIGER CAT / TABBY / EGYPTIAN CAT 상위 3개에 87%
필요한 것: AWS 계정(ECR pull 인증용, SageMaker 서비스는 불필요) + Docker + NVIDIA Container Toolkit
주의: CUDA/드라이버 버전이 이미지 태그에 고정돼 있어서, 로컬 GPU 드라이버와 이미지가 요구하는 CUDA가 안 맞으면 실행이 안 됩니다. aws ecr list-images로 실제 존재하는 태그를 먼저 조회하고 호환되는 걸 고르는 게 안전합니다.
이 실습이 확인한 것: model.deploy()가 클라우드에서 하던 일(포트 매핑, 헬스체크 /ping, 모델 로드)을 로컬에서 docker run으로 그대로 흉내낼 수 있다는 것. 즉 SageMaker의 마법은 대부분 오케스트레이션이지 컨테이너 자체가 아닙니다.
세 줄 요약
1. 컨테이너는 AWS 것을 쓰되, 모델 로딩과 추론 로직만 직접 짜서 끼워넣는다.
2.handle()하나가 진입점. 3장의ModelWorker구조와 사실상 동일하다.
3. 대가는 명확 — 이제 코드를 직접 쓰고 유지보수해야 한다. Option 1~3은 코드가 필요 없었다.
# serving.properties
engine=Python
option.tensor_parallel_degree=2
option.rolling_batch=vllm
option.s3url=s3://.../my-own-llm
# model.py — 3개 함수 구조
def initialize(properties): # 모델을 GPU에 로드
model = AutoModel.from_pretrained(model_id)
model.to(device); model.eval()
def run_inference(input_texts, model, tokenizer):
max_batch_size = 128
for i in range(0, len(input_texts), max_batch_size): # 수동 배칭 루프
batch_dict = tokenizer(input_texts[i:i+max_batch_size],
max_length=512, padding=True, truncation=True,
return_tensors='pt').to("cuda")
with torch.no_grad():
outputs = model(**batch_dict)
...
def handle(inputs: Input) -> None: # ★ 진입점
if not model:
model, tokenizer = initialize(inputs.get_properties())
if inputs.is_empty():
# Model server makes an empty call to warmup the model on startup
return None
data = inputs.get_as_json()
res = run_inference(data["inputs"], model, tokenizer)
return Output().add_as_json(res)
3장 single_model_llm_serving | Option 4 model.py |
|---|---|
ModelWorker.__init__의 self.model.to(device) | initialize(properties) |
generate_forward_batch()의 수동 배칭 루프 | run_inference()의 max_batch_size=128 루프 |
server.py의 /predict 핸들러 | handle() (FastAPI 데코레이터 대신 단일 함수 시그니처) |
주목할 프로덕션 패턴 하나:
if inputs.is_empty():
# 모델 서버가 부팅 시 빈 입력으로 한 번 호출해 워밍업
return None
2장에서 개념으로만 봤던 cold start / 워밍업 전략이 코드 레벨에서 이렇게 구현됩니다.
option.rolling_batch=vllm으로 vLLM을 켤 수는 있지만, 그 vLLM의 정확한 버전은 컨테이너가 정한 대로. 원하는 버전을 pip install할 자유는 없음/invocations, /ping 계약 그대로)세 줄 요약
1. 컨테이너 이미지를 통째로 직접 가져온다. 내부는 100% 사용자 책임.
2. SageMaker는 컨테이너를 "8080 포트에서/ping과/invocations를 지키는 블랙박스"로만 취급한다.
3. 분산 서빙(분산 KV 캐싱, 프롬프트 캐싱, 인스턴스 간 요청 라우팅)이 필요하면 이걸로도 부족하다.
할 수 있는 것
책임지는 것
언제
세 줄 요약
1. Option 1~5는 전부 SageMaker의 엔드포인트 관리를 어느 정도 계속 쓴다. Option 6는 그것마저 벗어난다.
2. 7개 레이어 전체를 사실상 직접 소유하게 된다.
3. 그래도 맨땅은 아니다 — AWS 관리형 프리미티브는 계속 활용한다.
[그림 4-3] Option 5와 6의 경계
Option 5: [내 Docker 이미지] ← SageMaker가 배포·스케일링·엔드포인트 URL 관리
Option 6: [내 Docker 이미지] ← 내가 EKS 위에서 배포·스케일링·라우팅까지 전부
직접 조립하는 것: EKS + GPU Nodes + vLLM/Triton/Ray Serve/KServe + Karpenter + Prometheus + Gateway
떠안는 것: 트래픽 관리, 오토스케일링, 보안, 관측성, 비용 통제
계속 쓰는 AWS 프리미티브: EKS, ALB/NLB, ECR, S3, EFS/EBS, IAM/IRSA, Karpenter/Cluster Autoscaler, CloudWatch
Kubernetes 애드온: Prometheus/Grafana, OpenTelemetry, Fluent Bit, CNI/NetworkPolicy, Gatekeeper/Kyverno, Argo CD/Rollouts
언제 쓰나 (4가지 시나리오)
| 시나리오 | 내용 |
|---|---|
| 서빙 런타임 완전 통제 | 커널, 배칭, 토크나이제이션, 커스텀 사이드카, 비표준 API(gRPC, SSE) — 고정된 /invocations 계약으로는 안 되는 것들 |
| 공격적 비용/성능 튜닝 | 스팟 GPU, GPU 공유(MIG/MPS), 모델 빈패킹, TPS 기반 커스텀 오토스케일링 |
| 엄격한 컴플라이언스 | 프라이빗 클러스터, VPC 전용 egress, tenant별 격리, 커스텀 감사 추적 |
| 최신 하드웨어/특수 최적화 | speculative decoding, KV cache 샤딩, 고급 라우팅 — 매니지드에 아직 없는 것들 |
세 줄 요약
1. 대부분의 팀은 중간에 있다 — 벤더 플랫폼 + 커스텀 핸들러/오토스케일링 신호/라우팅/비용 제어.
2. 직접 만드는 법을 알면 벤더를 안 쓰더라도 이득이 4가지 있다.
3. 성공적인 전략은 유연하다. 스펙트럼 상의 위치는 계속 바뀔 것을 예상해야 한다.
① 벤더 기능 해독
OPTION_SERVING_LOADER=vllm, OPTION_MAX_ROLLING_BATCH_SIZE를 보고 "3장에서 만진 텐서 병렬화·continuous batching이구나"라고 즉시 알아보는 능력. @serve.multiplexed를 보고 "손으로 짠 ModelManager의 LRU 캐시네"라고 대응시키는 능력.
② 트레이드오프 정량화
Option 3 vs Option 4 같은 선택지 사이의 손익을 실제로 계산할 수 있는 능력.
③ 커스터마이징 (80/20 원칙)
벤더 기본값을 80% 그대로 쓰고, 통제가 필요한 20%만 외과수술 하듯 교체. DLC 로컬 실습이 정확히 이 패턴이었습니다 — TorchServe 엔진, 8080 계약, 기본 핸들러는 그대로 두고 딱 두 곳(커스텀 .mar 주입, config.properties 토큰 인증)만 수술.
④ 디버깅
벤더 솔루션은 블랙박스처럼 동작하고, 내부 로직 문서가 부족하며, 로깅도 다 안 잡힙니다.
| 상황 | 선택 |
|---|---|
| SLO 충족 + 비용 허용 + 기능 속도가 최우선 | 벤더 관리형 유지 |
| 일부 엔드포인트에만 특별한 배칭/라우팅/튜닝/tenant 격리 필요 | 하이브리드 |
| 하드웨어·런타임·네트워킹 직접 통제 필요, 또는 비용이 높아 세밀 튜닝 필요 | BYO |
| 볼륨이 낮은 수준에서 안정되거나, 시스템 복잡성이 성과를 못 냄 | 벤더 관리형으로 복귀 |
마지막 항목이 중요합니다. 되돌아가는 것도 전략입니다.
책의 예시:
Bedrock: 입력 100만 token당 $0.10
전용 인스턴스: 시간당 $1.172
$1.172/hr ÷ $0.10/1M token = 11.72M token/hr
11,720,000 ÷ 3,600 = 약 3,255 token/s
→ 초당 약 3,255 token 이상을 꾸준히 처리하면 직접 호스팅이 저렴
여기서 반드시 짚어야 할 것: 이 3,255라는 숫자를 그대로 의사결정에 쓰면 위험합니다. 세 가지 보정이 필요합니다.
보정 ①: 가동률(utilization)
인스턴스는 24시간 과금되지만 트래픽은 주야 편차가 있습니다. 평균 가동률이 30%라면 실질 손익분기점은 약 3배 높아집니다. "피크 시점 3,255 token/s"가 아니라 "24시간 평균 3,255 token/s"여야 합니다.
보정 ②: 출력 token 가격
이 계산은 입력 token 단가만 씁니다. 출력 token은 보통 입력의 3~5배 단가입니다. 출력이 많은 워크로드(생성 위주)라면 실질 손익분기점은 훨씬 낮아집니다 — 즉 직접 호스팅이 더 빨리 유리해집니다.
보정 ③: 운영 인건비
전용 인스턴스에는 시간당 $1.172 외에 온콜, 업그레이드, 장애 대응 비용이 붙습니다. 이건 계산식에 안 들어가지만 실제로는 가장 큰 항목일 수 있습니다.
[그림 4-4] 보정 방향 (조작 포인트: 가동률과 출력 비중을 바꿔보기)
기본 계산 3,255 token/s
× 가동률 30% 보정 → 약 10,850 token/s (직접 호스팅이 불리해짐)
÷ 출력 비중 보정 → 낮아짐 (직접 호스팅이 유리해짐)
+ 인건비 → 더 높아짐 (직접 호스팅이 불리해짐)
세 보정이 서로 반대 방향으로 작용하므로, 자기 워크로드의 실제 숫자를 넣어봐야만 답이 나옵니다. 책의 숫자는 계산 방법의 예시지 결론이 아닙니다.
| 단계 | 옵션 | 과금 |
|---|---|---|
| 초기(검증) | Option 1 (Bedrock) | 입력 100만 token당 $0.10 |
| 사용량 증가 | Option 2/3 (JumpStart/DLC) | 인스턴스 시간당 $1.172 |
| 처리량↑·지연↓ 요구 심화 | Option 4/5/6 | 커스터마이징 수준에 따라 |
세 줄 요약
1. 아키텍처의 우아함만으로는 부족하다. 성능이 실현 가능성·사용자 경험·비용을 결정한다.
2. 에이전트는 체인에서 지연을 증폭시키고, 엔터프라이즈는 확장성과 비용을 저울질하고, build vs cloud는 결국 "예산 내 SLO 충족 가능한가"로 귀결된다.
3. 그래서 지표는 부수적인 게 아니라 방향을 제시하는 원칙이다.
핵심 지표는 두 축입니다.
세 줄 요약
1. TTFT는 prefill 단계에, ITL은 decode 단계에 대응한다.
2. E2E는 그 둘의 합에 시스템 오버헤드까지 얹은 것.
3. 어느 지표가 중요한지는 유스케이스가 정한다. 하나만 보면 반드시 틀린다.
| 지표 | 정의 | 대응 단계 |
|---|---|---|
| E2E latency | 요청 수신부터 전체 응답 완료까지. 큐 대기, 네트워크, 라우팅, 스케일링 오버헤드 등 시스템 요인 포함 가능 | 전체 |
| TTFT | 요청 수신부터 첫 token 발행까지. 사용자가 체감하는 "응답 속도" | prefill |
| ITL / TPOT | 첫 token 이후 각 token 생성 간격. 보통 비교적 일정 | decode |
[그림 5-1] E2E 안의 TTFT와 ITL (조작 포인트: 입력 길이와 출력 길이를 따로 늘려보기)
요청 도착
│
├─ tokenize (입력)
├─ ███████████ prefill ┐
├─ █ decode (첫 token) ├─ TTFT
├─ detokenize (첫 token) ┘
│
├─ █ decode → detokenize ┐
├─ █ decode → detokenize ├─ ITL × (N-1)
├─ █ decode → detokenize │
├─ ... ┘
▼
전체 응답 완료
├───────────────── E2E latency ─────────────────┤
읽는 법:
입력을 늘리면 → prefill 구간이 길어짐 → TTFT ↑ (ITL은 거의 그대로)
출력을 늘리면 → decode 구간이 길어짐 → E2E ↑ (TTFT는 그대로)
가정: ITL이 일정, 총 생성 token N, 토크나이즈/디토크나이즈 시간은 무시(실제로도 추론 대비 미미).
E2E latency = TTFT + ITL × (N − 1)
TTFT = 입력 토큰화 + prefill + 첫 token decode + 첫 token detokenize
≈ prefill + 첫 token decode
ITL = 새 token 1개 decode + detokenize
≈ 새 token 1개 decode
| 유스케이스 | 최우선 지표 | 이유 |
|---|---|---|
| 에이전틱 워크플로우 | E2E | 다운스트림이 전체 출력에 의존. 중간에 첫 token만 빨리 나오는 건 무의미 |
| 챗봇 (스트리밍) | TTFT | 응답성 그 자체 |
| 출력이 매우 긴 작업 | ITL | ITL이 높으면 "느릿느릿하다"고 체감 |
에이전틱 케이스를 구체적으로: 1편의 Knowledge Agent가 정확히 이 경우였습니다.
generate_analysis는generate_summary의 완성된 전체 출력을 context로 받아야 시작할 수 있었습니다.
스트리밍이 아무 의미가 없고, 각 단계의 E2E가 다음 단계를 블로킹합니다.
중요: 모든 최적화가 세 지표를 동시에 개선하지 않습니다. 타협이 필요하고, 그래서 어떤 기법을 고를지는 유스케이스가 결정합니다.
세 줄 요약
1. RPS/RPM은 요청 패턴에 민감해서, 트래픽 패턴이 다른 두 워크로드를 직접 비교하면 무의미하다.
2. TPS는 출력 token만 센다. 입력이나 입출력 합산이 아니다.
3. TPS는 인위적으로 부풀릴 수 있다. 벤더 벤치마크를 볼 때 측정 조건을 의심해야 한다.
| 지표 | 뜻 |
|---|---|
| RPS / RPM | 일정 시간 동안 처리 가능한 요청 수 |
| TPS | 초당 생성되는 출력 token 수. 입력 token이나 입출력 합산 아님 |
RPS/RPM의 약점 — 요청 패턴에 민감
긴 입력과 긴 출력이 성능에 부정적 영향을 주는데, 요청 단위로만 세면 이 차이가 안 보입니다. 그래서 RPS/RPM은 입력 길이, 출력 길이, 동시 사용자 수에 크게 좌우됩니다.
[그림 5-2] 같은 RPS가 전혀 다른 부하일 수 있다
워크로드 A: 100 RPS × (입력 50 token, 출력 20 token) = 초당 2,000 출력 token
워크로드 B: 100 RPS × (입력 4000 token, 출력 500 token) = 초당 50,000 출력 token
+ prefill 부하 80배
→ 같은 "100 RPS"인데 GPU 부하는 자릿수가 다르다
서로 다른 트래픽 패턴을 가진 두 워크로드 간 RPS 비교는 공정하지 않습니다.
TPS의 약점 — 부풀릴 수 있다
TPS는 LLM 분야 표준 지표이고 token당 비용 계산에도 쓰이지만, 인위적으로 높일 방법이 있습니다.
| 조작 방법 | 원리 |
|---|---|
| 입력 길이를 줄인다 | prefill이 짧아져 TTFT가 크게 감소 → 요청당 작업 부하 감소 → TPS가 실제보다 높게 보임 |
| 배치를 최적 구성한다 | 배치 크기를 키우거나 입출력 길이를 균일하게 맞추면 GPU 유휴 시간이 줄어 TPS 개선 |
벤더가 발표하는 TPS 숫자를 볼 때 "어떤 조건에서 측정했는가"를 따져야 합니다. 입력을 짧게 잘랐는지, 배치를 인위적으로 최적 구성했는지에 따라 같은 모델도 TPS가 크게 달라 보입니다.
4편의 "직접 만드는 법을 알면 좋은 이유 ① 벤더 기능 해독"이 바로 여기서 실전에 쓰입니다. 밑바닥을 알아야 화려한 숫자 뒤의 측정 조건을 의심하고 검증할 수 있습니다.
세 줄 요약
1. 목표를 먼저 정하고, E2E를 TTFT/ITL로 분해하고, 실제 트래픽을 그대로 흉내낸다.
2. 한 번에 노브 하나만 바꾸고, 하드웨어 활용률을 같이 본다.
3. 배포 후에도 계속 모니터링하고 주기적으로 재실행한다.
| # | 원칙 | 요점 |
|---|---|---|
| 1 | 지연 vs 처리량 트레이드오프 파악 | 오프라인/배치는 처리량, 챗봇/실시간 에이전트는 지연이 핵심 |
| 2 | "충분히 좋은" 목표 설정 | 1초→0.5초가 체감상 의미 없다면 그 노력을 처리량/비용으로 돌린다 |
| 3 | E2E를 TTFT/ITL로 분해 | 출력 길이가 제한된 시스템이면 TTFT가 ITL보다 중요할 수 있다 |
| 4 | 실제 트래픽 패턴 시뮬레이션 | 긴 프롬프트+짧은 답변과 짧은 프롬프트+긴 답변은 완전히 다르다. 트래픽은 절대 균일하지 않다 |
| 5 | 한 번에 노브 하나만 | 여러 개를 동시에 바꾸면 효과를 분리할 수 없다 |
| 6 | 하드웨어 활용률 모니터링 | 병목이 모델 때문인지 하드웨어 한계 때문인지 구분 |
| 7 | 지표를 부풀리지 말 것 | 3장의 TPS 조작 가능성 |
| 8 | 프로덕션에서 지속 모니터링 | 배포 후 사용자 행동 변화로 인한 급증/실패까지 |
| 9 | 테스트 스위트 주기적 재실행 | 회귀 방지, 스케일링 테스트, A/B 테스트 |
3장 실습에서 확인했던 것:
모델은 0.24 GiB인데 vLLM의
gpu_memory_utilization=0.9기본값 때문에 14.7 GB를 점유한다.
nvidia-smi를 안 봤으면 "메모리가 왜 이렇게 많이 잡히지"를 영원히 몰랐을 상황입니다. 지표 없이 튜닝하면 추측만 하게 됩니다.
3편의 RayService 부하 테스트를 다시 봅시다.
| 지표 | 값 |
|---|---|
| p50 / p95 / p99 | 2.02 s / 2.10 s / 2.10 s |
| 처리량 | 3.92 req/s |
| 실패 | 0건 |
편차가 0.08초. "매우 안정적"으로 읽히지만, 부하 방식 때문에 그렇게 나온 것입니다.
[그림 5-3] closed-loop vs open-loop (조작 포인트: 발사 방식을 바꿔보기)
■ closed-loop (그 테스트가 쓴 방식)
워커 8개가 각자 "응답 오면 즉시 다음 요청"
발사 속도 ──▶ 처리 속도에 자동 연동
시스템이 느려지면 발사도 느려짐
→ 동시성이 절대 8을 초과하지 않음
→ 큐가 쌓일 수 없음
⇒ 큐잉 지연도, 포화점도 측정 불가
■ open-loop (포화점 측정용)
응답과 무관하게 "초당 N건" 고정 발사
발사 속도 ──▶ 고정
처리 속도 < 발사 속도가 되는 순간
→ 큐가 무한히 쌓임
→ p99가 폭발하고 실패가 나기 시작
⇒ 여기서 비로소 시스템의 한계가 보인다
그 테스트가 증명한 것: 동시성 8에서는 여유롭다. GPU가 병목이며 예측 가능한 속도로 소화한다.
증명하지 못한 것: 시스템의 한계는 어디인가. max_ongoing_requests: 32가 실제로 어떻게 작동하는가.
실무에서 SLO를 정하려면 후자가 필요합니다. 동시성을 32 이상으로 올리거나 open-loop로 바꿔야 보입니다.
| 목적 | 내용 |
|---|---|
| 회귀 방지 | 새 업데이트가 지연/처리량을 악화시키지 않는지 |
| 스케일링 테스트 | 피크 트래픽 시뮬레이션 |
| A/B 테스트 | 새 최적화 기법을 프로덕션 배포 전에 데이터로 비교 |
세 줄 요약
1. 에이전트가 기본 패턴이 되면서 서빙 요구사항이 근본적으로 바뀌었다.
2. 그 요구를 감당하는 구조가 계층형 아키텍처이고, 오픈소스로도 클라우드로도 구현 가능하다.
3. 어느 쪽을 고르든 판단 근거는 측정된 지표다.
| 주제 | 핵심 |
|---|---|
| 에이전틱 서빙 | 질문 1개 → API 호출 4회. RAG(입력 보강) vs CAG(실행 최적화)는 조합 관계 |
| 엔터프라이즈 아키텍처 | 7개 레이어. 기술 문제이기 이전에 조직 설계 문제 |
| 오픈소스 스택 | Kubernetes(인프라) + Ray Serve(호스팅) + vLLM(엔진) |
| Build vs Cloud | 이분법이 아니라 스펙트럼. 대부분은 중간에 있고, 위치는 계속 옮겨간다 |
| 성능 지표 | Latency 3개(E2E/TTFT/ITL) + Throughput 2개(RPS/TPS). 공정한 벤치마킹이 전제 |
단일 요청-응답 구조와 달리, 제어 루프 안에서 반복 호출되는 것이 서빙 시스템에 어떤 부담을 주는가?
바뀌는 건 "부하가 늘어난다"가 아닙니다. 부하의 성질이 바뀝니다. 다섯 가지로 나뉩니다.
1편의 실측: 질문 1개 → API 호출 4회 (chat 3 + embedding 1).
[그림 6-1] 증폭 (조작 포인트: 계획 단계 수를 2 → 5로)
사용자 요청 1건
↓
계획 LLM 호출 ×1
검색 embedding ×k (action 중 RAG를 쓰는 개수)
action별 LLM 호출 ×n
↓
총 백엔드 왕복 = 1 + k + n
Figure 4-2의 3단계 계획이면 호출이 더 늘어납니다. 그리고 실무의 에이전트는 이보다 훨씬 깊습니다 — 도구 호출 루프가 조건 만족까지 반복되고, 여러 에이전트가 계층으로 쌓이면 곱셈이 됩니다.
책이 "체인된 호출 간 지연이 늘어난다"고만 쓴 부분인데, 산술을 보면 왜 심각한지 명확해집니다.
한 번의 호출이 p95를 넘길 확률이 5%라면, N번 연쇄에서 적어도 한 번 p95를 넘길 확률은:
1 − (1 − 0.05)^N
N = 1 → 5.0 %
N = 4 → 18.5 %
N = 10 → 40.1 %
N = 20 → 64.2 %
[그림 6-2] 왜 평균이 아니라 꼬리를 봐야 하는가
단일 요청: ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░ 95%는 빠르고 5%만 느림
→ 대부분의 사용자는 만족
4단계 체인: ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░ 18.5%가 한 번은 느린 구간을 만남
20단계 체인: ▓▓▓▓▓▓▓░░░░░░░░░░░░░ 64%가 느림 ← 이게 "평균적 경험"이 됨
시사점: 에이전트 워크로드에서는 평균 latency를 개선하는 최적화보다 p99를 깎는 최적화가 압도적으로 가치 있습니다. 단일 요청 서빙이라면 반대일 수도 있는데, 여기선 아닙니다. 이건 최적화 우선순위를 정할 때 실제로 판단을 뒤집는 기준입니다.
단일 요청 서빙은 사용자 도착이 대략 독립적입니다(Poisson에 가까움). 에이전트는 다릅니다.
[그림 6-3] 도착 패턴 변화
단일 요청: · · · · · · · · · 독립적, 평활
에이전트: ···· ···· ···· 사용자 1명 = 상관된 호출 묶음
└4개┘ └4개┘ └4개┘
같은 사용자 수여도 큐 길이의 분산이 훨씬 큽니다. 대기행렬 이론상 도착이 상관되면 같은 평균 이용률에서 대기 시간이 더 나빠집니다. 결과적으로 평균 GPU 사용률을 기준으로 용량을 잡으면 부족합니다.
context chaining 때문입니다.
[그림 6-4] 단계가 진행될수록 입력이 커진다 (조작 포인트: 단계 수 증가)
1단계: 검색 문서(5개) + 질문 → 입력 X token
2단계: 1단계 출력 + 질문 → 입력 X' token
3단계: 2단계 출력 + ... → 입력 X'' token
↑ 매 단계 앞 단계 출력이 입력에 얹힘
prefill은 compute-bound입니다. 입력이 커지면 TTFT가 직접 나빠지고, 이게 체인 전체에 누적됩니다. 그래서 에이전틱 워크로드에서는:
--enable-prefix-caching)의 가치가 급등합니다. 같은 system prompt와 도구 정의가 매 호출마다 반복 전송되기 때문입니다.--enable-chunked-prefill)이 필수에 가까워집니다. 긴 프롬프트 하나가 다른 사용자의 decode 스트림을 멈추게 두면 안 됩니다.이게 실무에서 가장 자주 틀리는 지점입니다.
| 단일 요청 서빙 | 에이전틱 서빙 | |
|---|---|---|
| SLO 단위 | 호출 1건 | 세션(사용자 요청) 1건 |
| 핵심 지표 | TTFT | E2E |
| 스트리밍 가치 | 큼 | 중간 단계는 거의 없음 |
"p99 TTFT 500ms 이내"라는 SLO를 만족해도, 4번 연쇄하면 사용자는 최악 2초를 기다립니다. 호출 단위 SLO가 전부 초록불인데 사용자 경험은 나쁠 수 있습니다. 세션 단위 추적(trace ID로 묶기)이 없으면 이걸 볼 수조차 없습니다.
취소 전파(cancellation propagation)
사용자가 중간에 창을 닫아도, 이미 발사된 3단계 호출은 GPU를 계속 태웁니다. 체인 전체에 취소를 전파하는 메커니즘이 없으면 순수 낭비입니다.
stateful 라우팅
같은 세션의 후속 호출은 이전 KV cache가 있는 replica로 보내는 게 훨씬 효율적입니다. 3편에서 본 Ray Serve의 "캐시 지역성/세션 친화성 기반 커스텀 라우팅"이 정확히 이 요구에 대응합니다. 단일 요청 서빙에서는 라운드로빈으로 충분했지만, 에이전틱에서는 라우팅이 성능 요소가 됩니다.
에이전트가 늘리는 건 GPU 부하만이 아닙니다. 호출 1번마다 tokenize / HTTP 파싱 / JSON 직렬화 / detokenize라는 CPU 작업이 따라붙습니다. 4배 호출이면 CPU 부하도 4배입니다.
이 CPU 작업이 GPU 스텝 사이에 끼면 GPU bubble이 생깁니다. 3장에서 프로세스를 분리한 이유가 여기서 4배로 중요해집니다 — GPU가 배치 N을 돌리는 동안 CPU가 배치 N+1을 준비하는 오버랩이 없으면, 에이전트 워크로드에서 GPU 유휴 시간이 눈에 띄게 커집니다.
| 레이어 | 역할 | 핵심 트레이드오프 | 오픈소스 구현 |
|---|---|---|---|
| 1. Public API | 인증·과금·rate limit·라우팅 | 보안·정확한 과금 ↔ 요청당 오버헤드 | FastAPI + nginx Ingress + JWT/API key + Redis |
| 2. Resource Mgmt | 하드웨어 배분, 예산 | 가동률 ↔ 여유 용량(버스트 대응) | Kubernetes scheduler, HPA, node pool, Karpenter |
| 3. Model Selection | 요청별 모델 결정 | 비용 ↔ 품질 ↔ 지연 (셋 중 둘) | 라우팅 미들웨어, canary weight, speculative decoding 분기 |
| 4. Distributed Serving | 멀티GPU/노드 + 분산 캐싱 | 병렬화 이득 ↔ 통신 비용 | Ray Serve (TP/PP, prefill-decode 분리, cache-aware routing) |
| 5. Core Inference | 실제 모델 실행 | 처리량 ↔ 지연 (배칭 크기) | vLLM / Triton / TensorRT-LLM / SGLang |
| 6. Model Optimization | 재학습 없는 성능 개선 | 속도·메모리 ↔ 품질 | 양자화(AWQ/GPTQ), speculative decoding, LMCache |
| 7. Model | 모델 공급·분류·버전 관리 | 최신성 ↔ 안정성 | 모델 레지스트리, S3/HF Hub, 태깅 |
레이어 1 — Public API
역할은 tenant 식별이 전부라고 해도 과언이 아닙니다. JWT든 API key든 결국 tenant 하나로 수렴하고, 이후 모든 정책(quota, 라우팅, 모델 선택, 과금)이 이 값에 매달립니다.
트레이드오프는 관문의 두께입니다. 검증을 촘촘히 하면 안전하지만 요청당 지연이 붙습니다. 그래서 rate limit을 두 겹으로 나눕니다 — Ingress의 limit-rps가 값싼 거친 방어선, 애플리케이션의 rate_limit(tenant)가 정교한 방어선.
레이어 2 — Resource Management
트레이드오프의 본질은 가동률과 여유의 상충입니다. GPU를 빈틈없이 채우면 비용 효율이 좋지만 버스트가 오면 큐가 터집니다. Q1의 ③에서 봤듯 에이전틱 워크로드는 버스트가 심해서, 평균 사용률 기준 용량 산정이 특히 위험합니다.
레이어 3 — Model Selection
Quality / Latency / Cost 삼각형에서 둘만 가져갈 수 있습니다. 판단 근거는 요청 자체에서 뽑습니다 — 예제는 max_new_tokens > 1024로 speculative decoding을 분기했습니다.
여기서 canary 판정이 tenant override보다 먼저 오는 순서에 주목할 가치가 있습니다. 카나리 샘플을 전체 트래픽에서 무작위로 뽑아야 특정 고객군에 편향되지 않은 대표성을 얻기 때문입니다.
레이어 4 — Distributed Serving
트레이드오프는 병렬화 이득 vs 통신 비용입니다. TP는 같은 노드 안이 기본이고, 노드를 넘어가면 PP를 씁니다. GPU를 늘려도 All-Reduce/Reduce-Scatter 통신이 병목이 되면 성능이 선형으로 안 늘고, 어떤 지점부터는 오히려 손해입니다.
레이어 5 — Core Inference
이 레이어의 트레이드오프가 3장 전체의 주제였습니다 — batch를 키우면 throughput↑, TTFT↑, ITL↑. 그리고 여기서 나오는 cold start(모델 load/unload)가 위 레이어들의 스케일링 정책을 제약합니다.
[그림 6-5] 레이어 ↔ 컴포넌트 매핑 (조작 포인트: 어느 레이어를 생략할지 결정해보기)
┌──────────────────────────────────────────────────────┐
│ 1. Public API → FastAPI + nginx Ingress + Redis │
├──────────────────────────────────────────────────────┤
│ 2. Resource Mgmt → Kubernetes │ ← 이 한 층이
│ 1의 라우팅/네트워킹 → Kubernetes │ 둘을 커버
├──────────────────────────────────────────────────────┤
│ 3. Model Selection → 라우팅 미들웨어 (직접 작성) │
├──────────────────────────────────────────────────────┤
│ 4. Distributed → Ray Serve (KubeRay로 K8s 통합) │
├──────────────────────────────────────────────────────┤
│ 5. Core Inference → vLLM / Triton │
└──────────────────────────────────────────────────────┘
Kubernetes가 바닥인 이유: 핵심 기능(배포/스케일링/관리 자동화)과 거대한 생태계(메트릭, 로깅, 네트워킹, 인가, 하드웨어 관리). 그래서 AWS/GCP/Azure와 OpenAI/Anthropic 양쪽 모두의 백본으로 채택됐습니다.
Ray Serve가 대체하는 것들
| 3장에서 손으로 만든 것 | Ray Serve |
|---|---|
ModelWorker + 수동 배칭/큐잉 | @serve.deployment + AsyncLLMEngine 위임 |
ModelManager의 OrderedDict LRU | @serve.multiplexed(max_num_models_per_replica=N) |
| HPA YAML의 replica/리소스 선언 | 데코레이터 인자 num_replicas, ray_actor_options |
model_id 필드 라우팅 | 헤더 ray_serve_multiplexed_model_id |
KubeRay 선택: 프로덕션 모델 서빙에는 RayService입니다. RayCluster만 쓰면 Serve 앱을 직접 serve run으로 올리고 pod이 죽으면 수동 재배포해야 하는데, GPU 워커 스펙 작성 난이도는 둘이 동일합니다. 즉 RayCluster를 고를 이유가 없습니다.
① 오토스케일링 이중화
Ray Serve의 autoscaling_config와 Kubernetes HPA를 동시에 켜면 서로 싸웁니다. Ray가 replica를 늘리려는데 K8s가 pod을 줄이는 식입니다. 한쪽만 주체로 정해야 합니다. RayService에서는 Ray가 Serve replica를, KubeRay가 워커 pod을 담당하는 계층 구조로 정리하는 게 일반적입니다.
② GPU replica에 CPU 기반 HPA를 쓰면 안 됨
3편의 HPA 예제는 averageUtilization: 70 (CPU)인데, 이건 API 서버용이라 맞습니다. GPU 추론 replica에 같은 방식을 쓰면 부하를 전혀 반영하지 못합니다. GPU는 CPU처럼 오버서브스크립션도 안 됩니다. 추론 replica는 target_ongoing_requests 같은 요청 기반 지표로 스케일해야 합니다.
③ 레이어를 나눌 때마다 홉이 늘어난다
2편에서 짚은 부분입니다. 레이어 하나당 RPC 1~수 ms. TTFT 목표가 200ms면 무시 가능하지만 30ms면 무시 불가입니다.
계층형 아키텍처는 조직 확장성을 사기 위해 latency budget을 지불하는 거래입니다. 대규모 조직이 아니라면 7개 레이어를 다 만들 필요가 없고, 만들지 않는 게 낫습니다. 레퍼런스 아키텍처는 도달 목표지 시작점이 아닙니다.
기준 ① 사용 편의성 (개발 속도)
AWS 컴포넌트를 많이 쓸수록 빨리 띄웁니다. 하지만 개발 속도는 여러 요인 중 하나일 뿐이고, 운영/유지보수 비용도 똑같이 중요합니다.
판단선: "이 모델이 3개월 뒤에도 필요한가?" 아니라면 Bedrock으로 끝내는 게 맞습니다. 검증 단계에서 인프라를 만드는 건 대부분 조기 최적화입니다.
기준 ② 비용 — 손익분기점 계산과 그 보정
책의 예시:
Bedrock: 입력 100만 token당 $0.10
전용 인스턴스: 시간당 $1.172
$1.172 ÷ $0.10/1M = 11.72M token/hr ÷ 3,600 = 약 3,255 token/s
이 숫자를 그대로 쓰면 안 됩니다. 세 가지 보정이 필요합니다.
| 보정 | 방향 | 이유 |
|---|---|---|
| 가동률 | 손익분기점 상승 | 인스턴스는 24h 과금, 트래픽은 주야 편차. 평균 가동률 30%면 실질 임계값이 약 3배 |
| 출력 token 단가 | 손익분기점 하락 | 계산이 입력 단가만 씀. 출력은 보통 3~5배 단가라 생성 위주 워크로드는 더 빨리 유리 |
| 운영 인건비 | 손익분기점 상승 | 온콜, 업그레이드, 장애 대응. 계산식에 없지만 실제로 가장 클 수 있음 |
핵심: "피크 3,255 token/s"가 아니라 "24시간 평균 3,255 token/s"여야 합니다. 세 보정이 서로 반대 방향이라, 자기 워크로드 숫자를 넣어봐야만 답이 나옵니다.
기준 ③ 커스터마이징 — "무엇이 막혔는가"로 판단
추상적으로 "유연성이 필요하다"가 아니라, 구체적으로 어느 옵션에서 막혔는지로 결정합니다.
[그림 6-6] 막힌 지점 = 필요한 옵션 (조작 포인트: 자기 요구사항을 여기 대입)
"내 모델이 카탈로그에 없다" → Option 3 (DLC)
"CUDA/프레임워크 버전을 골라야 한다" → Option 3
"전처리가 컨테이너 기본 동작으로 안 된다" → Option 4 (Script Mode)
"vLLM 특정 버전을 써야 한다" → Option 5 (BYO Image)
"HTTP 계약 자체를 바꿔야 한다(gRPC/SSE)" → Option 5
"인스턴스 간 KV cache를 공유해야 한다" → Option 6 (BYO Infra)
"스팟 GPU/MIG로 비용을 깎아야 한다" → Option 6
기준 ④ 트래픽 규모 — 규모가 아니라 안정성
절대 규모보다 안정성이 중요합니다. 트래픽이 예측 가능하고 꾸준하면 전용 인스턴스가 유리하고(가동률이 높으니), 스파이키하면 서버리스가 유리합니다(유휴 과금이 없으니). 같은 월 총량이어도 분포에 따라 답이 반대가 됩니다.
| 상황 | 선택 |
|---|---|
| SLO 충족 + 비용 허용 + 기능 속도 최우선 | 벤더 관리형 유지 |
| 일부 엔드포인트만 특별한 배칭/라우팅/tenant 격리 필요 | 하이브리드 |
| 하드웨어·런타임 직접 통제 필요, 또는 비용 세밀 튜닝 필요 | BYO |
| 볼륨이 낮은 수준에서 안정되거나, 복잡성이 성과를 못 냄 | 벤더 관리형으로 복귀 |
마지막이 실무에서 가장 자주 빠뜨리는 선택지입니다. 되돌아가는 것도 전략이고, 그러려면 API 계약을 안정적으로 유지해둬야 합니다.
Latency 3개
| 지표 | 어떤 판단에 쓰나 |
|---|---|
| E2E | 에이전틱 워크플로우의 SLO. 세션 단위로 측정 |
| TTFT | 챗봇 UX. prefill 최적화(prefix caching, chunked prefill)의 효과 측정 |
| ITL | 긴 출력의 체감 속도. decode 최적화(배칭, 양자화)의 효과 측정 |
E2E latency = TTFT + ITL × (N − 1)
Throughput 2개
| 지표 | 주의점 |
|---|---|
| RPS / RPM | 트래픽 패턴이 다르면 비교 무의미. 입력/출력 길이를 함께 명시해야 의미가 생김 |
| TPS | 출력 token만 셈. 입력을 짧게 자르거나 배치를 균일하게 맞추면 부풀릴 수 있음 |
함께 봐야 하는 것: GPU/CPU/메모리 활용률, KV cache hit rate, queue time, OOM 발생 여부
지표를 아는 것보다 어떻게 재느냐가 결론을 바꿉니다.
① closed-loop로는 포화점을 못 찾는다
3편 부하 테스트를 다시 봅시다.
| 지표 | 값 |
|---|---|
| p50 / p95 / p99 | 2.02 s / 2.10 s / 2.10 s |
| 처리량 | 3.92 req/s |
| 실패 | 0건 |
편차 0.08초를 "안정적"으로 읽으면 절반만 맞습니다. 워커 8개가 각자 "응답 오면 다음 요청"을 던지는 closed-loop 방식이라, 동시성이 8을 절대 넘지 못하고 따라서 큐가 쌓일 수 없습니다.
SLO를 정하려면 open-loop(응답과 무관하게 초당 N건 고정 발사)로 부하를 올려서 p99가 꺾이는 지점을 찾아야 합니다. 그 지점이 실제 용량이고, 그래야 "몇 대가 필요한가"를 답할 수 있습니다.
② 벤더 벤치마크는 조건을 먼저 확인한다
TPS는 입력을 짧게 자르거나 배치를 인위적으로 최적 구성하면 부풀릴 수 있습니다. 벤더 숫자를 볼 때 물어야 할 것: 입력/출력 길이는? 배치 구성은? 동시성은? closed-loop인가 open-loop인가?
밑바닥을 알아야 이 질문을 할 수 있습니다. 그리고 그게 "직접 만드는 법을 아는 것"의 실질적 가치입니다 — 직접 만들지 않더라도.
③ 한 번에 노브 하나만
여러 설정을 동시에 바꾸면 효과를 분리할 수 없습니다. 이건 원칙이라기보다 실패 경험에서 나오는 규칙입니다.
[그림 6-7] 의사결정 순서
1. 지금 SLO를 만족하는가?
아니오 → 어느 지표가 미달인가? (E2E / TTFT / ITL / RPS)
→ 그 지표를 개선하는 기능이 현재 옵션에 있는가?
없다 → 한 단계 아래 옵션으로
예 ↓
2. 비용이 허용 범위인가?
아니오 → 손익분기점을 (가동률·출력비중·인건비 보정해서) 계산
→ 넘었다면 한 단계 아래 옵션으로
예 ↓
3. 현재 위치 유지. 단, 트래픽이 변하면 1번부터 다시.
한 문장으로: 안정적인 API와 공유 텔레메트리를 유지하고, 효과가 있는 부분만 커스터마이징하고, 제품·트래픽·제약이 바뀌면 스펙트럼 상의 위치도 바뀔 것을 예상하는 것. 그게 유연한 전략입니다.