(2/3) LLM API 비용 추적 — 파이썬으로 4사 통합 비용 트래커 만들기
- 과금 구조와 usage 필드 — 4사 가격표 한눈에
- 파이썬으로 4사 통합 비용 트래커 만들기 ← 지금 글
- 공식 비용 API 대사와 예산 알림 자동화
Summary
1편에서 4사의 과금 구조와 usage 필드가 어떻게 다른지 봤어요. 이번 편은 그걸 코드로 합칩니다. 목표는 단순해요 — 어떤 프로바이더를 호출하든 한 줄로 감싸면, 비용이 자동으로 SQLite 에 쌓이고, 아침에 일별 리포트를 뽑아볼 수 있는 상태.
외부 서비스 없이 표준 라이브러리 + 각 사 SDK 만으로 만들어요. 파일 하나(llm_cost.py)에 다 들어가는 크기입니다.
💡 이 글에서 다루는 것
- 가격표를 코드로 —
PRICING딕셔너리 설계- 4사 usage 응답 → 공통 스키마 정규화 함수
- 호출 1건의 비용을 계산하는
estimate_cost()- SQLite 스키마와 기록 함수
log_usage()- 기존 코드를 안 고치고 감싸는
track()래퍼- 일별 · 모델별 리포트 쿼리
1. 가격표를 코드로 — PRICING 딕셔너리
1편의 가격표를 그대로 옮깁니다. 단위는 1M 토큰당 USD 로 통일하고, 캐시 읽기 단가를 별도 컬럼으로 둬요.
# llm_cost.py
# 2026-07 기준 $/1M tokens. 가격 개정 시 이 딕셔너리만 갱신하면 됩니다.
MILLION = 1_000_000
PRICING = {
# provider, model : (input, output, cached_input)
("anthropic", "claude-fable-5"): (10.00, 50.00, 1.00),
("anthropic", "claude-opus-4-8"): (5.00, 25.00, 0.50),
("anthropic", "claude-sonnet-4-6"): (3.00, 15.00, 0.30),
("anthropic", "claude-haiku-4-5"): (1.00, 5.00, 0.10),
("openai", "gpt-5.5"): (5.00, 30.00, 0.50),
("openai", "gpt-5.4"): (2.50, 15.00, 0.25),
("openai", "gpt-5.4-mini"): (0.75, 4.50, 0.075),
("openai", "gpt-5.4-nano"): (0.20, 1.25, 0.02),
("google", "gemini-3.5-flash"): (1.50, 9.00, 0.375),
("google", "gemini-2.5-flash"): (0.30, 2.50, 0.075),
("upstage", "solar-pro3"): (0.15, 0.60, 0.015),
("upstage", "solar-pro2"): (0.15, 0.60, 0.015),
}
몇 가지 설계 판단을 적어두면 이렇습니다.
- 키를 (provider, model) 튜플로 — 같은 모델명이 두 회사에 있을 일은 없지만, 나중에 Bedrock/Vertex 경유 같은 “같은 모델, 다른 단가” 케이스가 생기면 provider 축이 필요해져요.
- 캐시 쓰기 할증(Anthropic 1.25배)은 뺐어요 — 응답의
cache_creation_input_tokens에 1.25배를 적용하면 반영할 수 있는데, 추정치 단계에선 과대추정보다 단순함을 택했습니다. 필요하면 컬럼 하나 더 추가하면 돼요. - Gemini 3.1 Pro 같은 이단 요금제 모델은 이 단순 딕셔너리로 못 담아요. 쓰게 되면 함수형 가격(토큰 수를 받아 단가를 돌려주는)으로 확장합니다.
2. usage 정규화 — 4사 응답을 공통 스키마로
트래커의 심장이에요. 어떤 프로바이더의 응답이든 아래 다섯 필드로 바꿉니다.
def normalize_usage(provider, response):
"""프로바이더별 usage 응답 → 공통 스키마 dict."""
if provider == "anthropic":
u = response.usage
return {
"input_tokens": u.input_tokens + (u.cache_creation_input_tokens or 0),
"output_tokens": u.output_tokens,
"cached_tokens": u.cache_read_input_tokens or 0,
}
if provider == "openai":
u = response.usage
if hasattr(u, "input_tokens"): # Responses API
cached = u.input_tokens_details.cached_tokens if u.input_tokens_details else 0
return {
"input_tokens": u.input_tokens - cached,
"output_tokens": u.output_tokens,
"cached_tokens": cached,
}
cached = getattr(getattr(u, "prompt_tokens_details", None), "cached_tokens", 0) or 0
return { # Chat Completions API
"input_tokens": u.prompt_tokens - cached,
"output_tokens": u.completion_tokens,
"cached_tokens": cached,
}
if provider == "google":
m = response.usage_metadata
cached = m.cached_content_token_count or 0
return {
"input_tokens": m.prompt_token_count - cached,
"output_tokens": m.candidates_token_count + (m.thoughts_token_count or 0),
"cached_tokens": cached,
}
if provider == "upstage": # OpenAI Chat Completions 호환
u = response.usage
return {
"input_tokens": u.prompt_tokens,
"output_tokens": u.completion_tokens,
"cached_tokens": 0,
}
raise ValueError(f"unknown provider: {provider}")
1편에서 짚었던 함정들이 여기서 처리돼요.
- Gemini 의 thinking 토큰 —
thoughts_token_count를 출력에 더했습니다. 이거 하나로 Gemini 비용 추정이 반토막 나는 걸 막아요. - 캐시 토큰 이중가산 방지 — OpenAI 와 Gemini 는 캐시 토큰이 입력 합계에 포함돼서 오기 때문에 빼주고, Anthropic 은 처음부터 분리돼서 오기 때문에 그대로 씁니다. 방향이 반대라는 게 함정이에요.
- Upstage — OpenAI 구형 스키마와 동일해서 로직을 공유해도 되지만, 분기 하나 값이라 명시적으로 뒀습니다.
실제로 돌려보면 이렇게 나와요.
import anthropic
client = anthropic.Anthropic()
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
messages=[{"role": "user", "content": "SQLite 를 세 문장으로 소개해줘."}],
)
print(normalize_usage("anthropic", response))
{'input_tokens': 24, 'output_tokens': 118, 'cached_tokens': 0}
3. 비용 계산 — estimate_cost()
정규화된 usage 와 가격표를 곱하면 끝이에요.
def estimate_cost(provider, model, usage):
"""공통 스키마 usage → 추정 비용(USD)."""
price_in, price_out, price_cached = PRICING[(provider, model)]
return (
usage["input_tokens"] * price_in
+ usage["output_tokens"] * price_out
+ usage["cached_tokens"] * price_cached
) / MILLION
usage = {"input_tokens": 24, "output_tokens": 118, "cached_tokens": 0}
cost = estimate_cost("anthropic", "claude-opus-4-8", usage)
print(f"${cost:.6f}")
$0.003070
호출 한 번에 0.3센트. 이런 값들이 하루 수천 번 쌓이면 의미 있는 돈이 되고, 그래서 다음 단계가 저장입니다.
4. SQLite 에 쌓기 — 스키마와 log_usage()
관측 데이터는 호출 시점에 바로 기록하는 게 원칙이에요. 서버 DB 까지 갈 것 없이 SQLite 파일 하나면 충분합니다.
import sqlite3
from datetime import datetime, timezone
DB_PATH = "llm_usage.db"
SCHEMA = """
CREATE TABLE IF NOT EXISTS llm_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL, -- UTC ISO8601
provider TEXT NOT NULL,
model TEXT NOT NULL,
app TEXT NOT NULL, -- 어느 기능/서비스에서 썼는지
input_tokens INTEGER NOT NULL,
output_tokens INTEGER NOT NULL,
cached_tokens INTEGER NOT NULL,
cost_usd REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_usage_ts ON llm_usage (ts);
"""
def log_usage(provider, model, usage, cost, app="default"):
with sqlite3.connect(DB_PATH) as conn:
conn.executescript(SCHEMA)
conn.execute(
"INSERT INTO llm_usage "
"(ts, provider, model, app, input_tokens, output_tokens, cached_tokens, cost_usd) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
datetime.now(timezone.utc).isoformat(timespec="seconds"),
provider, model, app,
usage["input_tokens"], usage["output_tokens"],
usage["cached_tokens"], cost,
),
)
포인트는 app 컬럼이에요. “회사 챗봇”, “문서 요약 배치”, “실험 노트북”처럼 용도별로 태그를 달아두면, 나중에 “비용의 80%가 어디서 나오지?”라는 질문에 쿼리 한 줄로 답할 수 있습니다. 이 컬럼이 없으면 프로바이더별 총액밖에 못 봐요.
5. 기존 코드를 안 고치는 track() 래퍼
이제 부품 세 개(정규화 → 계산 → 기록)를 하나로 묶습니다. 기존 호출 코드를 최대한 안 건드리는 게 목표라, 응답을 받은 직후 한 줄만 끼워 넣는 형태로 만들어요.
def track(provider, model, response, app="default"):
"""API 응답을 받아 비용을 기록하고, 응답을 그대로 돌려준다."""
usage = normalize_usage(provider, response)
cost = estimate_cost(provider, model, usage)
log_usage(provider, model, usage, cost, app=app)
return response
쓰는 쪽은 이렇게 바뀝니다. 원래 코드에서 track(...) 으로 감싼 것만 차이예요.
# Claude
response = track("anthropic", "claude-opus-4-8", client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
messages=[{"role": "user", "content": "..."}],
), app="doc-summary")
# Upstage (OpenAI 호환 클라이언트)
response = track("upstage", "solar-pro3", upstage_client.chat.completions.create(
model="solar-pro3",
messages=[{"role": "user", "content": "..."}],
), app="doc-summary")
⚠️ 스트리밍 호출은 이 패턴이 바로 안 먹어요. 스트림은 응답 객체에 usage 가 마지막에야 채워지니까, Anthropic 은
stream.get_final_message(), OpenAI 는stream_options={"include_usage": True}로 최종 usage 를 받은 뒤track()에 넘기세요.
6. 일별 · 모델별 리포트
쌓였으면 뽑아야죠. 쿼리 두 개면 아침 점검 루틴이 됩니다.
def daily_report(days=7):
with sqlite3.connect(DB_PATH) as conn:
rows = conn.execute("""
SELECT substr(ts, 1, 10) AS day,
provider, model,
SUM(input_tokens), SUM(output_tokens), SUM(cached_tokens),
ROUND(SUM(cost_usd), 4), COUNT(*)
FROM llm_usage
WHERE ts >= datetime('now', ?)
GROUP BY day, provider, model
ORDER BY day DESC, SUM(cost_usd) DESC
""", (f"-{days} days",)).fetchall()
print(f"{'day':10} {'provider':10} {'model':18} {'in':>9} {'out':>8} {'cached':>8} {'USD':>9} {'calls':>6}")
for day, prov, model, tin, tout, tcache, usd, calls in rows:
print(f"{day:10} {prov:10} {model:18} {tin:>9,} {tout:>8,} {tcache:>8,} {usd:>9.4f} {calls:>6}")
daily_report(days=3)
day provider model in out cached USD calls
2026-07-06 anthropic claude-opus-4-8 812,440 103,215 611,020 6.9464 142
2026-07-06 google gemini-2.5-flash 1,204,331 88,410 0 0.5824 203
2026-07-06 upstage solar-pro3 311,208 40,112 0 0.0708 67
2026-07-05 anthropic claude-opus-4-8 744,102 96,882 548,330 6.4166 131
2026-07-05 openai gpt-5.4-mini 188,270 21,404 0 0.2375 54
이 출력에서 바로 읽히는 게 있어요. 호출 수는 Gemini 가 가장 많은데 돈은 Claude 가 90% 이상을 가져가죠. “비용 절감 = 호출 수 줄이기”가 아니라 “비싼 모델이 가는 트래픽을 다시 보기”라는 방향이 데이터에서 나옵니다. 그리고 Claude 의 cached 컬럼이 입력의 절반을 넘는 것도 보이는데, 프롬프트 캐싱이 실제로 일하고 있다는 증거예요.
용도별로 보고 싶으면 GROUP BY 에 app 을 추가하면 됩니다.
SELECT app, ROUND(SUM(cost_usd), 2) AS usd
FROM llm_usage
WHERE ts >= datetime('now', '-30 days')
GROUP BY app ORDER BY usd DESC;
💡 원화로 보고 싶다면 리포트 출력에서만 환산하세요. 환율은 매일 변하니까 DB 에는 USD 로 저장하고, 표시할 때
usd * 환율로 계산하는 게 뒤탈이 없습니다.
7. 정리
- 가격표는
PRICING딕셔너리 하나로 — 개정 시 여기만 수정 normalize_usage()가 4사 4색 스키마를 통일 (Gemini thinking 가산, 캐시 이중가산 방지 포함)estimate_cost()— 1M 단위 상수로 단위 실수 차단- SQLite
llm_usage테이블 +app태그로 용도별 추적 track()래퍼 — 기존 호출 코드에 한 줄만 추가- 일별 리포트에서 “호출 수와 비용은 다르게 움직인다”를 눈으로 확인
여기까지 만들면 “이번 달 얼마 썼지?”에 초 단위로 답할 수 있어요. 다만 이건 어디까지나 우리 쪽 관측치입니다. 실제 청구서와는 반올림, 서버 도구 과금, 무료 크레딧 차감 같은 이유로 어긋날 수 있어요.
일단 오늘은 여기까지….. 다음 글에서는 각 사의 공식 비용 API(Anthropic Usage & Cost API, OpenAI Usage/Costs API)로 추정치를 청구서와 대사하고, 게이트웨이 예산 관리와 Slack 알림까지 운영 루틴을 완성해볼게요.
← 이전 글: (1/3) LLM API 비용 추적 — Claude·OpenAI·Gemini·Upstage 과금 구조와 usage 필드 | 다음 글 →: (3/3) LLM API 비용 추적 — 공식 비용 API 대사와 예산 알림 자동화