Skip to main content
Summary

느린 AI는 똑똑해 보이기 전에 먼저 답답해 보입니다. 사용자가 질문을 입력하고 엔터를 눌렀는데 10초 동안 아무 일도 일어나지 않는다면, 그 뒤에 아무리 훌륭한 답변이 나와도 이미 마음은 조금 떠나 있습니다. 반대로 전체 답변이 완성되기까지 시간이 조금 걸리더라도, 첫 문장이 빠르게 나타나고 진행 상태가 자연스럽게 보이면 사용자는 기다릴 이유를 느낍니다.

지난 장에서 비용 최적화를 다뤘습니다. 흥미로운 점은 비용 최적화와 속도 최적화가 자주 같은 방향을 바라본다는 것입니다. 토큰을 줄이면 비용도 줄고, 대체로 응답도 빨라집니다. 불필요한 문서 조각을 RAG 프롬프트에 넣지 않으면 돈도 덜 쓰고, 모델도 덜 헤맵니다. 작은 모델로 충분한 작업을 큰 모델에 맡기지 않으면 평균 지연 시간도 낮아집니다.

하지만 속도 최적화는 단순히 "더 싼 모델을 쓰자"로 끝나지 않습니다. 좋은 AI 서비스는 똑똑할 뿐 아니라 기다릴 만한 경험을 제공해야 합니다. 이번 장에서는 LLM 서비스의 지연 시간을 쪼개서 측정하고, 병목을 찾고, 실제 지연과 체감 지연을 함께 줄이는 방법을 다룹니다.

응답 속도 개선

속도는 하나의 숫자가 아니다

LLM 응답이 5초 걸렸다고 말할 때, 그 5초 안에는 여러 시간이 섞여 있습니다. 사용자의 브라우저에서 서버까지 요청이 가는 시간, 서버가 데이터베이스를 조회하는 시간, RAG 검색을 수행하는 시간, LLM이 첫 토큰을 내놓기 전까지 준비하는 시간, 실제로 토큰을 하나씩 생성하는 시간, 마지막으로 JSON 파싱이나 출처 정리 같은 후처리 시간이 모두 포함됩니다.

그래서 "응답이 느리다"라는 말만으로는 고칠 수 없습니다. 의사가 환자를 진찰할 때 체온, 혈압, 맥박을 따로 보듯이, LLM 서비스도 지연 시간을 여러 지표로 나누어 봐야 합니다. OpenAI의 지연 시간 최적화 가이드 역시 모델 선택, 생성 토큰 수, 스트리밍, 병렬화처럼 서로 다른 층위의 최적화 포인트를 나누어 설명합니다.1

LLM 서비스에서 자주 보는 지연 시간 지표는 다음과 같습니다.

지표의미주로 영향을 주는 것
네트워크 왕복 시간클라이언트와 서버, 서버와 LLM API 사이를 오가는 시간사용자 위치, 서버 리전, API 제공자 리전
RAG 검색 시간질문 임베딩, 벡터 검색, 재랭킹, 원문 조회에 걸리는 시간벡터 DB, 인덱스, top-k, 문서 크기
TTFTTime to First Token, 첫 토큰이 도착하기까지의 시간입력 길이, 모델 크기, 서버 부하, 프리필 처리
토큰 생성 속도첫 토큰 이후 초당 몇 개의 토큰이 생성되는지모델 크기, 출력 길이, 디코딩 설정
후처리 시간응답을 검증, 파싱, 저장, 렌더링하는 시간구조화 출력 파싱, 출처 매핑, 안전성 필터
체감 대기 시간사용자가 느끼는 기다림스트리밍, 로딩 UI, 진행 상태, 점진적 응답

여기서 특히 중요한 지표가 TTFT와 토큰 생성 속도입니다. TTFT는 사용자가 "오, 뭔가 시작됐다"고 느끼는 순간을 결정합니다. 토큰 생성 속도는 긴 답변이 얼마나 빨리 완성되는지를 결정합니다. 여러 LLM 벤치마크에서도 첫 토큰 지연과 토큰당 지연을 분리해서 측정합니다. 두 지표가 서로 다른 사용자 경험을 만들기 때문입니다.2

예를 들어 두 모델이 있다고 해 보겠습니다. A 모델은 첫 토큰이 0.5초 만에 나오지만 이후 생성 속도가 느립니다. B 모델은 첫 토큰이 3초 뒤에 나오지만 이후 토큰을 매우 빠르게 생성합니다. 짧은 고객지원 답변에는 A가 더 좋아 보일 수 있습니다. 반면 긴 보고서를 백그라운드에서 생성하는 작업에는 B가 더 적합할 수 있습니다. "어느 모델이 빠른가"가 아니라 "어떤 사용 장면에서 무엇이 빠르게 느껴지는가"를 봐야 합니다.

먼저 병목 지도를 그린다

