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, 양자화로 최적화합니다.

실무 팁: TTFT와 TPS는 트레이드오프 관계입니다. 배치 크기를 늘리면 처리량은 증가하지만 개별 TTFT는 길어집니다. 서비스 특성에 맞게 균형점을 찾으세요.

추론 최적화

모델을 실제 서비스로 제공하기 위해 속도와 비용을 최적화합니다. 캐싱, 배치, 양자화, KV 캐시 등이 핵심입니다.

POST /api/generate
# Ollama 예시: 로컬 추론 서버에 요청
{
  "model": "llama3.3",
  "prompt": "추론 최적화 전략을 3가지로 요약",
  "stream": false
}
참고: 로컬 추론 API는 Ollama, vLLM, TGI 등에서 유사한 구조로 제공됩니다.

KV 캐시 상세

Transformer 기반 LLM은 매 토큰 생성 시 이전 모든 토큰에 대한 Attention 연산을 수행합니다. KV 캐시는 이미 계산된 Key/Value 텐서를 메모리에 저장하여 중복 연산을 방지하는 핵심 최적화 기법입니다.

Prefill과 Decode 단계

LLM 추론은 두 단계로 나뉩니다.

  • Prefill (프리필): 입력 프롬프트의 모든 토큰을 한 번에 처리하여 KV 캐시를 초기화합니다. GPU 연산 집약적이며 병렬 처리 가능합니다.
  • Decode (디코드): 캐시된 KV를 재사용하며 한 번에 하나의 토큰을 생성합니다. 메모리 대역폭 바운드이며 순차적으로 진행됩니다.
KV 캐시 메커니즘: Prefill → Decode Prefill 단계 T₁ T₂ T₃ ... Tₙ 모든 K,V 계산 KV Cache [K₁V₁ ... KₙVₙ] Decode 단계 (순차 생성) Tₙ₊₁ 캐시 재사용 +Kₙ₊₁Vₙ₊₁ Tₙ₊₂ +Kₙ₊₂Vₙ₊₂ 메모리 증가 Prefill: 모든 K,V 한 번에 계산 (GPU 연산 바운드) | Decode: 캐시 재사용 + 신규 토큰만 연산 (메모리 바운드)

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 × 21.25 GB // 요청 1건당!
주의: 동시 100명이면 KV 캐시만 125GB 필요. PagedAttention 같은 최적화가 필수입니다.

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까지 지원합니다.

실무 팁: FlashAttention과 PagedAttention은 상호 보완적입니다. vLLM은 두 기법을 모두 지원하며, 함께 사용할 때 최대 효과를 얻습니다.

양자화 (Quantization)

양자화는 모델 가중치의 정밀도를 낮춰 메모리 사용량과 연산 비용을 줄이는 기법입니다. FP32에서 INT4까지 다양한 수준이 있으며, 정밀도와 품질 사이의 트레이드오프를 이해해야 합니다.

양자화 비교: 70B 모델 VRAM·품질 정밀도 VRAM 품질 FP32 ~280 GB 100% FP16 ~140 GB (50%↓) ~99.9% INT8 ~70 GB (75%↓) ~99% INT4 ~35 GB (87.5%↓) ~95-97% FP16 사실상 무손실 | INT8 대부분 충분 | INT4 소비자 GPU에서 대형 모델 가능 (일부 추론 저하 주의)

양자화 수준별 메모리 절감과 품질 트레이드오프 — 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")
GGUF 가이드: Q4_K_M(범용 추천) > Q5_K_M(고품질) > Q3_K_M(저VRAM). Q2 이하는 비추천.

추론 최적화 세부 전략

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로 서빙하기 위한 주요 프레임워크를 비교합니다.

프레임워크주요 특징양자화최적 사용처
vLLMPagedAttention, continuous batching, OpenAI 호환AWQ, GPTQ, FP8프로덕션 GPU 서빙
TGIHF 통합, 스트리밍, 워터마크GPTQ, AWQHF 생태계
llama.cppCPU/GPU 혼합, GGUF, 단일 바이너리GGUF엣지/로컬/CPU
Ollamallama.cpp 래퍼, Docker식 관리GGUF개발/프로토타입
TensorRT-LLMNVIDIA 최적화, 커널 융합FP8, INT4NVIDIA 최대 성능
SGLangRadixAttention, 구조화 생성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",...}'
선택: 프로덕션→vLLM, 로컬→Ollama, CPU→llama.cpp, NVIDIA 최적화→TensorRT-LLM

