LLM 애플리케이션 지연시간 최적화: 프롬프트 캐싱·스트리밍 실전
LLM을 프로덕션에 투입하고 나면 곧바로 마주치는 현실이 있다. “왜 이렇게 느리지?” GPT-4급 모델의 평균 응답 완료 시간은 수십 초에 달할 수 있고, 사용자 경험 연구에 따르면 100ms를 초과하면 이미 “느리다”는 인식이 생긴다. LLM 지연시간은 단일 숫자가 아니라 여러 구성 요소의 합산이므로, 각각의 병목을 이해하고 적합한 최적화 기법을 적용해야 한다. 이 글에서는 TTFT부터 프롬프트 캐싱, 스트리밍, 병렬 호출까지 실전에서 검증된 기법을 코드와 함께 다룬다.
LLM 지연시간 해부: TTFT vs 전체 응답 완료 시간
LLM 응답의 지연시간은 크게 두 가지 관점으로 나뉜다.
| 지표 | 정의 | 영향 요소 | 최적화 방향 |
|---|---|---|---|
| TTFT (Time to First Token) | 요청 전송 ~ 첫 토큰 수신까지 | 프롬프트 길이, 서버 큐, KV 캐시 히트율 | 캐싱, 프롬프트 축소, 우선순위 큐 |
| TPOT (Time Per Output Token) | 출력 토큰 1개 생성 평균 시간 | 모델 크기, GPU 메모리 대역폭, 배치 크기 | 모델 양자화, 더 작은 모델 선택 |
| 전체 완료 시간 | TTFT + (TPOT × 출력 토큰 수) | max_tokens 설정, 생성 길이 | 출력 토큰 제한, 구조화 출력 |
대화형 UI에서는 TTFT가 체감 품질에 가장 크게 영향을 미친다. 첫 글자가 빨리 나오면 사용자는 “응답하고 있다”고 인식하기 때문이다. 반면 배치 처리(대량 분류, 요약)에서는 전체 완료 시간이 중요하다. 이 차이를 이해하고 어떤 지표를 최적화할지 먼저 결정해야 한다.
프롬프트 캐싱과 KV 캐시 활용
가장 극적인 TTFT 감소를 가져오는 기법은 프롬프트 캐싱이다. LLM 추론 시 프롬프트의 각 토큰은 Key-Value(KV) 행렬을 계산하는 “프리필(prefill)” 단계를 거친다. 같은 프롬프트 접두사가 반복되면 이 계산을 재사용(KV 캐시)할 수 있다.
Anthropic Claude API는 캐시 제어 헤더를 통해 명시적 프롬프트 캐싱을 지원한다. 시스템 프롬프트, 긴 문서, Few-shot 예시처럼 요청 간 변하지 않는 부분을 캐시 대상으로 지정하면 캐시 히트 시 입력 토큰 비용을 약 90% 절감하고 TTFT를 크게 단축할 수 있다.
import anthropic
client = anthropic.Anthropic()
# 시스템 프롬프트와 긴 문서를 캐시 지점으로 마킹
response = client.messages.create(
model="claude-opus-4-5",
max_tokens=1024,
system=[
{
"type": "text",
"text": "당신은 법률 문서 분석 전문가입니다. 아래 지침을 엄격히 따르세요...",
},
{
"type": "text",
"text": long_legal_document, # 수천 토큰의 고정 문서
"cache_control": {"type": "ephemeral"}, # 캐시 대상 지정
}
],
messages=[
{"role": "user", "content": user_question} # 이 부분만 매 요청마다 변경
]
)
# 응답 헤더에서 캐시 히트 여부 확인
usage = response.usage
print(f"입력 토큰: {usage.input_tokens}")
print(f"캐시 생성 토큰: {usage.cache_creation_input_tokens}")
print(f"캐시 읽기 토큰: {usage.cache_read_input_tokens}")
캐시 히트율을 높이려면 공통 접두사를 앞에, 가변 부분을 뒤에 배치하는 원칙을 지켜야 한다. 가변 내용이 캐시 대상 앞에 오면 캐시 무효화가 발생한다.
자체 호스팅 환경(vLLM, TGI 등)에서는 KV 캐시가 자동으로 동작하지만, Prefix Caching 옵션을 명시적으로 활성화해야 반복 접두사에 대한 캐시 재사용이 보장된다.
# vLLM에서 Prefix Caching 활성화
python -m vllm.entrypoints.openai.api_server
--model meta-llama/Llama-3.1-8B-Instruct
--enable-prefix-caching
--max-model-len 8192
--gpu-memory-utilization 0.90
스트리밍 응답으로 체감 지연 줄이기
전체 완료 시간을 줄이지 못하더라도, 스트리밍을 통해 사용자 체감 속도는 극적으로 개선할 수 있다. 스트리밍은 서버가 토큰을 생성하는 즉시 클라이언트로 전송하는 방식이다. 전체 응답이 완료될 때까지 기다리지 않아도 되므로 TTFT 이후 첫 텍스트가 화면에 표시되기 시작한다.
import anthropic
client = anthropic.Anthropic()
# 스트리밍 방식으로 응답 수신
with client.messages.stream(
model="claude-sonnet-4-5",
max_tokens=2048,
messages=[{"role": "user", "content": "쿠버네티스 HPA 동작 원리를 설명해줘"}]
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True) # 토큰 단위로 즉시 출력
# FastAPI + SSE 서버 예시
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import json
app = FastAPI()
@app.post("/chat/stream")
async def chat_stream(request: dict):
async def generate():
with client.messages.stream(
model="claude-sonnet-4-5",
max_tokens=1024,
messages=request["messages"]
) as stream:
for text in stream.text_stream:
# SSE 형식으로 전송
yield f"data: {json.dumps({'text': text})}nn"
yield "data: [DONE]nn"
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no", # nginx 버퍼링 비활성화 중요!
}
)
스트리밍 구현 시 흔한 함정은 리버스 프록시(nginx, CloudFront 등)의 응답 버퍼링이다. nginx에서는 proxy_buffering off;를, CloudFront에서는 캐시 동작에서 압축을 비활성화해야 진정한 실시간 스트리밍이 가능하다.
토큰 수 줄이기와 병렬 호출 전략
출력 토큰 제한: max_tokens를 실제 필요한 수준으로 제한하면 전체 완료 시간이 선형 감소한다. “JSON으로 결과만 반환해줘”처럼 형식을 구조화하고, 불필요한 설명 문장을 억제하는 프롬프트 지시를 추가한다. 구조화 출력(Structured Output)을 지원하는 모델이라면 JSON Schema를 직접 지정해 파싱 오류와 재시도를 줄일 수 있다.
프롬프트 길이 최적화: 입력 토큰이 많을수록 프리필 단계 시간이 늘어난다. Few-shot 예시를 과도하게 넣는 대신 Fine-tuning이나 RAG로 프롬프트를 짧게 유지하는 것을 검토한다. 또한 시스템 프롬프트를 매번 전체 전송하는 대신 캐싱 + 짧은 사용자 메시지 조합이 효율적이다.
병렬 호출: 독립적인 여러 작업을 순차 호출하지 말고 동시에 실행하면 전체 지연을 작업 수 만큼 나눌 수 있다.
import asyncio
import anthropic
client = anthropic.AsyncAnthropic()
async def analyze_single(text: str, aspect: str) -> str:
response = await client.messages.create(
model="claude-haiku-4-5", # 빠른 단순 작업엔 소형 모델 선택
max_tokens=256,
messages=[{
"role": "user",
"content": f"{aspect} 관점에서 다음을 분석해줘: {text}"
}]
)
return response.content[0].text
async def parallel_analyze(text: str) -> dict:
aspects = ["감성", "키워드", "카테고리"]
# 3개 분석을 동시에 실행 → 순차 대비 약 3배 빠름
results = await asyncio.gather(
*[analyze_single(text, aspect) for aspect in aspects]
)
return dict(zip(aspects, results))
# 실행
import time
start = time.time()
result = asyncio.run(parallel_analyze("고객 리뷰 텍스트..."))
print(f"병렬 처리 완료: {time.time() - start:.2f}초")
단, 병렬 호출 시 API Rate Limit(분당 요청 수, 분당 토큰 수)에 걸릴 수 있다. 토큰 버킷 알고리즘 기반의 속도 제한 래퍼나 asyncio.Semaphore로 동시성을 제어해야 한다.
실전 팁: 모델 선택과 측정 우선
작업별 모델 라우팅: 모든 요청을 최고성능 모델로 처리하는 것은 비용 낭비이자 불필요한 지연이다. 의도 분류·간단한 추출처럼 단순한 작업은 소형 모델(claude-haiku, gpt-4o-mini 등)로, 복잡한 추론·코드 생성은 대형 모델로 라우팅하는 계층화 전략이 효과적이다. 소형 모델의 TTFT는 대형 모델 대비 5~10배 빠른 경우가 많다.
측정 없이 최적화 없다: 지연시간 최적화는 반드시 계측에서 시작해야 한다. 아래는 간단한 LLM 호출 계측 데코레이터 예시다.
import time
import functools
from dataclasses import dataclass
from typing import Callable
@dataclass
class LLMMetrics:
ttft_ms: float
total_ms: float
input_tokens: int
output_tokens: int
cache_read_tokens: int = 0
def measure_llm(func: Callable):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
first_token_time = None
# 스트리밍 응답에서 TTFT 측정
result = func(*args, **kwargs)
total_ms = (time.perf_counter() - start) * 1000
print(f"[LLM] total={total_ms:.0f}ms")
return result
return wrapper
# Prometheus 메트릭으로 전송 (프로덕션 권장)
from prometheus_client import Histogram, Counter
llm_ttft = Histogram(
"llm_ttft_seconds",
"Time to first token",
["model", "cached"],
buckets=[0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0]
)
llm_total_duration = Histogram(
"llm_total_duration_seconds",
"Total LLM response duration",
["model"]
)
캐싱 계층 추가: 동일하거나 매우 유사한 쿼리가 반복된다면 LLM 호출 자체를 캐시할 수 있다. Redis에 쿼리 해시를 키로 응답을 저장하거나, 시맨틱 유사도(임베딩 + 벡터 DB)를 활용해 유사 쿼리 캐시를 구현하면 지연시간이 LLM 호출 없이 수 밀리초로 줄어든다. 단, 실시간성이 중요한 쿼리나 사용자 개인화 응답에는 적합하지 않다.
마무리
LLM 지연시간 최적화는 단일 마법 같은 해결책이 없다. TTFT를 줄이고 싶다면 프롬프트 캐싱이 가장 즉각적인 효과를 낸다. 사용자 체감을 개선하고 싶다면 스트리밍을 먼저 적용하라. 처리량을 늘리고 싶다면 병렬 호출과 모델 라우팅을 조합한다. 어떤 최적화든 시작은 측정이다. TTFT, TPOT, 캐시 히트율을 Prometheus로 수집하고 Grafana에서 시각화해 실제 병목이 어디인지 데이터로 확인한 후 최적화에 투자하라.
자주 묻는 질문
Q. 프롬프트 캐싱은 응답 품질에 영향을 주지 않나요?
A. 영향을 주지 않습니다. KV 캐시는 모델이 입력 토큰을 처리하는 방식을 재사용하는 것이지, 출력 생성 방식 자체를 변경하지 않습니다. 같은 입력에 대해 캐시 히트 여부와 관계없이 동일한 출력 분포를 가집니다. 다만 Temperature > 0인 경우 샘플링 랜덤성 때문에 토큰 단위의 완전 동일성은 보장되지 않습니다.
Q. 스트리밍을 사용하면 서버 부하가 더 높아지지 않나요?
A. LLM 서버 측 연산 부하는 동일합니다. 다만 각 스트리밍 연결이 HTTP 커넥션을 더 오래 점유하므로, 동시 접속자가 많을 때 커넥션 수 한계에 더 빨리 도달할 수 있습니다. 이를 위해 nginx worker_connections와 upstream keepalive 설정을 충분히 늘리고, LLM 서버 앞단에 비동기 큐(Redis, RabbitMQ)를 두는 것을 고려하세요.
Q. 토큰 수를 줄이면 응답 품질이 나빠지지 않나요?
A. max_tokens 제한은 출력 길이만 제한하며, 모델이 짧게 잘라낸 응답을 반환할 수 있습니다. 품질 저하 없이 토큰을 줄이려면 “간결하게 핵심만”, “JSON으로만 반환” 같은 출력 형식 지시를 프롬프트에 명확히 포함하거나, Structured Output을 활용해 불필요한 설명 문장을 모델 수준에서 억제하는 것이 효과적입니다.