모델 운영

MLOPS / 36번째 글

LLM 비용 — 청구서를 만드는 것과 줄이는 순서

LLM 청구서를 만드는 항목이 무엇이고 그것을 어떻게 세는지, 그리고 캐시·배치·토큰 손질·모델 교체를 어떤 순서로 대는지 숫자로 따라갑니다. 품질을 건드리지 않는 절감을 먼저 다 쓰고 나서 저울질로 넘어갑니다.

PALDYN Team45 MIN READ

지난 글에서 트레이스로 요청 하나의 안쪽을 들여다보는 방법을 봤다. 관측성을 붙여 놓으면 처음 며칠은 지연 시간을 보게 되지만, 한 달이 지나면 눈이 가는 곳이 바뀐다. 비용이다. 어느 기능이 얼마를 쓰는지 세어 두지 않으면 월말 청구서를 받고 나서야 그 사실을 알게 된다.

이 글은 두 가지를 한자리에서 다룬다. 하나는 청구서를 만드는 항목이 무엇이고 그것을 어떻게 세는가이고, 다른 하나는 손대는 순서다. 순서가 중요한 이유가 있다. LLM 비용을 줄이는 손잡이는 대여섯 개인데, 그중 절반은 답의 품질을 전혀 건드리지 않고 나머지 절반은 품질과 맞바꾼다. 공짜인 쪽을 다 쓰기 전에 저울질부터 시작하면, 필요 없는 품질 저하를 감수하고 있는 셈이 된다. 라우팅의 세부 산수는 라우팅과 캐스케이드가, 품질을 얼마에 파느냐 하는 저울질은 비용과 품질의 저울이 따로 들고 있으므로, 여기서는 구조·측정·순서만 본다.

비용 구성 항목

LLM 비용 구조 분해

입력과 출력의 단가

API 비용의 공식 자체는 한 줄이다.

비용=(입력 토큰×입력 단가)+(출력 토큰×출력 단가)\text{비용} = (\text{입력 토큰} \times \text{입력 단가}) + (\text{출력 토큰} \times \text{출력 단가})

토큰은 모델이 글을 자르는 단위로, 한국어에서는 대체로 한 글자에서 몇 글자 사이다. 단가는 보통 100만 토큰당 달러로 적히고, 출력 단가가 입력 단가보다 몇 배 비싸다. 지금 Claude 계열을 보면 Haiku 4.5가 입력 100만 토큰에 1달러·출력에 5달러, Sonnet 5가 2달러·10달러, Opus 5가 5달러·25달러다. 셋 다 출력이 입력의 다섯 배다. 단가는 자주 바뀌는 값이니 실제로 계산할 때는 벤더의 가격표를 다시 본다.

이 비율이 실무에서 뜻하는 바가 있다. 같은 토큰 하나라도 모델이 만들어 내는 쪽이 훨씬 비싸다는 것이다. 프롬프트를 100 토큰 줄이는 것과 응답을 100 토큰 줄이는 것은 절감액이 다섯 배 차이 난다. 그런데 실무에서 손대기 쉬운 쪽은 대개 입력이다 — 출력 길이는 모델이 정하고, 우리는 지시로 유도할 수 있을 뿐이다. 이 비대칭이 뒤에서 「출력 포맷을 묶는다」는 이야기가 나오는 이유다.

숫자를 하나 넣어 끝까지 따라가 보자. Sonnet 5로 챗봇을 돌리는데 요청 하나가 시스템 프롬프트 2,000 토큰, 대화 히스토리 1,000 토큰, 사용자 메시지 500 토큰을 넣고 500 토큰을 답한다고 하자. 입력은 3,500 토큰이니 3500×2/106=0.00703500 \times 2 / 10^6 = 0.0070 달러, 출력은 500×10/106=0.0050500 \times 10 / 10^6 = 0.0050 달러, 합쳐 요청당 0.012달러다. 하루 1만 건이면 120달러, 한 달이면 3,600달러다. 이 숫자를 기억해 두면 뒤에서 손잡이를 하나씩 댈 때마다 얼마가 깎이는지 그대로 비교할 수 있다.

반복 접두사

위 3,500 토큰을 다시 본다. 이 중 사용자가 실제로 쓴 것은 500 토큰뿐이고, 나머지 3,000 토큰은 우리가 매번 다시 보내는 것이다. 여기에 비용 구조의 핵심이 있다. LLM API는 상태를 들고 있지 않으므로 대화가 이어질수록 같은 내용을 계속 다시 보낸다. 되풀이되는 자리는 셋이다.

첫째는 시스템 프롬프트다. 역할 지시와 금지 사항과 출력 규칙을 적어 두면 금세 수천 토큰이 되는데, 그것이 모든 요청에 그대로 붙는다. 하루 1만 건에 2,000 토큰이면 하루 2,000만 토큰이 순수하게 반복이다. 둘째는 RAG 컨텍스트다. 검색해 온 문서 조각을 프롬프트에 넣는 구조라면, 조각당 500 토큰짜리를 열 개 넣는 순간 요청당 5,000 토큰이 컨텍스트만으로 나간다. 셋째는 대화 히스토리다. 이쪽은 시간이 갈수록 자라는 것이 특징이라, 세션이 길어지면 어느 순간 히스토리가 나머지 전부보다 커진다.

