13년차의 서버실

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

[태그:] 인프라 블로그

  • [Proxmox] Proxmox LLM Ollama 성능 측정 방법론: 재현 가능한 벤치마크

    [Proxmox] Proxmox LLM Ollama 성능 측정 방법론: 재현 가능한 벤치마크

    Proxmox LLM Ollama 환경에서 LLM 추론 성능 측정 방법론

    Proxmox LLM Ollama 조합으로 홈랩 AI 서버를 굴리다 보면, 제일 먼저 막히는 지점이 성능 자체보다 성능 해석이더라고요. 응답이 느릴 때 GPU 가속 경로가 문제인지, 모델 파일을 읽어오는 스토리지가 문제인지, 아니면 VM의 CPU 배치와 메모리 상태가 흔들리는지 한 번에 안 보입니다. 저도 처음엔 모델만 바꿔 가며 체감으로 판단했는데, 그 방식은 기록이 안 남고 원인 분리도 어렵더라고요. 이 글은 Proxmox + Ollama 조합에서 재현 가능한 LLM 성능 측정 방법을 만드는 데 집중합니다.

    핵심은 단순합니다. 총 응답 시간만 보면 거의 항상 잘못된 결론으로 갑니다. Ollama가 내려주는 load_duration, prompt_eval_duration, eval_duration을 분리하고, 같은 시점의 CPU, 메모리, 디스크, GPU 상태를 같이 봐야 합니다. 홈랩에서는 특히 이 구분이 중요합니다. 같은 하드웨어라도 다른 VM의 백업 작업, 메모리 ballooning, 스토리지 캐시 상태 때문에 결과가 꽤 쉽게 흔들리거든요.

    Proxmox 호스트와 게스트 VM, GPU 패스스루, Ollama API, 모니터링 명령 흐름을 한눈에 보는 구성도입니다.

    왜 Proxmox LLM Ollama 환경은 따로 측정해야 할까요?

    베어메탈에서 잘 나오던 수치가 Proxmox VM에서는 다르게 나오는 이유는 단순히 가상화 오버헤드 한 줄로 끝나지 않습니다. 실제로는 가상화 계층이 병목을 숨기거나 증폭하는 방식이 더 문제예요. 예를 들어 GPU 패스스루가 붙어 있어도 VM의 CPU affinity나 NUMA 배치가 어긋나면 생성 구간이 늘어질 수 있고, 모델 파일이 느린 스토리지에 있으면 첫 호출만 유난히 길어집니다. 이걸 분리하지 않으면 괜히 GPU만 탓하게 됩니다.

    • GPU Passthrough 상태: 장치가 VM에 보이는 것과 실제로 추론 가속에 쓰이는 건 다릅니다.
    • CPU affinity와 NUMA 배치: vCPU가 불리한 코어 배치에 놓이면 토큰 생성 지연이 출렁일 수 있습니다.
    • 스토리지 지연시간: 모델 교체가 잦을수록 첫 로딩 시간이 과장되기 쉽습니다.
    • ballooning, swap, 메모리 압박: 평소엔 티가 안 나다가 긴 프롬프트에서 갑자기 드러납니다.
    • Ollama 메트릭 해석 실수: 로딩 시간과 생성 시간을 한 덩어리로 보면 튜닝 방향이 엇나갑니다.

    제가 기준으로 삼는 문장은 하나입니다. 벤치마크는 순위를 매기는 작업이 아니라, 손해가 나는 구간을 특정하는 작업입니다. 이 관점으로 가면 Proxmox 위 LLM 성능 측정이 훨씬 덜 꼬입니다.

    LLM 성능 측정에서 반드시 분리해야 할 항목

    Ollama는 API 응답에 꽤 유용한 타이밍 필드를 포함합니다. 문제는 이 숫자를 읽는 방식입니다. 저는 최소한 아래 네 축을 분리하지 않으면 비교값으로 잘 안 봅니다.

    항목 의미 이 수치가 커질 때 먼저 볼 것 실무 해석
    load_duration 모델을 메모리로 올리는 시간 스토리지, 모델 상주 여부, 첫 호출 여부 첫 호출만 크면 정상 특성일 수 있지만, 반복 호출에서도 크면 적재가 반복되거나 메모리 압박이 있다는 뜻입니다.
    prompt_eval_duration 입력 프롬프트 토큰 처리 시간 프롬프트 길이, 컨텍스트 길이, CPU 상태 RAG나 긴 시스템 프롬프트를 쓰는 환경에서는 이 값이 체감 대기시간의 큰 비중을 차지할 수 있습니다.
    eval_duration 출력 토큰 생성 시간 GPU 가속, CPU 병목, 모델 크기 토큰/초 계산의 핵심 구간입니다. 장비 비교는 대부분 이 값 중심으로 보는 편이 맞습니다.
    시스템 지표 CPU, 메모리, 디스크, GPU 사용량 vmstat, iostat, nvidia-smi API 숫자만으로는 원인을 못 찍습니다. 같은 시점의 시스템 지표가 있어야 근본 원인을 좁힐 수 있습니다.

    여기서 많이 놓치는 포인트가 하나 있습니다. 짧은 프롬프트에서 빠른 모델이 실제 운영 워크로드에서도 빠르다는 보장은 없습니다. 특히 홈랩에서 RAG를 붙이거나 시스템 프롬프트가 길어지면 prompt_eval_duration이 병목으로 튀어나오는 경우가 적지 않았습니다. 그래서 저는 측정 목적을 먼저 나눕니다. 장비 비교인지, 실제 응답 대기시간 확인인지, GPU 패스스루 검증인지에 따라 봐야 할 값이 달라집니다.

    측정 전에 Proxmox 쪽에서 먼저 고정할 것들

    게스트 VM 안에서만 계측을 정교하게 해도, Proxmox 설정이 흔들리면 결과는 들쭉날쭉해집니다. 측정 전에 아래 조건부터 고정해두는 편이 낫습니다.

    1. 같은 모델 태그, 같은 프롬프트, 같은 출력 길이 조건으로 반복합니다.
    2. 테스트 시간대에는 다른 VM의 백업, 미디어 인덱싱, 대용량 복사 같은 잡음을 줄입니다.
    3. GPU 패스스루를 쓴다면 항상 같은 VM, 같은 게스트 커널 상태에서만 비교합니다.
    4. ballooning target, swap 개입 여부를 먼저 확인하고, 가능하면 벤치 구간에서는 메모리 조건을 고정합니다.
    5. 모델 파일 위치가 로컬 SSD인지, 네트워크 스토리지인지, ZFS ARC나 페이지 캐시 영향이 있는지 기록합니다.

    여기서 실전적으로 중요한 구분이 cold run과 warm run입니다. 첫 요청은 적재 비용이 섞이고, 두 번째부터는 메모리 상주 효과가 붙습니다. 둘을 한 표에 섞어두면 얼핏 데이터가 많아 보여도 해석에는 별 도움이 안 됩니다.

    Proxmox 쪽에서 제가 특히 먼저 확인하는 건 세 가지입니다. CPU affinity, NUMA 사용 여부, ballooning 조건 고정 여부입니다. CPU 배치가 계속 흔들리면 짧은 벤치에서는 티가 덜 나도 반복 측정 편차가 커집니다. ballooning도 운영 효율에는 도움이 되지만, 추론 성능 비교에는 변수로 끼어들 수 있습니다. 홈랩에서는 운영 효율과 벤치 재현성이 종종 충돌하는데, 벤치 시간에는 재현성을 우선하는 편이 낫더라고요.

    실전 구현 1: Ollama API로 응답 시간과 토큰 생성 속도 수집

    CLI 체감은 참고용으로는 괜찮지만, 기록용 벤치마크에는 조금 아쉽습니다. 반복 측정과 후처리를 생각하면 Ollama API의 원본 메트릭을 그대로 저장하는 편이 훨씬 낫습니다. 아래처럼 한 번 호출해서 핵심 필드를 바로 계산해보면 감이 빨리 옵니다.

    curl -s http://127.0.0.1:11434/api/generate \
      -H 'Content-Type: application/json' \
      -d '{
        "model": "llama3:8b",
        "prompt": "Explain the difference between CPU bottleneck and GPU bottleneck in one short paragraph.",
        "stream": false,
        "options": {
          "temperature": 0,
          "num_predict": 128
        }
      }' | jq '{
        model,
        created_at,
        total_duration_ns: .total_duration,
        load_duration_ns: .load_duration,
        prompt_eval_count,
        prompt_eval_duration_ns: .prompt_eval_duration,
        eval_count,
        eval_duration_ns: .eval_duration,
        tokens_per_second: (if .eval_duration > 0 then (.eval_count / (.eval_duration / 1000000000)) else 0 end)
      }'

    여기서 temperature를 0으로 두는 이유는 성능 측정에서 출력 다양성을 줄이기 위해서입니다. num_predict를 고정하는 이유도 같고요. 벤치마크에서 가장 흔한 실수가 출력 길이를 매번 다르게 두는 건데, 그렇게 되면 eval_count가 흔들리고 결국 토큰/초 비교도 흐려집니다.

    반복 측정은 최소 5회 정도는 권합니다. 평균만 보지 말고 최솟값, 최댓값, 편차까지 같이 남겨야 합니다. 홈랩에서는 한 번 빠르게 나온 결과보다 얼마나 덜 흔들리는지가 더 중요할 때가 많거든요.

    for i in 1 2 3 4 5; do
      curl -s http://127.0.0.1:11434/api/generate \
        -H 'Content-Type: application/json' \
        -d '{
          "model": "llama3:8b",
          "prompt": "Write exactly five bullet points about Linux memory pressure.",
          "stream": false,
          "options": {
            "temperature": 0,
            "num_predict": 96
          }
        }' | jq -r --arg run "$i" '[
          $run,
          .created_at,
          .total_duration,
          .load_duration,
          .prompt_eval_count,
          .prompt_eval_duration,
          .eval_count,
          .eval_duration,
          (if .eval_duration > 0 then (.eval_count / (.eval_duration / 1000000000)) else 0 end)
        ] | @csv'
    done >> ollama-bench.csv

    이 CSV 로그는 보기보다 강력합니다. 나중에 VM 설정을 바꿨을 때, 모델 파일 위치를 옮겼을 때, GPU를 교체했을 때 비교 기준이 그대로 남습니다. 저는 이 단계에서 대시보드부터 만들지 않는 편을 권합니다. 원본 로그가 안정적으로 쌓이는 체계가 먼저고, 시각화는 그 다음이더라고요.

    하나 더요. 벤치마크 목적이라면 stream: false를 유지하는 편이 낫습니다. 스트리밍 출력은 체감 속도 확인에는 좋지만, 수집 포맷이 지저분해지고 메트릭 해석이 섞이기 쉽습니다. 체감용 테스트와 기록용 테스트를 분리해두면 나중에 덜 꼬입니다.

    Ollama 벤치마크에서 LLM 성능 측정 지표를 설명하는 이미지

    Ollama API 응답에서 어떤 필드를 뽑아 성능 지표로 쓰는지 설명하는 시각 자료입니다.

    실전 구현 2: 시스템 자원 사용량을 같이 보세요

    Ollama API 숫자만 보면 원인 추정이 자꾸 빗나갑니다. 같은 요청이라도 CPU 압박인지, 디스크 대기인지, GPU가 실제로 일하는지 구분이 안 되기 때문입니다. 저는 최소한 아래 세 축은 같이 봅니다.

    vmstat 1
    
    iostat -xz 1
    
    nvidia-smi dmon -s pucm
    

    읽는 기준은 단순하지만, 해석은 꽤 실전적입니다.

    • vmstat 1: r가 계속 높고 si/so가 움직이면 CPU 경쟁이나 swap 개입을 먼저 의심합니다.
    • iostat -xz 1: 모델 로딩 시점에 await와 %util이 튀면 load_duration 상승과 연결해서 봅니다.
    • nvidia-smi dmon -s pucm: 생성 중에도 GPU 사용률과 메모리 사용량이 거의 반응하지 않으면 GPU 가속 경로를 다시 확인합니다.

    제가 자주 보는 실패 패턴은 이겁니다. GPU는 VM에서 보이는데, 실제 생성 구간에서는 GPU가 거의 일하지 않는 경우예요. 이때는 패스스루 성공과 추론 가속 성공을 분리해서 봐야 합니다. 장치 인식 자체는 성공했지만 드라이버 상태, 메모리 부족, 런타임 제약 때문에 기대한 만큼 가속이 안 붙는 경우가 있습니다. 반대로 GPU 사용률이 높다고 바로 좋은 것도 아닙니다. 메모리 이동 비용이나 CPU 병목이 남아 있으면 eval_duration 개선이 생각보다 작을 수 있습니다.

    재현 가능한 시나리오: cold run과 warm run 분리 측정

    제가 홈랩에서 가장 자주 쓰는 측정 패턴은 cold run과 warm run을 아예 별도 시나리오로 나누는 방식입니다. 같은 모델, 같은 프롬프트여도 첫 요청과 반복 요청은 성격이 다릅니다. 이 둘을 분리하면 적어도 로딩 비용과 생성 비용은 꽤 깔끔하게 갈라집니다.

    1. Ollama 서비스를 재시작하거나 충분히 유휴 상태를 만든 뒤 첫 요청을 보냅니다.
    2. 첫 요청의 load_duration, total_duration을 기록합니다.
    3. 같은 프롬프트를 3~5회 반복하고, 이 구간에서는 eval_duration과 토큰/초를 중심으로 봅니다.
    4. 동시에 vmstat, iostat, nvidia-smi dmon의 변화를 같은 시각 기준으로 맞춰둡니다.

    실제로는 아래처럼 분리 저장해두면 후처리가 편합니다.

    PROMPT='Explain why swap activity can distort LLM benchmark results in a VM.'
    MODEL='llama3:8b'
    
    # cold run
    curl -s http://127.0.0.1:11434/api/generate \
      -H 'Content-Type: application/json' \
      -d "{\"model\":\"${MODEL}\",\"prompt\":\"${PROMPT}\",\"stream\":false,\"options\":{\"temperature\":0,\"num_predict\":120}}" \
      | jq -r '["cold", .total_duration, .load_duration, .prompt_eval_duration, .eval_duration, .eval_count] | @csv' \
      >> bench-split.csv
    
    # warm runs
    for i in 1 2 3; do
      curl -s http://127.0.0.1:11434/api/generate \
        -H 'Content-Type: application/json' \
        -d "{\"model\":\"${MODEL}\",\"prompt\":\"${PROMPT}\",\"stream\":false,\"options\":{\"temperature\":0,\"num_predict\":120}}" \
        | jq -r --arg run "warm-${i}" '[$run, .total_duration, .load_duration, .prompt_eval_duration, .eval_duration, .eval_count] | @csv' \
        >> bench-split.csv
    done

    이 방식의 장점은 단순한데 실용적입니다. 첫 요청만 느리면 적재 비용 성격, 반복 요청도 계속 느리면 생성 경로 병목으로 빠르게 가설을 세울 수 있습니다. 홈랩에서는 이 구분만 제대로 해도 괜히 GPU부터 바꾸는 일을 꽤 줄일 수 있더라고요.

    홈랩 AI 서버에서 Proxmox와 Ollama 벤치마크를 수행하는 터미널 이미지

    cold run과 warm run을 비교하면서 시스템 자원 모니터링을 병행하는 실전 측정 화면 예시입니다.

    VM, LXC, 베어메탈 중 무엇을 기준점으로 삼아야 할까요?

    이 질문은 자주 나오는데, 저는 목적에 따라 답을 다르게 합니다. 무조건 하나가 낫다고 하기보다 무엇을 검증하려는지 먼저 정하는 편이 맞습니다.

    환경 이럴 때 추천 장점 주의할 점
    베어메탈 절대 성능 기준점이 필요할 때 가상화 변수가 적어 기준선 만들기 좋습니다. 실제 운영 환경이 Proxmox라면 결과 이식성이 떨어질 수 있습니다.
    Proxmox VM GPU 패스스루와 재현성 검증이 목적일 때 운영 환경과 같은 조건으로 측정하기 좋습니다. CPU affinity, NUMA, ballooning 같은 변수를 같이 관리해야 합니다.
    LXC 경량화와 운영 편의가 더 중요할 때 리소스 오버헤드가 적고 관리가 가볍습니다. GPU 활용 방식과 권한 구성이 환경에 따라 더 예민할 수 있어, 벤치 기준점으로는 다소 까다롭습니다.

    제 판단 기준은 이렇습니다. 운영도 Proxmox VM에서 할 예정이면 VM에서 잰 수치를 기준으로 삼는 편이 맞습니다. 베어메탈 수치가 더 좋아도 실제 배포 환경과 다르면 의사결정에는 덜 도움이 됩니다. 반대로 이 하드웨어가 어디까지 나오는지 보고 싶다면 베어메탈 기준선을 한 번 찍어두는 게 좋습니다.

    자주 겪는 문제와 해결 방법

    이 부분은 공식 문서만 봐서는 감이 잘 안 오는 영역입니다. 실제로 벤치를 돌리면 아래처럼 삐끗하는 경우가 많습니다.

    1. 프롬프트 길이가 매번 달라서 비교가 무너집니다

    prompt_eval_duration은 입력 길이에 직접 반응합니다. 질문을 바꾸거나 출력 길이를 열어두면, 모델 자체보다 워크로드 차이가 더 크게 반영됩니다. 벤치 프롬프트는 고정, 출력 길이는 num_predict 등으로 제한하는 편이 낫습니다.

    2. 스트리밍 체감 속도를 성능으로 착각합니다

    터미널에 토큰이 빨리 흘러나오면 체감상 빨라 보입니다. 그런데 그건 계측 기준으로는 불안정합니다. 수집용 벤치는 stream: false로 고정하고, 체감 테스트는 별도로 분리하세요. 둘을 섞어두면 나중에 기록이 비교 불가능해집니다.

    3. 디스크 병목이 숨어 있는데 GPU만 의심합니다

    모델을 자주 바꾸며 테스트할 때 특히 그렇습니다. load_duration이 갑자기 커지고 같은 시점 iostat의 await가 치솟는다면, 그건 GPU보다 모델 적재 경로를 먼저 봐야 합니다. 홈랩에서는 NVMe, SATA SSD, 네트워크 스토리지 차이가 생각보다 크게 드러납니다.

    4. Proxmox에서 다른 VM 부하가 끼어듭니다

    백업 작업, 미디어 트랜스코딩, ZFS scrub, 테스트용 쿠버네티스 VM이 동시에 돌면 반복 측정 편차가 커집니다. 이런 경우 평균값보다 분산이 커지는 패턴이 먼저 나타납니다. 숫자가 안 예쁜 게 아니라, 측정 조건이 불안정한 겁니다.

    5. GPU 사용률만 높고 성능 향상은 미미합니다

    이건 대개 GPU 연산 자체보다 CPU 준비 단계, 메모리 이동, 컨텍스트 처리가 먼저 막히는 경우입니다. 그래서 저는 GPU utilization을 목표값으로 보지 않습니다. eval_duration이 실제로 줄었는지를 먼저 확인합니다.

    6. 첫 요청이 계속 느린데 warm run처럼 해석합니다

    메모리 압박이나 unload 정책 때문에 모델이 자주 내려가는 환경에서는 매번 사실상 cold run이 됩니다. 이때는 첫 요청만 느린 건 정상이라고 넘기면 안 됩니다. 반복 호출인데도 load_duration이 매번 크게 잡히는지를 꼭 확인해보세요. 그건 상주 실패일 가능성이 큽니다.

    검증과 결과 해석: 숫자를 어떻게 읽어야 할까요?

    숫자를 예쁘게 정리하는 것보다 중요한 건, 어떤 패턴에서 어떤 결론을 내릴지 미리 정해두는 겁니다. 저는 보통 아래처럼 읽습니다.

    • load_duration만 크다: 모델 적재, 스토리지, 최초 호출 비용을 먼저 봅니다.
    • prompt_eval_duration이 길다: 긴 프롬프트, 큰 컨텍스트, CPU 토큰 처리 경로를 의심합니다.
    • eval_duration이 길다: 실제 생성 성능 문제입니다. GPU 가속 상태, CPU 할당, 모델 크기 적합성을 봐야 합니다.
    • GPU 사용률이 낮고 CPU만 바쁘다: 패스스루 성공 여부보다 실제 추론 가속 경로를 재확인합니다.
    • 반복 측정 편차가 크다: 다른 VM 간섭, 메모리 압박, 백그라운드 I/O를 먼저 정리합니다.

    여기서 중요한 결정 포인트가 있습니다. 무엇을 최적화할지 먼저 정해야 수치 해석이 쉬워집니다. 사용자 체감 첫 응답을 줄이는 게 목표라면 total_duration과 load_duration이 더 중요합니다. 장비 비교가 목적이면 eval_duration과 토큰/초가 핵심이고요. 긴 컨텍스트 운영이 목표라면 prompt_eval_duration이 병목인지부터 봐야 합니다.

    로그 포맷은 거창할 필요 없습니다. 다만 나중에 조건을 복원할 수 있을 만큼은 남겨두는 편이 좋습니다.

    timestamp,host,vm,model,prompt_name,run_type,total_duration,load_duration,prompt_eval_count,prompt_eval_duration,eval_count,eval_duration
    

    이 한 줄이 중요한 이유는 숫자와 환경을 같이 묶어두기 때문입니다. 벤치 결과만 남기고 VM 설정 이력을 빼먹으면 몇 주 뒤에는 왜 빨라졌는지, 왜 느려졌는지 복기가 잘 안 됩니다. 관련해서 Proxmox GPU 패스스루 설정 글이나 Ollama 설치 가이드를 같이 정리해두면 나중에 내부 링크 연결에도 꽤 유용합니다.

    Proxmox Ollama 환경의 LLM 추론 성능 결과를 시각화한 대시보드 이미지

    모델 로딩 시간과 실제 생성 시간을 분리해 해석하는 결과 대시보드 예시입니다.

    운영 팁: 측정 환경을 문서화해야 나중에 안 꼬입니다

    벤치마크는 숫자보다 맥락이 먼저입니다. 특히 홈랩 AI 서버는 장비를 자주 만지게 되니까, 어떤 조건에서 잰 수치인지 생각보다 빨리 잊어버립니다. 최소한 아래 항목은 같이 기록해두는 편이 좋습니다.

    • 모델 이름과 태그
    • 게스트 OS 종류와 커널 상태
    • vCPU 개수, 메모리 크기
    • GPU 패스스루 유무와 장치 종류
    • 모델 파일 저장 위치
    • 테스트 프롬프트 이름
    • cold run인지 warm run인지

    여기서 제 권장 방식은 단순합니다. 설정 변경이 있는 날에는 벤치마크를 다시 찍고, 변경 사유를 한 줄 메모로 남겨두세요. CPU affinity를 바꿨는지, 메모리를 늘렸는지, 모델 저장 위치를 옮겼는지 정도만 적어도 나중에 비교가 됩니다. 숫자만 예쁘게 모아두는 것보다 이게 훨씬 실용적이더라고요.

    FAQ: 현장에서 많이 헷갈리는 부분

    Q. Proxmox LLM 테스트는 VM이 낫나요, LXC가 낫나요?

    GPU 패스스루 검증과 재현성을 우선하면 저는 VM 쪽을 더 자주 씁니다. 운영 편의만 보면 LXC가 가벼울 수 있지만, 벤치 기준점을 만들 때는 변수를 줄이기 쉬운 쪽이 VM이었습니다.

    Q. Ollama 벤치마크는 CLI만으로 충분한가요?

    단발성 확인은 가능합니다. 다만 반복 측정, CSV 저장, 후처리, cold/warm 구분까지 생각하면 API 기반 수집이 훨씬 낫습니다.

    Q. 숫자는 좋은데 체감은 별로입니다

    짧은 프롬프트 위주로만 재면 실제 운영 패턴과 어긋날 수 있습니다. 특히 RAG, 긴 시스템 프롬프트, 멀티턴 대화가 붙으면 prompt_eval_duration 비중이 커집니다. 운영 워크로드와 비슷한 입력으로 다시 재보는 게 맞습니다.

    Q. 토큰/초가 높으면 무조건 좋은 건가요?

    장비 비교에는 유용하지만, 첫 응답 대기시간이 중요한 환경에서는 부족합니다. 사용자가 느끼는 체감은 종종 load_duration과 prompt_eval_duration에 더 크게 좌우됩니다.

    마지막으로: 이런 상황이면 이렇게 고르시면 됩니다

    제가 실제로 추천하는 선택 기준은 아래처럼 명확합니다.

    • 장비 비교가 목적: 같은 프롬프트와 같은 출력 길이로 고정하고 eval_duration과 토큰/초 중심으로 보세요.
    • 첫 응답 체감이 중요: load_duration을 포함한 total_duration을 같이 봐야 합니다. 모델 상주 전략과 스토리지 위치가 더 중요해질 수 있습니다.
    • GPU 튜닝이 목적: Ollama API 결과만 보지 말고 nvidia-smi dmon과 vmstat를 반드시 붙이세요.
    • 운영 전 검증이 목적: CSV 형태로 누적 저장하고, cold run과 warm run을 분리 기록하세요.
    • 결과가 자꾸 흔들린다: 모델을 바꾸기 전에 다른 VM 간섭, ballooning, swap, 스토리지 대기를 먼저 정리하세요.

    조금 더 직설적으로 말하면 이렇습니다. 첫 응답만 느리면 스토리지와 적재 전략부터, 반복 응답도 느리면 CPU/GPU 경로부터, 숫자가 출렁이면 Proxmox 자원 간섭부터 보시면 됩니다. 이 순서가 실제로 가장 덜 돌아가는 편이었습니다.

    제 경험상, Ollama 숫자만 보고 결론 내리면 절반은 빗나가고, Proxmox 호스트와 게스트의 시스템 지표까지 같이 보면 비로소 조정 포인트가 보입니다. Proxmox LLM Ollama 벤치마크는 장비 스펙보다 측정 설계가 더 중요할 때가 많습니다. 한 번만 잘 만들어두면, 그다음부터는 모델 교체든 VM 튜닝이든 훨씬 덜 감으로 움직이게 됩니다.

    Proxmox와 Ollama 기반 GPU 가속 성능 측정 요약 인포그래픽

    Proxmox 기반 Ollama 벤치마크 절차와 해석 기준을 요약한 인포그래픽입니다.

  • [AI] OpenVINO 도입 가이드: 기존 AI 모델 최적화 전환 기준

    [AI] OpenVINO 도입 가이드: 기존 AI 모델 최적화 전환 기준

    OpenVINO 도입 가이드: 기존 AI 모델 최적화 전환 기준

    OpenVINO 도입을 검토할 때 저는 먼저 병목부터 가릅니다. 엣지 장비나 사내 서버에서 추론만 오래 돌려보면 결국 질문이 하나로 모이거든요. 지금 느린 지점이 모델인지, 런타임인지, 하드웨어 배치인지를 먼저 나눠야 판단이 덜 흔들립니다. ONNX Runtime에서 이미 잘 도는데도 지연 시간이 출렁이거나 첫 요청만 유독 느릴 때가 있는데, 이런 경우 OpenVINO는 프레임워크를 갈아엎는 선택이라기보다 인텔 CPU 중심 추론 경로를 별도로 최적화하는 수단에 가깝습니다.

    이 글은 OpenVINO 입문서가 아니라, 기존 AI 모델을 언제 OpenVINO 경로로 분기할지 판단하는 실무 메모에 가깝습니다. 저는 보통 세 가지를 같이 봅니다. 첫째, 운영 하드웨어가 정말 인텔 CPU 중심인지. 둘째, ONNX 산출물이 이미 안정적으로 나오는지. 셋째, 병목의 본체가 모델 실행 경로인지 아니면 전처리·후처리·API 계층인지입니다. 사내 블로그에 ONNX export 체크리스트 글이 있다면 그 글과 함께 보시면 판단이 훨씬 빨라집니다.

    OpenVINO 도입 판단을 위한 전체 아키텍처 개요 이미지

    기존 ONNX 추론 경로와 OpenVINO 최적화 경로를 나란히 보여주는 개요 이미지입니다.

    OpenVINO 도입, 왜 여기서 판단이 갈리냐면요

    실무에서는 성능 숫자 하나보다 성능의 성격이 더 중요합니다. 같은 모델이라도 초당 많이 처리해야 하는 배치 작업과 한 요청 응답이 빨라야 하는 API는 최적점이 다르더라고요. OpenVINO는 특히 인텔 하드웨어에서 이 차이를 런타임 수준에서 조정하기 편합니다. 반대로 이미 NVIDIA GPU 최적화와 TensorRT 경로가 운영 표준이라면, OpenVINO를 주 경로로 들이는 순간 복잡도만 커질 수 있습니다.

    제가 보기에 OpenVINO는 아래 조건에서 특히 잘 맞았습니다.

    • 인텔 CPU가 메인 실행 장치라서 CPU 스레드 배치와 추론 요청 수 조정이 성능에 직접 반영되는 경우
    • 여러 엣지 노드에 같은 모델을 배포해야 해서 아티팩트를 단순하게 유지하고 싶은 경우
    • 학습 프레임워크는 그대로 두고, 배포 레이어만 별도로 최적화하고 싶은 경우
    • ONNX까지는 이미 정리됐지만 실제 서비스 지연 시간 편차가 커서 런타임 튜닝이 필요한 경우

    반대로 아래 상황이면 저는 먼저 보류합니다.

    • 커스텀 연산자가 많아 변환 호환성 자체가 리스크인 경우
    • GPU 혼합 환경에서 특정 벤더 최적화가 이미 운영 표준인 경우
    • 모델 추론보다 이미지 디코딩, 리사이즈, NMS, 직렬화가 더 무거운 경우
    • 서비스가 단건 응답 위주인데 비동기 다중 요청에서만 처리량이 좋아지는 경우

    OpenVINO 도입 기준을 AI 모델 최적화 관점에서 보면

    저는 “느리다”보다 “어디서 손해 보는가”를 먼저 적어 둡니다. 이 단계가 흐리면 OpenVINO를 붙인 뒤에도 왜 빨라졌는지, 왜 안 빨라졌는지 설명을 못 하게 되더라고요. 이 작업이 좀 귀찮아 보여도 나중엔 진짜 편합니다.

    상황 관찰 신호 OpenVINO 도입 적합도 제가 실제로 내리는 판단
    인텔 CPU 중심 API 서버 반복 추론 지연 편차가 크고 CPU 사용 패턴이 불안정함 높음 ONNX는 유지하고 OpenVINO IR을 추가 산출해 A/B 비교합니다.
    엣지 장비 다수 배포 GPU 없이 운영하고 패키징 단순성이 중요함 높음 IR 산출물 관리와 첫 로딩 시간, 캐시 전략까지 같이 검토합니다.
    NVIDIA GPU 표준 환경 TensorRT나 CUDA 경로가 이미 검증됨 낮음 주 경로는 유지하고 CPU fallback이 필요할 때만 제한적으로 씁니다.
    커스텀 연산자 다수 변환 경고가 반복되거나 지원 여부가 불명확함 주의 호환성 검증이 끝나기 전에는 마이그레이션 일정에 넣지 않습니다.
    서비스 응답보다 배치 처리량이 중요 동시 요청 수를 늘릴수록 전체 효율이 올라감 높음 <code>-hint throughput 기준으로 먼저 보고, API와 배치 경로를 분리합니다.
    첫 요청만 느림 웜업 이후엔 안정적이나 cold start가 큼 보통 이상 모델 캐시와 사전 컴파일 전략을 먼저 넣고 재측정합니다.

    OpenVINO 도입을 추천하는 가장 강한 신호는 이겁니다. 인텔 CPU 추론이 실제 핵심 경로이고, 모델은 이미 ONNX로 정리됐고, 남은 문제가 운영 레이어 성능과 일관성인 경우요. 이때는 투자 대비 회수 속도가 꽤 빠른 편입니다.

    반대로 학습 파이프라인까지 한 번에 옮기려는 접근은 저는 거의 항상 말립니다. 바꿔야 할 지점이 많아지는 순간 병목 분리가 안 되거든요. 가장 덜 아픈 경로는 여전히 학습은 기존 프레임워크 유지, 배포 추론만 OpenVINO로 분기하는 방식입니다.

    OpenVINO와 ONNX 관계, 실무에서는 이렇게 보시면 덜 헷갈립니다

    실무 관점에서 ONNX는 모델 교환용 공용 산출물이고, OpenVINO는 그 산출물을 인텔 실행 환경에 맞게 최적화해 배포하는 계층에 가깝습니다. 둘 중 하나만 고르는 관계가 아니라, ONNX를 중간 계약서처럼 두고 OpenVINO IR을 배포 전용 산출물로 추가하는 구조가 제일 안전했습니다.

    이 구조를 쓰면 좋은 점이 분명합니다.

    1. 학습 코드와 배포 코드를 억지로 묶지 않아도 됩니다.
    2. ONNX Runtime 경로를 롤백 스위치로 남길 수 있습니다.
    3. 변환 실패가 나도 원본 산출물 체계가 무너지지 않습니다.
    4. CPU 전용 최적화 실험을 서비스 전체 변경 없이 진행할 수 있습니다.

    실제로는 아래 흐름을 많이 씁니다. PyTorch에서 ONNX export를 만들고, OpenVINO IR로 한 번 더 변환한 뒤, benchmark_app으로 모델 단독 성능을 먼저 확인하고, 그다음 애플리케이션에 붙입니다. 이 순서를 지키면 앱 버그와 런타임 문제를 섞어 보지 않게 됩니다.

    실전 구현 1: ONNX 모델을 OpenVINO IR로 변환하기

    여기서는 이미 model.onnx가 있다고 가정하겠습니다. 현재 OpenVINO 배포판에서는 Python 패키지 openvino만으로 기본 런타임과 변환 도구를 시작할 수 있습니다. 예전 글처럼 openvino-dev를 기본 전제로 두는 문서는 아직 많지만, 최신 환경에서는 필수 전제처럼 쓰지 않는 편이 덜 헷갈립니다.

    python3 -m venv .venv
    source .venv/bin/activate
    python -m pip install --upgrade pip
    python -m pip install openvino onnx onnxruntime
    
    # 1) 가장 단순한 CLI 변환
    ovc model.onnx --output_model build/model.xml
    
    # 2) 입력 shape를 명시해야 하는 경우 예시
    ovc model.onnx --output_model build/model.xml --input_shape [1,3,224,224]

    여기서 --input_shape를 굳이 명시하는 이유는 변환 성공 여부보다 런타임 입력 계약을 명확히 하기 위해서입니다. 동적 차원을 넓게 둔 ONNX를 그대로 넘기면 앱 쪽 전처리 코드가 shape를 암묵적으로 가정하다가 운영 중에 터지는 일이 꽤 잦았습니다.

    import openvino as ov
    
    onnx_path = "model.onnx"
    ir_path = "build/model.xml"
    
    ov_model = ov.convert_model(onnx_path)
    ov.save_model(ov_model, ir_path)
    
    print("saved:", ir_path)

    변환 단계에서 제가 꼭 보는 건 세 가지입니다.

    • model.xml과 model.bin이 함께 생성되는지
    • 변환 로그에 unsupported operation, shape inference, precision 관련 경고가 남는지
    • 입력 이름과 입력 shape가 애플리케이션 코드가 기대하는 값과 맞는지

    중요한 건 변환 완료 자체보다 입력 계약이 명확해졌는가입니다. 현업에서 진짜 자주 나는 장애는 변환 실패보다 “변환은 됐는데 앱 입력이 미묘하게 안 맞는 상태”였어요.

    ONNX에서 OpenVINO IR로 변환되는 OpenVINO 도입 구성 이미지

    ONNX 파일이 OpenVINO IR로 바뀌고 CPU 플러그인으로 연결되는 과정을 보여주는 구성 이미지입니다.

    실전 구현 2: 인텔 CPU 추론과 기본 검증

    변환 직후에는 애플리케이션에 바로 붙이지 말고 모델 단독 성능부터 봅니다. 여기서 저는 목표를 둘로 나눕니다. API라면 지연 시간 중심, 배치 작업이라면 처리량 중심입니다. 이걸 안 나누고 평균 숫자 하나만 보면 해석이 자주 틀어집니다.

    # 지연 시간 우선: 단건 응답형 API 검증
    benchmark_app -m build/model.xml -d CPU -hint latency -api sync -t 15 \
      -report_type average_counters -report_folder reports/latency \
      -exec_graph_path reports/latency/exec_graph.xml
    
    # 처리량 우선: 다중 요청 또는 배치 작업 검증
    benchmark_app -m build/model.xml -d CPU -hint throughput -api async -t 15 \
      -report_type average_counters -report_folder reports/throughput \
      -pc

    제가 실제로 읽는 포인트는 아래입니다.

    • -hint latency에서 좋아지는 모델은 실시간 API 후보입니다. 반대로 -hint throughput에서만 성능이 오르면 단건 응답이 중요한 서비스에는 그대로 넣지 않는 편이 안전합니다.
    • -api async에서만 효율이 오르는 경우는 요청을 동시에 밀어 넣을 수 있을 때만 이득입니다. 호출 패턴이 순차형이면 체감 차이가 약할 수 있습니다.
    • -pc와 -report_type average_counters는 레이어별 힌트를 주지만, 서비스 전체 지연 시간을 그대로 대변하지는 않습니다.
    • -exec_graph_path를 남기면 실행 그래프를 따로 볼 수 있어서 레이어 융합 여부를 확인하기 좋습니다.

    CPU 쪽은 너무 빨리 저수준 옵션으로 내려가지 않는 게 중요합니다. 저는 보통 -hint latency나 -hint throughput로 시작하고, 그다음에만 -nthreads, -nireq, 필요 시 -pin 같은 옵션을 건드립니다. 시작부터 스레드 수를 직접 고정하면 휴리스틱보다 못한 조합으로 들어가는 일도 있더라고요.

    # CPU 스레드 경쟁이 의심될 때만 추가 비교
    benchmark_app -m build/model.xml -d CPU -hint latency -api async -t 15 \
      -nthreads 8
    
    # 동시 요청 수가 실제 서비스와 맞지 않는지 확인할 때
    benchmark_app -m build/model.xml -d CPU -hint throughput -api async -t 15 \
      -nireq 4

    NUMA나 멀티소켓 서버라면 스레드 고정 정책까지 볼 가치가 있습니다. 다만 이건 버전과 장치 구성에 따라 옵션 차이가 있어서, benchmark_app -h로 현재 설치 버전이 지원하는 플래그를 먼저 확인하는 게 안전합니다. 소형 단일 서버에서는 이런 저수준 옵션보다 입력 파이프라인 정리 쪽이 효과가 더 클 때가 많았습니다.

    파이썬 서비스에 붙일 때는 아래 정도로 시작하면 충분합니다.

    import openvino as ov
    import numpy as np
    
    core = ov.Core()
    core.set_property({"CACHE_DIR": "./ov_cache"})
    
    compiled_model = core.compile_model("build/model.xml", "CPU", {
        "PERFORMANCE_HINT": "LATENCY"
    })
    
    input_port = compiled_model.input(0)
    output_port = compiled_model.output(0)
    
    sample = np.random.rand(*input_port.shape).astype(np.float32)
    result = compiled_model({input_port.any_name: sample})
    output = result[output_port]
    
    print(output.shape)

    여기서 CACHE_DIR를 넣는 건 첫 요청 지연을 줄이기 위한 준비입니다. 다만 이건 무조건 빨라진다가 아니라 재시작이 잦고 컴파일 비용이 의미 있는 모델에서 특히 효과를 볼 수 있는 옵션으로 보는 편이 정확합니다. 장치와 모델에 따라 체감 차이가 달라서, 넣고 다시 재측정해야 합니다.

    주의사항과 트러블슈팅: 막히는 지점은 늘 비슷합니다

    제가 반복해서 본 실패 모드는 대체로 네 가지였습니다. 증상만 보는 것보다 근본 원인을 먼저 적어두면 대응이 훨씬 빨라집니다.

    1. 입력 shape 문제: 원인은 모델이 아니라 입력 계약 누락인 경우가 많습니다

    에러 메시지는 모델이 까다로운 것처럼 보이는데, 실제로는 전처리 코드가 NCHW, NHWC, dtype, 배치 차원을 제각각 가정하는 경우가 대부분이었습니다. 특히 ONNX 단계에서 동적 차원을 허용해 둔 모델은 “받아줄 줄 알았는데 실제 런타임 입력은 다르다”는 일이 자주 납니다. 해결은 단순합니다. 변환 시점에 입력 shape를 명시하고, 서비스 입력 스키마를 코드와 문서 양쪽에 고정하세요.

    2. 변환은 됐는데 추론 결과가 흔들리는 경우: 원인은 연산자 호환성보다 export 품질인 때도 많습니다

    이건 OpenVINO 쪽 문제로만 보면 안 됩니다. ONNX export 단계에서 이미 불안정한 그래프가 만들어졌거나, 커스텀 연산이 우회 변환되면서 의미가 달라지는 경우가 있습니다. 이런 때는 OpenVINO IR만 보지 말고 원본 프레임워크 출력, ONNX Runtime 출력, OpenVINO 출력을 같은 샘플로 나란히 비교해야 합니다. 분류 모델이면 top-k, 탐지 모델이면 박스 좌표와 score 분포를 같이 보는 편이 좋습니다.

    3. benchmark_app는 좋아 보이는데 API는 그대로인 경우: 원인은 모델 밖에 있습니다

    이건 정말 흔합니다. 예를 들어 FastAPI 뒤에 이미지 추론 API가 있고, 요청마다 파일 업로드를 받아 Pillow로 디코딩하고 NumPy 배열로 바꾸고, 후처리로 박스를 정리해 JSON으로 내보낸다고 해보겠습니다. 이 구조에서는 모델 추론이 빨라져도 디코딩, 리사이즈, 직렬화, 네트워크 대기가 더 크면 전체 응답 시간은 거의 안 줄 수 있습니다. OpenVINO 도입이 실패한 게 아니라 최적화 대상 선정이 틀린 것에 가깝습니다.

    4. 첫 요청만 유독 느린 경우: 원인은 cold start와 컴파일 비용입니다

    서비스 재배포 직후 첫 요청이 길게 나오는 건 흔히 모델 읽기, 컴파일, 캐시 미적용 때문입니다. 이때는 앱 시작 시 웜업 추론을 한 번 수행하고, CACHE_DIR를 켠 뒤 재시작 후 첫 요청을 다시 재보셔야 합니다. 첫 요청과 반복 요청을 같은 수치로 섞으면 운영 판단이 틀어집니다.

    제가 운영 검토 때 쓰는 간단한 구분법도 있습니다.

    증상 먼저 의심할 곳 근본 원인 후보 우선 조치
    모델은 로드되는데 입력 단계에서 바로 실패 전처리 shape, layout, dtype 불일치 입력 스키마를 고정하고 샘플 입력을 저장해 재현합니다.
    변환 경고 없이 결과만 이상함 export 품질 ONNX 그래프 의미 손실, 커스텀 연산 우회 프레임워크/ONNX/OpenVINO 3단 비교를 합니다.
    단독 벤치마크는 빠른데 API가 안 빨라짐 서비스 외곽 디코딩, 후처리, 직렬화, I/O 병목 모델과 서비스 벤치마크를 분리합니다.
    첫 요청만 느림 초기화 단계 컴파일, 캐시 미사용, 웜업 부재 캐시와 웜업을 넣고 cold start를 따로 측정합니다.
    인텔 CPU 추론 성능 검증과 OpenVINO 도입 병목 분석 이미지

    지연 시간과 처리량, CPU 사용 패턴을 함께 점검하는 검증 장면을 표현한 이미지입니다.

    OpenVINO 도입 검증에서 무엇을 보고 결정할지

    AI 모델 최적화에서 숫자 하나만 보면 거의 항상 놓치는 게 생깁니다. 저는 최소한 아래 네 축을 같이 봅니다.

    1. 기능 동등성: 같은 입력에서 결과 의미가 유지되는지 확인합니다. 분류면 top-k, 탐지면 박스 수와 score 경향, 세그멘테이션이면 마스크 경계를 봅니다.
    2. cold start와 steady state 분리: 첫 요청과 반복 요청을 같은 그래프에 섞지 않습니다.
    3. 단건 지연과 전체 처리량 분리: API냐 배치냐에 따라 좋은 설정이 다릅니다.
    4. 모델 안과 밖 분리: 모델 단독 벤치마크와 서비스 엔드투엔드 벤치마크를 따로 남깁니다.

    이 기준으로 보면 의사결정이 꽤 선명해집니다.

    • 정확도 변화가 없고 지연 시간 편차가 줄었다: 운영 전환 후보로 충분합니다.
    • 처리량은 좋아졌는데 단건 응답이 나빠졌다: 배치 경로엔 적합하지만 실시간 API엔 별도 설정이 필요합니다.
    • 변환 경고가 남고 일부 샘플 출력이 흔들린다: 런타임 튜닝 전에 export와 호환성부터 다시 잡아야 합니다.
    • 모델 숫자는 좋아졌는데 서비스 체감이 없다: 전처리와 후처리, I/O 비용이 본체일 가능성이 큽니다.

    실무에서는 여기서 결론을 분기하면 됩니다. 단건 응답형 서비스면 LATENCY 힌트부터, 다중 요청이나 배치형 작업이면 THROUGHPUT 힌트부터 시작하세요. 두 결과가 서로 다르게 나오면 “어느 쪽이 더 빠른가”보다 “현재 서비스 목표가 어느 쪽인가”로 결정하는 편이 맞습니다.

    운영 전환 체크리스트: 저는 이 항목이 안 채워지면 배포 안 합니다

    • 산출물 이원화: ONNX와 OpenVINO IR을 같이 보관하고, 어떤 버전에서 변환했는지 기록합니다.
    • 입력 계약 문서화: shape, dtype, layout, 채널 순서를 문서와 테스트 샘플로 고정합니다.
    • 검증 샘플 고정: 대표 입력 세트를 정해 변환 전후 비교를 자동화합니다.
    • 성능 측정 분리: 모델 단독 수치와 API 전체 수치를 별도로 저장합니다.
    • 롤백 스위치 유지: OpenVINO 경로에 장애가 나면 ONNX Runtime으로 즉시 되돌릴 수 있어야 합니다.
    • cold start 대비: 웜업 전략과 캐시 디렉터리 사용 여부를 배포 스크립트에 포함합니다.

    여기서 많이 놓치는 게 롤백입니다. 배포 표준을 바꾸는 게 아니라 실행 경로를 하나 더 두는 것이라고 생각하면 결정이 조금 쉬워집니다. 운영 안정성은 새로운 엔진을 넣는 것보다, 문제 났을 때 되돌아갈 길이 있는지가 더 크게 좌우합니다.

    OpenVINO 도입 전후 선택 기준을 정리한 요약 이미지

    어떤 환경에서 OpenVINO 도입이 유리한지 한눈에 정리한 요약 이미지입니다.

    자주 묻는 질문

    ONNX가 이미 있는데 굳이 OpenVINO까지 가야 하나요?

    인텔 CPU 추론이 핵심 경로라면 검토 가치는 충분합니다. 다만 기준은 단순하지 않습니다. ONNX Runtime이 이미 안정적이고 실제 병목이 전처리나 API 레이어라면 굳이 바꿀 이유는 약합니다. 반대로 CPU 지연 편차나 cold start, 요청 처리 패턴 최적화가 고민이라면 OpenVINO를 별도 경로로 두는 편이 낫습니다.

    엣지 AI 환경에서 특히 의미가 큰가요?

    네, 특히 GPU 없는 소형 노드에서 그렇습니다. 다만 “엣지라서 무조건”은 아닙니다. 장점은 하드웨어 특화 최적화와 배포 단순성이고, 단점은 변환 산출물 관리와 호환성 검증이 추가된다는 점입니다. 그래서 저는 엣지 환경일수록 산출물 버전 관리와 입력 스키마 고정을 더 엄격하게 잡습니다.

    도입 순서는 어떻게 잡는 게 안전한가요?

    가장 안전한 순서는 이렇습니다. 학습 코드는 그대로 유지하고, ONNX를 공통 산출물로 두고, OpenVINO IR을 배포 전용 산출물로 추가하세요. 그다음 모델 단독 벤치마크, 기능 비교, 서비스 A/B 테스트 순으로 가면 됩니다. 운영 경로를 한 번에 갈아엎는 방식은 추천하지 않습니다.

    여기서 이렇게 결정하시면 됩니다

    제 판단 기준은 꽤 단순합니다. 인텔 CPU 추론이 핵심이고, ONNX 산출물이 이미 안정적이며, 지금 문제의 본체가 모델 실행 경로라면 OpenVINO 도입 쪽으로 가는 편이 맞습니다. 이 경우에는 학습 스택을 건드리지 말고, 배포 레이어만 OpenVINO로 분기하세요. 특히 실시간 API라면 LATENCY 힌트 중심으로, 배치 작업이라면 THROUGHPUT 힌트 중심으로 검증하면 방향이 빨리 잡힙니다.

    반대로 GPU 최적화 체계가 이미 굳어 있거나, 모델보다 전처리·후처리·네트워크가 더 느리다면 지금 당장 OpenVINO로 옮길 이유는 약합니다. 이런 상황에서는 엔진을 바꾸기보다 병목 측정부터 다시 하는 게 맞습니다. 그리고 커스텀 연산자가 많은 모델이라면 성능 실험보다 먼저 호환성 검증을 별도 작업으로 떼어 두세요. 실무에서는 이 순서를 지키는 팀이 결국 덜 헤맵니다.

  • [AI] LlamaIndex RAG 시스템 구축 실패 사례: 흔한 문제와 디버깅 전략

    [AI] LlamaIndex RAG 시스템 구축 실패 사례: 흔한 문제와 디버깅 전략

    [AI/LLM] LlamaIndex RAG 시스템 구축 실패 사례: 흔한 문제와 디버깅 전략

    LlamaIndex RAG 시스템을 처음 붙일 때 많은 분들이 비슷한 지점에서 막히시더라고요. 문서는 잘 넣은 것 같은데 답이 엉뚱하게 나오고, 로그를 봐도 어디서 망가졌는지 감이 안 오고, 심지어 AI 에이전트(Agent) 문제인 줄 알았는데 알고 보니 검색 단계가 이미 틀어져 있던 경우도 많습니다. 저도 홈랩에서 이것저것 붙여 보면서 삽질 좀 했습니다 ㅎㅎ 처음엔 모델 탓인가 싶었는데, 실제로는 데이터 적재(Ingestion, 문서 수집 및 변환), 청킹(Chunking, 문서 분할), 검색(Retrieval, 관련 문맥 찾기), 응답 합성(Response Synthesis, 답변 생성) 중 하나가 어긋난 경우가 대부분이었습니다.

    이번 글은 제품 홍보나 멋진 데모가 아니라, LlamaIndex RAG를 구성하다가 망했던 패턴을 정리한 실패 사례 중심 글입니다. 특히 RAG 시스템 문제, LlamaIndex 오류, RAG 디버깅, AI 에이전트 실패를 한 번에 점검할 수 있는 흐름으로 정리해 보겠습니다. 혹시 “문서는 넣었는데 왜 이렇게 못 찾지?” 같은 경험 있으신가요? 그거 생각보다 정상입니다. 중요한 건 감으로 고치는 게 아니라, 단계별로 끊어서 확인하는 겁니다.

    LlamaIndex RAG 전체 아키텍처를 보여주는 홈랩 다이어그램

    문서 수집부터 청킹, 벡터 저장, 검색, 응답 생성까지 흐름을 한눈에 보는 개요 이미지입니다.

    LlamaIndex RAG를 쉽게 말하면 어디서 망가질까요?

    쉽게 말해 RAG(Retrieval-Augmented Generation, 검색 증강 생성)는 “모델이 원래 알고 있는 것”에만 기대지 않고, 질문이 들어오면 관련 문서를 먼저 찾아서 그 문맥을 바탕으로 답하게 만드는 구조입니다. LlamaIndex는 이 과정을 구성하기 쉽게 만들어 주는 프레임워크 쪽에 가깝고요.

    제가 직접 해보니, 실패 지점은 생각보다 단순했습니다.

    • 문서 적재 단계: 파일은 읽었는데 내용이 이상하게 잘렸습니다.
    • 인덱싱(Indexing, 검색용 구조화): 벡터 저장은 됐는데 실제 검색 품질이 낮았습니다.
    • 리트리버(Retriever, 관련 문맥 검색기): 질문과 맞는 조각을 못 가져왔습니다.
    • 응답 합성: 검색은 맞았는데 모델이 답변을 엉성하게 만들었습니다.
    • 영속화(Persistence, 디스크 저장): 재시작 후 이전 상태가 사라지거나 오래된 인덱스를 계속 읽었습니다.

    여기서 중요한 포인트! LlamaIndex RAG는 한 덩어리처럼 보이지만, 실제 디버깅은 단계별 분리 진단이 핵심입니다. 검색이 틀렸는데 프롬프트만 계속 고치면 시간만 날립니다.

    실패를 줄이는 기본 설계: 먼저 관측 가능한 구조로 만드세요

    저는 예전엔 일단 돌아가게만 만들고 나중에 로그를 붙였었는데요, 그 방식은 RAG에서 진짜 비효율적이더라고요. 처음부터 “문서가 어떻게 잘렸는지”, “질문에 대해 어떤 노드(Node, 검색 가능한 문서 조각)가 선택됐는지”, “최종 답변이 어떤 근거를 썼는지”가 보여야 합니다.

    구간 자주 보이는 증상 실제 원인 후보 우선 확인할 것
    문서 로딩 파일은 읽히는데 답변이 비어 있음 본문 추출 실패, 인코딩 문제, 예상과 다른 폴더 로드된 문서 수와 본문 샘플
    청킹 검색 결과가 너무 짧거나 문맥이 끊김 chunk_size 과소, overlap 부족 분할된 노드 샘플
    검색 관련 없는 문서가 자주 나옴 질문-문서 표현 불일치, 메타데이터 필터 오류 retriever 결과 목록
    응답 생성 찾아놓고도 엉뚱한 답변 프롬프트 제약, 컨텍스트 과다/과소 source nodes와 최종 응답 비교
    저장/재로딩 재실행 후 결과가 달라짐 인메모리 상태 의존, persist 누락 저장 경로와 로딩 경로

    LlamaIndex RAG 실전 구현: 최소 구성부터 시작하기

    처음부터 벡터 DB(Vector Database, 벡터 저장소)까지 크게 벌이지 말고, 최소한의 로컬 흐름으로 시작하는 걸 추천드립니다. 이유는 간단합니다. 실패 지점을 줄여야 하거든요.

    1. 문서를 읽는다.
    2. 인덱스를 만든다.
    3. 리트리버와 쿼리 엔진을 분리해서 테스트한다.
    4. 결과가 괜찮으면 그다음에 영속화와 외부 저장소를 붙인다.

    1. 설치와 기본 실행

    python -m venv .venv
    source .venv/bin/activate
    pip install llama-index
    

    여기서는 버전 핀을 일부러 박지 않았습니다. 글의 목적이 특정 릴리스 사용기가 아니라, 디버깅 흐름 자체에 있기 때문입니다.

    2. 가장 단순한 인덱스 생성

    from llama_index.core import SimpleDirectoryReader, VectorStoreIndex
    
    # data/ 폴더 아래 문서를 읽습니다.
    documents = SimpleDirectoryReader("data").load_data()
    
    # 가장 단순한 형태의 인덱스를 만듭니다.
    index = VectorStoreIndex.from_documents(documents)
    
    # 검색과 응답 생성을 분리해서 확인할 수 있도록 둘 다 만듭니다.
    retriever = index.as_retriever()
    query_engine = index.as_query_engine()
    
    question = "장애 대응 절차를 요약해줘"
    retrieved_nodes = retriever.retrieve(question)
    response = query_engine.query(question)
    
    print("[RETRIEVED NODES]")
    for i, node in enumerate(retrieved_nodes, start=1):
        print(f"--- node {i} ---")
        print(node.text[:300])
    
    print("[FINAL RESPONSE]")
    print(response)
    

    이 코드에서 핵심은 retriever와 query_engine을 따로 본다는 점입니다. 처음엔 이게 뭔가 싶었는데, 실제로 써보니까 이 분리가 디버깅 시간을 확 줄여주더라고요.

    3. 청킹을 직접 통제하는 적재 파이프라인

    from llama_index.core import Document
    from llama_index.core.ingestion import IngestionPipeline
    from llama_index.core.node_parser import SentenceSplitter
    from llama_index.core.extractors import TitleExtractor
    
    sample_docs = [
        Document(text="장애 조치 문서 예시입니다. 원인 분석, 임시 조치, 영구 조치가 포함됩니다."),
    ]
    
    pipeline = IngestionPipeline(
        transformations=[
            SentenceSplitter(chunk_size=256, chunk_overlap=32),
            TitleExtractor(),
        ]
    )
    
    nodes = pipeline.run(documents=sample_docs)
    
    for i, node in enumerate(nodes, start=1):
        print(f"NODE {i}")
        print(node.text)
        print(node.metadata)
    

    청킹은 진짜 중요합니다. 저는 처음에 chunk_size를 너무 작게 잡아서 문서 문맥이 다 잘려 나갔었는데, 검색 결과는 그럴듯하게 보여도 실제 답변은 자꾸 뜬구름 잡더라고요.

    LlamaIndex RAG 문서 청킹과 리트리버 동작을 설명하는 이미지

    문서가 여러 조각으로 분할되고 질문에 따라 관련 노드가 선택되는 구조를 설명하는 이미지입니다.

    4. 캐시와 저장 경로를 명시하기

    from llama_index.core import StorageContext, load_index_from_storage
    
    # 인덱스를 저장합니다.
    index.storage_context.persist(persist_dir="./storage")
    
    # 저장된 인덱스를 다시 로드합니다.
    storage_context = StorageContext.from_defaults(persist_dir="./storage")
    loaded_index = load_index_from_storage(storage_context)
    
    loaded_query_engine = loaded_index.as_query_engine()
    print(loaded_query_engine.query("장애 대응 절차를 요약해줘"))
    

    여기서 많이 터집니다. LlamaIndex는 기본적으로 인메모리(in-memory, 메모리 상주) 상태를 많이 쓰기 때문에, 재시작 후에도 같은 동작을 기대한다면 저장과 로딩 경로를 명확히 관리해야 합니다.

    ⚠️ 흔한 LlamaIndex 오류와 RAG 디버깅 전략

    이제부터는 제가 실제로 자주 밟았던 실패 패턴입니다. 증상만 보면 비슷한데 원인은 꽤 다릅니다.

    문제 1. 문서는 읽었는데 답변이 너무 일반적입니다

    이 경우 많은 분이 모델 성능부터 의심하시는데요, 사실은 검색 단계에서 충분한 근거가 안 들어간 경우가 많습니다.

    • 문서가 너무 잘게 쪼개져 핵심 문맥이 끊겼는지 봅니다.
    • 질문과 문서 표현이 다르면 검색 품질이 확 떨어집니다.
    • 리트리버가 뽑은 노드 본문을 직접 읽어 봅니다.

    제가 직접 해보니, 최종 답변보다 retrieved_nodes 출력을 먼저 보는 습관이 정말 중요했습니다. 답이 이상하면 먼저 “뭘 찾았는가”를 봐야 합니다.

    문제 2. 재색인했는데 결과가 안 바뀝니다

    이건 캐시나 저장 디렉터리 때문에 생기는 경우가 많습니다. 특히 테스트하면서 같은 경로를 계속 재사용하면 오래된 데이터가 남아 있을 수 있습니다.

    rm -rf ./storage
    rm -rf ./pipeline_storage
    

    물론 운영 환경에서는 이렇게 단순 삭제하면 안 됩니다. 다만 로컬 검증 단계에서는 “정말 새로 인덱싱된 게 맞나?”를 확인하는 용도로 한 번쯤 초기화가 필요하더라고요.

    문제 3. 검색은 맞는데 최종 답변이 엉뚱합니다

    이건 응답 합성 단계 문제일 가능성이 높습니다. 쉽게 말해 모델이 가져온 컨텍스트를 잘 못 쓰는 거죠.

    • 너무 많은 컨텍스트를 넣어 핵심이 묻히지 않았는지 봅니다.
    • 프롬프트에서 근거 기반 답변을 강하게 요구하는지 봅니다.
    • 답변과 함께 source nodes를 출력해 비교합니다.

    근데 여기서 중요한 포인트가 하나 있습니다. 검색 품질이 80인데 생성 품질이 20이면 프롬프트 손봐야 하고, 검색 품질이 20인데 생성만 만지면 계속 헛바퀴 돕니다.

    문제 4. AI 에이전트 실패처럼 보이는데 사실 RAG 실패입니다

    도구 호출(Tool Calling, 외부 기능 호출)이나 에이전트 라우팅이 문제인 줄 알았는데, 막상 까보면 검색된 문맥이 비어 있는 경우가 있습니다. 저도 처음엔 에이전트 흐름도를 한참 봤었는데요, 결국 원인은 리트리버였습니다. 그래서 저는 에이전트를 붙이기 전에 항상 아래 순서로 검증합니다.

    1. retriever.retrieve() 결과를 먼저 확인
    2. query_engine.query() 결과와 비교
    3. 그 다음에만 agent/tool 레이어 확인

    문제 5. 운영 환경에서만 이상합니다

    개발 환경에서는 한글 문서가 잘 검색됐는데 운영에서만 품질이 달라지는 경우도 있었습니다. 이런 건 대개 데이터셋 차이, 적재 타이밍 차이, 저장소 경로 차이, 혹은 문서 전처리 차이로 이어집니다. 이럴 때는 “운영 모델이 다르다” 같은 큰 가설보다, 실제로 어떤 문서가 들어갔는지를 먼저 비교하는 게 낫습니다.

    검증 방법: 정답률보다 먼저 파이프라인을 눈으로 확인하세요

    저는 RAG를 검증할 때 처음부터 화려한 평가 지표를 붙이지 않습니다. 물론 평가(Evaluation, 성능 측정)는 중요하지만, 초기에는 육안 검증이 훨씬 빠릅니다.

    1. 질문 10개를 만든다.
    2. 각 질문마다 검색된 노드 상위 결과를 저장한다.
    3. 최종 답변과 근거 문장을 같이 본다.
    4. 틀린 답변은 검색 실패인지 생성 실패인지 분류한다.
    test_questions = [
        "장애 보고 절차는 어떻게 되나요?",
        "임시 조치와 영구 조치의 차이는 무엇인가요?",
        "점검 창구는 어디인가요?",
    ]
    
    for question in test_questions:
        nodes = retriever.retrieve(question)
        answer = query_engine.query(question)
    
        print("=" * 40)
        print("Q:", question)
        print("[TOP NODES]")
        for node in nodes[:3]:
            print(node.text[:200])
        print("[ANSWER]")
        print(answer)
    

    실제로 써보니까, 이 정도만 해도 어디가 문제인지 금방 보입니다. 드디어 됐다! 싶은 순간도 보통 여기서 오더라고요.

    LlamaIndex RAG 검증 과정에서 검색 노드와 답변을 비교하는 대시보드 이미지

    질문, 검색된 문서 조각, 최종 응답을 나란히 놓고 비교하는 검증 화면 예시입니다.

    실패를 줄이는 운영 체크리스트

    LlamaIndex RAG를 조금 오래 운영하다 보면, 결국 반복 확인 항목이 정해집니다. 저는 아래 체크리스트를 배포 전 마지막 관문처럼 씁니다.

    • 문서 수와 예상 파일 수가 맞는가
    • 청크 샘플을 3개 이상 직접 읽어 봤는가
    • 검색 결과 상위 노드가 질문 의도와 맞는가
    • 재시작 후에도 같은 인덱스를 로드하는가
    • 캐시와 저장 디렉터리 충돌이 없는가
    • 에이전트 문제로 보기 전에 검색 결과를 확인했는가
    실패 유형 대응 우선순위 빠른 처방
    답변이 너무 일반적 높음 청킹과 검색 결과부터 확인
    재색인 후 결과 동일 높음 persist 경로와 캐시 정리 확인
    운영에서만 품질 저하 중간 실제 적재 문서와 경로 비교
    AI 에이전트 실패처럼 보임 높음 agent 이전에 retriever 단독 테스트

    자주 묻는 질문: RAG 시스템 문제를 어디서부터 봐야 하나요?

    Q1. LlamaIndex 오류가 나지 않아도 시스템이 틀릴 수 있나요?

    네, 그게 더 흔합니다. 에러 없이도 잘못된 문맥을 검색하면 결과는 충분히 틀릴 수 있습니다. 그래서 RAG 디버깅은 예외 메시지보다 중간 산출물 확인이 더 중요합니다.

    Q2. 청킹만 잘하면 해결되나요?

    아닙니다. 청킹은 출발점일 뿐이고, 메타데이터, 저장 경로, 검색기 설정, 응답 합성까지 같이 봐야 합니다.

    Q3. AI 에이전트 실패와 RAG 실패는 어떻게 구분하나요?

    에이전트를 떼고 retriever와 query_engine을 각각 테스트해 보시면 됩니다. 에이전트를 제거했는데도 답이 틀리면 대개 RAG 쪽입니다.

    마무리: 화려한 튜닝보다 관측 가능한 구조가 먼저입니다

    이번 글에서 말씀드리고 싶었던 건 딱 하나입니다. LlamaIndex RAG는 잘 만들면 강력하지만, 실패했을 때는 “어디서 틀어졌는지 보이게” 설계해야 한다는 점입니다. 저도 처음엔 모델만 계속 바꿔 봤었는데, 결국 해결은 대부분 로그, 노드 출력, 저장 경로 확인에서 나왔습니다. 사실 이런 건 멋이 없거든요. 근데 운영에서는 이런 기본기가 제일 셉니다.

    다음 글에서는 검색 품질이 낮을 때 메타데이터 필터링과 질문 정규화 전략을 어떻게 붙이는지 다뤄볼 예정입니다. 이전 글 참고 형식으로 이어서 보시면 흐름 잡기 편하실 겁니다.

    LlamaIndex RAG 디버깅 체크리스트와 실패 유형 요약 이미지

    실패 유형별 점검 순서와 대응 우선순위를 한 장으로 정리한 요약 이미지입니다.

    참고한 공식 문서