Published on

HuggingFace TGI로 로컬 LLM 서버화 실전 튜닝

Authors

로컬에서 LLM을 돌릴 때 가장 흔한 실패 패턴은 “모델은 뜨는데 서비스가 불안정하다”입니다. 단일 사용자 테스트는 잘 되지만, 동시 요청이 들어오면 지연이 급증하거나 OOM으로 프로세스가 죽고, 스트리밍이 끊기거나 토큰이 과도하게 생성되는 식이죠.

HuggingFace Text Generation Inference(TGI) 는 이런 운영 이슈를 줄이기 위해 만들어진 고성능 추론 서버입니다. 단순히 transformers로 직접 서빙하는 것보다, 배치 스케줄링, KV 캐시 관리, 스트리밍, 관측(메트릭), 멀티 GPU 등 “서버로서 필요한 기능”이 갖춰져 있습니다.

이 글은 “TGI로 로컬 LLM을 서버화”하는 기본 구성을 빠르게 잡고, 실제로 성능과 안정성을 끌어올리는 튜닝 포인트를 체크리스트 형태로 정리합니다.

참고로, RAG까지 같이 붙일 계획이라면 검색단 병목이 전체 지연을 잡아먹는 경우가 많습니다. 그때는 pgvector RAG 검색 느림? HNSW 튜닝 체크리스트 도 함께 보세요.

TGI를 선택하는 기준: “서버 기능”이 필요한가

다음 중 2개 이상이면 TGI가 체감됩니다.

  • 동시 요청이 들어오면 토큰 생성 속도가 들쑥날쑥하다
  • 스트리밍 응답이 필요하다
  • 여러 클라이언트가 붙는 API 서버로 운영할 예정이다
  • GPU 메모리를 꽉 채워서 처리량을 최대화하고 싶다
  • 운영 메트릭(지연, 토큰 수, 큐 대기)을 보고 싶다

반대로, 단일 사용자 로컬 실험만 한다면 vLLM이나 간단한 transformers 서빙이 더 편할 수도 있습니다. 다만 “로컬이지만 서버처럼” 쓰려면 TGI는 여전히 좋은 선택입니다.

준비물과 전제

  • NVIDIA GPU 권장(쿠다 환경)
  • Docker 사용(권장). 로컬 개발에서 재현성이 가장 좋습니다.
  • 모델은 Hugging Face Hub 또는 로컬 경로

아래 예시는 Linux 기준이며, Windows는 WSL2에서 동일하게 접근하는 편이 안전합니다.

1) 가장 빠른 실행: Docker로 TGI 띄우기

(1) Hugging Face Hub 모델을 바로 서빙

# 모델 캐시를 호스트에 유지하면 재시작이 빨라집니다.
export HF_HOME=$HOME/.cache/huggingface
export MODEL_ID=meta-llama/Llama-2-7b-chat-hf

docker run --gpus all --rm \
  -p 8080:80 \
  -v $HF_HOME:/data \
  -e HF_TOKEN=$HF_TOKEN \
  ghcr.io/huggingface/text-generation-inference:latest \
  --model-id $MODEL_ID \
  --max-input-length 2048 \
  --max-total-tokens 3072
  • --max-input-length: 입력 토큰 상한
  • --max-total-tokens: 입력+출력 합계 상한

이 두 값은 OOM 방지의 1차 안전장치입니다. 운영에서 가장 먼저 고정해야 하는 값입니다.

(2) 로컬 모델 경로 서빙

export LOCAL_MODEL_DIR=$HOME/models/my-llm

docker run --gpus all --rm \
  -p 8080:80 \
  -v $LOCAL_MODEL_DIR:/model \
  ghcr.io/huggingface/text-generation-inference:latest \
  --model-id /model \
  --max-input-length 2048 \
  --max-total-tokens 3072

2) 호출 방법: cURL로 스트리밍/비스트리밍 확인

TGI는 OpenAI 호환 엔드포인트를 제공하는 배포도 있고, 기본 생성 엔드포인트를 제공하는 형태도 있습니다. 여기서는 TGI 기본 생성 엔드포인트 예시를 듭니다.

