- Published on
Transformers 로컬 LLM 속도 2배 - GGUF+KV 캐시
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
로컬에서 LLM을 돌리다 보면 “모델은 잘 뜨는데 토큰이 너무 느리다”가 가장 흔한 불만입니다. 특히 transformers 기본 경로(예: FP16/FP32 가중치 + 일반 attention 계산)로 CPU 추론을 하면, 프롬프트가 길어질수록 속도가 급격히 떨어집니다.
이 글은 로컬 LLM 추론 속도를 체감 2배 수준으로 올리는 조합인 GGUF(양자화 포맷) + KV 캐시를 중심으로, 병목이 어디서 생기고 어떤 옵션이 실제로 효과가 있는지 정리합니다. 목표는 “벤치마크 숫자”보다 내 PC에서 바로 재현 가능한 설정입니다.
또한 성능 튜닝은 결국 “병목을 찾고 쪼개는 일”이라는 점에서, 프론트 성능에서 Long Task를 쪼개듯 접근하는 관점도 유용합니다. 필요하면 Chrome INP 나쁨 - Long Task 쪼개기 실전 가이드처럼 원인 분해 방식으로 접근해보세요.
왜 느린가: 로컬 LLM 추론의 2가지 병목
로컬 추론 성능은 크게 두 축으로 결정됩니다.
가중치 로딩/행렬곱 비용(Compute + Memory Bandwidth)
- CPU에서 FP16/FP32를 그대로 쓰면 메모리 대역폭과 캐시 미스가 치명적입니다.
- 양자화(예: 4bit, 5bit)를 통해 읽어야 할 바이트 수를 줄이면, 같은 하드웨어에서 토큰/초가 크게 오릅니다.
긴 컨텍스트에서 attention 재계산 비용
- 매 토큰 생성마다 과거 토큰들에 대한 attention을 다시 계산하면 O(n^2)로 비용이 커집니다.
- KV 캐시는 “이전 토큰의 key/value”를 저장해두고 다음 토큰에서 재사용하여, 매 스텝을 O(n) 수준으로 낮춥니다.
정리하면:
- GGUF는 “가중치 읽기/연산을 가볍게”
- KV 캐시는 “긴 프롬프트에서 반복 계산을 제거”
이 둘은 서로 다른 병목을 줄이므로 합쳐서 효과가 큽니다.
GGUF란 무엇이고, 왜 transformers만으로는 한계가 있나
GGUF는 llama.cpp 계열에서 널리 쓰이는 양자화된 모델 포맷입니다. 장점은 다음과 같습니다.
- 다양한 양자화 스킴(Q4, Q5, Q8 등)으로 CPU에서도 실용적인 속도
- 메모리 사용량 절감으로 더 큰 모델/컨텍스트를 올릴 수 있음
llama.cpp최적화(AVX2/AVX512, Metal, CUDA 백엔드 등)를 그대로 활용
반면 transformers는 기본적으로 PyTorch 텐서 기반으로 동작하며, GGUF를 “네이티브”로 로딩해 추론하는 경로가 제한적입니다. 그래서 실전에서는 보통 아래 중 하나를 선택합니다.
transformers+ (bitsandbytes 등)로 양자화 텐서를 쓰는 경로- GGUF는
llama.cpp런타임(파이썬 바인딩 포함)으로 돌리는 경로
이 글의 핵심은 “Transformers 생태계에서 로컬 LLM을 빠르게”이므로, 프론트엔드는 transformers 스타일로 유지하되 실제 추론 엔진은 GGUF에 강한 런타임을 쓰는 방식까지 포함해 다룹니다.
KV 캐시: use_cache 하나로 끝나지 않는 이유
많은 예제에서 KV 캐시는 use_cache=True로 소개됩니다. 하지만 실전에서는 다음을 같이 확인해야 합니다.
generate()호출 시 캐시가 활성화되는지- 프롬프트 길이가 길 때 실제로 속도 차이가 나는지
- 배치/스트리밍/대화형 루프에서 캐시를 “의도대로” 유지하는지
주의할 점은, 매번 프롬프트 전체를 다시 넣고 generate()를 호출하면 KV 캐시 이점이 줄어듭니다. 대화형 챗이라면:
- 이전 턴까지의
past_key_values를 재사용하거나 - 엔진이 내부적으로 세션 캐시를 유지하게 해야
진짜로 “턴이 누적될수록” 빨라집니다.
전략 1) transformers에서 KV 캐시 제대로 쓰기
아래는 가장 기본적인 형태입니다. 핵심은 use_cache=True와 torch.inference_mode()입니다.
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
model_id = "meta-llama/Llama-3.1-8B-Instruct" # 예시
tokenizer = AutoTokenizer.from_pretrained(model_id, use_fast=True)
model = AutoModelForCausalLM.from_pretrained(
model_id,
torch_dtype=torch.float16,
device_map="auto",
)
model.eval()
prompt = "로컬 LLM 속도를 높이는 방법을 3가지로 정리해줘."
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
with torch.inference_mode():
out = model.generate(
**inputs,
max_new_tokens=200,
do_sample=False,
use_cache=True,
)
print(tokenizer.decode(out[0], skip_special_tokens=True))
캐시가 실제로 도움이 되는 조건
- 프롬프트가 길수록 효과가 커집니다.
- “한 번의
generate()”에서도 토큰이 늘수록 도움은 되지만, 특히 대화형 멀티턴에서 캐시 재사용이 중요합니다.
멀티턴에서 past_key_values 재사용(개념 코드)
generate()는 내부적으로 캐시를 쓰지만, “턴 간 재사용”을 명시적으로 하려면 모델 forward 레벨에서 다루는 편이 확실합니다.
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
model_id = "meta-llama/Llama-3.1-8B-Instruct"
tokenizer = AutoTokenizer.from_pretrained(model_id, use_fast=True)
model = AutoModelForCausalLM.from_pretrained(
model_id,
torch_dtype=torch.float16,
device_map="auto",
).eval()
@torch.inference_mode()
def next_token_step(input_ids, past_key_values=None):
out = model(
input_ids=input_ids,
use_cache=True,
past_key_values=past_key_values,
)
logits = out.logits[:, -1, :]
next_id = torch.argmax(logits, dim=-1, keepdim=True)
return next_id, out.past_key_values
# 1) 첫 턴 프롬프트는 전체를 넣어 캐시를 만든다
prompt1 = "너는 성능 튜닝 전문가야. KV 캐시를 설명해."
input_ids = tokenizer(prompt1, return_tensors="pt").input_ids.to(model.device)
past = None
for _ in range(64):
next_id, past = next_token_step(input_ids if past is None else next_id, past)
# 2) 두 번째 턴은 새 입력만 추가하고, past_key_values를 이어서 사용
prompt2 = "그럼 GGUF랑 같이 쓰면 왜 더 빨라져?"
new_ids = tokenizer("\n" + prompt2, return_tensors="pt").input_ids.to(model.device)
# 새 입력 토큰들을 순차로 먹이면서 캐시를 확장
for i in range(new_ids.shape[1]):
tok = new_ids[:, i:i+1]
_, past = next_token_step(tok, past)
# 이후 생성은 계속 next_token_step으로 진행
이 방식은 구현 난이도가 있지만, “매 턴 전체 프롬프트 재토크나이즈 + 재계산”을 피하는 데 가장 확실합니다.
전략 2) GGUF로 가중치 병목 줄이기(현실적인 선택)
CPU 추론에서 2배 체감 가속을 노리면, 많은 경우 KV 캐시만으로는 부족합니다. 이유는 “토큰당 계산” 자체가 무겁기 때문입니다. 이때 GGUF(특히 Q4 계열)는 메모리 대역폭을 줄여 토큰/초를 크게 끌어올리는 경우가 많습니다.
Transformers만 고집하면 GGUF를 바로 쓰기 어렵기 때문에, 실전에서는 다음 구성이 흔합니다.
- 앱 코드는 파이썬으로 유지
- 추론 엔진은
llama.cpp기반 파이썬 바인딩을 사용 - 대화 세션은 엔진이 제공하는 KV 캐시/컨텍스트를 활용
llama-cpp-python로 GGUF + KV 캐시(예시)
아래 코드는 “GGUF 모델 + 컨텍스트 유지”를 가장 단순하게 보여줍니다. 같은 Llama 인스턴스를 유지하면, 내부적으로 컨텍스트가 쌓이며 KV 캐시가 활용됩니다.
from llama_cpp import Llama
llm = Llama(
model_path="./models/llama-3.1-8b-instruct-q4_k_m.gguf",
n_ctx=4096,
n_threads=8,
n_batch=512,
)
# 1) 첫 질문
res1 = llm.create_chat_completion(
messages=[
{"role": "system", "content": "너는 성능 튜닝 전문가야."},
{"role": "user", "content": "KV 캐시를 쉽게 설명해줘."},
],
temperature=0.2,
)
print(res1["choices"][0]["message"]["content"])
# 2) 같은 인스턴스에서 이어서 질문(컨텍스트가 유지되므로 유리)
res2 = llm.create_chat_completion(
messages=[
{"role": "user", "content": "GGUF 양자화가 CPU에서 특히 좋은 이유는?"},
],
temperature=0.2,
)
print(res2["choices"][0]["message"]["content"])
튜닝 포인트
n_threads: CPU 코어 수에 맞추되, 무작정 최대로 올리면 역효과가 날 수 있습니다.n_batch: 프롬프트 처리(prefill) 속도에 영향을 줍니다. 너무 크면 메모리 압박이 생깁니다.n_ctx: 컨텍스트가 커질수록 메모리 사용량이 증가합니다. “큰 컨텍스트 = 무조건 좋음”이 아닙니다.
속도 2배를 만드는 체크리스트(실전)
아래는 로컬에서 “진짜로 체감”이 바뀌는 순서로 정리한 체크리스트입니다.
1) 프롬프트를 매번 전체 재전송하지 않기
- 대화형이라면 이전 턴을 전부 붙여 보내는 방식은 비용이 큽니다.
- 가능하면 “세션 컨텍스트”를 엔진에 유지시키거나,
past_key_values를 재사용하세요.
2) KV 캐시가 꺼져 있지 않은지 확인
generate(..., use_cache=True)- 모델 설정에 따라
config.use_cache가 꺼져 있을 수 있으니 확인
print(model.config.use_cache)
model.config.use_cache = True
3) CPU라면 양자화가 거의 필수
- FP16/FP32 그대로 CPU 추론은 대부분 비효율적입니다.
- GGUF Q4 계열은 속도/품질/메모리의 균형이 좋아 “기본 선택지”가 되는 경우가 많습니다.
4) 프리필(prefill)과 디코드(decode)를 분리해서 측정
속도 문제를 디버깅할 때는 “전체가 느리다”가 아니라, 어느 구간이 느린지 쪼개야 합니다.
- Prefill: 긴 프롬프트를 한 번에 밀어 넣는 구간
- Decode: 한 토큰씩 생성하는 구간
측정 코드 예시:
import time
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
model_id = "meta-llama/Llama-3.1-8B-Instruct"
tok = AutoTokenizer.from_pretrained(model_id, use_fast=True)
model = AutoModelForCausalLM.from_pretrained(
model_id,
torch_dtype=torch.float16,
device_map="auto",
).eval()
prompt = "A" * 8000
inputs = tok(prompt, return_tensors="pt").to(model.device)
with torch.inference_mode():
t0 = time.perf_counter()
out = model(**inputs, use_cache=True)
t1 = time.perf_counter()
# decode 구간을 단순화해서 64토큰만 생성
gen = model.generate(**inputs, max_new_tokens=64, do_sample=False, use_cache=True)
t2 = time.perf_counter()
print(f"prefill forward: {t1 - t0:.3f}s")
print(f"generate(64): {t2 - t1:.3f}s")
이렇게 나누면 “프롬프트가 길어서 느린지(KV 캐시/배치/프리필 문제)” vs “토큰당 계산이 느린지(양자화/백엔드 문제)”가 분리됩니다.
권장 조합: 환경별로 이렇게 고르면 실패가 적다
CPU 중심(노트북/서버)
- 목표: 토큰/초 확보
- 추천: GGUF(Q4~Q5) +
llama.cpp계열 + 컨텍스트 유지(KV 캐시) - 팁: 모델 크기 욕심을 줄이고, 컨텍스트도 필요한 만큼만
NVIDIA GPU(로컬 워크스테이션)
- 목표: 높은 디코드 TPS
- 추천:
transformers+ FP16/BF16 + KV 캐시 + FlashAttention 계열(가능한 경우) - 팁: VRAM이 부족하면 텐서 양자화(bitsandbytes)로 타협
Apple Silicon(M 시리즈)
- 목표: Metal 백엔드 최적화
- 추천: GGUF + Metal 지원 런타임(
llama.cpp계열)
운영 관점: “빨라졌는데 가끔 멈춘다”를 피하는 법
속도 튜닝 후 흔한 장애는 다음입니다.
- 컨텍스트가 커지며 메모리 사용량이 누적
- 스레드/배치 튜닝으로 CPU가 100% 고정되어 시스템이 버벅임
- 모델 워밍업/캐시 미스로 첫 응답이 유독 느림
이런 문제는 결국 “재시작 루프”나 “리소스 스로틀링” 같은 운영 이슈로 이어질 수 있습니다. 서비스 형태로 붙였다면 장애 대응 관점에서 systemd 서비스 재시작 루프, 10분 디버깅 같은 체크리스트도 함께 챙겨두는 편이 안전합니다.
마무리: 2배는 ‘한 방’이 아니라 병목 2개를 동시에 줄이는 것
로컬 LLM 추론에서 체감 속도 2배를 만들려면 보통 한 가지 최적화만으로는 부족합니다.
- **GGUF(양자화)**로 “가중치/메모리 병목”을 줄이고
- KV 캐시로 “긴 컨텍스트에서 반복 계산”을 제거하면
둘이 곱해져서 체감이 크게 바뀝니다.
다음 단계로는 “프리필/디코드 분리 측정 → 병목별 옵션 튜닝” 순서로 접근해보세요. RAG까지 붙이는 경우에는 벡터 검색 쪽도 병목이 되기 쉬우니, RAG용 Qdrant HNSW 튜닝 실전 가이드처럼 검색 레이어 튜닝도 같이 보면 전체 응답 시간이 더 안정적으로 내려갑니다.