LLM 핸드북 4: 추론·RAG·도구사용
LLM 추론 최적화, RAG 파이프라인, 도구 사용 설계, 제공자별 연동 패턴을 실무 중심으로 정리합니다.
개요
이 페이지는 모델을 실제 서비스에 연결하는 마지막 단계인 추론 최적화와 RAG/도구 사용 전략을 다룹니다. 학습/정렬 자체는 LLM 핸드북 2: 학습·정렬에서 다룹니다.
실제 서비스에서는 지연 시간, 처리량, 비용, VRAM의 균형을 맞추는 엔지니어링이 필요합니다. KV 캐시, 양자화, 배치 처리, 서빙 프레임워크부터 하드웨어와 비용 최적화까지 실무 전략을 정리합니다.
추론 성능 핵심 메트릭
추론 성능을 평가할 때 반드시 구분해야 하는 두 가지 핵심 메트릭이 있습니다.
TTFT (Time To First Token)
요청 시점부터 첫 토큰 생성까지의 시간. Prefill 성능을 반영하며 체감 응답 속도에 직결됩니다. 대화형 서비스에서 500ms 이하가 목표이며, Prefix 캐싱과 FlashAttention으로 최적화합니다.
TPS (Tokens Per Second)
초당 생성 토큰 수. 디코딩 속도를 나타내며 응답 완료 시간에 영향. 사용자당 30 TPS 이상이면 읽기 속도보다 빠릅니다. Speculative decoding, continuous batching, 양자화로 최적화합니다.
추론 최적화
모델을 실제 서비스로 제공하기 위해 속도와 비용을 최적화합니다. 캐싱, 배치, 양자화, KV 캐시 등이 핵심입니다.
POST /api/generate
# Ollama 예시: 로컬 추론 서버에 요청
{
"model": "llama3.3",
"prompt": "추론 최적화 전략을 3가지로 요약",
"stream": false
}
KV 캐시 상세
Transformer 기반 LLM은 매 토큰 생성 시 이전 모든 토큰에 대한 Attention 연산을 수행합니다. KV 캐시는 이미 계산된 Key/Value 텐서를 메모리에 저장하여 중복 연산을 방지하는 핵심 최적화 기법입니다.
Prefill과 Decode 단계
LLM 추론은 두 단계로 나뉩니다.
- Prefill (프리필): 입력 프롬프트의 모든 토큰을 한 번에 처리하여 KV 캐시를 초기화합니다. GPU 연산 집약적이며 병렬 처리 가능합니다.
- Decode (디코드): 캐시된 KV를 재사용하며 한 번에 하나의 토큰을 생성합니다. 메모리 대역폭 바운드이며 순차적으로 진행됩니다.
KV 캐시 동작 원리 — Prefill에서 초기 캐시 생성, Decode에서 캐시 재사용하며 토큰당 메모리 선형 증가
KV 캐시 메모리 계산
KV 캐시 메모리는 다음 공식으로 계산합니다.
// KV 캐시 메모리 = 2 × layers × kv_heads × head_dim × seq_len × bytes
// 예시: Llama 3.1 70B (FP16, GQA kv_heads=8)
KV_memory = 2 × 80 × 8 × 128 × 4096 × 2 ≈ 1.25 GB // 요청 1건당!
PagedAttention
vLLM이 도입한 기법으로 KV 캐시를 OS 가상 메모리처럼 페이지 단위(16토큰)로 관리합니다. 기존 방식의 60~80% 메모리 낭비를 4% 이하로 줄여 동시 배치 크기를 2~4배 늘립니다. 동일 시스템 프롬프트 요청은 KV 캐시 페이지를 Copy-on-Write로 공유합니다.
FlashAttention
Attention 행렬을 전체 실체화하지 않고 타일 단위로 연산하여 HBM↔SRAM 데이터 이동을 최소화합니다. 긴 컨텍스트(32K~128K)에서 메모리를 O(N²)→O(N)으로 줄이고 속도를 2~4배 향상시킵니다. FlashAttention-3은 H100의 FP8까지 지원합니다.
양자화 (Quantization)
양자화는 모델 가중치의 정밀도를 낮춰 메모리 사용량과 연산 비용을 줄이는 기법입니다. FP32에서 INT4까지 다양한 수준이 있으며, 정밀도와 품질 사이의 트레이드오프를 이해해야 합니다.
양자화 수준별 메모리 절감과 품질 트레이드오프 — 70B 파라미터 모델 기준
양자화 기법 비교
- GPTQ: 후훈련 4/8비트. 보정 데이터 기반, GPU 추론 최적화 (vLLM, TGI)
- AWQ: 후훈련 4비트. 중요 채널 보존으로 GPTQ 대비 품질 우수 (vLLM 권장)
- GGUF: 후훈련 2~8비트. CPU/GPU 혼합 추론, 단일 파일 (llama.cpp, Ollama)
- bitsandbytes: 동적 4/8비트. 로드 시 양자화, QLoRA 결합 가능 (HF Transformers)
- FP8: H100+ 네이티브 8비트 부동소수. 거의 무손실 (vLLM, TensorRT-LLM)
# GGUF 양자화 모델 실행 (Ollama)
ollama run llama3.3:70b-instruct-q4_K_M
# bitsandbytes 4비트 로딩 (Python)
from transformers import AutoModelForCausalLM, BitsAndBytesConfig
config = BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type="nf4")
model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-3.1-70B-Instruct",
quantization_config=config, device_map="auto")
추론 최적화 세부 전략
Continuous Batching
정적 배치는 가장 긴 시퀀스가 끝날 때까지 대기하지만, continuous batching은 완료된 요청을 즉시 제거하고 새 요청을 투입합니다. 동일 하드웨어에서 처리량 최대 23배 향상됩니다 (Anyscale 블로그, vLLM 기준).
Speculative Decoding
작은 draft 모델이 K개 토큰을 미리 생성하고, target 모델이 1회 forward pass로 검증합니다. 출력 분포 손실 없이 코드 생성 등에서 2~3배 속도 향상. 창의적 텍스트에서는 효과 제한적입니다.
병렬화 전략
- Tensor Parallelism (TP): 하나의 레이어를 여러 GPU에 분할. NVLink 필요. 지연 감소
- Pipeline Parallelism (PP): 레이어 그룹을 다른 GPU에 할당. 처리량 증가
- 조합: 대형 모델은 TP + PP 혼합 (예: 8GPU에서 TP=4, PP=2)
기타 최적화
- 스트리밍 응답: SSE로 토큰 즉시 전송, 체감 지연 감소
- 프롬프트 캐싱: 동일 시스템 프롬프트 KV 캐시 재사용 (Anthropic prompt caching, vLLM prefix caching)
- 커널 융합: 연산을 단일 GPU 커널로 합쳐 메모리 이동 최소화 (TensorRT-LLM)
서빙 프레임워크 비교
LLM을 API로 서빙하기 위한 주요 프레임워크를 비교합니다.
| 프레임워크 | 주요 특징 | 양자화 | 최적 사용처 |
|---|---|---|---|
| vLLM | PagedAttention, continuous batching, OpenAI 호환 | AWQ, GPTQ, FP8 | 프로덕션 GPU 서빙 |
| TGI | HF 통합, 스트리밍, 워터마크 | GPTQ, AWQ | HF 생태계 |
| llama.cpp | CPU/GPU 혼합, GGUF, 단일 바이너리 | GGUF | 엣지/로컬/CPU |
| Ollama | llama.cpp 래퍼, Docker식 관리 | GGUF | 개발/프로토타입 |
| TensorRT-LLM | NVIDIA 최적화, 커널 융합 | FP8, INT4 | NVIDIA 최대 성능 |
| SGLang | RadixAttention, 구조화 생성 | AWQ, FP8 | 구조화 출력 서비스 |
# vLLM 서버 실행 (OpenAI 호환 API)
python -m vllm.entrypoints.openai.api_server \\
--model "meta-llama/Llama-3.1-8B-Instruct" \\
--max-model-len 8192 --gpu-memory-utilization 0.9
# Ollama (로컬 개발용)
ollama run llama3.3:8b
# API: curl http://localhost:11434/api/chat -d '{"model":"llama3.3:8b",...}'
하드웨어 선택 가이드
모델 크기와 양자화 수준에 따른 GPU VRAM 요구사항입니다.
GPU VRAM 요구사항 표
| 모델 크기 | FP16 | INT4 | 권장 GPU |
|---|---|---|---|
| 7~8B | 14~16 GB | 4~5 GB | RTX 4060 Ti 16GB |
| 13~14B | 26~28 GB | 8~9 GB | RTX 4090 24GB |
| 30~34B | 60~68 GB | 18~21 GB | A100 80GB |
| 65~70B | 130~140 GB | 35~40 GB | 2x A100 80GB |
| 405B | ~810 GB | ~210 GB | 8x H100 80GB |
주요 GPU 비교
- RTX 4090 (24GB, 1,008 GB/s): 로컬 개발, 소형 모델 서빙. ~$1,600
- A100 80GB (80GB, 2,039 GB/s): 프로덕션 서빙 표준. ~$15,000
- H100 SXM (80GB, 3,350 GB/s): 대규모 프로덕션, FP8 지원. ~$30,000
- L40S (48GB, 864 GB/s): 중간 규모 비용 효율. ~$8,000
- Apple M 시리즈 (통합 메모리 최대 192GB): CPU/GPU 공유 메모리로 대형 모델 로드 가능하나 속도 느림
로컬 추론 vs 클라우드 API 비교
LLM을 서비스에 통합할 때 로컬 자체 호스팅과 클라우드 API 중 어떤 방식이 적합한지 비교합니다.
| 항목 | 로컬 추론 | 클라우드 API |
|---|---|---|
| 비용 구조 | 고정 (GPU 구매/임대 + 전력) | 종량제 (토큰당 과금) |
| 확장성 | 수동 스케일링 | 자동 스케일링 |
| 지연 시간 | 네트워크 지연 없음 | 네트워크 RTT 추가 |
| 프라이버시 | 완전 제어 | 제공자 정책 의존 |
| 모델 품질 | 오픈소스 수준 | 최고 수준 (Claude 4.7, GPT-5.4) |
| 커스터마이징 | 파인튜닝, 양자화 자유 | 제한적 (API 파라미터 내) |
| 손익분기점 | 월 $3,000~5,000 이상 API 비용 시 자체 호스팅 고려 | |
비용 최적화 전략
LLM 서비스의 비용을 최적화하기 위한 실무 전략을 정리합니다.
- 프롬프트 압축/캐싱: 시스템 프롬프트 간결화 + Anthropic prompt caching (90% 절감)
- 배치 API: 비실시간 작업은 배치 API (50% 할인)
- 모델 계층화: 간단 → 소형 모델, 복잡 → 대형 모델 라우팅
- 스팟 인스턴스: AWS Spot / GCP Preemptible (60~90% 할인)
- 오토스케일링: 트래픽 패턴에 맞춰 GPU 수 조절
- 양자화: INT4로 필요 GPU 수 1/4 절감
# 비용 비교 예시: 1M 토큰/일 처리
# Claude API (Sonnet): ~$315/월 (입력 $3/1M + 출력 $15/1M)
# 자체 호스팅 (A100): ~$1,800/월 (GPU 임대, 처리량 높음)
# 로컬 (RTX 4090): ~$93/월 (전기+감가상각, 오픈소스만)
RAG와 도구 사용
모델이 모르는 정보는 검색/DB에서 가져오고, 계산/업무는 도구 호출로 처리합니다. LLM의 한계를 서비스 설계로 보완하는 핵심 패턴입니다.
- RAG: 문서 임베딩 + 검색 + 요약/합성
- Tool Use: 함수 호출, API 연동, 파일/DB 접근
- MCP: 표준화된 도구 생태계 연결
RAG 설계 체크리스트
- 문서 분할: 문단 단위로 적절히 쪼개기
- 임베딩 모델: 도메인 적합도, 비용 고려
- 검색 전략: 키워드 + 벡터 하이브리드
- 답변 합성: 근거 인용, 출처 표기
RAG 파이프라인 다이어그램
문서 검색 → 리트리버 → LLM 합성의 기본 구조
도구 사용 패턴
- 검증형: LLM 출력 후 도구로 사실 확인
- 생성형: LLM이 계획을 세우고 도구로 실행
- 혼합형: 요약/추론은 LLM, 계산/조회는 도구
도구 사용 흐름
Tool Use 기본 흐름 — LLM이 도구 호출을 결정하고, 실행 결과를 받아 최종 응답에 통합
Claude Tool Use 실전 예제
Claude의 Tool Use API를 사용해 실제 함수를 호출하는 완전한 예제입니다.
import anthropic
import json
client = anthropic.Anthropic()
# 1. 도구 정의
tools = [
{
"name": "get_weather",
"description": "특정 도시의 현재 날씨를 반환합니다",
"input_schema": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "도시 이름"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
},
"required": ["city"],
},
},
{
"name": "search_db",
"description": "내부 데이터베이스에서 레코드를 검색합니다",
"input_schema": {
"type": "object",
"properties": {
"query": {"type": "string"},
"limit": {"type": "integer", "default": 5},
},
"required": ["query"],
},
},
]
# 2. 실제 함수 구현
def get_weather(city: str, unit: str = "celsius") -> dict:
# 실제 날씨 API 호출 (예시)
return {"city": city, "temp": 22, "unit": unit, "desc": "맑음"}
def search_db(query: str, limit: int = 5) -> list:
return [{"id": 1, "title": f"{query} 관련 항목 1"}]
TOOL_MAP = {"get_weather": get_weather, "search_db": search_db}
# 3. 도구 호출 루프
def run_agent(user_message: str) -> str:
messages = [{"role": "user", "content": user_message}]
while True:
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
tools=tools,
messages=messages,
)
if response.stop_reason == "end_turn":
return response.content[0].text
if response.stop_reason == "tool_use":
tool_results = []
for block in response.content:
if block.type == "tool_use":
fn = TOOL_MAP[block.name]
result = fn(**block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps(result, ensure_ascii=False),
})
messages.extend([
{"role": "assistant", "content": response.content},
{"role": "user", "content": tool_results},
])
# 실행
result = run_agent("서울 날씨 알려줘. 그리고 '날씨 대응' 관련 DB도 검색해줘.")
print(result)
구조화 출력 (Structured Output)
LLM에서 JSON, 표, 코드 등 구조화된 형식으로 일관되게 출력받는 패턴입니다. 다운스트림 파이프라인과 연동할 때 필수적입니다.
JSON 출력 강제
import anthropic
import json
from pydantic import BaseModel
client = anthropic.Anthropic()
class ProductReview(BaseModel):
sentiment: str # positive / negative / neutral
score: int # 1-5
key_points: list[str]
improvement: str
def analyze_review(review_text: str) -> ProductReview:
schema = ProductReview.model_json_schema()
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
system=f"""다음 JSON 스키마에 맞게 리뷰를 분석하세요.
반드시 유효한 JSON만 출력하고, 설명 텍스트는 포함하지 마세요.
스키마: {json.dumps(schema, ensure_ascii=False)}""",
messages=[{"role": "user", "content": review_text}],
)
raw = response.content[0].text
# ```json ... ``` 블록 제거
if raw.startswith("```"):
raw = raw.split("\n", 1)[1].rsplit("```", 1)[0]
return ProductReview.model_validate_json(raw)
어시스턴트 프리필 기법
# 응답 시작을 강제하는 어시스턴트 프리필
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=[
{"role": "user", "content": "제품 정보를 JSON으로 추출해줘: iPhone 15 Pro, $999, 출시일 2023-09-22"},
{"role": "assistant", "content": "{"}, # ← JSON 시작 강제
],
)
# 응답은 '{' 이후부터 이어집니다. 파싱 시 '{'를 앞에 추가
json_text = "{" + response.content[0].text
instructor 라이브러리를 사용하세요. Pydantic 모델을 직접 지정하면 자동으로 재시도와 검증을 처리합니다.
MCP와 도구 사용 통합
Model Context Protocol(MCP)은 표준화된 인터페이스로 LLM이 외부 시스템과 상호작용하게 합니다. 개별 도구 구현 없이 MCP 서버를 연결하면 파일 시스템, 데이터베이스, 외부 API를 일관된 방식으로 사용합니다.
Tool Use vs MCP
| 항목 | 직접 Tool Use | MCP 서버 |
|---|---|---|
| 구현 위치 | 애플리케이션 코드 내부 | 독립 서버 프로세스 |
| 재사용성 | 단일 앱 | 여러 앱에서 공유 |
| 유지보수 | 앱과 동기화 필요 | 독립적으로 업데이트 |
| 적합한 경우 | 앱 전용 로직, 빠른 프로토타입 | 팀 공유 도구, 표준화된 인터페이스 |
간단한 MCP 서버 구현 예시
# MCP 서버: 날씨 API 래퍼 (Python + mcp 라이브러리)
from mcp.server import Server
from mcp.server.models import InitializationOptions
import mcp.types as types
server = Server("weather-server")
@server.list_tools()
async def handle_list_tools():
return [
types.Tool(
name="get_weather",
description="도시 날씨를 반환합니다",
inputSchema={
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
)
]
@server.call_tool()
async def handle_call_tool(name: str, arguments: dict):
if name == "get_weather":
city = arguments["city"]
# 실제 날씨 API 호출
data = fetch_weather_api(city)
return [types.TextContent(type="text", text=str(data))]
추론 지연 벤치마킹
실제 운영 환경에서 TTFT와 TPS를 측정하는 방법입니다.
import time, anthropic
client = anthropic.Anthropic()
def measure_inference(prompt: str, model: str = "claude-sonnet-4-6"):
tokens_received = 0
ttft = None
start = time.perf_counter()
with client.messages.stream(
model=model, max_tokens=512,
messages=[{"role": "user", "content": prompt}],
) as stream:
for text in stream.text_stream():
if ttft is None:
ttft = time.perf_counter() - start
tokens_received += len(text.split())
total = time.perf_counter() - start
tps = tokens_received / (total - ttft) if total > ttft else 0
return {
"ttft_ms": round(ttft * 1000, 1),
"total_ms": round(total * 1000, 1),
"tps": round(tps, 1),
"tokens": tokens_received,
}
# 10회 반복 p50/p95 측정
import statistics
results = [measure_inference("LLM 추론 최적화 전략을 설명해줘") for _ in range(10)]
ttfts = [r["ttft_ms"] for r in results]
print(f"TTFT p50={statistics.median(ttfts):.0f}ms p95={sorted(ttfts)[9]:.0f}ms")
제공자별 API 스니펫
엔드포인트, 모델명, 헤더는 제공자 문서 기준으로 교체하세요.
# Claude (예시)
curl -s "https://api.anthropic.com/v1/messages" \\
-H "x-api-key: $CLAUDE_API_KEY" \\
-H "content-type: application/json" \\
-d '{"model":"MODEL_ID","max_tokens":256,"messages":[{"role":"user","content":"요약해줘"}]}'
# OpenAI (예시)
curl -s "https://api.openai.com/v1/chat/completions" \\
-H "Authorization: Bearer $OPENAI_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{"model":"MODEL_ID","messages":[{"role":"user","content":"요약해줘"}],"max_tokens":256}'
# Gemini (예시)
curl -s "https://generativelanguage.googleapis.com/v1beta/models/MODEL_ID:generateContent?key=$GEMINI_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{"contents":[{"role":"user","parts":[{"text":"요약해줘"}]}]}'
# Ollama (로컬 예시)
curl -s "http://localhost:11434/api/generate" \\
-H "Content-Type: application/json" \\
-d '{"model":"llama3.3","prompt":"요약해줘","stream":false}'