비스트리밍

curl -s http://localhost:8080/generate \
  -H 'Content-Type: application/json' \
  -d '{
    "inputs": "Explain KV cache in LLM inference.",
    "parameters": {
      "max_new_tokens": 128,
      "temperature": 0.2,
      "top_p": 0.9
    }
  }' | jq

스트리밍

curl -N http://localhost:8080/generate_stream \
  -H 'Content-Type: application/json' \
  -d '{
    "inputs": "Write a short checklist for TGI tuning.",
    "parameters": {
      "max_new_tokens": 128,
      "temperature": 0.7
    }
  }'

스트리밍을 붙이면 UX는 좋아지지만, 서버 관점에서는 동시 연결 수 증가, 프록시 타임아웃, 클라이언트 재시도 폭주 같은 운영 이슈가 늘어납니다. 특히 LangChain 계열에서 스트리밍 중복/토큰 폭주가 발생한다면 LangChain 스트리밍 중복응답·토큰폭주 디버깅 을 같이 참고하면 원인 분리가 빨라집니다.

3) 성능 튜닝의 핵심: “배치”와 “토큰 상한”

TGI 튜닝은 결국 다음 2가지를 균형 잡는 게임입니다.

  • 처리량(throughput): 초당 생성 토큰 수를 최대화
  • 지연시간(latency): 첫 토큰까지 시간(TTFT)과 전체 응답 시간을 최소화

이를 좌우하는 가장 큰 레버는:

  1. 동적 배치(요청을 묶어 GPU를 더 효율적으로 사용)
  2. 토큰 상한(입력/출력 길이 제한으로 최악 케이스 차단)

토큰 상한을 먼저 고정해야 하는 이유

운영에서 OOM은 대부분 “긴 입력 + 긴 출력 + 동시 요청” 조합으로 발생합니다. 특히 KV 캐시는 시퀀스 길이에 비례해 커지기 때문에, 토큰 상한이 없으면 메모리 사용량이 예측 불가능해집니다.

권장 순서:

  • --max-input-length 를 실제 제품 입력 상한으로 고정
  • --max-total-tokens 를 “입력+출력” 기준으로 고정
  • API 레벨에서 max_new_tokens 상한도 별도로 검증

동시성은 “배치”로 흡수하고, “큐”로 제한

동시 요청이 늘면 GPU가 바빠져서 느려지는 건 자연스럽습니다. 문제는 느려지는 방식이 폭발적으로 변할 때입니다(큐가 무한히 쌓이거나, 컨텍스트가 길어져 KV 캐시가 터짐).

대응은 두 단계로 합니다.

  • 서버 내부: 배치로 GPU 효율을 끌어올림
  • 서버 외부: 큐/레이트리밋으로 최악 케이스를 차단

TGI는 내부적으로 배치를 잘 활용하지만, 제품에서는 앞단에 Nginx/Envoy 등으로 동시 연결 상한, 요청 타임아웃, 재시도 정책을 반드시 둬야 합니다.

4) 메모리 튜닝: OOM을 “예방”하는 체크리스트

(1) 입력 길이와 출력 길이의 곱을 제한

다음 조합이 가장 위험합니다.

  • 입력이 6k 이상(긴 RAG 컨텍스트)
  • 출력도 1k 이상(장문 생성)
  • 동시 요청 4개 이상

이 경우 모델 파라미터 자체보다 KV 캐시가 폭증합니다.

실전 팁:

  • 제품 요구사항이 허용한다면 max_new_tokens 를 256~512로 제한
  • “장문”이 필요하면 한 번에 길게 생성하지 말고, 요약/아웃라인 후 섹션별 생성처럼 다단 생성으로 나눔

(2) 정밀도/양자화로 여유 메모리 확보

모델이 GPU 메모리에 간당간당하게 올라가면, 작은 트래픽 변화에도 OOM이 납니다. 이때는 정밀도/양자화로 여유를 만드는 게 가장 효과적입니다.

  • FP16 또는 BF16 사용(가능한 환경이면 BF16이 안정적인 경우가 많음)
  • 더 공격적으로는 INT8/4bit 계열을 고려

