tiktoken 토큰 수와 청구서가 다른 이유, 모델별 토크나이저 차이

tiktoken으로 센 토큰 수가 청구서와 다른 이유 — 모델별 토크나이저와 시스템 프롬프트 오버헤드

tiktoken으로 센 토큰 수가 청구서와 다른 이유는 크게 두 가지입니다. 모델마다 실제로 사용하는 토크나이저(인코딩)가 다르고, API가 메시지를 처리할 때 시스템 프롬프트·역할 구분자·함수 스키마 같은 눈에 안 보이는 오버헤드가 추가로 붙기 때문입니다. 순수하게 본문 텍스트만 encode()로 세면 이 두 요소가 빠져서 실제 청구 토큰보다 항상 적게 나옵니다.

이 글에서는 어떤 모델이 어떤 인코딩을 쓰는지, 오버헤드가 정확히 몇 토큰인지, 그리고 tiktoken 계산값을 청구서에 최대한 가깝게 맞추는 실제 코드를 다룹니다.

tiktoken으로 센 토큰 수와 청구서 토큰 수, 왜 정확히 안 맞을까요?

가장 흔한 실수는 사용자 메시지 문자열 하나만 encoding.encode(text)에 넣고 그 길이를 “이번 요청의 토큰 수”라고 착각하는 것입니다. 하지만 실제로 모델에 들어가는 입력은 사용자 메시지 하나가 아니라, 시스템 프롬프트·이전 대화 기록·역할(role) 메타데이터·(있다면) 함수/도구 스키마까지 전부 합쳐진 형태입니다.

여기에 더해 채팅 모델은 메시지 하나마다 구분자 역할을 하는 특수 토큰을 추가로 소비합니다. 이 구분자는 텍스트로 보이지 않기 때문에 본문만 셀 경우 실제보다 적게 잡힙니다. 결과적으로 tiktoken 계산값은 항상 청구서보다 같거나 작게 나오고, 반대로 청구서가 더 작게 나오는 경우는 거의 없습니다.

모델마다 토크나이저가 다르다는 게 정확히 무슨 뜻인가요?

tiktoken은 모델별로 서로 다른 BPE(바이트 페어 인코딩) 사전을 씁니다. 같은 한글·영어 문장이라도 어떤 인코딩으로 세느냐에 따라 토큰 수 자체가 달라집니다. 정리하면 다음과 같습니다.

파이썬 코드와 토크나이저 분석 화면을 보는 개발자

인코딩 이름 주로 쓰이는 모델
o200k_base gpt-4o, gpt-4o-mini, o1 계열
cl100k_base gpt-4, gpt-4-turbo, gpt-3.5-turbo, text-embedding-ada-002
p50k_base text-davinci-002/003, code-davinci-002 (구형 Codex)
r50k_base(gpt2) GPT-3 구형 모델(davinci 등)

즉 같은 문장을 cl100k_base로 세면 300토큰인데 o200k_base로 세면 280토큰이 나오는 일이 실제로 생깁니다. 어떤 인코딩을 쓸지 직접 고를 필요 없이, tiktoken.encoding_for_model("gpt-4o")처럼 모델 이름을 넘기면 라이브러리가 알아서 매핑해 줍니다. 이 매핑 테이블은 tiktoken 공식 저장소model.py에 정의돼 있고, 새 모델이 나올 때마다 업데이트되므로 오래된 tiktoken 버전을 쓰면 최신 모델 이름을 못 알아보고 KeyError가 날 수 있습니다.

pip install --upgrade tiktoken

import tiktoken

enc = tiktoken.encoding_for_model("gpt-4o")
text = "청구서보다 토큰이 적게 나온 이유를 알아보자"
print(enc.encode(text))
print(len(enc.encode(text)))

이 코드를 최신 tiktoken(0.8.x 이상)에서 실행하면 gpt-4oo200k_base 인코딩으로 매핑되어 토큰 리스트와 개수를 출력합니다. 반대로 tiktoken이 0.5.x처럼 오래됐다면 gpt-4o를 인식하지 못해 예외가 발생하므로, try/exceptcl100k_base에 폴백시키는 방어 코드가 필요합니다.

시스템 프롬프트와 메시지 포맷 오버헤드는 몇 토큰이나 될까요?

채팅 API는 [{"role": "system", ...}, {"role": "user", ...}] 형태의 메시지 배열을 내부적으로 ChatML 비슷한 포맷으로 직렬화한 뒤 토큰화합니다. 이 직렬화 과정에서 메시지마다 고정 토큰이 붙습니다. OpenAI 쿡북에 공개된 계산 함수를 보면 그 규칙이 이렇습니다.

import tiktoken