속도를 개선하기 전에 반드시 해야 할 일이 있습니다. 측정입니다. 추측으로 최적화를 시작하면 대개 엉뚱한 곳을 고치게 됩니다. RAG 챗봇이 느리다고 해서 모델을 바꿨는데, 실제 병목은 벡터 데이터베이스가 아니라 PDF 원문을 매번 다시 읽는 코드였을 수도 있습니다. 답변 생성이 느리다고 생각했는데, 실제로는 프론트엔드가 스트림을 버퍼링해서 한 번에 렌더링하고 있었을 수도 있습니다.

가장 먼저 요청 하나를 단계별로 나누어 시간을 기록해 봅시다.

from contextlib import contextmanager
from time import perf_counter

@contextmanager
def span(name, trace):
    start = perf_counter()
    try:
        yield
    finally:
        trace[name] = round((perf_counter() - start) * 1000, 1)


def answer_question(question):
    trace = {}

    with span('embed_query_ms', trace):
        query_vector = embed(question)

    with span('vector_search_ms', trace):
        chunks = vector_db.search(query_vector, top_k=5)

    with span('prompt_build_ms', trace):
        prompt = build_prompt(question, chunks)

    with span('llm_total_ms', trace):
        answer = call_llm(prompt)

    with span('postprocess_ms', trace):
        result = attach_sources(answer, chunks)

    return result, trace

이 코드는 단순하지만 운영에서 매우 강력합니다. 한 요청의 전체 시간을 하나의 숫자로 뭉개지 않고, 질문 임베딩, 벡터 검색, 프롬프트 구성, LLM 호출, 후처리로 나누어 봅니다. RAG 시스템은 검색과 생성이 결합된 구조이기 때문에, 검색 단계와 생성 단계를 분리해서 평가해야 합니다. Google Cloud의 RAG 최적화 가이드도 테스트 프레임워크를 만들고, 검색과 생성의 각 요소를 하나씩 바꿔 가며 측정하는 접근을 강조합니다.3

스트리밍을 사용한다면 TTFT도 따로 기록해야 합니다.

from time import perf_counter


def stream_answer(client, messages):
    started_at = perf_counter()
    first_token_at = None
    token_count = 0
    parts = []

    stream = client.chat.completions.create(
        model='fast-chat-model',
        messages=messages,
        stream=True,
        max_tokens=500,
    )

    for event in stream:
        delta = event.choices[0].delta.content
        if not delta:
            continue

        if first_token_at is None:
            first_token_at = perf_counter()

        token_count += 1
        parts.append(delta)
        yield delta

    finished_at = perf_counter()

    metrics = {
        'ttft_ms': round((first_token_at - started_at) * 1000, 1) if first_token_at else None,
        'total_ms': round((finished_at - started_at) * 1000, 1),
        'stream_chunks': token_count,
    }

    save_latency_metrics(metrics)

운영 환경에서는 요청별 로그뿐 아니라 p50, p95, p99를 봐야 합니다. 평균이 2초라도 p95가 12초라면 20명 중 1명은 매우 나쁜 경험을 하고 있다는 뜻입니다. 특히 AI 서비스는 입력 길이와 출력 길이의 편차가 크기 때문에 평균만 보면 문제가 숨어 버립니다.

다음과 같은 기준으로 대시보드를 나누면 병목을 찾기 쉬워집니다.

  • 작업 유형별: 요약, Q&A, 분류, 글 생성, 에이전트 실행

  • 모델별: 빠른 모델, 균형 모델, 고성능 모델

  • 입력 길이별: 1천 토큰 이하, 1만 토큰 이하, 장문 컨텍스트

  • 출력 길이별: 100토큰 이하, 500토큰 이하, 장문 생성

  • 캐시 여부별: 캐시 hit, 캐시 miss

  • RAG 여부별: 일반 호출, 검색 포함 호출, 재랭킹 포함 호출

이렇게 나누면 막연한 "느리다"가 구체적인 문장으로 바뀝니다. "고객지원 Q&A에서 RAG 재랭킹을 사용하는 경우 p95가 높다." "긴 블로그 글 생성에서 TTFT는 괜찮지만 출력 토큰이 많아 total latency가 길다." 이렇게 문제를 말할 수 있어야 고칠 수 있습니다.

출력 토큰을 줄이면 가장 빨리 체감된다

LLM은 답변을 한 번에 통째로 뱉지 않습니다. 대체로 이전 토큰들을 바탕으로 다음 토큰을 하나씩 생성합니다. 그래서 출력이 길수록 시간이 길어집니다. 답변이 200토큰이면 빠르게 끝나지만, 2,000토큰이면 아무리 좋은 모델을 써도 사용자가 기다리는 시간이 늘어납니다.

많은 서비스가 처음부터 너무 많은 말을 합니다. 사용자는 "환불 가능해?"라고 물었는데, 챗봇은 환불 정책의 역사와 예외 조항과 고객 만족 철학까지 길게 설명합니다. 친절해 보이려고 장황해지는 순간, 속도와 사용성이 함께 나빠집니다.

출력 토큰을 줄이는 가장 직접적인 방법은 최대 출력 길이를 제한하는 것입니다.

response = client.chat.completions.create(
    model='fast-chat-model',
    messages=messages,
    max_tokens=250,
)

하지만 max_tokens만 낮추면 답변이 중간에 끊길 수 있습니다. 더 좋은 방법은 프롬프트에서 응답의 밀도를 설계하는 것입니다.

