13년차의 서버실

서버, 인프라, 홈랩 기술 블로그

[태그:] 로컬 AI

  • [AI] 로컬 LLM 성능: vLLM 벤치마크 측정 방법론

    [AI] 로컬 LLM 성능: vLLM 벤치마크 측정 방법론

    로컬 LLM 성능: vLLM 벤치마크 측정 방법론

    로컬 LLM 성능은 GPU 이름보다 측정 방식에서 갈립니다

    로컬 LLM 성능을 비교할 때 가장 위험한 문장은 ‘이 GPU면 충분히 빠르겠지’입니다. 실제 운영에 가까워질수록 병목은 GPU 코어 하나로 설명되지 않거든요. 프롬프트를 읽는 prefill, 토큰을 하나씩 생성하는 decode, KV cache가 차지하는 메모리, tokenizer와 HTTP 클라이언트, 동시 요청 스케줄링이 같이 움직입니다.

    저는 벤치마크를 볼 때 숫자 하나만 믿지 않습니다. 초당 토큰 수가 좋아도 첫 토큰이 늦으면 채팅 UX는 답답합니다. 반대로 TTFT가 괜찮아도 동시 요청을 올렸을 때 p95 지연 시간이 크게 튀면 내부 서비스로 쓰기 불안하더라고요.

    이 글은 vLLM을 띄우는 법을 소개하는 데서 멈추지 않습니다. 어떤 값을 고정하고, 무엇을 바꿔야 비교가 성립하는지, vLLM 옵션이 성능·비용·안정성에 어떤 영향을 주는지, 결과를 보고 운영선을 어떻게 잡는지까지 정리합니다. 특정 GPU의 성능 수치는 지어내지 않겠습니다. 대신 여러분 장비에서 재현할 수 있는 로컬 LLM 성능 측정 절차와 판단 기준을 남기겠습니다.

    로컬 LLM 성능 벤치마크 전체 아키텍처 다이어그램

    로컬 GPU 서버에서 vLLM API 서버를 띄우고, 벤치마크 클라이언트가 요청을 보내며, GPU/시스템 지표를 함께 수집하는 흐름입니다.

    vLLM 벤치마크 전에 성능 지표를 운영 관점으로 잡기

    vLLM은 OpenAI 호환 API 서버로 사용할 수 있는 LLM 추론 엔진입니다. 핵심은 여러 요청을 효율적으로 스케줄링하고, KV cache를 관리하며, 서버형 추론에서 처리량을 끌어올리는 데 있습니다. 여기서 성능 지표는 사전식 정의보다 ‘어떤 장애를 미리 알려주는가’로 보는 편이 훨씬 쓸모 있습니다.

    • TTFT(Time To First Token): 사용자가 요청한 뒤 첫 토큰이 나오기까지의 시간입니다. 길면 모델이 똑똑해도 느리게 느껴집니다. 긴 입력, prefill 부하, 큐 대기 시간이 주로 영향을 줍니다.
    • TPOT(Time Per Output Token): 첫 토큰 이후 출력 토큰 하나를 생성하는 평균 시간입니다. 답변이 길어질수록 체감 속도를 좌우합니다.
    • ITL(Inter Token Latency): 토큰 사이 간격입니다. 스트리밍 응답이 뚝뚝 끊기는지 확인할 때 봅니다.
    • E2E Latency: 요청부터 최종 응답 완료까지의 전체 시간입니다. 배치 요약, 문서 처리, 자동화 워크플로에서는 이 값이 더 중요할 수 있습니다.
    • Throughput: 초당 처리한 요청 수 또는 토큰 수입니다. 사용자 수를 늘릴 때 비용 효율을 판단하는 기준입니다.
    • Concurrency: 동시에 처리 중인 요청 수입니다. vLLM의 장점은 보통 단일 요청보다 이 구간에서 더 잘 드러납니다.
    • GPU Memory: 모델 가중치, KV cache, CUDA graph, 런타임 오버헤드가 함께 차지하는 메모리입니다. OOM은 대개 모델 크기 하나가 아니라 컨텍스트 길이와 동시성이 합쳐져 발생합니다.

    현장에서 가장 많이 본 착시는 ‘초당 토큰이 높으니 빠르다’는 판단입니다. 문서 요약처럼 입력이 긴 워크로드는 prefill 비용이 커서 TTFT가 먼저 무너질 수 있습니다. 반대로 짧은 채팅을 많이 받는 서비스는 decode보다 큐 대기와 스케줄링이 더 큰 문제가 되기도 합니다. 그래서 LLM 벤치마크는 항상 워크로드를 먼저 정하고 들어가야 합니다.

    LLM 벤치마크 환경은 README처럼 고정하세요

    LLM 벤치마크에서 비교가 깨지는 가장 흔한 이유는 실험 조건이 매번 조금씩 달라지는 겁니다. 모델만 같아도 토크나이저 버전, dtype, max model length, 요청 분포, temperature, 스트리밍 여부가 바뀌면 숫자는 달라집니다. 실험을 시작하기 전에 아래 항목을 먼저 적어두세요. 이거 진짜 편합니다. 나중에 결과가 이상할 때 기록이 없으면 원인 추적이 거의 감으로 변하거든요.

    항목 기록할 내용 왜 중요한가
    vLLM 버전 <code>vllm –version, 설치 방식, 컨테이너 태그 CLI 옵션과 스케줄러 동작은 버전에 따라 바뀔 수 있습니다.
    GPU/드라이버 nvidia-smi 출력, CUDA 런타임, GPU 개수 메모리 용량과 커널 지원 여부가 추론 안정성에 직접 영향을 줍니다.
    모델 Hugging Face ID, 로컬 경로, revision, 양자화 여부 가중치와 토크나이저가 다르면 같은 이름처럼 보여도 비교가 아닙니다.
    입력 길이 평균/최대 prompt token, 테스트 데이터셋 긴 입력은 prefill 비용과 KV cache 사용량을 키웁니다.
    출력 길이 max_tokens, 고정 출력 길이 사용 여부 decode 성능을 비교하려면 출력 길이를 통제해야 합니다.
    동시성 1, 2, 4, 8, 16처럼 단계별 증가 처리량이 늘다가 지연만 커지는 지점을 찾아야 합니다.
    서버 옵션 dtype, max-model-len, gpu-memory-utilization, max-num-seqs 메모리, 큐잉, 처리량, OOM 위험이 여기서 크게 갈립니다.
    요청 방식 chat/completions 여부, streaming 여부, timeout 클라이언트 측 지연과 서버 처리 지연을 분리해 봐야 합니다.

    ‘테스트 데이터셋’이라는 말에 너무 겁먹을 필요는 없습니다. 사내 문서 요약용이면 실제 문서에서 민감정보를 제거한 샘플 30~100개, 코딩 보조용이면 짧은 질문·중간 길이 수정 요청·긴 파일 기반 요청을 나눠 준비하면 됩니다. 핵심은 매번 같은 입력 분포를 쓰는 것입니다.

    vLLM 서버는 옵션 파일로 남기세요

    설치는 환경 의존성이 큽니다. CUDA, PyTorch, 드라이버 조합은 공식 설치 문서를 확인하는 편이 안전합니다. 아래 명령은 NVIDIA GPU가 있는 Linux 환경에서 시작점을 잡는 예시로 보세요. 벤치마크 재현성을 위해 서버 실행 옵션은 셸 히스토리에만 남기지 말고 YAML 파일로 고정하는 걸 권합니다.

    python3 -m venv .venv
    source .venv/bin/activate
    python -m pip install --upgrade pip
    pip install vllm
    vllm --version
    python - <<'PY'
    import torch
    print('cuda_available=', torch.cuda.is_available())
    print('gpu_count=', torch.cuda.device_count())
    PY

    아래는 단일 GPU에서 시작할 때의 보수적인 예시입니다. 모델 경로는 여러분 환경에 맞게 바꾸세요. vLLM의 --config는 YAML 설정 파일을 읽을 수 있으며, 같은 값이 CLI와 설정 파일에 동시에 있으면 CLI 인자가 우선합니다. 그래서 실제 운영에서는 중복을 줄이고, 실험에서는 어떤 값이 적용됐는지 로그로 한 번 더 확인하는 습관이 좋습니다.

    # vllm-serve.yaml
    model: /models/your-llm-model
    host: 0.0.0.0
    port: 8000
    dtype: auto
    max_model_len: 4096
    gpu_memory_utilization: 0.85
    max_num_seqs: 16
    enable_prefix_caching: true
    disable_log_requests: true
    vllm serve --config vllm-serve.yaml

    max_model_len은 서버가 받아들일 입력+출력의 최대 토큰 길이입니다. 무작정 크게 잡으면 긴 문서를 받을 수는 있지만 KV cache 여유가 줄어 동시성이 줄고 OOM 가능성이 커집니다. gpu_memory_utilization은 vLLM이 모델 실행에 사용할 GPU 메모리 비율입니다. 높이면 더 큰 KV cache를 확보할 수 있지만, 드라이버·라이브러리·다른 프로세스가 쓸 여유 공간은 줄어듭니다.

    max_num_seqs는 한 번의 스케줄링 반복에서 처리할 수 있는 시퀀스 수의 상한으로 이해하면 쉽습니다. 짧은 채팅을 많이 받는 서버라면 늘려볼 가치가 있고, 긴 문서 요약처럼 요청 하나가 큰 경우에는 과하게 늘릴수록 지연 시간과 메모리 압박이 커질 수 있습니다. enable_prefix_caching은 같은 시스템 프롬프트나 반복 prefix가 많은 워크로드에서 효과를 기대할 수 있지만, 요청 prefix가 매번 완전히 다르면 체감이 작을 수 있습니다.

    vLLM API 서버와 벤치마크 클라이언트 구성도

    vLLM 서버 실행 옵션과 벤치마크 클라이언트가 연결되는 구조를 한눈에 볼 수 있는 구성도입니다.

    추론 성능 측정은 워밍업, 단일 요청, 동시성 순서로 갑니다

    서버가 떠도 바로 측정값을 믿으면 안 됩니다. 첫 요청에는 모델 로딩 이후 남은 초기화, CUDA kernel 준비, 캐시 관련 비용이 섞일 수 있습니다. 저는 보통 헬스 체크, 워밍업, 단일 요청 확인, 동시성 테스트 순서로 갑니다. 이 순서를 지키면 서버가 느린 건지, 첫 요청만 느린 건지, 클라이언트가 병목인지 분리하기 쉽습니다.

    curl -s http://127.0.0.1:8000/v1/models | jq .
    
    for i in 1 2 3; do
      curl -s http://127.0.0.1:8000/v1/chat/completions \
        -H 'Content-Type: application/json' \
        -d '{
          "model": "/models/your-llm-model",
          "messages": [{"role": "user", "content": "warmup"}],
          "max_tokens": 16,
          "temperature": 0
        }' > /dev/null
      echo warmup-$i-done
    done

    간단한 smoke test에서는 응답이 오는지만 보지 말고 usage 필드도 확인하세요. 프롬프트 토큰과 completion 토큰이 예상보다 크게 다르면 테스트 입력이 통제되지 않은 것입니다. 특히 한국어와 영어는 토크나이저에서 토큰 수가 다르게 나올 수 있으니, 같은 글자 수가 곧 같은 부하라는 착각을 피해야 합니다.

    curl -s http://127.0.0.1:8000/v1/chat/completions \
      -H 'Content-Type: application/json' \
      -d '{
        "model": "/models/your-llm-model",
        "messages": [
          {"role": "system", "content": "간결하게 답하세요."},
          {"role": "user", "content": "로컬 LLM 성능 측정에서 TTFT가 왜 중요한지 설명해줘."}
        ],
        "max_tokens": 128,
        "temperature": 0
      }' | jq '{id, usage, finish_reason: .choices[0].finish_reason}'

    직접 만든 부하 스크립트도 유용합니다. 아래 코드는 스트리밍 응답에서 첫 chunk가 도착하는 시점과 전체 완료 시간을 분리해 기록합니다. 전문 도구를 대체하진 않지만, TTFT와 E2E가 왜 다르게 움직이는지 눈으로 확인하기 좋습니다.

    import asyncio
    import json
    import time
    import httpx
    
    URL = 'http://127.0.0.1:8000/v1/chat/completions'
    MODEL = '/models/your-llm-model'
    
    payload = {
        'model': MODEL,
        'messages': [
            {'role': 'user', 'content': 'vLLM으로 로컬 LLM 성능을 측정하는 기준을 설명해줘.'}
        ],
        'max_tokens': 128,
        'temperature': 0,
        'stream': True,
    }
    
    async def one_request(client, idx):
        start = time.perf_counter()
        first_chunk_at = None
        chunks = 0
        async with client.stream('POST', URL, json=payload, timeout=120) as response:
            response.raise_for_status()
            async for line in response.aiter_lines():
                if not line.startswith('data: '):
                    continue
                data = line.removeprefix('data: ').strip()
                if data == '[DONE]':
                    break
                chunks += 1
                if first_chunk_at is None:
                    first_chunk_at = time.perf_counter()
        end = time.perf_counter()
        ttft = None if first_chunk_at is None else first_chunk_at - start
        return {'request': idx, 'ttft_sec': ttft, 'e2e_sec': end - start, 'chunks': chunks}
    
    async def run(concurrency):
        limits = httpx.Limits(max_connections=concurrency, max_keepalive_connections=concurrency)
        async with httpx.AsyncClient(limits=limits) as client:
            results = await asyncio.gather(*(one_request(client, i) for i in range(concurrency)))
        for item in results:
            print(json.dumps(item, ensure_ascii=False))
    
    if __name__ == '__main__':
        asyncio.run(run(concurrency=4))

    이 스크립트를 concurrency=1, 2, 4, 8로 바꿔가며 돌려보면 패턴이 보입니다. TTFT만 급격히 늘면 큐 대기나 prefill 경쟁을 의심하고, TTFT는 괜찮은데 E2E가 길어지면 decode 구간의 경쟁이나 출력 길이 분포를 봅니다. 에러가 섞이면 평균값은 잠깐 내려놓고 실패율부터 봐야 합니다.

    vLLM 내장 벤치마크로 AI 성능 비교 기준선 만들기

    직접 만든 스크립트는 원인을 좁히는 데 좋고, 반복 가능한 비교에는 vLLM의 벤치마크 명령이 편합니다. 버전에 따라 세부 옵션은 달라질 수 있으니 vllm bench serve --help를 먼저 확인하세요. 핵심은 backend, endpoint, model, dataset 또는 synthetic 입력 조건, 동시성, 프롬프트 수를 명시하는 것입니다.

    vllm bench serve --help
    
    vllm bench serve \
      --backend openai-chat \
      --base-url http://127.0.0.1:8000 \
      --endpoint /v1/chat/completions \
      --model /models/your-llm-model \
      --num-prompts 100 \
      --max-concurrency 4 \
      --percentile-metrics ttft,tpot,itl,e2el

    여기서 주의할 점은 ‘벤치마크 클라이언트에서 측정한 지연 시간’이라는 사실입니다. 서버 내부 처리 시간, 네트워크, 클라이언트 이벤트 루프, JSON 파싱이 한 덩어리로 섞일 수 있습니다. 그래서 같은 서버에서 한 번, 다른 머신에서 한 번, 가능하면 클라이언트 CPU 사용률까지 같이 보는 편이 좋습니다.

    데이터셋 기반 테스트를 할 때는 입력 길이 분포를 꼭 기록하세요. 평균 토큰만 보면 안 됩니다. p95 입력 길이가 긴 데이터셋은 짧은 요청 다수보다 훨씬 거칠게 서버를 흔듭니다. 특히 문서 요약형 워크로드는 평균보다 꼬리가 문제입니다. 운영 장애는 보통 평균 요청이 아니라 긴 요청 몇 개가 큐를 막을 때 시작됩니다.

    로컬 LLM 성능 테스트에서 자주 보이는 실패 모드

    OOM과 느린 첫 응답은 표면 증상입니다. 같은 OOM이라도 원인은 모델이 너무 큰 경우, 컨텍스트를 과하게 연 경우, 동시성을 너무 높인 경우, logprobs 같은 옵션이 메모리를 더 쓰는 경우로 나뉩니다. 해결책도 다릅니다. 무조건 작은 모델로 내리면 문제는 사라지지만, 품질과 비용의 균형점을 찾을 기회를 잃습니다.

    watch -n 1 nvidia-smi
    
    nvidia-smi --query-gpu=timestamp,name,index,utilization.gpu,utilization.memory,memory.used,memory.total,power.draw \
      --format=csv -l 1
    증상 가능성이 높은 근본 원인 먼저 볼 것 우선 조치
    첫 요청만 유독 느림 워밍업 부족, 커널 초기화, 캐시 미형성 워밍업 전후 TTFT 차이 측정 전 워밍업 요청을 제외하고 기록합니다.
    동시성 증가 시 TTFT가 먼저 튐 큐 대기, 긴 prefill 경쟁, max_num_seqs 과다 입력 토큰 분포, p95 TTFT 긴 입력과 짧은 입력을 분리 측정하고 동시성 상한을 낮춥니다.
    출력 중간부터 느려짐 decode 병목, 긴 출력 요청 혼재 max_tokens, 실제 completion token 출력 길이를 고정한 테스트와 실제 분포 테스트를 따로 돌립니다.
    GPU 메모리가 가득 찬 뒤 OOM KV cache 증가, 과한 max_model_len, 동시 요청 과다 memory.used 추이, 요청별 입력 길이 max_model_len, max_num_seqs, 동시성을 순서대로 줄입니다.
    GPU 사용률이 낮은데 응답이 느림 클라이언트 병목, tokenizer/CPU 병목, 연결 재사용 실패 클라이언트 CPU, HTTP keep-alive, 서버 로그 비동기 클라이언트, 연결 풀, 클라이언트 분리 실행을 확인합니다.
    평균은 좋은데 p95가 나쁨 긴 요청 꼬리, 큐 대기 증가 요청 길이 p50/p95, 실패 요청 로그 워크로드를 길이별로 나누고 운영 한계는 평균이 아니라 p95로 잡습니다.

    운영에서는 ‘성능을 더 뽑는 옵션’보다 ‘실패할 때 예측 가능한 옵션’이 더 가치 있을 때가 많습니다. 예를 들어 gpu_memory_utilization을 올리면 실험실 처리량은 좋아질 수 있지만, 같은 GPU에서 모니터링 에이전트나 다른 프로세스가 함께 돌면 여유 메모리가 부족해질 수 있습니다. 벤치마크가 목적이면 한계를 밀어보고, 서비스가 목적이면 일부러 여유를 남기는 선택이 맞습니다.

    검증과 결과 해석: 평균값보다 꺾이는 지점을 찾으세요

    추론 성능 측정에서 제가 가장 신뢰하는 그림은 동시성별 TTFT, TPOT, E2E latency, tokens/sec, 실패율을 한 표에 놓은 것입니다. 평균만 있으면 위험합니다. 최소한 p50과 p95를 같이 보세요. p50은 평소 체감, p95는 불만이 시작되는 구간에 가깝습니다.

    1. 단일 요청에서 TTFT가 제품 UX에 맞는가?
    2. 동시성을 올렸을 때 처리량이 증가하는 구간과 지연만 늘어나는 구간이 어디인가?
    3. 먼저 오는 한계가 GPU 메모리인지, decode 처리량인지, 클라이언트 병목인지 구분했는가?
    4. 실패율이 0에 가까운 구간과 단순히 평균이 좋은 구간이 같은가?

    예를 들어 내부 문서 요약 챗봇을 만든다고 해보겠습니다. 사용자는 한 번에 긴 문서를 넣고 답변을 기다립니다. 이 경우 짧은 프롬프트로 초당 토큰 수만 재면 거의 도움이 안 됩니다. 2천 토큰, 8천 토큰, 16천 토큰처럼 입력 길이를 나누고, 출력 길이는 고정한 뒤 TTFT와 E2E를 봐야 합니다. 반면 짧은 고객 응대 챗봇이면 입력 길이보다 동시 요청과 p95 TTFT가 더 중요합니다.

    vLLM 로컬 LLM 성능 벤치마크 결과 대시보드

    동시성 증가에 따른 응답 시간, 처리량, GPU 메모리 사용량을 함께 보여주는 결과 대시보드 이미지입니다.

    비교 목적 고정할 값 바꿀 값 판단 기준
    모델 후보 비교 프롬프트, max_tokens, 동시성, vLLM 옵션 모델 경로 또는 revision 품질 평가는 별도로 두고, 여기서는 지연·처리량·메모리만 비교합니다.
    동시성 한계 확인 모델, 입력 길이, 출력 길이 max-concurrency, 클라이언트 수 처리량 증가가 둔화되고 p95 지연이 급등하기 직전이 운영 후보입니다.
    긴 컨텍스트 영향 모델, 출력 길이, 동시성 입력 토큰 길이, max_model_len TTFT와 GPU 메모리가 함께 증가하는지 봅니다.
    서버 옵션 튜닝 모델, 테스트 데이터, 클라이언트 gpu_memory_utilization, max_num_seqs 평균 처리량보다 실패율과 p95 안정성을 우선합니다.
    prefix cache 효과 확인 시스템 프롬프트, 모델, 동시성 반복 prefix 여부, enable_prefix_caching 같은 prefix가 많은 워크로드에서만 의미 있게 비교합니다.

    설정값 선택 기준: 빠르게보다 맞게 고르는 게 먼저입니다

    vLLM 옵션은 레버처럼 움직입니다. 하나를 올리면 다른 쪽에서 비용을 냅니다. max_model_len을 키우면 긴 입력을 받을 수 있지만 KV cache 예산이 커지고, max_num_seqs를 키우면 동시 처리 가능성은 늘지만 요청 하나의 지연 시간이 튈 수 있습니다. 그래서 설정은 ‘최대 성능’이 아니라 ‘내 워크로드에 맞는 실패 방식’을 고르는 일에 가깝습니다.

    상황 추천 방향 피해야 할 선택
    개인 실험, 단일 사용자 작은 max_num_seqs, 필요한 만큼의 max_model_len, 단일 요청 TTFT 확인 운영도 안 할 긴 컨텍스트를 크게 열어 메모리를 낭비하는 것
    짧은 채팅을 여러 사용자가 사용 동시성 단계 테스트, p95 TTFT 기준 운영선 설정 평균 tokens/sec만 보고 동시성 상한을 높게 잡는 것
    긴 문서 요약 입력 길이 버킷별 측정, max_model_len 보수적 설정, 긴 요청 별도 큐 고려 짧은 프롬프트 벤치마크 결과를 긴 문서 처리에 적용하는 것
    동일 시스템 프롬프트 반복 enable_prefix_caching 효과를 별도 실험 prefix가 매번 다른데 캐시 효과를 기대하는 것
    한 GPU에 다른 프로세스도 실행 gpu_memory_utilization을 낮춰 여유 확보 벤치마크 순간 최대 처리량만 보고 메모리를 끝까지 쓰는 것

    제 기준의 기본값은 이렇습니다. 처음에는 max_model_len을 실제 요청 p95보다 약간 큰 수준으로 잡고, gpu_memory_utilization은 여유를 남긴 값에서 시작합니다. 그다음 동시성을 올리며 p95 TTFT와 실패율을 봅니다. 처리량이 조금 더 나오더라도 p95가 급격히 튀는 지점은 운영선으로 잡지 않습니다.

    자주 묻는 질문

    Q. vLLM만 쓰면 무조건 빨라지나요?

    아닙니다. vLLM은 서버형 추론, 특히 동시 요청 처리에서 강점이 큽니다. 하지만 단일 요청을 짧게 돌리는 실험에서는 차이가 작을 수 있고, 모델이 GPU 메모리에 빠듯하게 올라가는 환경에서는 설정을 잘못 잡으면 오히려 불안정해질 수 있습니다.

    Q. 로컬 LLM 성능 비교에서 가장 먼저 볼 지표는 뭔가요?

    채팅 UX라면 TTFT와 p95 TTFT를 먼저 봅니다. 배치 요약이나 내부 자동화라면 E2E latency와 처리량을 더 봅니다. 여러 사용자가 붙는 서비스라면 평균보다 실패율과 p95가 우선입니다.

    Q. 블로그에 있는 벤치마크 숫자를 믿어도 되나요?

    참고는 됩니다. 다만 그대로 의사결정에 쓰면 위험합니다. GPU, 드라이버, vLLM 버전, 모델 revision, 입력 길이, 출력 길이, 동시성, 스트리밍 여부가 다르면 결과가 바뀝니다. 같은 절차로 내 장비에서 다시 재는 것이 가장 확실합니다.

    Q. 양자화 모델은 항상 좋은 선택인가요?

    메모리 절감에는 유리할 수 있지만 품질, 지원 커널, decode 성능, 운영 안정성을 함께 봐야 합니다. ‘올라간다’와 ‘서비스하기 좋다’는 다릅니다. 양자화 여부를 바꿀 때는 모델 품질 평가와 추론 성능 평가를 분리하세요.

    Q. GPU 사용률이 낮으면 vLLM 설정 문제인가요?

    항상 그렇지는 않습니다. 클라이언트가 요청을 충분히 못 넣거나, CPU 토크나이징이 막히거나, 네트워크/HTTP 연결이 병목일 수 있습니다. 서버 옵션을 바꾸기 전에 클라이언트가 실제로 원하는 동시성을 만들고 있는지 확인하는 편이 빠릅니다.

    마지막 판단: 운영선은 가장 빠른 지점이 아니라 덜 흔들리는 지점입니다

    로컬 LLM 성능에서 중요한 질문은 ‘어떤 모델이 제일 빠른가’가 아니라 ‘내 서버가 어떤 요청 분포까지 예측 가능하게 버티는가’입니다. 저는 단일 최고 점수보다 반복 측정에서 비슷하게 나오는 구간을 더 신뢰합니다. 운영자는 평균값이 아니라 나쁜 날의 p95를 상대해야 하니까요.

    개인 실험용이면 작은 모델부터 시작해 TTFT와 GPU 메모리 여유를 확인하세요. 여러 사용자가 붙는 내부 서비스라면 vLLM 서버를 띄운 뒤 동시성별 p50/p95 TTFT, E2E latency, tokens/sec, 실패율을 함께 기록하세요. 긴 문서 요약처럼 입력이 긴 워크로드라면 짧은 채팅 벤치마크를 버리고 입력 길이 버킷을 따로 만들어야 합니다.

    제가 실제로 권하는 절차는 단순합니다. 워밍업을 제외하고, 같은 프롬프트 세트와 같은 출력 길이로, 동시성을 1에서 시작해 단계적으로 올립니다. 처리량이 더 늘지 않거나 p95 지연 시간이 갑자기 커지거나 실패가 섞이는 지점이 나오면, 그 직전 단계를 운영 후보로 잡습니다. 그다음 max_model_len, max_num_seqs, gpu_memory_utilization을 하나씩만 바꿔 다시 측정합니다.

    다음 글에서는 Prometheus와 Grafana를 붙여 vLLM 추론 서버의 요청 지표, GPU 메모리, 지연 시간 분포를 시각화하는 방법을 다룰 예정입니다. 관련 글로는 GPU 모니터링, 모델 서빙 아키텍처, LLM 비용 최적화 글을 함께 연결하면 독자가 다음 단계로 넘어가기 좋습니다. 벤치마크는 한 번 찍는 스크린샷이 아니라 운영 기준을 만드는 과정입니다.

    TTFT, 처리량, 동시성, GPU 메모리 기준으로 로컬 LLM 성능 비교 방법을 요약한 인포그래픽입니다.

    제 기준의 한 줄 권고는 이렇습니다. 빠른 숫자 하나를 찾지 말고, 같은 조건에서 동시성을 올리며 p95 지연 시간과 실패율이 꺾이는 지점을 찾으세요. 그 직전이 여러분 로컬 LLM 서버의 현실적인 운영선입니다.

  • [AI] Ollama 비용 비교: 로컬 AI vs 클라우드 API 실측 분석

    [AI] Ollama 비용 비교: 로컬 AI vs 클라우드 API 실측 분석

    [AI 운영] Ollama 비용 비교: 로컬 AI vs 클라우드 API

    로컬 AI를 붙여볼까, 아니면 그냥 API로 갈까. 이 고민 한 번쯤 해보셨을 겁니다. 저도 홈랩에서 이것저것 붙여 보다가 Ollama 비용이 생각보다 단순하지 않다는 걸 꽤 늦게 깨달았거든요. 처음엔 “로컬은 공짜 아니야?”라고 생각했는데, 실제로 써보니까 하드웨어 감가상각, 전력, 운영 시간, 장애 대응까지 다 합쳐서 봐야 하더라고요. 반대로 클라우드 API는 비싸 보이지만, 잘 쓰면 운영 부담이 거의 없어서 총비용(TCO, Total Cost of Ownership) 기준으로는 더 낫기도 합니다.

    이 글은 제가 13년차 인프라 엔지니어로서 실제 운영 관점에서 정리한 비교입니다. 특정 벤치마크 숫자를 억지로 붙이기보다, 어떻게 측정하고 어떤 기준으로 판단해야 하는지에 집중하겠습니다. 특히 로컬 LLM, Claude API 비용, 그리고 흔히 오해하기 쉬운 AI 운영 비용까지 같이 보겠습니다.

    Ollama 비용 비교를 위한 로컬 서버와 클라우드 API 아키텍처 개요 이미지

    로컬 추론 서버, 사내 애플리케이션, 외부 클라우드 API가 어떻게 연결되는지 한 장으로 보여주는 개요 이미지입니다.

    1. 왜 다들 Ollama 비용에서 헷갈릴까요?

    쉽게 말해, 로컬은 토큰당 과금이 안 보일 뿐이지 비용이 없는 게 아닙니다. 반대로 클라우드는 청구서가 너무 잘 보여서 비싸게 느껴지는 거고요. 제가 처음엔 이 부분에서 삽질 좀 했습니다 ㅎㅎ 눈앞에 보이는 건 API 청구서뿐인데, 실제로는 집이나 사무실에 있는 장비가 계속 전기를 먹고 있고, 디스크도 차고, 백업도 해야 하고, 장애 나면 내가 직접 봐야 하거든요.

    • 로컬 AI(Ollama): 토큰 과금 대신 하드웨어, 전력, 운영 인건비가 들어갑니다.
    • 클라우드 API: 초기 투자 없이 바로 쓰지만, 사용량이 늘면 월 비용이 빠르게 올라갑니다.
    • 핵심 포인트: 둘 중 뭐가 싼지는 “감정”이 아니라 “트래픽 패턴”으로 결정됩니다.

    2. 로컬 LLM vs 클라우드 API, 개념을 아주 쉽게 풀어보면

    Ollama는 오픈 웨이트(open weights, 공개 배포 모델)를 로컬에서 쉽게 실행하게 해주는 러너(runner)라고 보시면 됩니다. 설치가 간단하고, 모델을 pull 해서 바로 돌릴 수 있어서 입문 장벽이 낮습니다. 제가 직접 써보니 개발 PC나 홈랩에서 빠르게 검증할 때 정말 편하더라고요.

    반면 클라우드 API는 모델 자체를 직접 운영하지 않고, 요청(request)과 응답(response)에 대해 비용을 내는 구조입니다. 예를 들어 Anthropic의 Claude API는 공식 가격 페이지 기준으로 모델별 입력 토큰(input token)과 출력 토큰(output token) 단가가 분리되어 있습니다.

    항목 Ollama 로컬 실행 클라우드 API
    초기비용 장비가 필요함 거의 없음
    월비용 구조 전력, 감가상각, 운영비 토큰 사용량 기반
    확장성 장비 한계에 좌우 상대적으로 쉬움
    보안/격리 오프라인 운영 가능 정책 검토 필요
    운영 난이도 직접 관리 낮은 편

    여기서 중요한 포인트!

    Ollama NPU 같은 키워드 때문에 “NPU만 있으면 로컬 AI가 무조건 유리하다”라고 생각하시는 분도 있는데요, 실제 운영에서는 모델 크기, 메모리 용량, 메모리 대역폭, 동시 요청 수가 더 크게 체감됩니다. 저도 처음엔 가속기 종류만 보다가, 나중에 병목이 저장소가 아니라 메모리 쪽이라는 걸 체감했었습니다.

    3. Ollama 비용 계산은 이렇게 해야 덜 틀립니다

    제가 권하는 방식은 아주 단순합니다. 로컬은 월 고정비처럼 보고, 클라우드는 사용량 기반 변동비처럼 보시면 됩니다.

    로컬 AI 운영 비용 계산식

    1. 장비 구매비를 사용 예정 개월 수로 나눕니다.
    2. 월 전력 비용을 더합니다.
    3. 스토리지, 백업, 모니터링 같은 부대비용을 더합니다.
    4. 운영 시간까지 돈으로 환산할지 결정합니다.
    월 로컬 비용 = (장비 구매비 / 사용 개월 수) + 월 전력비 + 부대비용 + 운영 인건비(선택)

    예를 들어 개발팀에서 내부 문서 검색 보조나 코드 요약처럼 예측 가능한 고정 부하가 있다면, 로컬이 생각보다 유리해질 수 있습니다. 반대로 요청이 들쑥날쑥하고 야간 피크가 큰 서비스라면 장비를 놀리는 시간이 많아져서 비효율이 생깁니다.

    Claude API 비용 계산식

    Anthropic 공식 가격 페이지를 보면 Claude Sonnet 4.6은 입력 1MTok당 $3, 출력 1MTok당 $15이고, Claude Haiku 4.5는 입력 1MTok당 $1, 출력 1MTok당 $5입니다. 여기서 중요한 건 입력과 출력이 따로 과금된다는 점입니다.

    월 API 비용 = (월 입력 토큰 / 1,000,000 × 입력 단가) + (월 출력 토큰 / 1,000,000 × 출력 단가)

    이 계산식만 붙여도 Claude API 비용 감이 꽤 빨리 옵니다. 특히 프롬프트가 길거나, RAG(Retrieval-Augmented Generation, 검색증강생성)로 문서를 많이 붙이는 구조라면 입력 토큰이 생각보다 많이 나옵니다.

    4. 실전 구현: 로컬 Ollama와 클라우드 API를 같은 기준으로 재보기

    비교는 공정해야 합니다. 제가 보통 맞추는 기준은 4개입니다. 같은 작업 유형, 비슷한 길이의 프롬프트, 같은 동시성, 같은 로그 포맷. 이 4개가 안 맞으면 숫자가 예쁘게 나와도 의미가 없습니다.

    1. 로컬에 Ollama를 설치합니다.
    2. 테스트용 모델을 하나 내려받습니다.
    3. 동일 프롬프트로 로컬과 API를 각각 호출합니다.
    4. 지연시간(latency, 응답 지연), 실패율, 토큰 사용량을 같은 형식으로 남깁니다.
    curl -fsSL https://ollama.com/install.sh | sh
    ollama pull qwen2
    ollama run qwen2

    설치는 정말 빠릅니다. 근데 여기서 끝이 아니더라고요. 실무에서는 “잘 실행된다”보다 “반복 호출해도 안정적인가”가 더 중요합니다.

    로컬 LLM 구성에서 Ollama 비용과 자원 사용 흐름을 보여주는 이미지

    Ollama 설치 이후 모델 다운로드, 로컬 추론, 애플리케이션 호출 흐름을 단계별로 보여주는 구성 이미지입니다.

    같은 프롬프트를 보내는 간단한 테스트 예시

    import os
    import time
    import requests
    
    PROMPT = "사내 장애 보고서를 5줄로 요약하고, 후속 조치 3가지를 제안해 주세요."
    
    def test_ollama():
        started = time.time()
        r = requests.post(
            "http://localhost:11434/api/generate",
            json={"model": "qwen2", "prompt": PROMPT, "stream": False},
            timeout=120,
        )
        elapsed = time.time() - started
        return {"target": "ollama", "status": r.status_code, "elapsed_sec": round(elapsed, 2)}
    
    def test_claude():
        started = time.time()
        r = requests.post(
            "https://api.anthropic.com/v1/messages",
            headers={
                "x-api-key": os.environ["ANTHROPIC_API_KEY"],
                "anthropic-version": "2023-06-01",
                "content-type": "application/json",
            },
            json={
                "model": "claude-sonnet-4-6",
                "max_tokens": 300,
                "messages": [{"role": "user", "content": PROMPT}],
            },
            timeout=120,
        )
        elapsed = time.time() - started
        return {"target": "claude_api", "status": r.status_code, "elapsed_sec": round(elapsed, 2)}
    
    print(test_ollama())
    print(test_claude())

    이 정도만 돌려도 감이 옵니다. 로컬은 장비 상태 영향을 많이 받고, 클라우드는 네트워크 왕복이 있지만 운영은 단순합니다.

    5. ⚠️ 주의사항: 제가 실제로 자주 부딪힌 문제들

    1) 로컬은 “무료”가 아니라 “숨은 비용”이 많습니다

    가장 흔한 오해입니다. Ollama 비용을 0원처럼 보면 의사결정이 틀어집니다. 디스크가 꽉 차거나 모델 캐시가 꼬이면 정리 시간이 들어가고, 백업 정책 없으면 장애 복구도 직접 해야 합니다.

    2) 모델 성능 비교를 제품 간 단순 비교로 하면 안 됩니다

    같은 작업이라도 로컬 모델과 상용 API 모델은 튜닝 상태, 컨텍스트 길이, 응답 품질이 다릅니다. 그래서 저는 “정답률” 하나만 보지 않고 아래 항목을 같이 봅니다.

    • 응답 품질 일관성
    • 지연시간 편차
    • 실패 재시도 비율
    • 운영자가 손대야 하는 빈도

    3) Ollama NPU만 보고 설계하면 낭패 보기 쉽습니다

    이건 특히 노트북 기반 테스트에서 많이 보입니다. NPU가 있더라도 모든 워크로드가 자동으로 유리해지는 건 아닙니다. 실제로는 모델 메모리 요구사항과 드라이버 성숙도, 배치(batch, 일괄 처리) 특성이 더 중요할 때가 많았습니다.

    4) 클라우드는 프롬프트 설계가 곧 비용 절감입니다

    제가 써보니까 API 쪽은 인프라보다 프롬프트 정리가 더 큰 절감 포인트인 경우가 많았습니다. 시스템 프롬프트를 너무 길게 쓰거나, RAG 문서를 매번 과하게 붙이면 비용이 금방 올라갑니다.

    6. 검증/결과: 무엇을 보면 판단이 빨라질까요?

    벤치마크 툴을 거창하게 만들 필요는 없습니다. 아래 5가지만 수집해도 충분합니다.

    1. 평균 지연시간과 p95 지연시간
    2. 실패율과 재시도 횟수
    3. 월 입력/출력 토큰
    4. 동시 요청 수 증가 시 품질 저하 여부
    5. 운영자가 개입한 시간

    저는 여기서 마지막 항목을 꼭 봅니다. 왜냐하면 AI 운영 비용은 서버 비용만이 아니라, 결국 사람 시간까지 포함해서 봐야 하거든요.

    Ollama 비용과 Claude API 비용을 비교하는 운영 대시보드 이미지

    응답 지연, 실패율, 토큰 사용량, 추정 월 비용을 한 번에 비교하는 운영 대시보드 이미지입니다.

    상황 로컬 Ollama가 유리한 경우 클라우드 API가 유리한 경우
    보안 오프라인 또는 내부망 우선 외부 반출 정책 검토 가능
    트래픽 예측 가능한 고정 사용량 변동 폭이 큼
    운영 인력 직접 관리 가능 인프라 운영 여력이 적음
    초기 도입 실험 장비가 이미 있음 빠른 PoC가 필요함
    확장 제한적 상대적으로 유연함

    정리하면 이렇습니다. Ollama 비용은 장기적이고 예측 가능한 내부 업무에 잘 맞고, Claude API 비용은 초기 투자 없이 빠르게 시작해야 하는 팀에 잘 맞습니다. 어느 쪽이 무조건 낫다기보다, 트래픽 곡선과 운영 역량이 답을 정해 줍니다.

    7. 자주 묻는 질문

    Q1. 작은 팀도 로컬 LLM을 바로 도입할 만한가요?

    가능은 합니다. 다만 장비보다 먼저 운영 목표를 정하시는 게 좋습니다. 사내 요약, 분류, 초안 작성처럼 반복 업무가 명확하면 훨씬 판단이 쉽습니다.

    Q2. API 비용이 무서운데, 바로 로컬로 가야 할까요?

    꼭 그렇진 않습니다. 제가 추천하는 순서는 보통 API로 빠르게 검증한 뒤, 사용 패턴이 굳으면 그때 로컬 이전을 검토하는 방식입니다. 이게 실패 비용이 낮더라고요.

    Q3. 실측 분석은 얼마나 돌려봐야 믿을 수 있나요?

    최소한 평일 업무 시간대와 야간 시간대는 나눠서 보시는 걸 권합니다. 짧게 한두 번 돌려서는 운영 현실이 잘 안 보입니다.

    8. 마무리: 결론은 “누가 더 싼가”가 아니라 “내 패턴에 뭐가 맞는가”입니다

    처음엔 저도 로컬이면 무조건 이득일 줄 알았습니다. 근데 실제로 써보니까, 운영 가능한 로컬과 그냥 돌아가는 로컬은 완전히 다른 이야기더라고요. 반대로 클라우드 API도 비싸 보이지만, 운영 복잡도를 줄여 준다는 점에서 값어치를 할 때가 분명히 있습니다.

    그래서 제 기준은 이겁니다. 고정 부하, 데이터 통제, 반복 업무면 Ollama 쪽을 먼저 보고, 빠른 검증, 유연한 확장, 낮은 운영 부담이면 클라우드 API를 먼저 봅니다. 혹시 지금 막 비교 중이시라면, 장비 스펙부터 보지 마시고 먼저 한 달 사용량과 프롬프트 길이부터 적어 보세요. 그게 제일 빨랐습니다.

    Ollama 비용 기준으로 로컬 AI와 클라우드 API 선택 포인트를 정리한 이미지

    보안, 비용 구조, 성능, 운영 난이도를 기준으로 어떤 선택이 맞는지 한눈에 정리한 요약 인포그래픽입니다.

    다음 글에서는 Ollama + 벡터DB(vector database) + RAG 조합에서 실제로 비용이 어디서 새는지 더 구체적으로 다뤄보겠습니다. 이전 글에서 다룬 홈랩 관측성(observability) 구성과도 연결해 보시면 훨씬 이해가 쉬우실 겁니다.

  • [AI] Ollama로 로컬 AI 환경 구축: LLM 모델 설치 및 활용 완벽 가이드

    클라우드 AI에 지쳐서 직접 내 서버에 AI를 올려봤습니다

    솔직히 말씀드리면, 저도 처음엔 GPT API 쓰면 되지 뭘 굳이 로컬에서 돌리나 싶었거든요. 근데 쓰다 보니까 문제가 생기더라고요. 회사 코드를 붙여넣기 하기가 찜찜하고, API 비용은 은근히 쌓이고, 인터넷 연결이 불안정한 환경에서는 아예 못 쓰고. 이런 상황이 반복되다 보니 결국 로컬 AI 환경 구축을 진지하게 고민하게 됐습니다.

    그러다 발견한 게 Ollama입니다. 처음 써봤을 때 진짜 “이게 이렇게 쉬워도 되나?” 싶을 정도로 간단했어요. 명령어 몇 줄로 LLM 모델 설치가 끝나고, 바로 터미널에서 대화할 수 있거든요. 오늘은 제가 홈랩에서 Ollama를 세팅하면서 겪은 경험을 바탕으로, 처음 시작하시는 분들도 막히지 않도록 단계별로 정리해 드리겠습니다.

    ▲ Ollama를 중심으로 구성된 로컬 AI 환경의 전체 흐름. 사용자 요청이 Ollama 서버를 통해 LLM 모델로 전달되는 구조입니다.

    Ollama란? — 쉽게 말해서 LLM용 Docker

    Ollama를 처음 접하시는 분들을 위해 간단히 설명드릴게요. Ollama는 LLM(대규모 언어 모델)을 로컬 환경에서 쉽게 실행할 수 있도록 도와주는 오픈소스 도구입니다. 쉽게 말해서, 도커(Docker)가 컨테이너 이미지를 pull 받아서 실행하듯이, Ollama는 LLM 모델을 pull 받아서 바로 실행해 준다고 보시면 됩니다.

    기존에 로컬에서 LLM을 돌리려면 Python 환경 세팅하고, 모델 파일 직접 다운로드하고, 의존성 패키지 맞추고… 이게 보통 일이 아니었거든요. 저도 llama.cpp 직접 빌드하다가 CUDA 버전 안 맞아서 몇 시간 날린 적 있습니다. Ollama는 이 복잡함을 싹 없애줍니다.

    • ✅ 단일 바이너리 설치 — 별도 Python 환경 불필요
    • ✅ 모델 허브 제공 — ollama pull 모델명 한 줄로 다운로드
    • ✅ REST API 기본 제공 — 웹 앱 연동이 쉬움
    • ✅ GPU 가속 자동 감지 — NVIDIA, AMD, Apple Silicon 모두 지원
    • ✅ 완전 오프라인 동작 — 모델 다운로드 후에는 인터넷 불필요

    Ollama 지원 모델 한눈에 보기

    Ollama에서 공식적으로 제공하는 모델들 중 자주 쓰이는 것들을 정리해 봤습니다. 처음 시작하신다면 llama3나 mistral부터 해보시는 걸 추천드려요.

    모델명 파라미터 크기 특징 권장 RAM
    llama3 8B / 70B Meta의 최신 오픈소스 모델, 범용 성능 우수 8GB / 40GB+
    mistral 7B 가볍고 빠름, 코딩과 요약에 강점 8GB
    gemma 2B / 7B Google의 경량 모델, 저사양에서도 동작 4GB / 8GB
    qwen 다양한 크기 한국어와 중국어 포함 다국어 성능 양호 모델별 상이
    codellama 7B / 13B 코드 생성과 완성에 특화된 모델 8GB / 16GB
    phi3 3.8B Microsoft의 소형 고성능 모델 4GB

    💡 팁: 모델 파라미터 수가 클수록 성능은 좋지만 그만큼 VRAM과 RAM이 많이 필요합니다. 본인 장비 사양에 맞게 고르는 게 핵심이에요.

    Ollama 설치 — 생각보다 훨씬 간단합니다

    자, 이제 실전으로 넘어가 볼게요. 설치 자체는 정말 간단합니다. OS별로 방법이 조금씩 다른데, 하나씩 보여드릴게요.

    Linux와 macOS 설치

    # 공식 설치 스크립트 (Linux / macOS 공통)
    curl -fsSL https://ollama.com/install.sh | sh

    이 한 줄이면 끝납니다. 진짜로요. 설치 스크립트가 OS를 자동 감지해서 적절한 바이너리를 내려받고 서비스 등록까지 해줍니다. macOS라면 GUI 앱으로도 설치할 수 있는데, 저는 CLI가 더 편해서 위 방법을 씁니다.

    Windows 설치

    Windows는 공식 홈페이지(ollama.com)에서 설치 파일을 받아서 실행하면 됩니다. 설치 후 자동으로 백그라운드 서비스로 뜨거든요.

    Docker로 설치하기 (서버 환경 추천)

    # CPU 전용
    docker run -d -v ollama:/root/.ollama -p 11434:11434 --name ollama ollama/ollama
    
    # NVIDIA GPU 사용시 (nvidia-docker 설치 필요)
    docker run -d --gpus=all -v ollama:/root/.ollama -p 11434:11434 --name ollama ollama/ollama

    저는 홈랩 서버에서는 Docker로 올려두고 쓰고 있어요. 관리가 편하거든요. 특히 -v ollama:/root/.ollama 볼륨 마운트를 꼭 해주셔야 모델 파일이 컨테이너 재시작 후에도 유지됩니다. 처음에 이거 빠뜨려서 모델을 다시 받았던 기억이 나네요.

    ▲ Ollama 설치 완료 후 llama3 모델을 pull 받는 터미널 화면. 도커처럼 레이어 단위로 다운로드되는 걸 확인할 수 있습니다.

    LLM 모델 설치 및 실행하기

    Ollama가 설치됐으면 이제 모델을 받아볼 차례입니다. 명령어 구조가 Docker랑 정말 비슷해서, Docker 써보신 분들은 금방 익숙해지실 거예요.

    모델 다운로드 (pull)

    # 모델 목록 확인
    ollama list
    
    # 모델 다운로드 (예: llama3 8B 모델)
    ollama pull llama3
    
    # 특정 버전과 크기 지정
    ollama pull llama3:8b
    ollama pull llama3:70b
    
    # mistral 모델
    ollama pull mistral
    
    # 경량 모델 (저사양 PC 추천)
    ollama pull phi3
    ollama pull gemma:2b

    모델 실행 및 대화하기

    # 터미널에서 바로 대화 (대화형 모드)
    ollama run llama3
    
    # 실행 후 이런 프롬프트가 뜹니다:
    # >>> 여기에 질문을 입력하세요
    # >>> 안녕하세요! 파이썬으로 피보나치 수열 짜는 법 알려주세요
    
    # 종료는 /bye 또는 Ctrl+D
    >>> /bye

    처음 ollama run llama3 입력했을 때 드디어 로컬에서 AI가 답변하는 걸 보고 진짜 신기했습니다. 인터넷 끊어도 되고, API 키도 필요 없고. 이 맛에 로컬 AI 하는 거죠 🎉

    단일 질의 (Non-interactive 모드)

    # 스크립트에서 활용할 때 유용
    ollama run llama3 "리눅스에서 디스크 사용량 확인하는 명령어 알려줘"
    
    # 파이프로 입력 전달
    echo "이 코드 리뷰해줘" | ollama run codellama

    REST API로 호출하기

    Ollama는 기본적으로 11434 포트로 REST API 서버를 띄워줍니다. 이걸 활용하면 어떤 언어에서든 Ollama와 통신할 수 있어요.

    # curl로 API 직접 호출
    curl http://localhost:11434/api/generate -d '{
      "model": "llama3",
      "prompt": "파이썬으로 Hello World 출력하는 법",
      "stream": false
    }'

    응답은 JSON 형식으로 돌아오는데, 여기서 response 필드에 AI의 답변이 들어있습니다. "stream": true로 설정하면 스트리밍 방식으로 답변을 받을 수 있어서 사용자 경험이 더 좋습니다.

    Python에서 Ollama 활용하기

    Python 개발자라면 이렇게 간단하게 Ollama를 연동할 수 있습니다.

    import requests
    import json
    
    def ask_ollama(prompt, model="llama3"):
        url = "http://localhost:11434/api/generate"
        payload = {
            "model": model,
            "prompt": prompt,
            "stream": False
        }
        response = requests.post(url, json=payload)
        return response.json()["response"]
    
    # 사용 예시
    answer = ask_ollama("Ollama가 뭔가요?")
    print(answer)

    이제 Python 스크립트에서 로컬 LLM을 쉽게 활용할 수 있습니다. API 키 없이, 인터넷 연결 없이 말이죠.

  • [AI] LLM 파인튜닝 실전 가이드: LoRA/QLoRA로 도메인 특화 모델 만들기

    “GPT한테 물어봤더니 우리 회사 용어를 하나도 모르더라고요”

    이런 경험 한 번쯤 있으시죠? 저도 작년에 사내 기술 문서 Q&A 봇을 만들려고 ChatGPT API를 붙여봤는데… 일반적인 질문엔 잘 대답하는데 우리 팀 특유의 약어나 내부 시스템 이름을 물어보면 완전 엉뚱한 소리를 하더라고요. RAG(Retrieval-Augmented Generation, 검색 기반 생성)로 어느 정도 해결은 됐는데, 근본적으로 모델 자체가 도메인을 이해하게 하고 싶다는 생각이 들었습니다.

    그래서 시작한 게 LLM 파인튜닝이었는데요. 처음엔 “GPU 수십 장 없이 가능하겠어?” 싶었는데, LoRA(Low-Rank Adaptation)와 QLoRA(Quantized LoRA) 덕분에 홈랩 서버 한 대로도 충분히 가능하다는 걸 알게 됐습니다. 오늘은 제가 직접 삽질하면서 익힌 실전 LLM 파인튜닝 과정을 공유해 드릴게요.

    ▲ LLM 파인튜닝의 전체 파이프라인 — 데이터 준비부터 학습, 추론까지의 흐름을 한눈에 볼 수 있습니다.

    LoRA / QLoRA가 뭔지부터 짚고 넘어가죠

    파인튜닝이라는 개념 자체는 간단합니다. 이미 잘 학습된 모델을 내 데이터로 추가 학습시키는 거예요. 근데 문제가 있습니다. LLaMA 3나 Mistral 같은 모델은 파라미터가 수십억 개거든요. 이걸 전부 다시 학습시키려면 A100 GPU가 여러 장 필요하고, 메모리도 수백 GB가 필요합니다. 현실적으로 홈랩에서 불가능한 수준이죠.

    그래서 나온 게 LoRA(Low-Rank Adaptation, 저랭크 적응 학습)입니다. 쉽게 말해서, 모델 전체를 학습시키는 게 아니라 “변화량”만 학습시키는 방식이에요. 원래 모델의 가중치(weight)는 그대로 얼려두고(freeze), 그 옆에 훨씬 작은 보조 행렬(adapter)만 학습시키는 거거든요.

    그리고 QLoRA(Quantized LoRA)는 여기서 한 발 더 나아가서, 원본 모델을 4비트(4-bit)로 양자화(quantization)해서 메모리를 훨씬 적게 쓰면서 LoRA 학습을 하는 방식입니다. 제가 RTX 3090 한 장으로 13B 모델을 파인튜닝할 수 있었던 게 바로 QLoRA 덕분이었어요.

    방식 전체 파인튜닝 (Full Fine-tuning) LoRA QLoRA
    학습 파라미터 수 전체 (수십억) 소수 (수백만) 소수 (수백만)
    메모리 요구량 매우 높음 중간 낮음
    원본 모델 정밀도 FP16/BF16 FP16/BF16 4-bit (NF4)
    홈랩 가능 여부 ❌ (7B 이상 어려움) △ (7B 정도 가능) ✅ (13B~34B도 가능)
    성능 손실 없음 (기준) 미미 약간 있음

    저처럼 GPU 한 장으로 실험하시는 분이라면 QLoRA가 현실적인 선택입니다. 성능 차이가 생각보다 크지 않아서 실제 서비스에서도 충분히 쓸 수 있는 수준이더라고요.

    환경 준비 — 이 부분에서 삽질 좀 했습니다 ㅎㅎ

    제 홈랩 환경 기준으로 설명드릴게요. CUDA 버전 호환성 때문에 처음에 꽤 고생했거든요. CUDA 버전과 PyTorch 버전, bitsandbytes 버전이 삼각형처럼 맞물려야 합니다. 하나라도 틀리면 에러 폭탄이 터져요.

    # Python 가상환경 먼저 만들어주세요 (conda 추천)
    conda create -n llm-finetune python=3.10
    conda activate llm-finetune
    
    # PyTorch 설치 (CUDA 11.8 기준 — 본인 환경에 맞게 조정)
    pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118
    
    # 핵심 라이브러리 설치
    pip install transformers==4.40.0
    pip install peft==0.10.0          # LoRA 구현체
    pip install bitsandbytes==0.43.0  # 4-bit 양자화
    pip install accelerate==0.29.0
    pip install datasets==2.18.0
    pip install trl==0.8.6            # SFT Trainer 포함
    
    # 설치 확인
    python -c "import torch; print(torch.cuda.is_available())"

    💡 팁: bitsandbytes는 Windows에서 설치가 까다롭습니다. WSL2나 Linux 환경을 강력 추천드려요. 저도 처음에 Windows에서 삽질하다가 결국 Ubuntu로 갈아탔습니다.

    학습 데이터 준비 — 사실 이게 제일 중요해요

    파인튜닝에서 코드보다 더 중요한 게 데이터입니다. 진짜로요. 좋은 데이터 1000개가 나쁜 데이터 10만 개보다 낫다는 걸 직접 경험했거든요.

    LLM 파인튜닝용 데이터는 보통 Instruction 형식으로 만듭니다. 이렇게 생겼어요:

    [
      {
        "instruction": "우리 회사의 배포 프로세스를 설명해줘",
        "input": "",
        "output": "우리 회사의 배포 프로세스는 다음과 같습니다. 먼저 개발자가 feature 브랜치에서 작업 후 PR을 올리면..."
      },
      {
        "instruction": "다음 에러 로그를 분석해줘",
        "input": "ERROR: Connection refused to internal-db-01:5432",
        "output": "이 에러는 PostgreSQL 데이터베이스 서버에 연결이 거부된 것입니다. 주요 원인으로는..."
      }
    ]

    이걸 학습 형식으로 변환하는 코드를 보여드릴게요:

    from datasets import Dataset
    import json
    
    def format_instruction(sample):
        """Alpaca 스타일의 프롬프트 포맷"""
        if sample["input"]:
            prompt = f"""### Instruction:
    {sample["instruction"]}
    
    ### Input:
    {sample["input"]}
    
    ### Response:
    {sample["output"]}"""
        else:
            prompt = f"""### Instruction:
    {sample["instruction"]}
    
    ### Response:
    {sample["output"]}"""
        return {"text": prompt}
    
    # 데이터 로드 및 변환
    with open("my_domain_data.json", "r", encoding="utf-8") as f:
        raw_data = json.load(f)
    
    dataset = Dataset.from_list(raw_data)
    dataset = dataset.map(format_instruction)
    print(f"총 학습 데이터: {len(dataset)}개")
    print(dataset[0]["text"][:200])  # 첫 번째 샘플 미리보기

    ⚠️ 주의사항: 데이터 품질 체크는 필수입니다. 중복 데이터, 너무 짧은 응답(10자 이하), 특수문자가 깨진 데이터는 학습 전에 꼭 걸러내세요. 저는 이걸 건너뛰었다가 모델이 이상한 패턴을 학습해서 처음부터 다시 한 적 있습니다.

    QLoRA 파인튜닝 실전 코드

    드디어 본론입니다. 제가 실제로 사용하는 학습 스크립트예요. 주석을 충분히 달아놨으니 따라가 보세요.

    ▲ QLoRA 학습 진행 중 GPU 메모리 사용량과 학습 손실(loss) 변화 — RTX 3090 기준으로 13B 모델도 충분히 학습 가능합니다.

    import torch
    from transformers import (
        AutoModelForCausalLM,
        AutoTokenizer,
        BitsAndBytesConfig,
        TrainingArguments
    )
    from peft import LoraConfig, get_peft_model, TaskType
    from trl import SFTTrainer
    from datasets import load_dataset
    
    # ===== 1. 모델 설정 =====
    # 베이스 모델 선택 (Hugging Face Hub에서 다운로드)
    # 예시: meta-llama/Meta-Llama-3-8B, mistralai/Mistral-7B-v0.1 등
    BASE_MODEL = "mistralai/Mistral-7B-v0.1"  # 본인 용도에 맞게 변경
    OUTPUT_DIR = "./my-domain-model"
    
    # ===== 2. 4-bit 양자화 설정 (QLoRA의 핵심) =====
    bnb_config = BitsAndBytesConfig(
        load_in_4bit=True,               # 4-bit로 모델 로드
        bnb_4bit_use_double_quant=True,  # 이중 양자화로 메모리 추가 절약
        bnb_4bit_quant_type="nf4",       # NF4 타입 (QLoRA 논문 권장)
        bnb_4bit_compute_dtype=torch.bfloat16  # 계산은 bfloat16으로
    )
    
    # ===== 3. 모델 & 토크나이저 로드 =====
    print("모델 로딩 중... (처음엔 시간이 좀 걸려요)")
    model = AutoModelForCausalLM.from_pretrained(
        BASE_MODEL,
        quantization_config=bnb_config,
        device_map="auto",  # GPU 자동 할당
        trust_remote_code=True
    )
    model.config.use_cache = False  # 학습 시엔 꺼야 합니다
    
    tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL, trust_remote_code=True)
    tokenizer.pad_token = tokenizer.eos_token  # 패딩 토큰 설정
    tokenizer.padding_side = "right"  # 오른쪽 패딩 (중요!)
    
    # ===== 4. LoRA 어댑터 설정 =====
    lora_config = LoraConfig(
        task_type=TaskType.CAUSAL_LM,
        r=16,             # 랭크(rank) — 클수록 더 많이 학습, 메모리도 더 씀
        lora_alpha=32,    # 스케일링 파라미터 (보통 r의 2배)
        target_modules=[  # 어떤 레이어에 LoRA 적용할지
            "q_proj", "k_proj", "v_proj", "o_proj",
            "gate_proj", "up_proj", "down_proj"
        ],
        lora_dropout=0.05,
        bias="none"
    )
    
    model = get_peft_model(model, lora_config)
    model.print_trainable_parameters()  # 학습 파라미터 비율 확인
    # 출력 예시: trainable params: 20,185,088 || all params: 3,772,923,904 || trainable%: 0.5348
    
    # ===== 5. 학습 인자 설정 =====
    training_args = TrainingArguments(
        output_dir=OUTPUT_DIR,
        num_train_epochs=3,
        per_device_train_batch_size=4,
        gradient_accumulation_steps=4,  # 유효 배치 = 4 * 4 = 16
        learning_rate=2e-4,
        fp16=False,
        bf16=True,           # bfloat16 사용 (Ampere 이상 GPU)
        logging_steps=10,
        save_steps=100,
        warmup_ratio=0.03,
        lr_scheduler_type="cosine",
        report_to="none",    # wandb 쓰시면 "wandb"로 변경
        optim="paged_adamw_8bit"  # 메모리 효율적인 옵티마이저
    )
    
    # ===== 6. 데이터셋 로드 =====
    # 앞서 준비한 데이터셋 사용
    dataset = load_dataset("json", data_files="my_domain_data.json", split="train")
    dataset = dataset.map(format_instruction)  # 앞서 정의한 함수
    
    # ===== 7. SFT Trainer로 학습 시작 =====
    trainer = SFTTrainer(
        model=model,
        train_dataset=dataset,
        peft_config=lora_config,
        dataset_text_field="text",
        max_seq_length=2048,
        tokenizer=tokenizer,
        args=training_args,
    )
    
    print("학습 시작!")
    trainer.train()
    
    # ===== 8. 어댑터 저장 =====
    trainer.model.save_pretrained(OUTPUT_DIR)
    tokenizer.save_pretrained(OUTPUT_DIR)
    print(f"\n✅ 학습 완료! 모델 저장 위치: {OUTPUT_DIR}")

    여기서 중요한 포인트! r 값(랭크)을 너무 크게 잡으면 과적합(overfitting) 위험이 있고, 너무 작으면 학습이 덜 됩니다. 저는 도메인 데이터 양에 따라 데이터 1000개 이하면 r=8, 5000개 이상이면 r=16~32를 주로 씁니다.

    학습된 모델로 추론하기

    학습이 끝났으면 실제로 써봐야죠! 저장된 LoRA 어댑터를 베이스 모델에 합쳐서 추론하는 코드입니다.

    from transformers import AutoModelForCausalLM, AutoTokenizer
    from peft import PeftModel
    import torch
    
    BASE_MODEL = "mistralai/Mistral-7B-v0.1"
    ADAPTER_PATH = "./my-domain-model"
    
    # 베이스 모델 로드 (추론 시엔 양자화 없이 할 수도 있어요)
    tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL)
    model = AutoModelForCausalLM.from_pretrained(
        BASE_MODEL,
        torch_dtype=torch.float16,
        device_map="auto"
    )
    
    # LoRA 어댑터 합치기
    model = PeftModel.from_pretrained(model, ADAPTER_PATH)
    model = model.merge_and_unload()  # 어댑터를 모델에 완전히 병합
    model.eval()
    
    def generate_response(instruction, input_text="", max_new_tokens=512):
        if input_text:
            prompt = f"### Instruction:\n{instruction}\n\n### Input:\n{input_text}\n\n### Response:\n"
        else:
            prompt = f"### Instruction:\n{instruction}\n\n### Response:\n"
        
        inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
        
        with torch.no_grad():
            outputs = model.generate(
                **inputs,
                max_new_tokens=max_new_tokens,
                temperature=0.7,
                do_sample=True,
                top_p=0.9,
                repetition_penalty=1.1  # 반복 방지
            )
        
        response = tokenizer.decode(outputs[0], skip_special_tokens=True)
        # 프롬프트 부분 제거하고 응답만 반환
        return response.split("### Response:")[-1].strip()
    
    # 테스트
    response = generate_response("우리 회사의 배포 프로세스를 설명해줘")
    print(response)

    ⚠️ 실제로 겪은 트러블슈팅 모음

    이 섹션이 사실 제일 중요할 수도 있어요. 저처럼 삽질 안 하시라고 정리해봤습니다.

    문제 1: CUDA out of memory 에러

    가장 흔한 문제입니다. 해결책은 이렇습니다:

    • per_device_train_batch_size를 줄이고 gradient_accumulation_steps를 늘리세요 (유효 배치 크기는 유지하면서)
    • max_seq_length를 줄여보세요. 2048 → 1024로만 줄여도 메모리가 확 줄어듭니다
    • 학습 전에 torch.cuda.empty_cache() 호출

    문제 2: 모델이 Instruction을 무시하고 이상한 말을 함

    데이터 포맷이 안 맞는 경우가 대부분입니다. 학습 데이터의 프롬프트 형식과 추론 시 프롬프트 형식이 완전히 동일해야 합니다. 공백 하나, 줄바꿈 하나도 다르면 모델이 헷갈려해요.

    문제 3: Loss가 안 줄어듦

    Learning rate 문제일 가능성이 높습니다. 2e-4가 일반적이지만, 데이터가 적으면 1e-4로 낮춰보세요. Warmup 비율도 0.03 → 0.05로 올려보는 것도 방법입니다.

    문제 4: 학습 후 모델이 기존 능력을 잃음 (Catastrophic Forgetting)

    이건 LoRA의 장점 중 하나인데, 그래도 데이터에 너무 도메인 특화 내용만 있으면 발생할 수 있어요. 일반 대화 데이터를 10~20% 섞어주면 많이 해결됩니다. Alpaca 데이터셋이나 OpenHermes 같은 공개 데이터셋에서 일부 샘플링해서 섞어주세요.

    ▲ 파인튜닝 전후 도메인 특화 질문에 대한 응답 비교 — 학습 후 도메인 용어와 맥락을 정확히 이해하는 것을 확인할 수 있습니다.

    🎉 결과 확인 — 드디어 됐다!

    제가 실제로 사내 기술 문서 약 2,000개로 파인튜닝했을 때 결과를 공유하면, 파인튜닝 전에는 우리 팀 특유의 시스템 이름이나 약어를 물어보면 모르거나 엉뚱한 답을 했는데, 파인튜닝 후에는 정확하게 내부 컨텍스트에 맞는 답변을 해주더라고요. 특히 “이 에러 코드가 뭔 뜻이야?”류의 질문에서 차이가 극명했습니다.

    모델 평가는 정성적 평가(직접 질문해보기)와 더불어, 보류해둔 테스트 셋으로 간단히 정량 평가도 해보세요. 저는 ROUGE 스코어보다는 실제 사용자 피드백이 더 의미 있다고 생각하는 편이에요.

    # 학습된 모델 파일 구조 확인
    ls -la ./my-domain-model/
    # adapter_config.json    — LoRA 설정 정보
    # adapter_model.safetensors  — 학습된 가중치 (수십~수백 MB)
    # tokenizer.json
    # tokenizer_config.json
    # special_tokens_map.json
    
    # 파일 크기 확인 (베이스 모델 대비 훨씬 작습니다)
    du -sh ./my-domain-model/
    # 예시: 약 80~300MB (베이스 모델은 수 GB인 것과 비교)

    이게 LoRA의 또 다른 장점인데요, 어댑터 파일만 배포하면 되니까 용량이 매우 작습니다. 베이스 모델은 공유하고 어댑터만 바꿔끼는 방식으로 여러 도메인 모델을 관리할 수 있어서 정말 편해요.

    ▲ LoRA/QLoRA 파인튜닝 핵심 파라미터 요약 — r, alpha, target_modules 등 주요 설정값 선택 가이드

    정리 — 오늘 배운 것들

    처음에 “GPU 한 장으로 LLM 파인튜닝이 가능할까?”라는 의심으로 시작했는데, QLoRA 덕분에 충분히 가능하다는 걸 직접 확인했습니다. 핵심만 정리해볼게요.

    • ✅ QLoRA는 소비자급 GPU로도 수십억 파라미터 모델을 파인튜닝할 수 있게 해줍니다
    • ✅ 데이터 품질이 양보다 중요합니다. 적더라도 잘 만든 데이터가 훨씬 낫습니다
    • ✅ LoRA rank(r)는 데이터 양에 맞게 조절하세요. 처음엔 r=16이 무난합니다
    • ✅ 프롬프트 형식을 학습과 추론에서 완전히 동일하게 유지하세요
    • ✅ 일반 데이터를 일부 섞어서 Catastrophic Forgetting을 방지하세요

    다음 글에서는 이렇게 만든 모델을 Ollama나 vLLM으로 서빙(serving)하는 방법을 다룰 예정입니다. API 서버로 띄워서 실제 서비스에 붙이는 과정까지 이어서 정리해드릴게요. 이전 글에서 RAG 구축 과정을 다뤘으니 참고하시면 파인튜닝과 함께 쓰는 방법도 이해하기 쉬울 거예요.

    궁금한 점은 댓글로 남겨주세요. 저도 아직 공부 중이라 같이 논의하면 더 좋을 것 같습니다 😊

    자주 묻는 질문 (FAQ)

    Q. 최소 얼마나 많은 데이터가 필요한가요?
    A. 경험상 최소 500~1000개 정도의 고품질 instruction 데이터면 의미 있는 파인튜닝이 됩니다. 물론 많을수록 좋지만, 품질이 더 중요합니다.
    Q. 어떤 베이스 모델을 선택하는 게 좋을까요?
    A. 한국어 도메인이라면 한국어 데이터로 사전학습된 모델을 베이스로 쓰는 게 유리합니다. EEVE-Korean, EXAONE 등 국내에서 공개한 모델들도 좋은 선택지입니다. 영어 도메인이라면 Mistral 7B나 LLaMA 3 8B가 무난합니다.
    Q. 파인튜닝 한 번에 얼마나 걸리나요?
    A. 데이터 양과 GPU 성능에 따라 다르지만, RTX 3090 기준 1000개 데이터, 3 에폭 학습에 1~2시간 정도 예상하시면 됩니다.
    Q. LoRA 어댑터를 베이스 모델에 완전히 병합해야 하나요?
    A. 추론 시 편의를 위해 merge_and_unload()로 병합할 수 있지만, 어댑터를 분리 유지하면 나중에 다른 어댑터로 교체하기 쉽습니다. 여러 도메인 모델을 관리한다면 분리 유지를 추천합니다.