LLM API를 프로덕션에 붙이고 나면 곧 청구서가 눈에 들어옵니다. 토큰 단가는 낮아 보여도, 긴 시스템 프롬프트와 누적되는 대화 기록, RAG로 밀어 넣는 문서 청크가 합쳐지면 요청당 토큰 수가 순식간에 불어납니다. 게다가 입력 토큰이 많으면 비용뿐 아니라 지연도 함께 늘어납니다. LLM 토큰 비용 최적화는 “같은 품질을 더 적은 토큰으로” 달성하려는 엔지니어링입니다.

이 글에서는 컨텍스트 압축, 요약 캐시, 프롬프트 캐싱, 그리고 모델 라우팅이라는 네 가지 실전 전략을 다룹니다. 무엇을 버리고 무엇을 지킬지에 대한 트레이드오프까지 함께 짚습니다.

먼저 측정: 토큰이 어디서 새는가

최적화의 시작은 토큰 회계입니다. 요청 하나가 시스템 프롬프트, 대화 기록, 검색 문서, 사용자 질문 중 어디에 토큰을 쓰는지 분해해야 어디를 손볼지 보입니다. 토크나이저로 실제 토큰 수를 세는 것이 첫걸음입니다.

import tiktoken

enc = tiktoken.get_encoding("cl100k_base")

def count(text: str) -> int:
    return len(enc.encode(text))

parts = {
    "system": system_prompt,
    "history": "\n".join(chat_history),
    "context": retrieved_docs,
    "question": user_question,
}
for name, txt in parts.items():
    print(f"{name:10s}: {count(txt):6d} tokens")

대부분의 팀이 이 분해를 처음 해보면 “대화 기록”과 “검색 문서”가 전체의 70~80%를 차지한다는 사실에 놀랍니다. 시스템 프롬프트를 아무리 다듬어도 여기를 안 건드리면 효과가 미미합니다.

전략 1: 컨텍스트 압축

컨텍스트 압축은 모델에 넣기 전에 불필요한 토큰을 걷어내는 것입니다. RAG에서 특히 효과가 큽니다. 검색으로 가져온 청크 중 상당수는 질문과 무관한 노이즈이기 때문입니다.

  • 재순위(re-ranking) 후 절단: 임베딩 검색으로 상위 20개를 가져온 뒤, 크로스 인코더로 재순위해 진짜 상위 3~5개만 남깁니다.
  • 추출적 압축: 각 문서에서 질문과 관련된 문장만 뽑아 넘깁니다.
  • 중복 제거: 여러 청크가 같은 내용을 반복하면 하나만 남깁니다.
# 크로스 인코더 재순위로 상위 K개만 컨텍스트에 포함
from sentence_transformers import CrossEncoder

reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")

def compress_context(question, candidates, top_k=4):
    scores = reranker.predict([(question, c) for c in candidates])
    ranked = sorted(zip(scores, candidates), reverse=True)
    return [c for _, c in ranked[:top_k]]

트레이드오프: 너무 공격적으로 자르면 답에 필요한 근거까지 사라져 환각이나 “모르겠다”가 늘어납니다. 압축률은 반드시 정답률 평가셋으로 검증하며 조절해야 합니다. 토큰만 보고 품질을 안 보면 비용은 줄지만 서비스가 나빠집니다.

전략 2: 대화 요약 캐시

멀티턴 챗봇에서 전체 대화 기록을 매 요청마다 다 보내면 턴이 쌓일수록 토큰이 선형으로 증가합니다. 해법은 슬라이딩 윈도우 + 요약입니다. 최근 N턴은 원문 그대로 두고, 그 이전은 하나의 요약으로 압축해 캐시합니다.

class ConversationMemory:
    def __init__(self, keep_recent=4, summary_trigger=8):
        self.turns = []
        self.summary = ""
        self.keep_recent = keep_recent
        self.summary_trigger = summary_trigger

    def add(self, role, content):
        self.turns.append({"role": role, "content": content})
        if len(self.turns) > self.summary_trigger:
            self._compact()

    def _compact(self):
        old = self.turns[:-self.keep_recent]
        # 오래된 턴을 LLM으로 요약해 하나의 문자열로 대체(캐시)
        self.summary = summarize(self.summary, old)
        self.turns = self.turns[-self.keep_recent:]

    def build_prompt(self):
        head = f"[이전 대화 요약]\n{self.summary}\n" if self.summary else ""
        recent = "\n".join(f"{t['role']}: {t['content']}" for t in self.turns)
        return head + recent