사용자의 질문에 먼저 3문장 이내로 답하세요.
필요한 경우에만 최대 3개의 항목으로 근거를 덧붙이세요.
모르는 내용은 추측하지 말고 확인이 필요하다고 말하세요.

긴 설명이 필요한 작업이라면 "요약 우선" 전략을 쓰면 좋습니다.

먼저 핵심 결론을 2문장으로 답하세요.
그 다음 사용자가 원하면 자세한 설명을 이어갈 수 있도록
'자세히 보기'에 해당하는 후속 질문 2개를 제안하세요.

이 방식은 전체 지식을 숨기는 것이 아닙니다. 사용자가 지금 필요한 만큼 먼저 주는 것입니다. 모바일 화면에서 고객이 원하는 것은 논문이 아니라 해결입니다. "가능합니다. 주문 후 7일 이내라면 미개봉 상품은 환불할 수 있습니다."라는 첫 문장이 먼저 보이고, 그 뒤에 예외 조건이 따라오면 훨씬 빠르게 느껴집니다.

구조화 출력에서도 토큰을 아낄 수 있습니다. 예를 들어 내부 시스템끼리 주고받는 JSON이라면 사람이 읽기 좋은 장황한 필드명을 꼭 쓸 필요가 없을 수 있습니다. 다만 이때는 유지보수성과 디버깅 가능성을 함께 고려해야 합니다. 너무 짧은 키 이름은 빠르지만, 팀원이 봤을 때 의미를 알아차리기 어렵습니다. 운영용 API에서는 summary, labels, risk_level처럼 명확한 이름을 쓰고, 정말 대량 처리되는 내부 배치에서만 더 압축된 형식을 고려하는 편이 안전합니다.

좋은 응답 속도는 말의 양을 줄이는 데서 시작합니다. 짧게 답하라는 말은 무성의하게 답하라는 뜻이 아닙니다. 사용자가 지금 결정하는 데 필요한 문장을 먼저 주라는 뜻입니다.

입력 컨텍스트도 다이어트가 필요하다

출력 토큰이 전체 시간을 크게 좌우하지만, 입력 토큰도 무시할 수 없습니다. 입력이 길면 모델은 답변을 시작하기 전에 긴 프롬프트를 처리해야 합니다. RAG에서는 검색된 문서 조각이 길어질수록 TTFT가 늘어날 수 있고, 대화형 서비스에서는 오래된 대화 기록이 계속 누적되면서 매 호출이 점점 무거워집니다.

LLM 애플리케이션에서 자주 낭비되는 입력은 놀랄 만큼 평범합니다. 매번 반복되는 긴 시스템 프롬프트, 이미 해결된 과거 대화, 현재 질문과 관계없는 문서 조각, 전체 파일 중 실제로 필요한 몇 줄을 제외한 나머지 코드가 대표적입니다. LLM 추론 최적화 자료에서도 오래된 대화 기록과 보일러플레이트 프롬프트, 불필요한 전체 파일 포함이 토큰 낭비와 지연의 주요 원인으로 지적됩니다.4

컨텍스트 다이어트의 기본 원칙은 간단합니다. 모델에게 "혹시 모르니 다 줄게"라고 하지 말고, "이번 답변에 필요한 것만 줄게"라고 해야 합니다.

대화 기록은 최근 몇 턴만 그대로 유지하고, 오래된 내용은 요약 메모로 바꿀 수 있습니다.

대화 요약:
- 사용자는 스타트업 채용 공고 작성을 도와 달라고 했다.
- 톤은 친근하지만 과장되지 않기를 원한다.
- 대상 직무는 백엔드 엔지니어이며 Python, PostgreSQL 경험을 중시한다.

최근 대화:
user: 복지 항목을 조금 더 현실적으로 바꿔 줘.
assistant: ...

RAG 검색 결과도 무작정 많이 넣지 않습니다. top-k를 10으로 올리면 왠지 더 정확해질 것 같지만, 실제로는 관련 없는 조각이 섞여 모델을 헷갈리게 하고 응답을 늦출 수 있습니다. 검색 결과를 넣기 전에 다음 질문을 해 봐야 합니다.

  • 이 조각은 사용자의 질문에 직접 답하는가?

  • 같은 내용을 반복하는 조각이 여러 개 있지 않은가?

  • 제목, 섹션명, 날짜 같은 메타데이터만으로 걸러낼 수 없는가?

  • 전체 문단이 필요한가, 아니면 특정 문장 몇 개면 충분한가?

  • 정확한 숫자, 파일 경로, 정책 문구처럼 원문 보존이 중요한가?

특히 주의할 점은 요약 기반 압축입니다. 문서를 요약해서 넣으면 토큰은 줄지만, 파일 경로, 에러 코드, 날짜, 정책 조항처럼 정확해야 하는 정보가 바뀌거나 사라질 수 있습니다. 고객지원, 법률, 보안, 개발자 도구처럼 정확한 문구가 중요한 서비스에서는 생성 요약보다 "불필요한 부분을 삭제하되 남은 문장은 원문 그대로 유지하는 방식"이 더 안전할 때가 많습니다.

