AI API 비용은 대부분 두 군데에서 새어 나갑니다. 하나는 쉬운 작업까지 비싼 모델로 돌리는 것, 다른 하나는 실시간이 필요 없는 대량 작업을 실시간 단가로 처리하는 것입니다. 같은 결과를 내면서도 비용을 절반 이하로 줄이는 핵심 레버는 네 가지입니다 — (1) 모델 티어를 작업 난이도에 맞게 고르고, (2) 토큰을 정확히 세서 max_tokens를 적정값으로 잡고, (3) 비실시간 작업은 Message Batches API로 묶고, (4) 반복되는 프리픽스는 프롬프트 캐싱으로 재사용하는 것입니다. 이 글은 Claude를 1차 예제로, 실제 동작하는 Python/Laravel 코드와 함께 각 레버를 실무 관점에서 정리합니다.

모델 티어 선택: Opus / Sonnet / Haiku

Claude는 동일한 Messages API 위에서 여러 모델 티어를 제공합니다. 비용 최적화의 출발점은 "이 작업에 정말 최상위 모델이 필요한가?"를 묻는 것입니다. 분류·추출·요약처럼 정답이 비교적 명확한 작업은 저렴한 모델로도 충분하고, 복잡한 추론·장기 에이전트·코드 리뷰처럼 실수 비용이 큰 작업에만 상위 티어를 씁니다.

모델모델 ID입력 ($/1M)출력 ($/1M)컨텍스트적합한 작업
Opus 4.8claude-opus-4-8$5$251M복잡 추론·장기 에이전트·코드 리뷰 (정밀)
Sonnet 4.6claude-sonnet-4-6$3$151M대부분의 프로덕션 워크로드 (균형)
Haiku 4.5claude-haiku-4-5$1$5200K분류·추출·단순 응답 (빠름·저렴)
Fable 5claude-fable-5$10$501M최고 난도 장기 추론 (최고 성능)

가격 차이를 체감해 보면, 동일한 작업을 Haiku로 처리하면 Opus 대비 입력은 1/5, 출력은 1/5 비용입니다. 출력 토큰이 많은 작업일수록 이 격차가 그대로 청구서에 반영됩니다. 출력 단가가 입력의 5배라는 점($25 vs $5 등)도 기억하세요 — 비용 절감의 1순위는 보통 불필요하게 긴 출력을 줄이는 것입니다.

기본 권장값은 다음과 같습니다. 정밀도가 핵심이고 실수 비용이 크면 claude-opus-4-8, 속도·비용·품질의 균형이 필요하면 claude-sonnet-4-6, 대량·단순·지연 민감하지 않은 작업이면 claude-haiku-4-5. 컨텍스트가 200K를 넘는 긴 문서를 Haiku에 넣으면 안 된다는 점만 주의하세요(Haiku는 200K 한도).

라우팅: 쉬운 작업은 Haiku, 어려운 작업은 Opus

실전에서는 단일 모델로 고정하지 말고, 작업 난이도에 따라 요청을 라우팅하면 비용을 크게 아낄 수 있습니다. 가장 단순한 형태는 입력 길이·키워드·작업 유형 같은 휴리스틱으로 분기하는 것입니다. 아래는 Claude를 사용하는 라우터 예제입니다.

from anthropic import Anthropic

client = Anthropic()  # ANTHROPIC_API_KEY 환경변수 사용

def route_model(task_type: str, input_text: str) -> str:
    """작업 난이도에 따라 적절한 모델 ID를 반환한다."""
    # 단순·정형 작업은 Haiku로
    if task_type in {"classify", "extract", "tag", "moderate"}:
        return "claude-haiku-4-5"
    # 복잡 추론·코드 리뷰·장기 에이전트는 Opus로
    if task_type in {"reasoning", "code_review", "agent"}:
        return "claude-opus-4-8"
    # 입력이 매우 길면(대략 토큰 기준) 균형형 Sonnet
    if len(input_text) > 20_000:
        return "claude-sonnet-4-6"
    # 기본값: 균형형
    return "claude-sonnet-4-6"