세 자리의 성질이 서로 다르다는 점이 중요하다. 시스템 프롬프트는 요청마다 똑같고, RAG 컨텍스트는 질문마다 다르며, 히스토리는 대화 안에서 앞부분만 같다. 뒤에서 볼 프롬프트 캐시는 「똑같은 앞부분」에만 듣기 때문에, 이 셋에 대한 처방이 각각 다르다. 시스템 프롬프트는 캐시가 거의 완벽하게 듣고, 히스토리는 앞쪽만 듣고, 매번 새로 검색되는 RAG 컨텍스트는 아예 안 듣는다.

시간당 과금

모델을 직접 띄워 쓰는 경우에는 계량기가 아예 다르다. API가 요청당 과금이라면 자체 서빙은 시간당 과금이다. GPU를 빌려 두는 동안에는 요청이 하나도 안 들어와도 같은 돈이 나간다.

이 차이가 만드는 결과를 숫자로 보면 이렇다. 시간당 2달러짜리 GPU 한 장을 한 달 내내 켜 두면 24×30×2=1,44024 \times 30 \times 2 = 1{,}440 달러다. 그 한 달에 요청이 10만 건 들어왔다면 요청당 0.0144달러이고, 100만 건이었다면 0.00144달러다. 같은 장비, 같은 청구서인데 요청당 비용이 열 배 갈린다. 자체 서빙에서 「비용을 줄인다」는 말은 대개 단가를 깎는다는 뜻이 아니라 이 활용률(빌린 시간 중 실제로 계산에 쓴 비율)을 올린다는 뜻이다.

그래서 두 방식의 손익분기는 트래픽이 정한다. 위 예에서 월 10만 건이면 자체 서빙 요청당 0.0144달러가 앞서 계산한 API 0.012달러보다 오히려 비싸고, 100만 건이면 여덟 배 이상 싸진다. 물론 자체로 띄우는 오픈 웨이트 모델과 프런티어 API 모델은 품질이 같지 않으므로 이 비교는 「같은 일을 그 모델로 해낼 수 있을 때」만 성립한다. 다만 구조는 분명하다 — 트래픽이 작고 들쭉날쭉하면 API가, 크고 꾸준하면 자체 서빙이 유리한 쪽으로 기운다.

사용량 계측

호출당 비용

최적화 이야기를 하기 전에 계기부터 붙인다. 이유는 단순하다. 기준선이 없으면 무엇을 고쳤을 때 얼마가 줄었는지 말할 수 없고, 그러면 효과가 없는 손질도 계속 들고 가게 된다.

셀 재료는 응답이 다 준다. Anthropic SDK는 응답의 usage에 입력·출력 토큰 수를 실어 주고, 캐시를 쓰면 캐시에 쓴 토큰과 캐시에서 읽은 토큰이 따로 붙는다. 여기에 우리가 아는 것 셋을 더한다 — 어느 모델을 썼는지, 어느 기능에서 났는지, 누가 일으켰는지.

from collections import defaultdict
from dataclasses import dataclass, field
import datetime

# 100만 토큰당 달러. 벤더 가격표가 바뀌면 여기만 고친다.
PRICES = {
    "claude-haiku-4-5": {"in": 1.0, "out": 5.0, "cache_read": 0.1, "cache_write": 1.25},
    "claude-sonnet-5":  {"in": 2.0, "out": 10.0, "cache_read": 0.2, "cache_write": 2.5},
    "claude-opus-5":    {"in": 5.0, "out": 25.0, "cache_read": 0.5, "cache_write": 6.25},
}

@dataclass
class CostTracker:
    daily_budget_usd: float = 50.0
    by_day: dict = field(default_factory=lambda: defaultdict(float))
    by_feature: dict = field(default_factory=lambda: defaultdict(float))
    by_user: dict = field(default_factory=lambda: defaultdict(float))

    def record(self, model: str, usage, feature: str, user_id: str) -> float:
        p = PRICES[model]
        cached = getattr(usage, "cache_read_input_tokens", 0) or 0
        written = getattr(usage, "cache_creation_input_tokens", 0) or 0
        cost = (
            usage.input_tokens * p["in"]      # 캐시에서 안 온 입력
            + cached * p["cache_read"]
            + written * p["cache_write"]      # 캐시를 만든 요청은 웃돈이 붙는다
            + usage.output_tokens * p["out"]
        ) / 1_000_000

        today = datetime.date.today().isoformat()
        self.by_day[today] += cost
        self.by_feature[feature] += cost
        self.by_user[user_id] += cost

        if self.by_day[today] > self.daily_budget_usd:
            alert(f"일일 예산 초과: ${self.by_day[today]:.2f}")
        return cost

