OpenAI, Anthropic, Gemini 간 토큰 사용량 추적하기: 스트리밍 시 겪은 모든 문제점들
요약
OpenAI, Anthropic, Gemini 등 주요 LLM 제공업체별 스트리밍 응답 시 토큰 사용량 추적 방식의 차이점과 문제점을 분석합니다. 오픈 소스 관측성 도구인 Spanlens를 구축하며 겪은 파싱 이슈와 캐시 계산 방식의 차이를 다룹니다.
핵심 포인트
- 제공업체마다 스트리밍 응답 내 토큰 사용량 위치가 상이함
- OpenAI는 특정 옵션 설정 시 마지막 청크에 사용량 포함
- Anthropic은 입력과 출력 토큰을 서로 다른 이벤트로 분리하여 전달
- 캐시된 토큰 계산 방식이 업체별로 달라 비용 추적 시 주의 필요
OpenAI, Anthropic, Gemini는 각각 토큰 사용량(token usage)을 다르게 보고하며, 이는 LLM 비용을 추적하는 순간 더 이상 사소한 문제가 아니게 됩니다. 저는 세 서비스 앞에 프록시(proxy)로 위치하여 모델, 지연 시간(latency), 토큰, 비용과 함께 모든 호출을 기록하는 오픈 소스 LLM 관측성(observability) 도구인 Spanlens를 구축했습니다. 비용 부분을 처리하기 위해, 저는 스트리밍(streaming) 응답을 포함한 모든 응답에서 토큰 사용량을 읽어옵니다.
저는 세 제공업체가 대략 비슷한 방식으로 사용량을 보고할 것이라고 가정했습니다. 결국 그들은 입력 토큰(input tokens), 출력 토큰(output tokens), 그리고 아마도 캐시된 수치(cached count)와 같은 동일한 종류의 데이터를 보내기 때문입니다. 얼마나 다를 수 있겠습니까.
결과적으로 매우 달랐습니다. 여기 그 전체 내용을 하나의 표로 정리하였으며, 이어서 리포지토리(repo)의 실제 파서(parser) 코드와 함께 각 문제점(gotcha)을 자세히 설명하겠습니다.
| 제공업체 (Provider) | 사용량 위치 (스트리밍) | 캐시 계산 (Cache accounting) | 필드 이름 (Field names) |
|---|---|---|---|
| OpenAI | 마지막 청크(final chunk), stream_options: { include_usage: true } 필요 | prompt_tokens에 캐시 포함 | prompt_tokens / completion_tokens |
| ... |
문제점 1: 사용량 수치가 스트림의 서로 다른 위치에 존재함
비스트리밍(non-streaming) 호출의 경우 이는 지루할 정도로 단순합니다. 모든 제공업체가 응답 본문(response body)에 usage 객체를 전달하며, 여러분은 그것을 읽기만 하면 됩니다. 스트리밍은 토큰 수(token counts)가 콘텐츠 청크(content chunks) 안에 들어있지 않기 때문에 이상해지기 시작합니다. 토큰 수는 다른 어딘가에 나타나며, 그 "다른 어딘가"는 제공업체마다 다릅니다.
OpenAI는 모든 콘텐츠가 끝난 후, [DONE] 직전의 마지막 청크(final chunk)에 사용량을 넣습니다. stream_options: { include_usage: true }를 통해 요청했을 때만 이를 받을 수 있습니다. 이 플래그를 놓치면 전체 응답을 스트리밍하게 되지만, 결국 사용량 정보는 전혀 얻지 못하게 됩니다.
export function parseOpenAIStreamChunk(line: string): Partial<ParsedUsage> | null {
if (!line.startsWith('data: ')) return null
const data = line.slice(6).trim()
...
Anthropic은 이를 두 개의 서로 다른 이벤트로 나눕니다. 입력 토큰 (input tokens)은 message_start에서 일찍 도착합니다. 출력 토큰 (output tokens)은 마지막에 message_delta에서 도착합니다. 만약 하나의 이벤트만 수신한다면, 숫자의 절반이 누락됩니다.
// 입력 측: message_start에서 도착
if (json.type === 'message_start') {
const usage = json.message?.usage
...
따라서 OpenAI의 경우 마지막 청크 (chunk)를 유지하고, Anthropic의 경우 첫 번째 이벤트와 나중 이벤트를 하나로 합쳐야 합니다. 이미 두 제공업체에 대해 두 가지의 멘탈 모델 (mental models)이 필요해진 것입니다.
주의사항 2: 캐시된 토큰 (cached tokens)은 반대되는 방식으로 계산됩니다
이 부분은 비용 수치를 조용히 오염시킬 수 있으므로, 천천히 주의 깊게 살펴볼 가치가 있습니다.
OpenAI와 Anthropic 모두 프롬프트 캐싱 (prompt caching)을 지원하며, 둘 다 캐시된 토큰 수를 보고합니다. 함정은 해당 캐시 수치와 관련하여 "입력 토큰 (input tokens)" 숫자가 무엇을 의미하느냐에 있습니다.
OpenAI의 경우, prompt_tokens에는 이미 캐시된 토큰이 포함되어 있습니다. 캐시된 수치는 그 중 일부 (subset)입니다. 캐시되지 않은 부분을 원한다면 이를 빼면 됩니다.
Anthropic의 경우, input_tokens는 캐시되지 않은 부분만을 의미합니다. 캐시된 토큰은 별도로 보고되며 해당 숫자에는 포함되어 있지 않습니다. 실제 총합을 구하려면 이 둘을 더해야 합니다.
개념은 같지만, 계산 방식은 반대입니다. 어떤 제공업체로부터 왔든 상관없이 나의 promptTokens 컬럼이 항상 "캐시를 포함한 총 입력 (total input including cache)"을 의미하도록 Anthropic을 정규화 (normalize)하는 방법은 다음과 같습니다:
const inputTokens = usage.input_tokens ?? 0
const cacheRead = usage.cache_read_input_tokens ?? 0
const cacheWrite = usage.cache_creation_input_tokens ?? 0
...
그리고 캐시된 수치가 이미 prompt_tokens 안에 들어 있는 OpenAI의 경우입니다:
const promptTokens = usage.prompt_tokens ?? 0 // OpenAI: 이미 총합임
const cacheReadTokens = usage.prompt_tokens_details?.cached_tokens ?? 0 // 일부 (subset)
만약 이 점을 고려하지 않고 하나의 함수를 작성하여 두 제공자(provider)에게 모두 전달한다면, 에러가 발생하지는 않습니다. 대신 캐시 크기만큼 틀린 비용 수치를 얻게 되며, 캐시 히트(cache hit)가 발생하는 대량 호출 시점에서 이 오류는 가장 커집니다. 에러를 발생시키지 않으면서 잘못된 재무 데이터를 제공하는 것은 최악의 버그 유형이므로, 저는 이제 캐시 관례를 추측하기보다는 각 제공자별로 직접 찾아봐야 하는 사실(fact)로 취급합니다.
주의사항 3: Gemini는 두 가지 다른 형태로 스트리밍합니다
OpenAI와 Anthropic은 모두 data:로 시작하는 라인인 서버 전송 이벤트(SSE, Server-Sent Events)를 스트리밍합니다. Gemini도 그렇게 할 수 있지만, URL에 ?alt=sse를 추가해야만 가능합니다. 이를 추가하지 않으면, 기본 streamGenerateContent 엔드포인트는 하나의 거대한 JSON 배열, 즉 한 글자씩 전달되는 하나의 큰 [ ... ]를 스트리밍합니다.
따라서 Gemini 스트림 파서(parser)는 이 두 가지를 모두 처리할 수 있어야 합니다. 제가 만든 파서는 먼저 SSE를 시도하고, 그다음 버퍼를 JSON 배열로 파싱하는 방식으로 폴백(fallback)하며, 그 다음에는 청크(chunk)처럼 보이는 것이 있는지 라인별로 스캔하는 방식으로 다시 한번 폴백합니다.
// 1. SSE 형태 ("data: {json}")
for (const line of lines) {
if (line.startsWith('data: ')) appendTextFromGeminiChunk(line.slice(6).trim(), parts)
...
필드 이름도 다릅니다. OpenAI는 prompt_tokens와 completion_tokens를 제공합니다. Gemini는 usageMetadata 객체 내에 promptTokenCount와 candidatesTokenCount를 제공합니다. 어느 것도 일치하지 않으므로, 정규화기(normalizer)의 역할이 중요합니다.
더 작은 문제: 요청한 티어(tier)가 항상 받은 티어는 아닙
세 제공자 모두 서비스 티어(default, flex, priority 등)를 보고할 수 있으며, 비용은 이에 따라 달라집니다. 알아두어야 할 점은 응답에 포함된 티어가 실제로 서비스된 티어이며, 이것이 항상 당신이 요청한 티어와 일치하지는 않는다는 것입니다. OpenAI는 부하가 걸릴 경우 우선순위(priority) 요청을 기본(default) 티어로 강등(downgrade)할 수 있으며, 이 강등은 응답에서만 나타납니다. 따라서 저는 요청에서 무엇을 요구했는지보다 응답에서 제공된 티어를 항상 신뢰합니다. 왜냐하면 그것이 실제로 청구되는 기준이기 때문입니다.
Gemini 또한 티어(tier)를 일관되지 않은 대소문자로 보고합니다. 때로는 일반적인 flex로, 때로는 ..._FLEX와 같은 Screaming Snake Case 상수 형태로 보고하기 때문에, 이를 위한 별도의 작은 강제 변환(coercion) 단계가 필요했습니다.
과거의 나에게 해주고 싶은 말
만약 여러 제공자(provider) 간의 사용량을 정규화(normalizing)하려 한다면, 공유 함수를 먼저 작성하지 마세요. 제공자당 하나의 파서(parser)를 작성하여 실제 응답을 바탕으로 각각을 정확하게 구현한 다음, 그제야 공통된 형태(common shape)로 통합하세요. 차이점은 단순히 외관상의 문제가 아닙니다. 숫자가 어디에 위치하는지, 캐시(cache)가 포함되는지, 그리고 필드 이름이 무엇인지가 모두 제공자마다 다르며, 초기에 단일 추상화(abstraction)를 적용하면 차이가 발생하는 바로 그 부분들을 가려버리게 됩니다.
또 다른 교훈은 비용이 발생하는 숫자들에 대해 강력하게 단언(assert)하라는 것입니다. 타입 오류(Type error)는 개발 단계의 첫 번째 요청에서 발견됩니다. 하지만 캐시 크기만큼 차이가 나는 토큰 수(token count)는 조용히 배포되어 몇 주 뒤에 청구 금액 불일치로 나타납니다. 이러한 비대칭성은 테스트를 해볼 가치가 있습니다.
이 모든 내용은 제가 여기서 생략한 스트리밍 재조립(streaming reassembly) 및 티어 처리 로직을 포함한 전체 버전을 보고 싶다면, 저장소의 apps/server/src/parsers/에 구현되어 있습니다.
이 글은 Spanlens를 구축하며 겪은 두 번째 주의사항(gotcha) 기록입니다. 첫 번째는 만약 해당 마이그레이션을 고민 중이시라면, Postgres에서 ClickHouse로 LLM 로그를 이동하는 과정에 관한 글이었습니다.
Spanlens는 오픈 소스(MIT)입니다. 단 한 줄의 baseURL 교체만으로 모든 LLM 호출의 토큰, 비용, 지연 시간(latency)을 기록하고 싶다면, 무료로 체험하거나 하나의 Docker 명령어로 셀프 호스팅할 수 있습니다.
이 글이 여러분의 디버깅 시간을 아껴주었다면, GitHub에서 Star를 눌러주세요. 다른 사람들이 이 프로젝트를 찾는 데 진심으로 도움이 됩니다.
제공자 간의 사용량이나 비용을 정규화하면서 어떤 주의사항(gotchas)을 겪으셨나요? 여러분의 이야기를 듣고 싶습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기