def run(task_type: str, prompt: str) -> str:
    model = route_model(task_type, prompt)
    msg = client.messages.create(
        model=model,
        max_tokens=1024,
        messages=[{"role": "user", "content": prompt}],
    )
    # 응답 content는 블록 리스트 — 텍스트 블록만 모은다
    return "".join(block.text for block in msg.content if block.type == "text")

print(run("classify", "이 리뷰는 긍정인가요 부정인가요? '배송이 너무 느렸어요'"))
print(run("reasoning", "다음 시스템의 동시성 버그를 단계별로 분석해줘: ..."))

한 단계 더 나아가면 2단계(escalation) 라우팅을 씁니다 — 먼저 Haiku로 시도하고, 모델이 자신 없다고 표시하거나 검증에 실패하면 Opus로 재시도하는 패턴입니다. 이렇게 하면 트래픽의 대다수(쉬운 케이스)는 저렴하게 처리하고, 일부 어려운 케이스에만 상위 모델 비용을 지불합니다. 라우팅 판단을 모델에게 맡길 때는 별도의 route 도구(tool)를 정의해 tool_choice로 강제하는 방법도 있습니다. 다만 라우팅 자체를 위해 매번 LLM을 한 번 더 호출하면 그 비용이 절감액을 갉아먹을 수 있으니, 규칙으로 풀 수 있는 분기는 코드로 처리하는 편이 낫습니다.

토큰 카운팅: count_tokens로 정확히, tiktoken 금지

비용은 토큰 단위로 청구되므로, 비용을 예측·제어하려면 토큰을 정확히 세야 합니다. 여기서 가장 흔한 실수가 OpenAI의 tiktoken으로 Claude 토큰을 추정하는 것입니다. tiktoken은 다른 토크나이저라서 Claude 토큰을 일반 텍스트 기준 약 15~20% 적게 세고, 코드나 비영어 입력에서는 오차가 훨씬 커집니다. 반드시 Claude의 count_tokens 엔드포인트를 사용하세요. 이 엔드포인트는 무료이고 모델별로 토큰 수가 다르므로, 실제 추론에 쓸 모델 ID를 그대로 넘깁니다.

from anthropic import Anthropic

client = Anthropic()

resp = client.messages.count_tokens(
    model="claude-opus-4-8",
    messages=[{"role": "user", "content": open("CLAUDE.md").read()}],
)
print(resp.input_tokens)  # 이 모델 기준 정확한 입력 토큰 수

토큰 수를 알면 호출 전에 비용을 추정하고, 예산을 초과하는 요청을 사전에 거를 수 있습니다. 입력 토큰 × 입력 단가 + 예상 출력 토큰 × 출력 단가로 대략의 상한을 잡습니다. 같은 입력이라도 모델마다 토큰 수가 다를 수 있으니, 모델을 바꿔 비용을 비교할 때도 두 모델로 각각 count_tokens를 호출해 비교하세요.

max_tokens 적정값: 잘라먹지도, 낭비하지도 않기

max_tokens는 응답 출력의 상한입니다. 이 값을 너무 낮게 잡으면 출력이 문장 중간에 잘려(stop_reason == "max_tokens") 재호출이 필요하고, 너무 높게 잡는 것 자체는 청구에 영향을 주지 않지만(실제 생성된 토큰만 과금) 작업 성격에 맞는 현실적인 상한을 두는 것이 비용 폭주를 막는 안전장치가 됩니다.

  • 분류·라벨링: 출력이 짧으므로 max_tokens=256 수준이면 충분합니다.
  • 일반 비스트리밍 요청: ~16000을 기본으로 잡으면 SDK HTTP 타임아웃을 피하면서 잘림도 방지합니다.
  • 긴 출력(스트리밍): max_tokens가 16,000을 넘으면 스트리밍이 필수입니다(타임아웃 방지). 이때는 ~64000까지 여유 있게 잡고 .stream() + .get_final_message()를 씁니다.