모델 이름을 키로 쓰지 않고 PRICES.get(model, 기본값) 식으로 넘어가게 두면 오타가 조용히 통과한다. 없는 모델에서 예외가 나게 두는 편이 낫다 — 이름이 바뀐 모델을 몇 주 동안 잘못된 단가로 세는 것보다 배포 직후에 터지는 쪽이 싸다.

기능·사용자별 집계

하루 총액만 세면 「이번 달에 3,600달러 나왔다」까지밖에 못 간다. 그 숫자로는 아무것도 못 고친다. 고칠 수 있으려면 어디서 났는지가 함께 있어야 한다.

기능별로 쪼개 보면 대개 편중이 심하다. 문서 요약 하나가 전체의 절반을 먹고 있거나, 별로 안 쓰는 관리자용 분석 기능이 요청당 비용은 스무 배인 식이다. 이 분포를 모르면 최적화 순서를 감으로 정하게 된다 — 전체의 3%인 기능의 캐시를 튜닝하느라 이틀을 쓰는 일이 실제로 일어난다. 사용자별 합계도 같은 이유로 쓸모가 있다. 상위 몇 명이 전체의 큰 몫을 차지하는 분포가 흔하고, 그게 헤비 유저인지 자동화된 스크립트인지 아니면 무한 루프에 빠진 클라이언트인지를 가르는 것이 첫 일이다.

여기에 붙는 값이 하나 더 있다. 캐시 히트율이다. 캐시를 켜 두고도 cache_read_input_tokens가 계속 0이면 캐시가 안 듣고 있는 것인데, 오류가 안 나므로 이 값을 안 보면 영영 모른다. 원인은 대개 접두사를 깨뜨리는 무언가다 — 시스템 프롬프트 안의 현재 시각, 매번 순서가 달라지는 JSON 직렬화, 요청마다 조금씩 바뀌는 도구 목록. 자세한 진단 목록은 캐시가 듣는 프롬프트와 듣지 않는 프롬프트에 따로 있다.

예산선과 경보

계기를 붙였으면 선을 하나 긋는다. 일일 예산을 정하고 그 비율에 다다르면 알림을 보내는 장치다. 위 코드가 하는 일이 그것이고, 실무에서는 보통 80% 지점에서 한 번 경고하고 100%에서 다시 알린다.

예산선의 값어치는 절감이 아니라 사고 감지에 있다. 비용이 서서히 오르는 것은 트래픽이 는다는 뜻이라 반가운 일이지만, 하루 만에 열 배로 뛰는 것은 대개 사고다. 재시도 로직이 실패에 대고 무한히 재시도하고 있거나, 히스토리 절단이 풀려 대화가 계속 자라거나, 누군가 프로덕션 키로 대량 배치를 돌린 것이다. 이런 것은 몇 시간 안에 알아야 하고, 월말 청구서는 그러기에 너무 늦다.

경보를 어디에 거는지도 정해 둔다. 총액에만 걸면 놓치는 것이 있다 — 전체는 평소와 비슷한데 특정 기능 하나가 스무 배로 뛴 경우다. 기능별 합계에도 상대적인 선을 하나 걸어 두면 그런 것이 잡힌다. 다만 선을 너무 촘촘히 걸면 알림이 잦아 아무도 안 보게 되므로, 처음에는 총액과 상위 기능 두셋으로 시작해 실제로 울린 것만 남긴다.

무손실 절감

여기서부터가 손잡이다. 이 절의 셋은 답이 달라지지 않는다 — 같은 모델이 같은 프롬프트를 받고 같은 답을 낸다. 그러니 저울질할 것이 없고, 순서상 가장 먼저 댄다.

프롬프트 캐시의 접두사 규칙

프롬프트 캐싱은 프롬프트의 앞부분을 서버에 저장해 두고 다음 요청에서 그 부분의 계산을 건너뛰는 기능이다. 캐시에서 읽은 토큰은 원래 입력 단가의 10분의 1만 청구된다. Claude API에서는 캐시로 잡을 블록에 cache_control을 붙여 지정한다.

resp = client.messages.create(
    model="claude-sonnet-5",
    max_tokens=1024,
    system=[{
        "type": "text",
        "text": LONG_SYSTEM_PROMPT,           # 2,000토큰짜리 지시문
        "cache_control": {"type": "ephemeral"},
    }],
    messages=[{"role": "user", "content": question}],   # 매번 바뀌는 것은 뒤에
)
print(resp.usage.cache_creation_input_tokens)   # 캐시에 쓴 토큰
print(resp.usage.cache_read_input_tokens)       # 캐시에서 읽은 토큰