간단한 컨텍스트 예산 함수를 만들어 두면 서비스 전체의 지연 시간을 안정적으로 관리할 수 있습니다.

def build_context(chunks, max_chars=6000):
    selected = []
    used = 0

    for chunk in chunks:
        text = chunk['text'].strip()
        if not text:
            continue

        if used + len(text) > max_chars:
            remaining = max_chars - used
            if remaining <= 500:
                break
            text = text[:remaining]

        selected.append({
            'source': chunk['source'],
            'text': text,
        })
        used += len(text)

    return selected

실전에서는 문자 수보다 토큰 수 기준이 더 정확합니다. 하지만 핵심은 같습니다. 컨텍스트에는 예산이 있어야 합니다. 예산이 없는 컨텍스트는 금방 비대해지고, 비대한 컨텍스트는 비용과 속도를 동시에 잡아먹습니다.

RAG 검색도 응답 시간의 일부다

RAG 챗봇이 느릴 때 사람들은 LLM만 봅니다. 하지만 RAG는 작은 공장입니다. 질문을 임베딩하고, 벡터 데이터베이스에서 비슷한 조각을 찾고, 필요하면 키워드 검색과 합치고, 재랭커로 순서를 다시 매기고, 원문과 메타데이터를 가져와 프롬프트를 만듭니다. 이 중 하나라도 느리면 전체 응답이 느려집니다.

RAG 지연 시간을 줄일 때는 각 단계를 따로 봅니다.

사용자 질문
→ 질문 임베딩
→ 벡터 검색
→ 키워드 검색 또는 필터링
→ 재랭킹
→ 원문 조회
→ 프롬프트 구성
→ LLM 생성

질문 임베딩이 느리다면 자주 나오는 질문의 임베딩을 캐시할 수 있습니다. 벡터 검색이 느리다면 인덱스 파라미터, top-k, 필터 조건, 벡터 DB 리전을 확인해야 합니다. 재랭킹이 느리다면 모든 요청에 재랭킹을 적용하지 말고, 검색 점수가 애매한 경우에만 적용할 수 있습니다. 원문 조회가 느리다면 벡터 DB에 답변에 필요한 스니펫과 출처 메타데이터를 함께 저장해 별도 DB 조회를 줄일 수 있습니다.

또 하나 중요한 전략은 "검색 실패를 빨리 인정하는 것"입니다. 관련 문서를 충분히 찾지 못했는데도 긴 프롬프트를 만들어 LLM에게 억지 답변을 시키면 느리고 위험합니다. 검색 점수가 낮거나 출처가 부족하면 짧게 답해야 합니다.

현재 문서에서 이 질문에 답할 근거를 찾지 못했습니다.
질문을 더 구체적으로 바꾸거나 관련 문서를 추가해 주세요.

이 답변은 화려하지 않지만 빠르고 정직합니다. 그리고 환각을 줄입니다.

작은 모델과 큰 모델을 나누어 쓴다

모든 요청에 가장 강한 모델을 쓰는 것은 모든 이동에 트럭을 부르는 것과 비슷합니다. 이삿짐을 옮길 때는 트럭이 필요하지만, 편의점에 물 한 병 사러 갈 때는 자전거가 더 빠릅니다.

LLM 서비스에도 작업 난이도가 있습니다. 사용자의 감정이 섞인 복잡한 상담, 여러 문서를 비교해야 하는 분석, 긴 글의 구조를 잡는 창작 작업은 강한 모델이 필요할 수 있습니다. 반면 카테고리 분류, 간단한 정보 추출, JSON 형식 변환, 금칙어 검사, 언어 감지 같은 작업은 작은 모델이나 전통적인 코드로도 충분한 경우가 많습니다. 모델 라우팅은 요청의 성격에 따라 적절한 모델을 선택해 비용과 지연 시간을 함께 줄이는 대표적인 방법입니다.5

간단한 라우터는 이렇게 시작할 수 있습니다.

def select_model(task, input_tokens, user_tier):
    if task in {'classify', 'extract', 'rewrite_short'}:
        return 'small-fast-model'

    if input_tokens > 20000:
        return 'long-context-model'

    if task in {'reasoning', 'complex_analysis'}:
        return 'strong-model'

    if user_tier == 'free':
        return 'small-fast-model'

    return 'balanced-model'

처음부터 완벽한 라우터를 만들 필요는 없습니다. 작업 유형을 몇 개로 나누고, 간단한 규칙으로 시작하면 됩니다. 이후 로그를 보면서 "작은 모델이 자주 실패하는 요청"을 찾아 큰 모델로 올리거나, "큰 모델을 썼지만 단순했던 요청"을 작은 모델로 내리면 됩니다.

더 안정적인 방식은 캐스케이드입니다. 먼저 빠른 모델이 답하게 하고, 자신감이 낮거나 검증에 실패하면 큰 모델로 넘깁니다.

def answer_with_cascade(request):
    draft = call_model('small-fast-model', request)

    if passes_quality_gate(draft):
        return draft

    return call_model('strong-model', request)