from anthropic import Anthropic

client = Anthropic()

# 분류: 짧은 출력이므로 max_tokens를 작게 잡아 안전장치로 사용
msg = client.messages.create(
    model="claude-haiku-4-5",
    max_tokens=256,
    messages=[{"role": "user", "content": "다음 문의를 '환불/배송/기타' 중 하나로만 답해: 주문이 아직 안 왔어요"}],
)
label = "".join(b.text for b in msg.content if b.type == "text")
print(label)

# 긴 출력: 16K 초과는 스트리밍 필수
with client.messages.stream(
    model="claude-opus-4-8",
    max_tokens=64000,
    messages=[{"role": "user", "content": "이 설계 문서를 바탕으로 상세 구현 계획을 작성해줘: ..."}],
) as stream:
    final = stream.get_final_message()
print(final.stop_reason)  # 'end_turn'이면 정상 완료

Message Batches API: 비실시간 대량 작업 비용 절감

야간 일괄 처리, 대량 분류, 데이터셋 라벨링, 대규모 요약처럼 즉각적인 응답이 필요 없는 작업은 Message Batches API로 묶어 처리하는 것이 비용 면에서 유리합니다. 수천 건의 요청을 하나의 배치로 제출하고, 비동기로 결과를 회수하는 구조입니다. 동기 호출을 수천 번 반복하는 대신 한 번에 제출하므로 운영도 단순해집니다.

from anthropic import Anthropic

client = Anthropic()

# 1) 여러 요청을 하나의 배치로 제출
batch = client.messages.batches.create(
    requests=[
        {
            "custom_id": f"review-{i}",
            "params": {
                "model": "claude-haiku-4-5",   # 대량 분류는 저렴한 Haiku로
                "max_tokens": 256,
                "messages": [
                    {"role": "user", "content": f"다음 리뷰의 감정을 '긍정/부정/중립'으로만: {text}"}
                ],
            },
        }
        for i, text in enumerate(reviews)
    ]
)
print(batch.id, batch.processing_status)

# 2) 완료될 때까지 폴링 (실전에서는 적절한 간격으로)
batch = client.messages.batches.retrieve(batch.id)
if batch.processing_status == "ended":
    # 3) 결과 스트리밍 회수 — custom_id로 원래 요청과 매칭
    for entry in client.messages.batches.results(batch.id):
        if entry.result.type == "succeeded":
            msg = entry.result.message
            text = "".join(b.text for b in msg.content if b.type == "text")
            print(entry.custom_id, text)
        elif entry.result.type == "errored":
            print(entry.custom_id, "ERROR", entry.result.error)

배치는 모델 티어 선택·토큰 카운팅과 함께 쓸 때 효과가 배가됩니다. 즉, "비실시간 대량 작업"을 "저렴한 Haiku"로, "적정 max_tokens"로 묶으면 세 레버가 곱셈으로 작동합니다. 단, 배치는 결과가 즉시 나오지 않으므로 사용자 대기 화면이 있는 실시간 경로에는 적합하지 않습니다.

프롬프트 캐싱 결합: 반복되는 프리픽스 재사용

같은 시스템 프롬프트, 같은 few-shot 예시, 같은 문서를 매 요청마다 다시 보내고 있다면 그만큼 입력 토큰을 반복 과금하는 셈입니다. 프롬프트 캐싱은 이 반복되는 프리픽스를 캐시해, 두 번째 요청부터는 캐시 읽기 단가(약 0.1배)로 처리합니다. 캐시 쓰기는 약 1.25배(5분 TTL)이므로, 같은 프리픽스를 두 번 이상만 재사용해도 이득입니다.

핵심 규칙은 단 하나입니다 — 캐싱은 프리픽스(접두) 일치라는 점. 프리픽스 어딘가에서 한 바이트라도 바뀌면 그 뒤는 전부 캐시가 무효화됩니다. 따라서 안정적인 내용(고정 시스템 프롬프트)은 앞에, 가변적인 내용(타임스탬프·요청별 질문)은 뒤에 두고, 안정 영역의 마지막 블록에 cache_control 브레이크포인트를 답니다.