하드웨어 선택 가이드

모델 크기와 양자화 수준에 따른 GPU VRAM 요구사항입니다.

GPU VRAM 요구사항 표

모델 크기FP16INT4권장 GPU
7~8B14~16 GB4~5 GBRTX 4060 Ti 16GB
13~14B26~28 GB8~9 GBRTX 4090 24GB
30~34B60~68 GB18~21 GBA100 80GB
65~70B130~140 GB35~40 GB2x A100 80GB
405B~810 GB~210 GB8x H100 80GB
주의: 위 수치는 가중치만의 VRAM입니다. KV 캐시 등으로 20~40% 추가 필요합니다.

주요 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 공유 메모리로 대형 모델 로드 가능하나 속도 느림
핵심: LLM 추론은 메모리 대역폭 바운드입니다. TFLOPS보다 GB/s를 우선 고려하세요.

로컬 추론 vs 클라우드 API 비교

LLM을 서비스에 통합할 때 로컬 자체 호스팅과 클라우드 API 중 어떤 방식이 적합한지 비교합니다.

항목로컬 추론클라우드 API
비용 구조고정 (GPU 구매/임대 + 전력)종량제 (토큰당 과금)
확장성수동 스케일링자동 스케일링
지연 시간네트워크 지연 없음네트워크 RTT 추가
프라이버시완전 제어제공자 정책 의존
모델 품질오픈소스 수준최고 수준 (Claude 4.7, GPT-5.4)
커스터마이징파인튜닝, 양자화 자유제한적 (API 파라미터 내)
손익분기점월 $3,000~5,000 이상 API 비용 시 자체 호스팅 고려
권장: 초기에는 클라우드 API로 시작, 트래픽 안정 후 자체 호스팅 검토. 하이브리드(일반→로컬, 복잡→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/월 (전기+감가상각, 오픈소스만)
핵심: 가장 큰 비용 레버는 모델 선택입니다. 난이도별 라우팅으로 70% 이상 절감 가능.

RAG와 도구 사용

모델이 모르는 정보는 검색/DB에서 가져오고, 계산/업무는 도구 호출로 처리합니다. LLM의 한계를 서비스 설계로 보완하는 핵심 패턴입니다.

  • RAG: 문서 임베딩 + 검색 + 요약/합성
  • Tool Use: 함수 호출, API 연동, 파일/DB 접근
  • MCP: 표준화된 도구 생태계 연결
상세 가이드: RAG의 청킹, 임베딩, 벡터DB, 고급 기법, 평가까지 심층 내용은 RAG 완전 가이드를 참고하세요.

RAG 설계 체크리스트

  • 문서 분할: 문단 단위로 적절히 쪼개기
  • 임베딩 모델: 도메인 적합도, 비용 고려
  • 검색 전략: 키워드 + 벡터 하이브리드
  • 답변 합성: 근거 인용, 출처 표기

RAG 파이프라인 다이어그램

문서 저장소 검색/리트리버 LLM 응답 합성

문서 검색 → 리트리버 → LLM 합성의 기본 구조

도구 사용 패턴

  • 검증형: LLM 출력 후 도구로 사실 확인
  • 생성형: LLM이 계획을 세우고 도구로 실행
  • 혼합형: 요약/추론은 LLM, 계산/조회는 도구

도구 사용 흐름

Tool Use 흐름 (ReAct 패턴) 사용자 요청 LLM 추론 도구 필요 판단 Tool Call 생성 name + arguments 실제 함수 실행 API / DB / 파일 LLM 응답 결과 통합 tool_result 메시지로 결과 반환 → 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))]
상세 가이드: MCP 서버 설치, 설정, 클라이언트 연동은 MCP 입문 가이드를 참고하세요.

추론 지연 벤치마킹

실제 운영 환경에서 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}'

참고자료