여기서 핵심은 품질 게이트입니다. 구조화 출력이라면 JSON 스키마 검증을 할 수 있습니다. RAG 답변이라면 출처 포함 여부와 근거 문장 일치 여부를 볼 수 있습니다. 분류 작업이라면 confidence가 낮을 때만 큰 모델을 호출할 수 있습니다.

모델 라우팅의 목표는 무조건 작은 모델을 쓰는 것이 아닙니다. 사용자에게 들키지 않을 정도로 쉬운 일은 빠르게 처리하고, 정말 어려운 일에는 충분한 지능을 쓰는 것입니다.

스트리밍은 사용자의 시간을 되찾아 준다

실제 총 시간이 줄지 않아도 사용자가 느끼는 대기 시간은 줄일 수 있습니다. 가장 대표적인 방법이 스트리밍입니다. 기존 방식은 모델이 전체 답변을 완성한 뒤 한 번에 반환합니다. 스트리밍 방식은 생성되는 조각을 바로바로 사용자에게 보냅니다. 사용자는 답변이 완성되기 전에 첫 문장을 읽기 시작할 수 있습니다.

스트리밍은 마술이 아닙니다. 모델이 더 빨리 생각하는 것은 아닙니다. 하지만 사용자의 화면에서는 큰 차이가 납니다. 긴 글 생성, 문서 요약, 코드 작성처럼 출력이 긴 작업에서는 스트리밍이 사실상 필수 UX에 가깝습니다. SSE나 WebSocket을 이용한 LLM 스트리밍 구현에서는 서버가 생성된 델타를 작은 이벤트로 보내고, 클라이언트가 이를 누적해서 렌더링합니다.6

가장 단순한 콘솔 스트리밍은 다음과 같습니다.

stream = client.chat.completions.create(
    model='fast-chat-model',
    messages=messages,
    stream=True,
)

for event in stream:
    delta = event.choices[0].delta.content
    if delta:
        print(delta, end='', flush=True)

웹 애플리케이션에서는 서버가 스트림을 중계하고, 프론트엔드가 조각을 받아 화면에 이어 붙입니다. 이때 운영에서 자주 놓치는 점이 있습니다. 리버스 프록시나 CDN이 응답을 버퍼링하면 스트리밍이 깨집니다. 서버는 조각을 보내고 있다고 생각하지만, 중간 프록시가 모아 두었다가 한 번에 보내면 사용자는 여전히 기다리게 됩니다. 그래서 SSE 응답에는 버퍼링을 막는 헤더를 설정하고, 클라이언트 연결이 끊기면 즉시 LLM 호출을 중단해야 합니다.

스트리밍과 함께 쓰면 좋은 UX 패턴은 "먼저 짧게 답하고 이어서 자세히 쓰기"입니다.

답변 형식:
1. 첫 줄에는 결론을 한 문장으로 말하세요.
2. 그 다음 필요한 근거를 최대 3개만 설명하세요.
3. 마지막에 더 확인해야 할 사항이 있으면 한 줄로 덧붙이세요.

이렇게 하면 첫 토큰이 나온 뒤에도 사용자가 바로 의미 있는 정보를 얻습니다. "네, 환불 가능합니다."라는 문장이 먼저 보이면 사용자는 안심하고 세부 조건을 기다릴 수 있습니다. 반대로 "저희 서비스를 이용해 주셔서 감사합니다. 문의하신 내용에 대해 확인해 보겠습니다." 같은 문장이 먼저 나오면 스트리밍을 해도 체감 속도가 좋아지지 않습니다. 첫 문장부터 정보가 있어야 합니다.

로딩 상태도 중요합니다. AI 서비스에서 사용자는 내부에서 무슨 일이 일어나는지 모릅니다. 특히 RAG나 에이전트는 여러 단계를 거치기 때문에 아무 표시 없이 기다리게 하면 불안합니다. 다음처럼 단계를 보여 주는 것만으로도 체감 경험이 달라집니다.

문서를 검색하고 있어요...
관련 근거 3개를 찾았어요.
답변을 작성하고 있어요...
출처를 정리하고 있어요...

진행 상태는 정직해야 합니다. 실제로 하지 않는 일을 보여 주면 안 됩니다. 하지만 실제 파이프라인 단계와 UI 메시지를 연결하면 사용자는 기다리는 시간을 "멈춤"이 아니라 "진행"으로 받아들입니다.

캐시: 가장 빠른 LLM 호출은 하지 않는 호출이다

속도 최적화에서 가장 강력한 문장은 이것입니다. 호출하지 않으면 가장 빠릅니다.

많은 서비스에는 반복 질문이 있습니다. 고객지원 챗봇에는 환불, 배송, 비밀번호, 요금제 질문이 반복됩니다. 문서 Q&A에는 설치 방법, API 키 발급, 오류 코드 설명이 반복됩니다. 매번 같은 질문을 LLM에 보내면 돈도 들고 시간도 듭니다. 캐시는 이전에 계산한 결과를 저장해 두었다가 다시 쓰는 방법입니다. AWS의 생성형 AI 캐싱 가이드도 프롬프트 캐싱과 요청-응답 캐싱을 구분하며, 반복 요청에서 비용과 지연 시간을 크게 줄일 수 있다고 설명합니다.7