모델 경량화 접근이 필요하다면, ONNX+INT8 파이프라인 관점에서 PyTorch 모델을 ONNX+INT8로 4배 경량화하는 법 도 참고할 만합니다. TGI 자체의 지원 범위와는 별개로, “메모리/속도 문제를 어떻게 풀지” 사고방식을 잡는 데 도움이 됩니다.

(3) 스왑/오버커밋에 기대지 않기

GPU OOM은 CPU 스왑으로 해결되지 않습니다. 오히려 스왑이 켜져 있으면 CPU 메모리 압박으로 전체 시스템이 느려지고, 결국 서버가 더 불안정해집니다.

  • GPU 메모리 여유 확보가 정답
  • 토큰 상한 + 배치 + 정밀도 조합으로 해결

5) 지연시간 튜닝: TTFT와 토큰 속도를 분리해서 보자

LLM 응답 지연은 크게 두 덩어리로 나뉩니다.

  • TTFT(Time To First Token): 첫 토큰이 나오기까지
  • Generation speed: 이후 토큰이 나오는 속도

TTFT가 느린 경우 체크:

  • 입력이 너무 길다(프리필 비용 증가)
  • 동시 요청이 많아 큐 대기 시간이 길다
  • 콜드 스타트(모델 로딩 직후)

Generation speed가 느린 경우 체크:

  • 배치/스케줄링이 비효율적
  • GPU가 충분히 활용되지 않음(너무 작은 배치, 잦은 컨텍스트 스위칭)
  • 온도/샘플링 설정으로 토큰이 불필요하게 길어짐(예: 반복/장황)

실전 팁:

  • 제품 KPI를 “TTFT 1.5초 이하”처럼 명확히 두고, max_input_length 를 줄이는 게 가장 즉효입니다.
  • RAG라면 “컨텍스트를 무조건 많이 넣기”보다, 검색 품질과 압축(요약/리랭크)로 입력 토큰을 줄이는 쪽이 전체 UX가 좋아집니다.

6) 동시 요청 튜닝: 안전한 큐잉과 타임아웃

로컬 서버화에서 가장 많이 놓치는 부분이 “클라이언트 재시도”입니다.

  • 서버가 잠깐 느려짐
  • 클라이언트 타임아웃 발생
  • 재시도 폭주로 서버가 더 느려짐
  • 결국 OOM 또는 연결 폭발

권장 설정(앞단 프록시/클라이언트 포함):

  • 스트리밍 요청 타임아웃을 충분히 길게(예: 120초 이상)
  • 재시도는 멱등 요청에만 제한적으로
  • 서버는 429(Too Many Requests)로 과부하를 명확히 신호

또한 동시성 제어는 “GPU 1장당 워커 N개” 같은 단순 공식으로 끝나지 않습니다. 모델 크기, 입력 길이 분포, 출력 길이 분포에 따라 최적점이 달라집니다. 그래서 아래처럼 시나리오 기반 부하 테스트가 필요합니다.

7) 실전 부하 테스트: 토큰 분포를 반영한 시나리오

단순히 hey 같은 짧은 프롬프트로 QPS를 올리면 의미가 없습니다. 실제 트래픽은 보통 다음이 섞입니다.

  • 짧은 질의(100~300 토큰)
  • RAG 포함 질의(1k~4k 토큰)
  • 장문 생성(출력 500~1500 토큰)

아래는 Python으로 간단히 동시 요청을 날려 TTFT/총 시간을 측정하는 예시입니다.

import asyncio
import time
import httpx

TGI_URL = "http://localhost:8080/generate"

payloads = [
    {
        "inputs": "Summarize the following text in 5 bullets: " + ("A" * 2000),
        "parameters": {"max_new_tokens": 200, "temperature": 0.2},
    },
    {
        "inputs": "Write a detailed checklist for deploying a local LLM server.",
        "parameters": {"max_new_tokens": 400, "temperature": 0.7},
    },
]