from anthropic import Anthropic

client = Anthropic()

LARGE_SYSTEM_PROMPT = open("system_prompt.md").read()  # 매 요청 공유되는 큰 프롬프트

def ask(question: str):
    msg = client.messages.create(
        model="claude-opus-4-8",
        max_tokens=1024,
        system=[
            {
                "type": "text",
                "text": LARGE_SYSTEM_PROMPT,
                # 안정적인 시스템 프롬프트 끝에 캐시 브레이크포인트
                "cache_control": {"type": "ephemeral"},
            }
        ],
        # 가변적인 질문은 캐시 영역 뒤(messages)에
        messages=[{"role": "user", "content": question}],
    )
    u = msg.usage
    # 캐시 적중 확인: 반복 요청에서 cache_read_input_tokens가 0이면 무효화 발생
    print("write:", u.cache_creation_input_tokens, "read:", u.cache_read_input_tokens)
    return msg

ask("환불 정책을 요약해줘")
ask("배송 지연 시 보상 기준은?")  # 두 번째부터 시스템 프롬프트가 캐시에서 읽힘

주의할 점: 캐시 가능한 최소 프리픽스는 모델마다 다릅니다(Opus 4.8·Haiku 4.5는 약 4,096토큰, Sonnet 4.6·Fable 5는 약 2,048토큰). 이보다 짧으면 cache_control을 달아도 조용히 캐시되지 않습니다(에러 없이 cache_creation_input_tokens: 0). 또 시스템 프롬프트에 datetime.now() 같은 매 요청 바뀌는 값을 넣으면 캐시가 매번 깨지므로, 그런 가변 값은 캐시 브레이크포인트 뒤로 옮기세요. 적중 여부는 항상 usage.cache_read_input_tokens로 검증합니다.

Laravel(PHP)에서의 적용

국내 실무에는 Laravel 사용자가 많으므로 PHP SDK 예제도 함께 둡니다. 패키지 설치는 composer require anthropic-ai/sdk이고, 클라이언트는 환경변수 ANTHROPIC_API_KEY로 초기화합니다. 아래는 토큰 수에 따라 모델을 라우팅하고, 분류 작업에 저렴한 Haiku를 쓰는 예입니다.

use Anthropic\Client;

$client = new Client(apiKey: getenv("ANTHROPIC_API_KEY"));

function pickModel(string $taskType, string $input): string
{
    // 분류·추출 등 단순 작업은 Haiku
    if (in_array($taskType, ["classify", "extract", "tag"], true)) {
        return "claude-haiku-4-5";
    }
    // 복잡 추론은 Opus
    if (in_array($taskType, ["reasoning", "code_review"], true)) {
        return "claude-opus-4-8";
    }
    // 기본: 균형형 Sonnet
    return "claude-sonnet-4-6";
}

$model = pickModel("classify", "주문이 아직 안 왔어요");

$message = $client->messages->create(
    model: $model,
    maxTokens: 256,  // 분류는 짧은 출력 — 적정 상한
    messages: [
        ["role" => "user", "content" => "다음 문의를 '환불/배송/기타'로만 분류: 주문이 아직 안 왔어요"],
    ],
);

// content는 폴리모픽 블록 배열 — 텍스트 블록만 추출
foreach ($message->content as $block) {
    if ($block->type === "text") {
        echo $block->text;
    }
}

Laravel에서 프롬프트 캐싱을 쓸 때는 system:을 텍스트 블록 배열로 주고 마지막 블록에 cacheControl(camelCase)을 답니다. 적중 여부는 $message->usage->cacheReadInputTokens로 확인합니다. 비실시간 대량 작업은 마찬가지로 배치로 묶어 큐 잡(예: Laravel Queue) 안에서 제출·폴링·회수하도록 구성하면 운영이 깔끔합니다.