LLM 서비스에서 쓸 수 있는 캐시는 여러 층이 있습니다.

캐시 종류저장하는 것효과주의할 점
정확 일치 응답 캐시같은 요청의 최종 답변가장 빠름프롬프트 버전, 모델, 권한을 키에 포함해야 함
임베딩 캐시질문이나 문서의 임베딩 벡터반복 임베딩 호출 감소임베딩 모델이 바뀌면 무효화 필요
검색 결과 캐시질문에 대한 top-k 문서RAG 검색 시간 감소문서 업데이트 시 갱신 필요
프롬프트 캐시반복되는 긴 프롬프트 prefix긴 시스템 프롬프트 처리 감소제공자별 지원 방식 확인 필요
의미 기반 응답 캐시비슷한 질문과 답변FAQ성 질문에 강함유사도 임계값을 잘못 잡으면 엉뚱한 답변 가능

정확 일치 캐시는 구현이 쉽습니다.

import hashlib
import json
from time import time

cache = {}


def cache_key(payload):
    raw = json.dumps(payload, sort_keys=True, ensure_ascii=False)
    return hashlib.sha256(raw.encode('utf-8')).hexdigest()


def get_or_generate(payload, ttl_seconds=600):
    key = cache_key(payload)
    now = time()

    if key in cache:
        item = cache[key]
        if now - item['created_at'] < ttl_seconds:
            return item['value'], True

    value = call_llm(payload)
    cache[key] = {
        'value': value,
        'created_at': now,
    }
    return value, False

실제 서비스에서는 메모리 딕셔너리 대신 Redis, Valkey, DynamoDB, PostgreSQL 같은 외부 저장소를 씁니다. 여러 서버가 같은 캐시를 공유해야 하기 때문입니다.

캐시 키에는 생각보다 많은 것이 들어가야 합니다. 같은 질문이라도 시스템 프롬프트가 바뀌면 답변이 달라질 수 있습니다. 모델이 바뀌어도 답변이 달라질 수 있습니다. 사용자 권한이 다르면 볼 수 있는 문서가 다릅니다. 그래서 RAG 서비스의 캐시 키에는 보통 다음 요소가 포함됩니다.

  • 사용자 질문 또는 정규화된 질문

  • 프롬프트 템플릿 버전

  • 모델 이름과 주요 파라미터

  • 검색 대상 문서 집합의 버전

  • 사용자 권한 또는 테넌트 ID

  • 언어와 출력 형식

의미 기반 캐시는 더 조심해야 합니다. "환불은 어떻게 하나요?"와 "반품 신청 방법 알려줘"는 같은 답변을 써도 될 수 있습니다. 하지만 "무료 체험 환불은 어떻게 하나요?"와 "연간 요금제 환불은 어떻게 하나요?"는 비슷해 보여도 정책이 다를 수 있습니다. 의미 캐시는 빠르지만, 잘못된 hit가 발생하면 빠르게 틀린 답변을 내놓습니다. 따라서 정책, 법률, 의료, 결제처럼 민감한 영역에서는 유사도 임계값을 높게 잡고, 문서 버전과 카테고리 필터를 함께 써야 합니다.

캐시는 속도를 높이는 동시에 일관성을 높입니다. 같은 질문에 매번 조금씩 다른 답변이 나오는 것을 줄여 주기 때문입니다. 하지만 캐시에는 만료 정책이 있어야 합니다. 오래된 배송 정책을 캐시가 계속 들고 있으면 빠르게 틀립니다. 빠른 오답은 느린 정답보다 나쁩니다.

오래 걸리는 작업은 비동기로 분리한다

모든 AI 작업을 채팅창 안에서 즉시 끝내야 하는 것은 아닙니다. 사용자가 "이 200쪽짜리 PDF를 요약해 줘"라고 요청했을 때, 브라우저가 2분 동안 멈춰 있는 것은 좋은 경험이 아닙니다. 긴 리포트 생성, 대량 문서 요약, 여러 도구를 순차적으로 호출하는 에이전트 작업은 비동기 작업으로 분리하는 편이 좋습니다.

비동기 패턴은 사용자의 인터랙션을 막지 않습니다. 서버는 요청을 받으면 즉시 작업 ID를 반환합니다. 백그라운드 워커가 실제 작업을 처리하고, 클라이언트는 진행 상태를 조회하거나 완료 알림을 받습니다.

jobs = {}


def submit_report_job(user_id, document_id):
    job_id = create_id()
    jobs[job_id] = {
        'status': 'queued',
        'progress': 0,
        'result': None,
    }

    queue.enqueue(run_report_job, job_id, user_id, document_id)
    return {'job_id': job_id, 'status': 'queued'}


def run_report_job(job_id, user_id, document_id):
    jobs[job_id]['status'] = 'running'
    jobs[job_id]['progress'] = 10

    chunks = load_document_chunks(document_id)
    partials = []

    for index, chunk in enumerate(chunks):
        partials.append(summarize_chunk(chunk))
        jobs[job_id]['progress'] = int((index + 1) / len(chunks) * 80)

    final = merge_summaries(partials)
    jobs[job_id]['status'] = 'done'
    jobs[job_id]['progress'] = 100
    jobs[job_id]['result'] = final