이름이 「캐싱」이라 아무거나 저장해 두는 것처럼 들리지만 실제 규칙은 훨씬 좁다. 접두사 일치다. 프롬프트를 앞에서부터 훑어 내려오다 한 바이트라도 다른 자리가 나오면 그 뒤는 전부 캐시가 안 듣는다. 그래서 배치 순서가 규칙의 전부가 된다 — 변하지 않는 것을 앞에, 매 요청 달라지는 것을 뒤에 둔다. 위 코드에서 사용자 질문이 시스템 프롬프트 뒤에 있는 것이 그 이유다.

앞의 예를 다시 계산해 보자. 요청당 0.012달러 중 시스템 프롬프트 2,000 토큰이 차지하는 몫은 2000×2/106=0.0042000 \times 2 / 10^6 = 0.004 달러, 전체의 33%다. 그 부분이 90% 싸지므로 실제 절감은 0.33×0.9=0.300.33 \times 0.9 = 0.30, 곧 30%다. 요청당 0.0084달러, 월 2,520달러가 된다. 「90% 할인」이 청구서에서 30%가 되는 자리가 여기다 — 할인율은 캐시된 부분에만 걸리고, 실제 절감은 그 부분이 전체에서 차지하는 몫을 한 번 더 곱한 값이다. 이 계산을 안 해 두면 캐시를 붙이고 나서 「생각보다 안 줄었다」고 느끼게 된다.

캐시를 만드는 첫 요청은 오히려 조금 비싸다. 캐시에 쓰는 토큰은 정가보다 비싸게 청구되기 때문이다. 그래도 읽기 한 번의 절감이 그 웃돈보다 크므로 같은 접두사를 한 번만 더 쓰면 본전을 넘는다. 진짜 문제는 횟수가 아니라 간격이다. 캐시에는 유효 기간(TTL)이 있고 기본값이 짧아서, 요청이 뜸하면 매번 새로 만들다 끝난다 — 그때는 캐시를 안 쓰느니만 못하다. 이 손익분기를 닫힌 식으로 푼 것이 프롬프트 캐시의 손익분기에 있다.

시맨틱 캐시

캐시가 한 층 더 있다. 프롬프트 캐시가 「같은 접두사의 계산을 건너뛰는 것」이라면, 시맨틱 캐시는 「뜻이 같은 질문에 이미 만든 답을 그대로 돌려주는 것」이다. 이쪽은 모델을 아예 호출하지 않으므로 히트한 요청의 비용이 0이 된다.

시맨틱 캐시 구조

동작은 세 걸음이다. 질문을 임베딩(문장을 벡터로 바꾼 값)으로 만들고, 저장해 둔 질문 벡터들과 코사인 유사도를 재고, 임계값을 넘는 것이 있으면 그 답을 그대로 준다.

class SemanticCache:
    def __init__(self, store, embed, threshold: float = 0.95, ttl: int = 3600):
        self.store, self.embed = store, embed
        self.threshold, self.ttl = threshold, ttl

    def get(self, query: str) -> str | None:
        vec = self.embed(query)
        hit, score = self.store.nearest(vec)      # 벡터 인덱스가 최근접 하나를 준다
        return hit["response"] if score >= self.threshold else None

    def set(self, query: str, response: str) -> None:
        self.store.put(self.embed(query), {"response": response}, ttl=self.ttl)

store를 따로 뺀 것은 규모에 따라 갈리기 때문이다. 항목이 몇백 개일 때는 Redis에 넣어 두고 전부 훑어도 되지만, 수만 개가 되면 전수 비교가 LLM 호출보다 느려지는 지점이 온다. 그때는 pgvector나 Qdrant 같은 벡터 인덱스로 옮긴다.

임계값이 이 장치의 유일한 손잡이이자 위험이다. 0.95쯤에서 시작해 실제 로그로 조정하는데, 낮추면 절감은 늘지만 엉뚱한 답이 섞인다. 「서울 지점 영업시간」과 「부산 지점 영업시간」은 유사도가 꽤 높게 나오는데 답은 완전히 달라야 한다. 그래서 캐시하면 안 되는 질문을 먼저 걸러 내는 편이 임계값을 만지는 것보다 안전하다 — 개인화된 답, 시간에 따라 달라지는 답, 권한에 따라 달라지는 답이 그렇다. 흔히 예로 드는 「오늘 날씨 어때?」와 「지금 날씨가 어떻게 돼?」는 시맨틱 캐시의 교과서적인 예처럼 보이지만, 사실 답이 시간에 따라 변하므로 캐시하면 안 되는 질문이다. 뜻이 같은지보다 답이 고정인지를 먼저 본다.

배치 API

실시간으로 답을 돌려줄 필요가 없는 일이 있다. 야간에 도는 문서 분류, 상품 설명 일괄 생성, 로그 요약, 평가용 대량 추론 같은 것들이다. 이런 것은 배치 API로 보낸다 — 요청을 묶어 보내고 결과를 나중에 받는 창구이고, 같은 모델·같은 프롬프트에 가격이 절반이다.

batch = client.messages.batches.create(requests=[
    {
        "custom_id": f"doc-{i}",
        "params": {
            "model": "claude-haiku-4-5",
            "max_tokens": 512,
            "messages": [{"role": "user", "content": f"3줄로 요약: {doc}"}],
        },
    }
    for i, doc in enumerate(documents)
])