비용 최적화 체크리스트

  • 모델 티어: 작업 난이도로 시작 — 단순은 Haiku, 균형은 Sonnet, 정밀·고난도는 Opus, 최고 성능은 Fable. "기본은 Opus"가 아니라 "필요할 때만 Opus".
  • 출력 토큰 우선: 출력 단가가 입력의 5배. 불필요하게 긴 출력을 줄이는 것이 1순위 절감.
  • 토큰 카운팅: count_tokens로 정확히. tiktoken 금지(Claude 토큰 부정확).
  • max_tokens: 분류 ~256, 비스트리밍 ~16000, 스트리밍 ~64000(16K 초과는 스트리밍 필수).
  • 배치: 지연 민감하지 않은 대량 작업은 Message Batches API로 묶기.
  • 캐싱: 반복 프리픽스는 cache_control로 재사용, cache_read_input_tokens로 적중 검증.
  • 라우팅: 규칙으로 풀 수 있는 분기는 코드로(라우팅용 LLM 호출이 절감액을 갉아먹지 않게).

자주 묻는 질문 (FAQ)

Q. Claude 토큰을 셀 때 tiktoken을 쓰면 안 되나요?
안 됩니다. tiktoken은 OpenAI 토크나이저라 Claude 토큰을 일반 텍스트 기준 약 15~20% 적게 세고, 코드·비영어에서는 오차가 더 큽니다. Claude의 count_tokens 엔드포인트(무료)를 쓰고, 추론에 사용할 모델 ID를 그대로 넘기세요. 토큰 수는 모델별로 다릅니다.

Q. 배치 API는 얼마나 저렴하고, 언제 쓰면 안 되나요?
Message Batches API는 비실시간 대량 작업을 비동기로 묶어 처리해 비용·운영 효율을 높입니다. 다만 결과가 즉시 나오지 않으므로 사용자가 응답을 기다리는 실시간 채팅·자동완성 같은 경로에는 부적합합니다. 야간 일괄 분류·라벨링·대량 요약처럼 지연을 허용하는 작업에 쓰세요.

Q. max_tokens를 크게 잡으면 그만큼 더 청구되나요?
아니요. 과금은 실제로 생성된 출력 토큰 기준이며, max_tokens는 상한일 뿐입니다. 다만 너무 낮으면 출력이 잘려(stop_reason == "max_tokens") 재호출 비용이 들고, 너무 높게 두는 것은 비용 폭주의 안전장치를 없애는 셈이라 작업 성격에 맞는 현실적 상한을 권장합니다. 16,000 초과 출력은 타임아웃 방지를 위해 스트리밍이 필수입니다.

Q. 프롬프트 캐싱은 언제 이득인가요?
같은 프리픽스(시스템 프롬프트, few-shot, 문서)를 반복해서 보낼 때입니다. 캐시 읽기는 약 0.1배, 쓰기는 약 1.25배(5분 TTL)라 같은 프리픽스를 두 번 이상만 재사용해도 이득입니다. 단, 프리픽스가 매 요청 달라지면 캐시할 게 없으니 쓰지 마세요. 적중은 usage.cache_read_input_tokens로 확인합니다.

Q. 라우팅을 위해 매번 모델에게 난이도를 물어봐도 되나요?
가능하지만 그 추가 호출 비용이 절감액을 깎아먹을 수 있습니다. 입력 길이·작업 유형·키워드처럼 규칙으로 판별 가능한 분기는 코드로 처리하고, LLM 기반 라우팅은 규칙으로 풀기 어려운 경우에만 제한적으로 쓰는 것이 좋습니다. 2단계(먼저 Haiku, 실패 시 Opus) 패턴도 효과적입니다.


AI 개발이 필요하신가요?

DevTeam은 OpenAI·Claude 등 AI API 연동, 챗봇, RAG, 업무 자동화를 설계부터 배포까지 구현합니다. AI 연동 개발 또는 무료 견적 문의.

개발 파트너가 필요하신가요?

DevTeam은 MVP·웹·앱·AI 개발을 설계부터 배포·운영까지 한 팀이 책임집니다.