이 구조에서는 사용자가 기다리는 동안 다른 일을 할 수 있습니다. UI는 "요약 작업을 시작했습니다. 완료되면 알림을 보내 드릴게요."라고 말할 수 있습니다. 사용자가 굳이 화면을 보고 있지 않아도 됩니다.

비동기 작업에 적합한 예는 다음과 같습니다.

  • 긴 PDF나 책 전체 요약

  • 여러 문서 비교 리포트 생성

  • 대량 고객 문의 분류

  • 수백 개 상품 설명 자동 생성

  • 에이전트가 여러 도구를 호출해야 하는 조사 작업

  • 코드베이스 전체 분석

반대로 비밀번호 재설정 안내, 배송 상태 확인, 짧은 문장 교정처럼 즉시성이 중요한 작업은 동기 응답이 좋습니다. 모든 작업을 비동기로 만들면 서비스가 둔해 보입니다. 핵심은 사용자의 의도를 보는 것입니다. 지금 바로 결정해야 하는 질문은 즉시 답하고, 오래 걸려도 되는 산출물은 작업으로 넘깁니다.

병렬화와 요청 수 줄이기

LLM 애플리케이션은 종종 여러 호출로 구성됩니다. 먼저 의도를 분류하고, 다음에 검색하고, 그다음 답변을 만들고, 마지막으로 안전성 검사를 합니다. 이 모든 단계를 순차적으로 실행하면 각 단계의 시간이 더해집니다.

순서가 꼭 필요한 작업과 그렇지 않은 작업을 구분해야 합니다. 예를 들어 질문 임베딩과 사용자 권한 조회는 서로 독립적일 수 있습니다. 이 둘은 동시에 실행할 수 있습니다. RAG에서 벡터 검색과 키워드 검색도 병렬로 돌린 뒤 결과를 합칠 수 있습니다. 여러 도구를 호출하는 에이전트라면 날씨 조회와 환율 조회처럼 독립적인 도구는 동시에 호출할 수 있습니다.

async def prepare_context(question, user_id):
    user_task = get_user_profile(user_id)
    vector_task = vector_search(question)
    keyword_task = keyword_search(question)

    user, vector_hits, keyword_hits = await gather(
        user_task,
        vector_task,
        keyword_task,
    )

    return merge_context(user, vector_hits, keyword_hits)

반대로 요청 수 자체를 줄일 수도 있습니다. 어떤 서비스는 작은 LLM 호출을 너무 많이 쪼갭니다. "의도 분류", "톤 분석", "언어 감지", "답변 생성"을 모두 별도 호출로 만들면 네트워크 왕복과 모델 호출 오버헤드가 누적됩니다. 단순한 분류는 규칙이나 작은 모델로 처리하고, 꼭 필요한 경우에만 큰 모델을 호출해야 합니다.

LLM을 기본값으로 삼지 않는 것도 속도 최적화입니다. 이메일 형식 검사는 정규표현식이 더 빠릅니다. 고정된 FAQ 버튼은 미리 작성한 텍스트가 더 빠릅니다. 카테고리 선택지가 5개뿐이라면 전통적인 분류 모델이나 간단한 키워드 규칙이 충분할 수 있습니다. LLM은 만능 도구지만, 모든 나사를 망치로 박을 필요는 없습니다.

자체 모델을 서빙한다면 보는 지표가 달라진다

호스팅 API를 쓰는 팀은 주로 애플리케이션 층을 최적화합니다. 입력을 줄이고, 출력을 줄이고, 캐시하고, 라우팅하고, 스트리밍합니다. 반면 자체 모델을 GPU에 올려 서빙하는 팀은 시스템 층도 봐야 합니다.

자체 서빙에서 중요한 개념은 KV 캐시, 배치 처리, 양자화, 추론 엔진입니다. KV 캐시는 이전 토큰들의 attention 계산 결과를 저장해 다음 토큰 생성 때 재사용하는 메모리입니다. 컨텍스트가 길고 동시 요청이 많을수록 KV 캐시가 GPU 메모리를 많이 차지합니다. vLLM의 PagedAttention이나 SGLang의 prefix 재사용 같은 기법은 이런 병목을 줄이기 위해 등장했습니다. 최신 추론 최적화 자료들도 연속 배치, KV 캐시 관리, speculative decoding, 양자화 같은 기법을 주요한 지연 시간 개선 수단으로 다룹니다.8

간단히 말해 자체 서빙에서는 다음 질문을 보게 됩니다.

  • GPU가 계산을 하고 있는가, 아니면 요청을 기다리며 놀고 있는가?

  • KV 캐시 때문에 더 많은 요청을 동시에 처리하지 못하는가?

  • 긴 요청 하나가 짧은 요청들의 TTFT를 밀어내고 있지 않은가?

  • 양자화로 메모리 사용량을 줄여도 품질이 유지되는가?

  • 같은 시스템 프롬프트 prefix를 매번 다시 계산하고 있지 않은가?