# 완료된 뒤 결과를 훑는다. 순서는 보장되지 않으므로 custom_id로 맞춘다.
for r in client.messages.batches.results(batch.id):
    if r.result.type == "succeeded":
        save(r.custom_id, r.result.message.content[0].text)

앞의 요청당 0.012달러가 여기서는 0.006달러가 된다. 조건은 하나뿐이다 — 결과를 언제 받아도 괜찮은가. 결과는 정해진 창(보통 하루) 안에 오지만 그 안에서 언제인지는 정해져 있지 않으므로, 배치를 「느린 실시간」으로 쓰면 안 된다. 사용자가 화면 앞에서 기다리는 자리에는 못 쓰고, 파이프라인의 한 단계로 끼워 넣는 자리에는 거의 언제나 쓸 수 있다.

결과 처리에서 한 군데 함정이 있다. 결과가 보낸 순서대로 오지 않는다. 위 코드가 custom_id로 맞추는 것이 그래서다. 리스트 인덱스로 짝지으면 대부분의 배치에서는 그냥 돌아가다가 어느 날 조용히 어긋난 결과가 저장된다. 그리고 개별 요청이 실패할 수 있으므로 성공 여부를 항목마다 확인한다.

자체 서빙에서도 같은 원리가 계량기만 바꿔 성립한다. GPU는 요청 하나를 처리할 때 대부분의 연산 유닛이 놀고 있으므로, 여러 요청을 한 묶음으로 넣으면 같은 시간에 훨씬 많이 처리한다. vLLM 같은 추론 엔진에서 배치 크기를 키우고 GPU 메모리 사용률을 높게 잡는 이유가 이것이다. API 쪽의 배치가 단가를 깎는다면 자체 서빙의 배치는 앞 절에서 본 활용률을 올린다.

토큰 절감

입력 축소

캐시가 「같은 것을 다시 계산하지 않기」라면 이 절은 「애초에 덜 보내기」다. 셋으로 나뉜다.

시스템 프롬프트 압축이 첫째다. 오래 쓴 지시문에는 대개 군더더기가 쌓여 있다. 「당신은 친절하고 도움이 되는 AI 어시스턴트입니다. 사용자의 질문에 항상 정중하게 답변하고…」 같은 문장은 「친절한 어시스턴트. 정중·간결·솔직」으로 줄여도 모델 동작이 거의 안 바뀐다. 다만 여기서 주의할 것이 있다 — 줄인 뒤에는 반드시 품질을 다시 재야 한다. 지시문의 어떤 한 줄이 실제로 특정 실패를 막고 있었는데 「군더더기처럼 보여서」 지운 경우가 흔하다.

둘째는 히스토리 관리다. 대화가 길어지면 히스토리가 무한히 자라는데, 처리 방식이 둘이다. 최근 몇 턴만 남기고 자르거나, 오래된 부분을 요약으로 갈아 끼우는 것이다. 요약 쪽이 정보를 덜 잃지만 요약하는 데도 호출이 한 번 드니 그 비용을 계산에 넣는다 — 값싼 모델로 요약해서 비싼 모델의 입력을 줄이는 구조라야 이득이 남는다.

def compress_history(history: list[dict], keep: int = 6) -> list[dict]:
    """오래된 대화는 한 문단 요약으로, 최근 keep개는 원본 그대로."""
    old, recent = history[:-keep], history[-keep:]
    if not old:
        return recent

    text = "\n".join(f"{m['role']}: {m['content'][:200]}" for m in old)
    summary = client.messages.create(
        model="claude-haiku-4-5",          # 요약은 값싼 모델로
        max_tokens=300,
        messages=[{"role": "user", "content": f"다음 대화를 200자로 요약:\n{text}"}],
    ).content[0].text

    return [
        {"role": "user", "content": f"[이전 대화 요약] {summary}"},
        {"role": "assistant", "content": "확인했습니다."},
        *recent,
    ]

셋째는 RAG 컨텍스트다. top_k를 10에서 3으로 줄이면 입력 토큰이 그만큼 줄지만, 답에 필요한 조각이 빠지면 답이 틀린다. 그러니 이 값은 감이 아니라 검색 품질 지표로 정한다. 관련 없는 조각을 넣는 것은 비용만 드는 것이 아니라 답도 흐리게 만들므로, 잘 고른 셋이 아무렇게나 고른 열보다 나은 경우가 많다.

출력 길이 제약

출력 단가가 다섯 배 비싸다는 것을 앞에서 봤다. 그런데 출력은 우리가 직접 자를 수 없고 유도만 할 수 있다. 손잡이가 셋 있다.

첫째는 max_tokens다. 이것은 상한선이라 초과하면 문장 중간에서 잘린다. 그러니 「비용을 줄이려고」 낮게 잡으면 잘린 답을 다시 요청하게 되어 오히려 비싸진다. 실제로 필요한 최대 길이보다 여유 있게 잡되, 지금 값이 실제 응답 길이의 열 배쯤 되면 한 번 재고한다.