async def one_call(client, payload):
    t0 = time.perf_counter()
    r = await client.post(TGI_URL, json=payload, timeout=120)
    r.raise_for_status()
    t1 = time.perf_counter()
    return t1 - t0, r.json()

async def main(concurrency=8, rounds=20):
    async with httpx.AsyncClient() as client:
        latencies = []
        for _ in range(rounds):
            tasks = []
            for i in range(concurrency):
                tasks.append(one_call(client, payloads[i % len(payloads)]))
            results = await asyncio.gather(*tasks)
            latencies.extend([x[0] for x in results])

        latencies.sort()
        p50 = latencies[int(len(latencies) * 0.50)]
        p95 = latencies[int(len(latencies) * 0.95)]
        print({"count": len(latencies), "p50": p50, "p95": p95})

if __name__ == "__main__":
    asyncio.run(main())

이 테스트로 최소한 다음을 확인하세요.

  • 동시성 증가에 따라 p95가 어느 지점에서 급격히 튀는지
  • 입력 길이가 긴 케이스가 전체를 얼마나 느리게 만드는지
  • max_total_tokens 를 낮췄을 때 안정성이 얼마나 좋아지는지

8) 운영 관측: 메트릭 없이는 튜닝도 없다

로컬 서버화라도 메트릭을 붙이면 튜닝이 쉬워집니다.

  • 요청 수, 에러율
  • 큐 대기 시간
  • 토큰 생성량(입력/출력)
  • GPU 사용률, GPU 메모리 사용량

가장 중요한 건 “느려졌다”가 아니라:

  • TTFT가 느린가?
  • 생성 속도가 느린가?
  • 큐에서 기다리다 느린가?

를 분해해서 보는 것입니다.

9) 자주 겪는 장애와 해결책

(1) 갑자기 OOM으로 죽는다

  • 토큰 상한부터 고정: --max-input-length, --max-total-tokens
  • 제품 레벨에서 max_new_tokens 검증
  • 긴 컨텍스트는 요약/리랭크로 줄이기

(2) 스트리밍이 중간에 끊긴다

  • 프록시 타임아웃 확인
  • 클라이언트 타임아웃 확인
  • 재시도 정책이 중복 호출을 만들지 확인

(3) 응답이 장황하고 토큰을 과도하게 쓴다

  • temperature 를 낮추고 max_new_tokens 상한 설정
  • 시스템 프롬프트에 출력 포맷을 강제

특히 “추론 과정 유출”까지 함께 막고 싶다면 출력 스키마 강제가 효과적입니다. CoT 유출 막는 프롬프트 - JSON 스키마 강제 의 접근을 응용하면, 토큰 낭비와 정책 리스크를 동시에 줄일 수 있습니다.

10) 추천 튜닝 순서(현장에서 가장 효율적)

  1. 토큰 상한 고정: max_input_length, max_total_tokens, max_new_tokens
  2. 대표 시나리오 부하 테스트: 짧은/긴 입력, 짧은/긴 출력 섞기
  3. OOM 여유 확보: 정밀도 조정 또는 더 작은 모델/양자화 고려
  4. 스트리밍/타임아웃/재시도 정리: 중복 호출과 폭주 방지
  5. 메트릭 기반 반복: TTFT vs 생성 속도 vs 큐 대기 분해

마무리

TGI로 로컬 LLM을 “서버처럼” 운영하려면, 단순 실행보다 상한선(토큰/동시성)관측(메트릭) 을 먼저 세팅하는 게 핵심입니다. 그 다음에 배치 효율과 메모리 여유를 확보하면, 같은 하드웨어에서도 처리량과 안정성이 눈에 띄게 좋아집니다.

다음 단계로 RAG까지 붙일 계획이라면, 모델 서버 튜닝과 함께 검색단(HNSW/인덱스/리랭크) 튜닝을 같이 진행해야 전체 지연이 내려갑니다. 결국 사용자는 “LLM이 빠르다”가 아니라 “답이 빨리 온다”를 체감하니까요.