이 책의 주요 실습은 API 기반 서비스에 초점을 맞추고 있으므로 자체 서빙을 깊게 다루지는 않습니다. 하지만 언젠가 오픈소스 모델을 직접 배포한다면, 애플리케이션 코드만큼이나 추론 엔진 선택이 중요해집니다. 같은 모델이라도 어떻게 서빙하느냐에 따라 지연 시간과 처리량은 크게 달라질 수 있습니다.

속도 개선을 위한 실전 순서

속도 최적화는 한 번에 모든 것을 바꾸는 작업이 아닙니다. 하나씩 바꾸고, 측정하고, 품질이 유지되는지 확인해야 합니다. 다음 순서로 접근하면 시행착오를 줄일 수 있습니다.

  1. 먼저 현재 상태를 측정합니다. 전체 지연 시간, TTFT, 생성 시간, RAG 검색 시간, 후처리 시간, p50과 p95를 기록합니다.

  2. 작업 유형을 나눕니다. 짧은 Q&A, 긴 요약, 글 생성, 에이전트 작업을 한데 섞어 평균을 내지 않습니다.

  3. 출력 토큰을 줄입니다. 최대 출력 길이, 간결한 응답 지시, 결론 우선 형식을 적용합니다.

  4. 입력 컨텍스트를 줄입니다. 오래된 대화, 반복 프롬프트, 불필요한 문서 조각을 제거합니다.

  5. RAG 검색을 점검합니다. top-k, 재랭킹, 원문 조회, 메타데이터 필터, 검색 실패 처리를 최적화합니다.

  6. 모델 라우팅을 도입합니다. 쉬운 작업은 빠른 모델에 맡기고, 어려운 작업만 강한 모델로 보냅니다.

  7. 스트리밍을 적용합니다. 첫 토큰을 빨리 보여 주고, 진행 상태를 사용자에게 설명합니다.

  8. 캐시를 추가합니다. 정확 일치 캐시, 임베딩 캐시, 검색 결과 캐시, 의미 기반 캐시를 단계적으로 도입합니다.

  9. 긴 작업은 비동기로 분리합니다. 리포트, 대량 요약, 장시간 에이전트 작업은 큐와 작업 상태로 처리합니다.

  10. 품질을 다시 평가합니다. 빨라졌지만 틀려졌다면 최적화가 아니라 퇴보입니다.

여기서 마지막이 가장 중요합니다. 속도 개선은 품질 평가와 떨어질 수 없습니다. 컨텍스트를 줄였더니 답변이 빨라졌지만 근거를 놓치기 시작했다면, 줄이는 방식이 잘못된 것입니다. 작은 모델로 라우팅했더니 평균 속도는 좋아졌지만 VIP 고객의 복잡한 문의에서 실패한다면, 라우팅 규칙을 고쳐야 합니다.

속도는 제품 품질의 일부입니다. 하지만 유일한 품질은 아닙니다.

기다림을 설계하는 서비스

사용자는 밀리초 단위의 내부 지표를 보지 않습니다. 사용자는 화면을 봅니다. 아무 일도 일어나지 않는 화면, 의미 없는 스피너, 끝나지 않는 로딩 문구를 봅니다. 또는 첫 문장이 빠르게 나타나고, 관련 문서를 찾았다는 메시지가 보이고, 답변이 차근차근 완성되는 화면을 봅니다.

두 서비스의 실제 처리 시간이 같아도 사용자의 느낌은 다릅니다.

LLM 서비스에서 응답 속도 개선은 단지 서버 시간을 줄이는 일이 아닙니다. 무엇을 먼저 보여 줄지, 어느 작업은 기다리게 하고 어느 작업은 백그라운드로 보낼지, 언제 짧게 답하고 언제 자세히 설명할지 설계하는 일입니다.

좋은 AI 서비스는 빠른 척만 하지 않습니다. 실제 병목을 측정하고 줄입니다. 동시에 사용자가 기다리는 동안 불안하지 않도록 진행을 보여 줍니다. 그리고 필요 없는 말은 줄이고, 필요한 답을 먼저 줍니다.

다음 장에서는 속도만큼 중요한 운영의 또 다른 축, 품질 평가와 로그 기반 개선을 다룹니다. 빠른 답변이 좋은 답변인지, 사용자가 만족했는지, 어디에서 실패했는지 알아야 AI 서비스는 계속 나아질 수 있습니다.

참고

1Latency optimization - OpenAI API

2LLM Latency Benchmark by Use Cases in 2026

3Optimizing RAG retrieval: Test, tune, succeed

4LLM Inference Optimization: A Practical Guide to Cutting Cost and Latency

55 Ways to Optimize Costs and Latency in LLM-Powered Applications

6Streaming Responses for Real-Time UX in ChatGPT Apps

7Optimize LLM response costs and latency with effective caching

8LLM Inference Optimization: Techniques That Actually Reduce Latency and Cost

책 목록

처음 만나는 LLM: 원리부터 Mini GPT 구현, RAG 서비스 개발까지

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기