둘째는 출력 형식 제약이다. 분류나 추출처럼 답의 모양이 정해진 일에는 「JSON만 반환하고 설명은 붙이지 마라」 같은 지시를 넣는다. 「물론이죠! 요청하신 분석 결과를 정리해 드리겠습니다」 같은 도입부와 「추가로 궁금한 점이 있으면 말씀해 주세요」 같은 마무리가 응답마다 수십 토큰씩 붙는데, 프로그램이 받아 파싱할 답에는 그것이 전부 낭비다.

셋째는 아예 형식을 강제하는 것이다. 요즘 API에는 응답을 스키마에 맞추도록 제약하는 기능이 있어서, 지시문으로 부탁하는 대신 구조로 못 박을 수 있다. 파싱 실패로 인한 재요청이 사라지므로 절감이 두 번 온다 — 짧아진 응답에서 한 번, 없어진 재시도에서 한 번.

압축과 캐시의 충돌

여기서 앞 절과 부딪히는 자리가 하나 나온다. 프롬프트를 요청마다 동적으로 압축하면 접두사가 매번 달라져 프롬프트 캐시가 통째로 안 듣는다.

압축과 캐시가 부딪힐 때의 요청당 비용

숫자로 보면 어느 쪽이 손해인지 분명하다. 시스템 프롬프트를 2,000 토큰에서 1,400 토큰으로 30% 줄였다고 하자. 캐시가 없으면 입력이 3,500에서 2,900으로 줄어 요청당 0.0108달러, 10% 절감이다. 그런데 캐시가 듣고 있었다면 원래 0.0084달러였고, 압축 때문에 캐시가 깨져 0.0108달러가 되었으니 29% 더 비싸졌다. 압축이 절감처럼 보이는 자리에서 실제로는 손해를 본 것이다.

그래서 순서가 이렇게 정해진다. 압축은 한 번 하고 고정한다. 지시문을 사람이 손으로 줄여 정적인 문자열로 박아 두면 접두사는 그대로 안정적이고 캐시도 그대로 듣는다. 요청 내용에 따라 프롬프트를 골라 끼우는 구조가 필요하면, 변형이 몇 가지뿐이 되게 만들어 각각이 자기 캐시를 갖게 한다 — 변형이 무한하면 캐시는 어느 것에도 안 듣는다. 앞에서 히트율이 0에서 안 올라가는 원인으로 꼽은 것들과 같은 이야기다.

모델 교체

여기서부터는 답이 달라진다. 앞 절들과 성격이 다르므로 저울질이 필요하고, 그래서 순서상 나중이다.

티어별 단가

같은 계열 안에서도 모델 등급에 따라 단가가 층을 이룬다. 앞의 요청 하나(입력 3,500·출력 500 토큰)를 세 등급에 각각 태워 보면 이렇다.

모델 입력 / 출력 (100만 토큰당) 요청당 비용
Haiku 4.5 $1 / $5 $0.0060
Sonnet 5 $2 / $10 $0.0120
Opus 5 $5 / $25 $0.0300

위아래 끝이 정확히 다섯 배다. 이 비율은 다른 어떤 손잡이보다 크다 — 캐시가 30%, 배치가 50%를 깎는 데 비해 모델 교체는 한 번에 80%를 깎는다. 그래서 유혹이 크고, 그래서 위험도 크다.

가르는 기준은 「작은 모델로도 충분한 일인가」 하나다. 분류, 감성 판단, 정해진 형식으로의 추출, 짧은 번역, 의도 인식처럼 정답이 좁게 정해진 일은 작은 모델이 큰 모델과 거의 같은 결과를 낸다. 반대로 다단계 추론, 긴 코드 생성, 판단이 섞인 분석은 등급 차이가 그대로 품질 차이로 나타난다. 다만 이 구분을 감으로 하면 안 된다. 바꾸기 전에 그 일의 실제 예시 수십 건을 두 모델에 태워 보고 정확도와 비용을 나란히 적는다. 분류 같은 일에서 작은 모델이 큰 모델의 정확도를 거의 따라잡는 경우가 흔한데, 「흔하다」는 것이 「당신의 데이터에서도 그렇다」는 보증은 아니다.

분류기 기반 라우팅

일마다 모델을 고정하는 것이 아니라 요청마다 고르는 구조가 라우팅이다. 앞에 값싼 분류기를 하나 두고 복잡도를 판정한 뒤 그 결과로 모델을 정한다.

모델 라우팅

TIERS = {"simple": "claude-haiku-4-5",
         "medium": "claude-sonnet-5",
         "complex": "claude-opus-5"}