def num_tokens_from_messages(messages, model="gpt-4o-mini"):
    try:
        encoding = tiktoken.encoding_for_model(model)
    except KeyError:
        encoding = tiktoken.get_encoding("cl100k_base")

    tokens_per_message = 3
    tokens_per_name = 1

    num_tokens = 0
    for message in messages:
        num_tokens += tokens_per_message
        for key, value in message.items():
            num_tokens += len(encoding.encode(value))
            if key == "name":
                num_tokens += tokens_per_name
    num_tokens += 3  # 응답 시작을 알리는 토큰
    return num_tokens

messages = [
    {"role": "system", "content": "당신은 친절한 한국어 어시스턴트입니다."},
    {"role": "user", "content": "토큰 계산법 알려줘"},
]
print(num_tokens_from_messages(messages))

메시지당 3토큰, 그리고 응답을 시작하기 위한 3토큰이 본문 텍스트와 별도로 붙는 구조입니다. 시스템 프롬프트가 짧은 한두 문장이라도 이 고정 오버헤드 때문에 순수 본문 토큰 합계보다 6~10토큰 정도는 항상 더 나온다고 보면 됩니다. 대화가 길어져 메시지 수가 늘어날수록 이 오버헤드도 메시지 개수에 비례해 누적됩니다.

클라우드 API 사용 청구서와 비용 계산 화면

tiktoken 계산값을 청구서에 최대한 가깝게 맞추려면 어떻게 해야 하나요?

우선 사용자 메시지 하나만이 아니라 실제로 API에 보내는 messages 배열 전체(시스템 프롬프트 포함)를 위 num_tokens_from_messages 함수에 넣어야 합니다. tools나 function calling을 쓰는 경우라면 함수 스키마(JSON)도 프롬프트에 직렬화되어 토큰으로 계산되므로, 스키마 문자열까지 합산 대상에 넣어야 오차가 줄어듭니다.

두 번째로, 모델 스냅샷 이름을 정확히 맞춰야 합니다. gpt-4o처럼 별칭(alias)으로 부르면 실제로는 gpt-4o-2024-08-06 같은 특정 스냅샷이 호출되는데, tiktoken의 인코딩 자체는 같아도 모델 매핑 로직이 tiktoken 버전에 따라 달라질 수 있어 항상 최신 버전을 유지하는 편이 안전합니다. 세 번째로, 실제 응답을 받은 뒤에는 API가 돌려주는 usage 필드(prompt_tokens, completion_tokens, total_tokens)와 사전 계산값을 비교해서 오차를 로그로 남겨두면, 나중에 예산을 설계할 때 보정 계수로 쓸 수 있습니다.

그래도 usage 필드랑 다르게 나올 때는 뭘 봐야 하나요?

위 방법을 다 적용해도 딱 맞아떨어지지 않는 경우가 있습니다. 대표적으로 o1이나 최신 추론 계열 모델은 답변을 만들기 전에 내부적으로 “추론 토큰(reasoning tokens)”을 생성하는데, 이 토큰은 사용자에게 보이는 텍스트에 포함되지 않으면서도 completion_tokens에 합산되어 과금됩니다. tiktoken은 실제로 눈에 보이는 문자열만 세는 도구이기 때문에, 애초에 보이지 않는 이 토큰을 미리 예측할 방법이 없습니다.

이 부분이 이 방식의 명확한 한계입니다. 사전 계산은 “입력 프롬프트 쪽 예상치”를 정교하게 만드는 데는 유효하지만, 출력 쪽 추론 토큰이나 모델이 내부적으로 재시도·검열 등을 처리하며 소비하는 토큰까지는 tiktoken만으로 재현할 수 없습니다. 예산을 엄격하게 관리해야 한다면 사전 계산값을 참고용 상한선 정도로만 쓰고, 최종 근거는 항상 응답의 usage 필드로 삼는 편이 안전합니다.

정확한 사전 예측이 꼭 필요한 상황이 아니라면, 매 요청마다 무거운 토큰 계산 로직을 돌리는 것 자체가 비효율일 수도 있습니다. 짧은 채팅 앱처럼 오차 몇십 토큰이 큰 의미가 없는 경우에는 대략적인 문자 수 기반 추정만으로도 충분한 경우가 많습니다.

이렇게 모델별 인코딩 차이와 메시지 오버헤드를 감안해서 tiktoken 계산을 다시 짜면, 순수 본문만 셌을 때보다 청구서와의 오차를 크게 줄일 수 있습니다. 다음 단계로는 본인이 쓰는 모델 이름을 tiktoken.encoding_for_model()에 넣어 실제 인코딩이 무엇으로 매핑되는지 먼저 확인해 보고, num_tokens_from_messages 함수를 프로젝트에 맞게 이식해서 요청 직전에 예상 토큰 수를 로그로 남겨보는 것을 권합니다.

프롬프트 캐싱 90% 할인, 청구서는 왜 25%만 줄었을까

Leave a Comment