요약 생성 자체가 LLM 호출이라 비용이 들지만, 요약은 한 번 만들어 캐시하면 이후 모든 턴에서 재사용되므로 대화가 길수록 이득이 커집니다. 요약 트리거 임계값(summary_trigger)이 손익분기를 결정합니다.

전략 3: 프롬프트 캐싱 활용

많은 LLM 제공자가 프롬프트 캐싱(prompt caching)을 지원합니다. 요청마다 반복되는 접두부(긴 시스템 프롬프트, 도구 정의, 고정 지식)를 캐시해 두면, 다음 요청에서 그 부분을 다시 처리하지 않아 입력 토큰 비용이 크게 할인되고 지연도 줄어듭니다.

핵심 원칙은 변하지 않는 것을 프롬프트 앞쪽에, 매번 바뀌는 사용자 입력을 뒤쪽에 배치하는 것입니다. 캐시는 접두부 일치로 동작하기 때문입니다. 예를 들어 Anthropic Claude API는 cache_control로 캐시 구간을 명시합니다.

import anthropic

client = anthropic.Anthropic()

resp = client.messages.create(
    model="claude-sonnet-4-5",
    max_tokens=1024,
    system=[
        {
            "type": "text",
            "text": LONG_SYSTEM_PROMPT,     # 매번 동일한 대용량 지침
            "cache_control": {"type": "ephemeral"},  # 이 지점까지 캐시
        }
    ],
    messages=[{"role": "user", "content": user_question}],  # 매번 바뀜
)
# usage에서 cache_read_input_tokens / cache_creation_input_tokens 확인

캐시된 접두부는 최초 1회 생성 비용이 약간 더 들지만, 이후 재사용 시 읽기 비용이 크게 낮아집니다. 따라서 같은 접두부가 짧은 시간 안에 여러 번 재사용되는 워크로드(동일 시스템 프롬프트를 공유하는 다수 요청)에서 가장 효과적입니다. 정확한 단가와 캐시 유효시간은 제공자 공식 문서에서 확인하세요.

전략 4: 모델 라우팅과 출력 절약

모든 요청에 최상위 모델을 쓸 필요는 없습니다. 간단한 분류·추출·포맷팅은 저렴한 소형 모델로 처리하고, 복잡한 추론만 대형 모델로 보내는 라우팅이 비용을 크게 낮춥니다.

def route(task_type, prompt):
    if task_type in ("classify", "extract", "format"):
        model = "claude-haiku-4-5"      # 빠르고 저렴
    else:
        model = "claude-sonnet-4-5"     # 복잡한 추론
    return call_llm(model, prompt)

출력 토큰도 종종 입력보다 단가가 높으므로 함께 관리해야 합니다.

  • max_tokens를 태스크에 맞게 제한해 폭주를 막습니다.
  • “간결하게, 불필요한 서론 없이” 같은 지시로 장황한 출력을 억제합니다.
  • 구조화된 출력이 필요하면 JSON 스키마를 강제해 설명 문장을 생략시킵니다.

측정 없는 최적화는 도박이다

모든 전략은 품질과의 저울질입니다. 따라서 최적화 전후로 (1) 요청당 평균 토큰과 비용, (2) 태스크 정답률·품질 지표를 함께 추적해야 합니다. 비용만 보면 품질 저하를 놓치고, 품질만 보면 절감 여지를 놓칩니다.

# 응답 usage를 로깅해 비용 대시보드로 집계
def log_usage(resp):
    u = resp.usage
    record = {
        "input": u.input_tokens,
        "output": u.output_tokens,
        "cache_read": getattr(u, "cache_read_input_tokens", 0),
        "cache_write": getattr(u, "cache_creation_input_tokens", 0),
    }
    metrics.emit("llm.tokens", record)   # 관측 파이프라인으로 전송
    return record

마무리

토큰 비용 최적화는 단일 스위치가 아니라 여러 전략의 조합입니다. 먼저 토큰 회계로 어디서 새는지 찾고, RAG 컨텍스트는 재순위·압축으로 줄이며, 긴 대화는 요약 캐시로 접고, 반복되는 접두부는 프롬프트 캐싱으로 재사용하고, 태스크 난이도에 맞게 모델을 라우팅하는 것이 실전 순서입니다. 무엇보다 모든 변경은 정답률 평가셋과 나란히 검증해야 합니다. 아낀 토큰이 서비스 품질을 갉아먹었다면, 그건 절감이 아니라 손실이기 때문입니다.