def classify(query: str) -> str:
    r = client.messages.create(
        model="claude-haiku-4-5",
        max_tokens=8,
        system="쿼리 복잡도를 simple/medium/complex 중 하나로만 답하라.",
        messages=[{"role": "user", "content": query}],
    )
    label = r.content[0].text.strip().lower()
    return label if label in TIERS else "medium"      # 불확실하면 가운데로

산수를 해 보면 크기가 보인다. 전체의 80%가 Haiku로, 20%가 Opus 5로 간다고 하자. 평균 요청 비용은 0.8×0.0060+0.2×0.0300=0.01080.8 \times 0.0060 + 0.2 \times 0.0300 = 0.0108 달러이고, 여기에 분류기 비용이 붙는다. 분류기는 질문 100 토큰에 시스템 100 토큰을 넣고 다섯 토큰을 답하니 요청당 0.00023달러 남짓이다. 합쳐 0.0110달러 — 전부 Opus 5로 보냈을 때의 0.030달러 대비 63% 절감이고, 분류기가 차지하는 몫은 전체의 2%다. 분류기 비용을 걱정할 필요는 없다는 뜻이다.

정작 흔들리는 것은 분포다. 8:2가 아니라 5:5라면 평균이 0.0182달러가 되어 절감은 39%로 떨어진다. 라우팅의 절감폭은 라우팅 구조가 아니라 트래픽 분포가 정한다. 그러니 라우팅을 붙이기 전에 실제 로그를 분류기에 태워 분포부터 재 본다. 쉬운 질문이 별로 없는 서비스라면 라우팅을 붙여도 얻는 것이 적다.

품질 쪽 위험은 잘못 보내는 요청에 있다. 어려운 질문을 작은 모델에 보내면 두 가지 중 하나가 일어나는데, 틀린 것을 알아채고 큰 모델로 다시 보내면 두 번 값을 치르고, 못 알아채면 틀린 답이 그대로 나간다. 비용 관점에서 무서운 것은 재시도가 아니라 후자다. 재시도는 위 숫자에서 절반 넘게 발생해야 이득이 사라지는 반면, 조용히 나간 오답은 청구서에 아무 흔적을 안 남기고 서비스만 망가뜨린다. 그래서 라우팅에는 답을 그대로 믿지 않는 장치가 함께 필요하다 — 승격 신호를 무엇으로 만들고 임계값을 어떻게 고르는지는 라우팅과 캐스케이드에서 따로 다뤘다.

양자화와 스팟 인스턴스

모델을 직접 띄우는 쪽에는 API에 없는 손잡이가 둘 더 있다.

하나는 양자화다. 모델 가중치를 낮은 비트 수로 표현해 메모리를 줄이는 기법이고, 계산은 그대로 두고 저장 형식만 바꾼다. 16비트로 저장하던 것을 4비트로 바꾸면 가중치가 차지하는 자리가 4분의 1이 된다 — 70B 파라미터 모델이라면 16비트에서 약 140GB, 4비트에서 약 35GB다. 80GB짜리 GPU 한 장에 안 들어가던 것이 들어가는 지점이 여기이고, 두 장을 한 장으로 줄이면 그 시간당 비용이 그대로 절반이 된다. 대신 정밀도를 낮춘 만큼 품질이 조금 깎이므로, 어떤 방식이 얼마나 깎는지는 양자화 완전 정복에서 따로 봐야 한다.

다른 하나는 스팟 인스턴스(클라우드가 남는 용량을 싸게 빌려주되 필요할 때 회수해 가는 방식)다. 온디맨드보다 눈에 띄게 싸지만 언제든 중단될 수 있으므로, 중단을 전제로 짜야 쓸 수 있다. 요청 큐를 앞에 두고, 인스턴스가 사라지면 처리 중이던 요청이 다른 인스턴스로 넘어가게 하고, 상태를 노드에 두지 않는 식이다. 실시간 응답 경로에 그대로 놓기는 어렵고, 앞에서 본 배치 처리와는 궁합이 좋다 — 어차피 결과를 나중에 받는 일이라 중단되면 다시 하면 된다.

적용 순서

최적화 계층

LLM 서빙 비용 최적화 레이어

지금까지 본 것을 계층으로 놓으면 요청이 지나는 길과 나란히 선다. 요청이 들어오면 먼저 캐시를 만나고(히트하면 여기서 끝난다), 통과하면 모델 선택을 지나고, 그다음 프롬프트가 다듬어지고, 실시간이 아니면 배치 창구로 빠지고, 마지막으로 하드웨어 위에서 계산된다.

이 계층이 유용한 이유는 효과가 곱해지기 때문이다. 캐시에서 40%가 걸러지면 뒤의 모든 계층은 남은 60%만 처리한다. 그러니 앞쪽 계층부터 손대는 것이 항상 이득이 크고, 반대로 뒤쪽을 아무리 최적화해도 앞에서 걸러지는 몫은 애초에 안 온다.

계층을 나눠 두면 측정도 쉬워진다. 계층마다 통과율을 세면 「캐시 히트 30%, 라우팅에서 Haiku로 간 것 70%, 배치로 빠진 것 15%」처럼 한 줄로 현황이 나온다. 이 세 숫자가 곧 다음에 어디를 손댈지 알려 준다.

절감폭과 구현 난이도

전략을 한 표에 놓고 본다. 절감폭은 워크로드가 정하는 값이라 폭이 넓고, 그래서 표는 순서를 정하는 데 쓰고 숫자는 자기 로그로 다시 잰다.

순서 전략 품질 영향 구현 난이도 대략의 절감
1 프롬프트 캐시 없음 낮음 반복 접두사가 클수록 (앞 예에서 30%)
2 배치 API 없음 낮음 비실시간 요청분의 50%
3 시맨틱 캐시 임계값에 달림 중간 반복 질문 비율만큼
4 토큰 손질 작음 낮음 10~30%
5 모델 교체·라우팅 큼 중간 분포에 달림 (앞 예에서 63%)
6 양자화·스팟 중간 높음 자체 서빙에만 해당

순서가 절감폭 순이 아니라는 점이 이 표의 요점이다. 라우팅이 63%로 가장 크지만 다섯째에 있다. 품질을 안 건드리는 것을 먼저 다 쓰고 나서 저울질로 넘어간다는 원칙이 그 이유이고, 실용적인 이유도 하나 붙는다 — 캐시와 배치를 먼저 적용해 두면 라우팅으로 넘어갈 때 「얼마나 절박한가」가 달라진다. 이미 절반을 줄여 놓았다면 품질을 걸고 남은 몫을 더 깎을 이유가 줄어들 수도 있다.

첫 수는 표의 1번이 아니라 앞 절의 계기다. 기능별 분해를 하루만 돌려 봐도 어디에 손대야 하는지가 대개 그 자리에서 보인다. 반복되는 시스템 프롬프트가 비용의 절반이면 캐시가 먼저이고, 야간 배치가 절반이면 배치 API가 먼저이며, 요청 유형이 제각각이면 라우팅이 먼저다.

회수 기간

손질에도 값이 든다. 사람이 며칠을 쓰는 일이므로 그 시간과 절감액을 나란히 놓고 본다. 계산은 나눗셈 하나다 — 개발에 든 시간을 사람 시간당 비용으로 환산하고, 그것을 월 절감액으로 나누면 몇 달 만에 본전을 넘는지 나온다. 월 3,600달러를 쓰던 서비스에서 30%를 줄이면 월 1,080달러이므로, 며칠짜리 작업은 대개 첫 달 안에 회수된다.

이 계산이 알려 주는 것은 「하라」보다 「어디서 멈출까」다. 월 100달러짜리 서비스에 일주일을 쏟는 것은 회수가 안 되고, 그 시간에 다른 일을 하는 편이 낫다. 반대로 월 수천 달러 규모라면 표의 1~4번은 거의 언제나 회수된다. 그리고 회수 계산을 하려면 절감액을 알아야 하고, 절감액을 알려면 기준선이 있어야 한다 — 이 글이 계기 이야기로 시작한 이유가 여기로 돌아온다.

마지막으로 하나. 위 표에서 캐시는 1번과 3번, 두 줄을 차지했다. 순서의 맨 앞자리와 가운데를 하나씩 잡은 셈인데, 사실 이 글에 안 나온 층이 하나 더 있다 — 완전히 같은 문자열을 해시로 거르는 층이다. 다음 글에서는 이 세 층을 어떻게 겹쳐 쌓는지, 무엇을 어느 층에 맡기고 각 층의 TTL과 무효화를 어떻게 정하는지를 구조와 코드로 본다. 이 글에서 「30%」와 「히트율」로만 다룬 자리가 거기서 실제 설계가 된다.


읽어주셔서 감사합니다. 😊

LATEST

모델 운영의 최신 글

모델 운영2026.09.04

KV 캐시 양자화 — 가중치보다 이쪽이 먼저 넘친다

긴 문맥에서 GPU 메모리를 실제로 잡아먹는 것은 가중치가 아니라 KV 캐시입니다. 캐시 크기를 계산하는 법, K와 V를 다르게 다뤄야 하는 이유, 어디까지 줄여도 되는지를 정리합니다.

16 MIN
모델 운영2026.09.04

양자화 보정 — 데이터 128개가 모델 품질을 정한다

양자화에서 스케일을 정하는 절차가 보정입니다. 무엇을 재는지, 데이터를 어디서 몇 개 뽑아야 하는지, 자르는 지점을 어떻게 고르는지, 그리고 잘못된 보정이 어떤 모양으로 드러나는지를 정리합니다.

21 MIN
모델 운영2026.09.03

과제 특화 증류 — 큰 모델의 답을 작은 모델에 옮긴다

범용 성능이 아니라 우리 과제 하나만 잘하는 작은 모델을 만드는 방법입니다. 교사에게 무엇을 받아야 하는지, 데이터를 어떻게 모으고 거르는지, 손익 분기가 어디인지를 정리합니다.

15 MIN