Tokenizer 를 들여다보게 된 이유
API 요금은 토큰 수로 정해지고, 컨텍스트 윈도우도 토큰 수로 제한된다.
프롬프트를 설계하거나 비용을 산정할 때, "이 텍스트가 대체 몇 토큰인데?"라는 질문은 피할 수가 없을 것이다.
- 토큰을 어떻게 최적화 할 수 있을까?
- 같은 텍스트인데 왜 모델마다 토큰 수가 다를까?
- Tokenizer 는 정확히 어떤 알고리즘으로 텍스트를 분할하는 걸까?
- OpenAI, Anthropic, Google은 같은 방식의 Tokenizer 를 쓸까?
이런 궁금증이 하나둘 쌓이다 보니 자연스럽게 Tokenizer 코드를 뜯어보게 되었다.
Token 이란?
LLM은 텍스트를 그대로 처리하지 않고, 텍스트를 일정한 단위로 쪼개서 숫자(ID)로 변환한 뒤, 그 숫자를 입력으로 받는다. 이때 쪼개진 각 단위가 토큰(Token)이고, 이 변환 과정을 수행하는 것이 토크나이저(Tokenizer)이다.
예를 들어, "Hello, world!"라는 텍스트는 다음과 같이 변환된다:
"Hello, world!" → ["Hello", ",", " world", "!"] → [9906, 11, 1917, 0]
토큰은 단어 단위가 아니고, 자주 등장하는 문자열은 하나의 토큰이 되고, 드물게 등장하는 문자열은 더 작은 단위로 쪼개진다.
한국어처럼 문자 종류가 많은 언어는 영어보다 같은 의미에 더 많은 토큰이 필요한 경우가 많다.
실제 토크나이저 데이터 파일 안에는 두 가지가 들어있다.
- Vocab - 토큰 문자열과 ID의 매핑. 사전 그 자체다.
- Merges - "이 두 조각을 만나면 하나로 합쳐라"는 병합 규칙. 사전에 없는 문자열을 만났을 때 어떻게 쪼개서 사전에 있는 조각으로 매핑할지를 결정한다.
// claude.json 내부 구조 (단순화)
vocab: { "Hello": 1234, "Ġthe": 567, "안": 8901, ... } ← 65,000개의 토큰 사전
merges: ["Ġ t", "e r", "Ġt h", ...] ← 64,739개의 병합 규칙
Vocab 크기가 65,000이면 토크나이저가 65,000가지의 서로 다른 토큰을 구분할 수 있다는 의미다.
Vocab에 등록되지 않은 문자열은 더 작은 단위로 쪼개져야 하므로, Vocab이 클수록 텍스트를 더 적은 토큰으로 표현할 수 있다.
14MB짜리 gemma3.json이 큰 이유도, 262K개의 토큰을 수록한 거대한 사전과 51만 개의 병합 규칙이 들어있기 때문이다.
그러면 사전이 클수록 무조건 좋은 걸까? 토큰 수는 줄어들지만 공짜는 아니다.
"안녕하세요"가 사전에 통째로 등록되어 있으면 1토큰이지만, 사전에 없으면 "안", "녕", "하", "세", "요"로 쪼개져서 5토큰이 된다.
즉, 사전이 클수록 토큰 수는 줄어든다. 하지만 사전이 크면 그만큼 초기화 비용과 메모리 사용량이 늘어난다.
| Vocab이 작은 경우 (50K) | Vocab이 큰 경우 (262K) | |
|---|---|---|
| 토큰 수 | 많음 (잘게 쪼갬) | 적음 (크게 묶음) |
| 초기화 시간 | 빠름 (데이터 파일 작음) | 느림 (14MB JSON 파싱) |
| 메모리 사용 | 적음 | 많음 |
| 인코딩 속도 | 비슷하거나 느림 | 비슷하거나 빠름 |
결국 토큰 수 vs 초기화 비용의 트레이드오프인데, 그래서 싱글턴 캐싱이 중요하다.
무거운 초기화를 한 번만 수행하고 재사용하면, 큰 Vocab의 이점(적은 토큰 수)만 취할 수 있다.
BPE(Byte Pair Encoding) 알고리즘
주요 LLM Tokenizer 의 핵심은 BPE(Byte Pair Encoding) 알고리즘이고, 낯설게 느꼈지만 생각보다 원리는 간단하다.
가장 자주 등장하는(우선순위가 높은) 인접 쌍을 반복적으로 병합한다.
구체적인 과정은 다음과 같다.
1단계: 초기화
텍스트를 가장 작은 단위(바이트 또는 개별 문자)로 분리한다.
"lower" → ["l", "o", "w", "e", "r"]
2단계: 인접 쌍 확인 및 병합
사전에 학습된 merge 규칙(또는 rank) 목록에서 현재 인접 쌍 중 우선순위가 가장 높은 것을 찾아 병합한다.
merge 규칙: ("l", "o") → "lo" (우선순위 높음)
["l", "o", "w", "e", "r"]
→ ["lo", "w", "e", "r"] // "l"+"o" 병합
→ ["lo", "w", "er"] // "e"+"r" 병합
→ ["low", "er"] // "lo"+"w" 병합
→ ["lower"] // "low"+"er" 병합
이 과정을 트리 구조로 시각화하면 다음과 같다.
"lower" (최종 토큰)
├── "low"
│ ├── "lo"
│ │ ├── "l" ← 초기 바이트
│ │ └── "o" ← 초기 바이트
│ └── "w" ← 초기 바이트
└── "er"
├── "e" ← 초기 바이트
└── "r" ← 초기 바이트
병합 순서: 1)l+o → 2)e+r → 3)lo+w → 4)low+er
반대로 merge 규칙에 "lower"가 통째로 없다면, 중간 단계에서 병합이 멈춘다.
예를 들어 "low"와 "er"의 병합 규칙이 없으면 결과는 ["low", "er"]로, 2개의 토큰이 된다.
vocab에 얼마나 많은 병합 규칙이 등록되어 있느냐가 최종 토큰 수를 결정짓는 셈이다.
3단계: 종료
더 이상 merge 규칙에 해당하는 인접 쌍이 없으면 병합을 중단한다. 최종 결과의 각 심볼이 하나의 토큰이 된다.
이 BPE에는 크게 두 가지 변형이 존재하는데, 각각 다른 LLM 프로바이더에서 사용된다.
변형 1: Rank 기반 BPE (tiktoken 방식)
OpenAI의 tiktoken은 rank(순위) 기반 방식을 사용한다.
모든 가능한 바이트 조합에 rank 번호를 부여하고, rank가 낮을수록 우선순위가 높아 먼저 병합된다.
/**
* rank 기반 BPE의 핵심 병합 루프
*
* 1. 각 바이트를 개별 구간(part)으로 시작
* 2. 인접한 두 구간을 합쳤을 때의 rank를 확인
* 3. rank가 가장 낮은(우선순위 높은) 쌍을 병합
* 4. 더 이상 병합할 수 없을 때까지 반복
*/
function bytePairMerge(piece, ranks) {
let parts = Array.from({ length: piece.length }, (_, i) => ({
start: i,
end: i + 1,
}));
while (parts.length > 1) {
let minRank = null;
for (let i = 0; i < parts.length - 1; i++) {
const key = piece.slice(parts[i].start, parts[i + 1].end).join(',');
const rank = ranks.get(key);
if (rank != null && (minRank == null || rank < minRank[0])) {
minRank = [rank, i];
}
}
if (minRank == null) break;
const i = minRank[1];
parts[i] = { start: parts[i].start, end: parts[i + 1].end };
parts.splice(i + 1, 1);
}
return parts;
}
이 방식의 특징은 rank 맵에 바이트 시퀀스가 존재하는지 여부로 병합 가능성을 판단한다는 점이다.
rank 맵이 곧 vocabulary이자 merge 규칙을 동시에 담고 있다.
변형 2: Merge 목록 기반 BPE (HuggingFace 방식)
Anthropic(Claude)과 Google(Gemini - Gemma3)은 HuggingFace의 tokenizer.json 포맷을 사용하며, merge 목록 기반 방식을 따른다.
merge 규칙이 배열로 나열되어 있고, 배열의 앞에 있을수록 우선순위가 높다.
/**
* merge 목록 기반 BPE
*
* merges: ["▁ t", "e r", "▁t h", ...]
* → "▁"와 "t"를 먼저 병합, 그 다음 "e"와 "r" 병합, ...
*/
function mergeBPE(symbols, mergeRanks) {
if (symbols.length <= 1) return symbols;
let word = [...symbols];
while (word.length > 1) {
let bestPair = null;
let bestRank = Infinity;
for (let i = 0; i < word.length - 1; i++) {
const pair = word[i] + ' ' + word[i + 1];
const rank = mergeRanks.get(pair);
if (rank !== undefined && rank < bestRank) {
bestRank = rank;
bestPair = [word[i], word[i + 1]];
}
}
if (bestPair == null) break;
// 해당 쌍의 모든 출현을 병합
const [first, second] = bestPair;
const merged = first + second;
const newWord = [];
let i = 0;
while (i < word.length) {
if (i < word.length - 1 && word[i] === first && word[i + 1] === second) {
newWord.push(merged);
i += 2;
} else {
newWord.push(word[i]);
i++;
}
}
word = newWord;
}
return word;
}
rank 기반과 가장 큰 차이는, merge 목록 방식에서는 같은 쌍이 여러 번 등장하면 한 번에 모두 병합한다는 점이다.
정리하면 같은 BPE 계열이라도 구현 방식은 다를 수 있고, OpenAI는 rank 기반, Claude와 Gemini 계열은 merge 목록 기반에 가깝다고 보면 이해가 쉽다.
LLM 3사 토크나이저 비교
같은 BPE 알고리즘을 사용하더라도, 텍스트를 BPE에 넣기 전후의 전처리/후처리 과정이 각각 다른데, 이 파이프라인이 토크나이저의 성격을 결정짓는다.
쉽게 생각하면, ML 모델링에서 데이터를 다루는 방식과 같다.
[ ML 모델링 ]
데이터 수집 → 전처리(정규화, 스케일링) → 모델(학습/추론) → 후처리(역변환, 포맷팅)
[ 토크나이저 ]
텍스트 입력 → 전처리(Normalize, PreTokenize) → 모델(BPE 병합) → 후처리(BOS 추가 등)
HuggingFace의 tokenizer.json 구조가 이 4단계를 그대로 명시하고 있다.
같은 BPE "모델"을 쓰더라도 전처리에서 어떻게 텍스트를 정제하느냐에 따라 결과가 달라지는 것은,
같은 ML 알고리즘이라도 feature engineering에 따라 성능이 달라지는 것과 같은 이치다.
토크나이저를 들여다보면서 느낀 건, 결국 우리가 쓰는 최신 기술들도 이미 있던 근본적인 개념에서 파생된 것이라는 점이다.
BPE는 원래 1994년에 데이터 압축 알고리즘으로 제안된 것이고, 2016년에 NLP 분야로 가져온 것에 불과하다.
전처리-모델-후처리 파이프라인은 ML의 기본 중 기본이고, 바이트 레벨 인코딩도 유니코드와 UTF-8이라는 수십 년 된 표준 위에서 동작한다.
LLM이 아무리 혁신적으로 보여도, 토크나이저 한 겹만 벗겨보면 압축 알고리즘, 정규식, 바이트 매핑 같은 컴퓨터 과학의 기본기가 그대로 들어있다.
새로운 것을 이해하려면 결국 근본으로 돌아가게 된다는 걸 다시 한번 느꼈다.
OpenAI - tiktoken
OpenAI의 토크나이저는 다음 파이프라인을 따른다.
텍스트
→ 정규식(pat_str)으로 청크 분리
→ 각 청크를 UTF-8 바이트로 변환
→ rank 기반 BPE 적용
→ 토큰 ID 배열
핵심은 pat_str 정규식인데, 이 정규식이 텍스트를 단어, 숫자, 공백, 특수문자 등으로 먼저 분리하고, 분리된 각 조각에 BPE를 적용한다.
// o200k_base의 pat_str 정규식 (축약)
// 영어 축약형, 단어, 숫자, 특수문자, 공백 등을 단위로 분리
const regex = /(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}{1,3}| ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]|\s+(?!\S)|\s+/gu;
인코딩 데이터는 bpe_ranks 문자열에 Base64로 인코딩되어 저장되고,
각 토큰 바이트 시퀀스의 위치(인덱스)가 곧 rank 번호가 된다.
OpenAI는 모델 세대별로 서로 다른 인코딩을 사용한다.
| 인코딩 | 대상 모델 | Vocab 크기 |
|---|---|---|
| o200k_base | GPT-4.1, o3, o4-mini, GPT-4o | ~200K |
| cl100k_base | GPT-4-turbo, GPT-4, GPT-3.5 | ~100K |
| p50k_base | Codex, text-davinci | ~50K |
Anthropic - Claude (ByteLevel BPE)
아래는 공개된 데이터를 기반으로 분석한 Claude 토크나이저의 구조인데, HuggingFace의 tokenizer.json 포맷을 따르고, ByteLevel BPE 방식이다.
텍스트
→ NFKC 유니코드 정규화
→ GPT-2 스타일 정규식으로 청크 분리
→ UTF-8 바이트 → GPT-2 유니코드 매핑
→ merge 목록 기반 BPE 적용
→ vocab에서 ID 조회
→ 토큰 ID 배열
여기서 흥미로운 부분은 GPT-2의 byte-to-unicode 매핑이다.
UTF-8 바이트는 0-255 범위인데, 이 중 제어 문자(0-32번, 127-160번, 173번)는 화면에 표시할 수 없다.
GPT-2는 이 "보이지 않는" 바이트들을 U+0100 이후의 유니코드 문자로 치환한다.
// 바이트 0x20 (공백, 보이지 않음) → U+0120 (Ġ)
// 바이트 0x41 ('A', 출력 가능) → 'A' (그대로)
// 바이트 0x0A (줄바꿈, 제어문자) → U+010A (Ċ)
이렇게 하면 모든 바이트열을 "눈에 보이는 문자열"로 표현할 수 있어서, vocab 사전에 문자열 키로 저장할 수 있다.
Claude 토크나이저의 스펙은 다음과 같다.
| 항목 | 값 |
|---|---|
| Normalizer | NFKC |
| PreTokenizer | ByteLevel (GPT-2 정규식) |
| Vocab 크기 | 65,000 |
| Merge 규칙 수 | 64,739 |
| BOS 토큰 | 없음 |
Google - Gemini (SentencePiece BPE)
Gemini는 같은 HuggingFace tokenizer.json 포맷이지만, SentencePiece 스타일의 BPE를 사용한다.
Gemma는 Gemini의 오픈소스 경량 버전으로, 동일한 토크나이저를 공유한다.
아래는 Gemma3의 tokenizer.json 데이터를 기준으로 분석한 내용이다.
텍스트
→ 공백(" ")을 "▁" 마커로 치환
→ 전체 텍스트를 하나의 청크로 처리
→ 문자 단위로 분리
→ merge 목록 기반 BPE 적용
→ vocab에서 ID 조회
→ <bos> 토큰(id=2) 앞에 추가
→ 토큰 ID 배열
Claude와의 가장 큰 차이는 PreTokenizer가 없다는 점이다.
Claude는 GPT-2 정규식으로 텍스트를 먼저 단어 단위로 분리한 뒤 각 조각에 BPE를 적용하는 반면,
Gemini는 전체 텍스트를 한 번에 BPE에 넣는다.
그리고 공백 처리 방식도 다르다. SentencePiece에서는 공백을 ▁ (U+2581, Lower One Eighth Block) 문자로 치환한다.
| 항목 | 값 |
|---|---|
| Normalizer | Replace (" " → 공백 마커) |
| PreTokenizer | 없음 (전체를 하나의 청크) |
| Vocab 크기 | 262,144 |
| Merge 규칙 수 | 514,906 |
| BOS 토큰 | <bos> (id=2) 자동 추가 |
3사 비교 정리
같은 텍스트라도 토크나이저에 따라 토큰 수가 크게 달라진다.
입력: "Hello, world! 안녕하세요."
[ OpenAI ]
▶ o200k_base : 8 Tokens
▶ cl100k_base : 9 Tokens
▶ p50k_base : 15 Tokens
[ Anthropic ]
▶ Claude : ~10 Tokens (근사치)
[ Google ]
▶ Gemini : 9 Tokens
차이가 발생하는 이유를 정리하면 다음과 같다.
| 구분 | OpenAI (tiktoken) | Claude (ByteLevel) | Gemini (SentencePiece) |
|---|---|---|---|
| BPE 변형 | Rank 기반 | Merge 목록 기반 | Merge 목록 기반 |
| 전처리 | 정규식 분리 → UTF-8 바이트 | NFKC 정규화 → 정규식 분리 → byte-to-unicode | 공백 마커 치환 → 문자 단위 분리 |
| 데이터 포맷 | tiktoken 자체 포맷 | HuggingFace tokenizer.json | HuggingFace tokenizer.json |
| Vocab 크기 | 50K ~ 200K | 65K | 262K |
| 데이터 크기 | 0.5 ~ 2.2MB | 1.7MB | 14MB |
vocab 크기가 클수록 더 긴 문자열을 하나의 토큰으로 표현할 수 있어서, 일반적으로 토큰 수가 줄어드는 경향이 있다.
Gemini(Gemma3)의 vocab이 262K로 가장 크고, 실제로 많은 텍스트에서 가장 적은 토큰 수를 보인다.
토크나이저 파이프라인 비교
세 토크나이저의 전체 파이프라인을 나란히 놓고 보면 구조가 더 명확해진다.
텍스트로 정리하면 다음과 같다.
| 단계 | OpenAI (tiktoken) | Claude (ByteLevel) | Gemma3 (SentencePiece) |
|---|---|---|---|
| Normalize | (없음) | NFKC | Replace (" " → 공백 마커) |
| PreTokenize | pat_str 정규식 | ByteLevel (GPT-2 정규식) | (없음 - 전체를 하나로) |
| Encode | UTF-8 바이트 변환 | byte → GPT-2 유니코드 매핑 | 문자 단위 분리 |
| BPE | rank 기반 병합 | merge 목록 기반 병합 | merge 목록 기반 병합 |
| PostProcess | (없음) | (없음) | BOS 토큰 추가 |
텍스트가 토큰이 되기까지 - 내부 동작 상세
토크나이저에 텍스트를 넣으면 내부적으로 어떤 일이 벌어지는지, 실제 코드 흐름을 따라가 보자.
countTokens 호출부터 시작
통합 API의 진입점은 countTokens 함수다. 프로바이더에 따라 적절한 토크나이저를 선택하고, encode 메서드를 호출한 뒤 결과 배열의 길이를 반환한다.
export function countTokens(text, provider) {
switch (provider) {
case 'openai_o200k':
return getTiktoken('o200k_base').encode(text).length;
case 'openai_cl100k':
return getTiktoken('cl100k_base').encode(text).length;
case 'anthropic':
return getHF('claude').encode(text).length;
case 'google':
return getHF('gemma3').encode(text).length;
// ...
}
}
여기서 getTiktoken과 getHF가 핵심인데, 이 함수들이 싱글턴 캐싱을 담당한다.
싱글턴 캐싱 - 왜 필요한가
싱글턴(Singleton)은 인스턴스를 하나만 생성하고 이후에는 그 인스턴스를 재사용하는 디자인 패턴이다.
여기서는 무거운 토크나이저 객체를 처음 한 번만 만들고, 두 번째 호출부터는 이미 만들어진 객체를 돌려주는 방식으로 사용한다.
왜 이게 필요할까? 토크나이저 데이터 파일은 크다. o200k_base.json이 2.2MB, gemma3.json은 14MB에 달한다.
이 JSON을 파싱하면 내부적으로 다음 작업이 발생한다.
tiktoken 포맷 (OpenAI):
bpe_ranks문자열을 줄 단위로 분리- 각 줄의 Base64 토큰을 바이트 배열로 디코딩
- 바이트 배열을 쉼표 구분 문자열로 변환하여
Map에 저장 - o200k_base 기준으로 약 200,000개의 항목이 Map에 들어감
HuggingFace 포맷 (Claude, Gemma3):
model.vocab객체를 그대로 사용 (65K ~ 262K 항목)model.merges배열을 순회하며Map에 우선순위 매핑 구축- Gemma3 기준으로 514,906개의 merge 규칙을 Map에 등록
이 초기화 과정이 수십~수백 밀리초가 걸리기 때문에, 매 호출마다 반복하면 낭비다.
const cache = {};
function getTiktoken(encoding) {
// 이미 캐시에 있으면 바로 반환 (JSON 파싱 건너뜀)
if (!cache[encoding]) {
// 처음 호출 시에만 JSON 파싱 + 토크나이저 초기화
cache[encoding] = new TiktokenTokenizer(loadJSON(`${encoding}.json`));
}
return cache[encoding];
}
function getHF(name) {
if (!cache[name]) {
cache[name] = new HFTokenizer(loadJSON(`${name}.json`));
}
return cache[name];
}
모듈 레벨 cache 객체에 한 번 생성된 토크나이저 인스턴스를 저장하고, 두 번째 호출부터는 JSON 파싱 없이 캐시된 인스턴스를 바로 사용한다.
OpenAI 토크나이저의 encode 메서드가 텍스트를 처리하는 과정을 단계별로 살펴보자.
encode(text) {
// 1. pat_str 정규식으로 텍스트를 청크로 분리
const regex = new RegExp(this.patStr, 'ug');
const result = [];
for (const match of text.matchAll(regex)) {
// 2. 매치된 텍스트 조각을 UTF-8 바이트로 변환
const piece = Array.from(textEncoder.encode(match[0]));
// 3. 전체가 하나의 토큰인 경우 빠르게 처리 (최적화)
const singleToken = this.rankMap.get(piece.join(','));
if (singleToken != null) {
result.push(singleToken);
continue;
}
// 4. BPE 병합을 통해 토큰 ID들을 추출
result.push(...bytePairEncode(piece, this.rankMap));
}
return result;
}
Step 1 - 정규식 분리
"Hello, world!"가["Hello", ",", " world", "!"]로 분리된다.- 이 정규식이 중요한 이유는, BPE가 이 청크 경계를 넘어서 병합하지 않기 때문이다.
- 즉,
"Hello"와","는 절대 하나의 토큰으로 합쳐지지 않는다.
Step 2 - UTF-8 바이트 변환
"Hello"→[72, 101, 108, 108, 111].- 한국어
"안"은 UTF-8로 3바이트([236, 149, 136])가 된다. - 이것이 한국어가 더 많은 토큰을 소비하는 근본 원인 중 하나다.
Step 3 - 싱글 토큰 최적화
"Hello"같은 흔한 단어는 rank 맵에 통째로 등록되어 있을 수 있다.- 이 경우 BPE 루프를 건너뛰고 바로 토큰 ID를 반환한다.
- 이 최적화가 없으면 모든 텍스트에
O(n²)BPE 루프가 돌아가게 된다.
Step 4 - BPE 병합
- 싱글 토큰으로 처리되지 않는 경우에만
bytePairEncode를 호출한다. - 이 함수 내부에서
bytePairMerge가 실행되며, 인접 바이트 쌍을 rank가 가장 낮은 것부터 반복 병합한다.
HuggingFace 포맷은 4단계 파이프라인을 명시적으로 구분한다.
encode(text) {
// 1. Normalize - 텍스트 정규화
text = this.normalize(text);
// 2. PreTokenize - 청크 분리
const pieces = this.preTokenize(text);
const result = [];
// 3. PostProcess - BOS 토큰 추가 (Gemma3만 해당)
if (this.bosTokenId != null) result.push(this.bosTokenId);
// 4. 각 청크에 BPE 적용
for (const piece of pieces) {
result.push(...this.encodePiece(piece));
}
return result;
}
Normalize 단계에서 Claude와 Gemma3는 완전히 다르게 동작한다.
| 단계 | Claude | Gemini |
|---|---|---|
| Normalize | text.normalize('NFKC') - 유니코드 호환 정규화. "fi" → "fi", "Ⅲ" → "III" | text.split(' ').join(SPACE_MARKER) - 공백을 SentencePiece의 공백 마커로 치환 |
| PreTokenize | GPT-2 정규식으로 단어/숫자/공백 분리 | 전체 텍스트를 하나의 청크로 반환 |
encodePiece 내부에서도 심볼 생성 방식이 다르다.
Claude (ByteLevel):
"Hello" → UTF-8 바이트 [72,101,108,108,111]
→ GPT-2 매핑 ["H","e","l","l","o"]
→ BPE 병합
→ vocab 조회
Gemini (SentencePiece):
"공백 마커 + Hello" → 문자 단위 분리 ["공백 마커","H","e","l","l","o"]
→ BPE 병합
→ vocab 조회
rank 맵 구축 과정 - tiktoken의 bpe_ranks 파싱
tiktoken의 데이터 포맷은 독특하다. bpe_ranks 필드에 모든 토큰이 Base64로 인코딩되어 저장된다.
// bpe_ranks 원본 데이터 (실제 형태)
"MARKER 0 IQ== Ig== Iw== JA== JQ== Jg== ..."
이 문자열을 파싱하는 과정은 다음과 같다.
constructor(data) {
this.rankMap = new Map();
const lines = data.bpe_ranks.split('\n').filter(Boolean);
for (const line of lines) {
// "MARKER OFFSET token1 token2 token3 ..."
const [, offsetStr, ...tokens] = line.split(' ');
const offset = parseInt(offsetStr, 10);
for (let i = 0; i < tokens.length; i++) {
// 1. Base64 디코딩: "SGVsbG8=" → [72, 101, 108, 108, 111]
const bytes = base64ToBytes(tokens[i]);
// 2. 바이트 배열을 쉼표 구분 문자열로 변환: "72,101,108,108,111"
const key = Array.from(bytes).join(',');
// 3. 이 키의 rank = offset + 현재 인덱스
this.rankMap.set(key, offset + i);
}
}
}
MARKER는 무시하고, OFFSET은 이 줄의 첫 번째 토큰에 부여될 rank 시작 번호다.
OFFSET이 0이고 토큰이 3개면, 각 토큰의 rank는 0, 1, 2가 된다.
rank가 낮을수록 자주 등장하는 바이트 조합이므로 먼저 병합된다.
merge 규칙 맵 구축 과정 - HuggingFace의 merges 파싱
HuggingFace 포맷은 더 직관적이다. model.merges 배열에 merge 규칙이 우선순위 순서대로 나열되어 있다.
constructor(data) {
this.mergeRanks = new Map();
// merges: ["Ġ t", "e r", "Ġt h", "i n", ...]
// 배열의 인덱스가 곧 우선순위 (0이 가장 높음)
for (let i = 0; i < data.model.merges.length; i++) {
const m = data.model.merges[i];
const key = Array.isArray(m) ? m.join(' ') : m;
this.mergeRanks.set(key, i);
}
}
merges[0]이 "Ġ t"이면, Ġ와 t의 병합이 가장 높은 우선순위(rank 0)를 갖는다.
BPE 루프에서 현재 인접 쌍 중 이 맵에서 rank가 가장 낮은 쌍이 먼저 병합된다.
코드를 뜯어보면서 알게 된 것들
Base64 디코딩도 필요하다
tiktoken의 bpe_ranks 데이터는 Base64로 인코딩된 바이트 시퀀스다. 외부 의존성 없이 동작하려면 Base64 디코더도 들어있어야 한다.
const B64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
const b64Lookup = new Uint8Array(128);
for (let i = 0; i < B64_CHARS.length; i++) {
b64Lookup[B64_CHARS.charCodeAt(i)] = i;
}
데이터 크기가 상당하다
토크나이저 데이터 파일의 크기가 꽤 크다. 특히 Gemma3의 경우 262K 개의 vocab과 51만 개의 merge 규칙이 들어있어서 JSON 파일 하나가 14MB에 달한다.
data/
├── o200k_base.json (2.2MB)
├── cl100k_base.json (1.0MB)
├── p50k_base.json (0.5MB)
├── claude.json (1.7MB)
└── gemma3.json (14MB) ← 51만 개의 merge 규칙
이런 큰 JSON을 매번 파싱하면 성능에 영향이 있으므로, 싱글턴 캐싱으로 한 번만 파싱하고 재사용하는 구조로 되어 있다.
const cache = {};
function getTiktoken(encoding) {
if (!cache[encoding]) {
cache[encoding] = new TiktokenTokenizer(loadJSON(`${encoding}.json`));
}
return cache[encoding];
}
정확도
토크나이저의 정확도를 정리하면 다음과 같다.
| Provider | 토크나이저 데이터 출처 | 정확도 |
|---|---|---|
| OpenAI | tiktoken 공식 인코딩 데이터 포팅 | 공식 tiktoken과 동일 |
| Anthropic | Xenova/claude-tokenizer | 근사치 (~10% 오차 가능) |
| unsloth/gemma-3-1b-it | Gemma3 토크나이저와 동일 |
한 가지 짚고 넘어갈 점이 있다. OpenAI의 "공식 tiktoken과 동일"이라는 정확도는 한국어에도 그대로 적용된다.
tiktoken은 바이트 레벨에서 동작하기 때문에, 언어에 관계없이 동일한 UTF-8 바이트 시퀀스는 항상 동일한 토큰으로 분할된다.
공식 tiktoken의 rank 데이터를 그대로 포팅했으므로, 한국어든 일본어든 아랍어든 결과가 정확히 일치한다.
반면 Anthropic의 ~10% 오차는 실제 Claude API가 내부적으로 사용하는 토크나이저와 공개된 데이터 사이의 차이에서 비롯된다.
Anthropic이 공식 토크나이저를 공개하지 않는 이상, 이 오차를 줄이기는 어렵다.
컨텍스트 윈도우와 토큰
토큰을 이야기할 때 빠질 수 없는 개념이 컨텍스트 윈도우(Context Window)다.
컨텍스트 윈도우란
컨텍스트 윈도우란 LLM이 한 번의 요청에서 처리할 수 있는 토큰의 최대 개수를 말한다.
비유하자면, 시험 시간에 펼쳐놓을 수 있는 책상의 크기와 같다.
책상이 넓으면 더 많은 참고 자료를 펼쳐놓고 문제를 풀 수 있지만, 책상 크기에는 한계가 있다.
여기에는 시스템 프롬프트, 사용자 입력, 이전 대화 기록, 그리고 LLM의 응답까지 모두 포함된다.
주요 모델의 컨텍스트 윈도우를 비교하면 다음과 같다.
| 모델 | 컨텍스트 윈도우 (입력) | 최대 출력 토큰 |
|---|---|---|
| GPT-4.1 | 1,047,576 토큰 (1M) | 32,768 |
| GPT-4o | 128,000 토큰 (128K) | 16,384 |
| Claude 4 Opus / Sonnet | 200,000 토큰 (200K) | 64,000 |
| Gemini 2.5 Pro | 1,048,576 토큰 (1M) | 65,536 |
숫자만 보면 넉넉해 보이지만, 실제 사용에서는 생각보다 빨리 소진된다.
입력 토큰과 출력 토큰
API 과금에서 중요한 구분이 입력 토큰(Input Tokens)과 출력 토큰(Output Tokens)이다.
- 입력 토큰: 시스템 프롬프트 + 사용자 메시지 + 이전 대화 기록 + 컨텍스트 데이터
- 출력 토큰: LLM이 생성하는 응답
대부분의 API에서 출력 토큰이 입력 토큰보다 2~4배 비싸다.
예를 들어 OpenAI GPT-4o 기준, 입력 토큰은 100만 토큰당 $2.5이지만 출력 토큰은 $10으로 4배 차이가 난다.
따라서 비용 최적화를 할 때는 출력을 얼마나 간결하게 받느냐도 중요하지만, 매 요청마다 반복 전송되는 입력 토큰(시스템 프롬프트, 대화 기록)을 줄이는 것이 누적 효과가 크다.
컨텍스트 윈도우가 꽉 차면 어떻게 되는가
대화가 이어질수록 이전 대화 기록이 쌓이면서 컨텍스트 윈도우를 차지한다. 10턴 정도만 오가도 수천~수만 토큰이 누적될 수 있다.
시스템 프롬프트도 매 요청마다 함께 전송되므로, 시스템 프롬프트가 길면 매번 고정 비용이 발생한다. RAG로 참고 문서를 컨텍스트에 넣는 경우에는 문서 길이만큼 토큰이 추가로 소비된다.
컨텍스트 윈도우가 꽉 차면 어떻게 될까?
- API 호출 자체가 실패하는 경우: 입력 토큰이 모델의 컨텍스트 윈도우를 초과하면 에러가 반환된다.
- 이전 대화가 잘려나가는 경우: 일부 플랫폼에서는 가장 오래된 대화부터 자동으로 잘라내서(truncation) 컨텍스트 윈도우 안에 맞춘다.
- 응답이 중간에 끊기는 경우: 입력 + 출력의 합이 컨텍스트 윈도우를 초과하면 응답이 도중에 잘린다.
어떤 경우든, 이전에 주었던 지시사항이나 중요한 맥락이 사라지면서 응답 품질이 떨어지는 현상이 발생한다. "아까 말한 것과 다른 답변을 하네"라는 경험이 있다면, 컨텍스트 윈도우 초과가 원인일 가능성이 높다.
토큰 효율이 곧 컨텍스트 효율
결국 토큰을 효율적으로 사용한다는 것은 같은 컨텍스트 윈도우 안에 더 많은 유의미한 정보를 담는다는 의미다. 프롬프트에서 토큰을 아끼면 그만큼 더 긴 대화를 유지하거나, 더 많은 참고 자료를 넣을 수 있다. API 비용 절감도 중요하지만, 컨텍스트 윈도우를 전략적으로 사용하는 것이 응답 품질에 직접적으로 영향을 미친다.
토큰을 아끼는 프롬프트 작성법
토크나이저의 동작 원리를 이해하면, 프롬프트를 쓸 때 토큰을 의식하게 된다. 하지만, 토큰 효율만 따라가다 보면 오히려 프롬프트의 품질이 떨어지는 경우가 있다. 실제 사용에서 참고할 만한 점들을 정리해 본다.
간결하게 쓰는 게 제일 중요하고, 제일 어렵다
"프롬프트를 간결하게 써라"는 조언은 어디서든 볼 수 있다. 그런데 막상 실천하려고 하면 생각보다 쉽지 않다.
의도를 정확히 전달하려고 하면 자연스럽게 문장이 길어지고, 너무 줄이면 LLM이 의도를 못 알아듣는 경우가 생긴다.
[너무 줄인 경우]
"요약해"
→ 무엇을 어떤 기준으로 요약할지 모호함
[적당히 간결한 경우]
"아래 텍스트를 3줄 이내로 요약. 핵심 키워드 포함."
→ 명확하면서도 군더더기 없음
[불필요하게 장황한 경우]
"아래에 제시된 텍스트를 읽고, 중요한 내용을 중심으로 요약을 작성해주세요.
요약은 3줄 이내로 작성해주시고, 요약에는 핵심 키워드를 반드시 포함해주세요."
→ 같은 말을 두 번 하고 있음
좋은 프롬프트란 결국 "한 번 읽었을 때 바로 이해되는 프롬프트" 가 아닐까 싶다. 사람이 읽어도 바로 이해되면 LLM도 잘 이해한다. 불필요한 수식어, 반복, 과도한 존대를 빼는 것만으로도 토큰이 꽤 줄어든다.
무조건 영어로 쓰는 건 답이 아니다
토큰 효율만 따지면 영어가 유리한 건 맞다. 한국어 한 글자가 UTF-8로 3바이트인데 영어는 1바이트니까.
"다음 텍스트를 요약해주세요." → 약 12 토큰 (OpenAI cl100k 기준)
"Summarize the following text." → 약 5 토큰
그래서 "프롬프트는 영어로 쓰고, 응답만 한국어로 받아라"는 조언도 있다.
하지만 실제로 해보면, 한국어 컨텍스트를 다루는 프롬프트를 영어로 쓰면 문맥 흐름이 깨지는 경우가 있다.
예를 들어, 한국어 문서를 분석하는 프롬프트에서 지시사항만 영어로 쓰면
한국어 데이터와 영어 지시사항 사이에서 언어 전환이 반복되면서 LLM의 응답 품질이 미묘하게 떨어지는 경우가 있다.
특히 한국어 특유의 뉘앙스나 맥락이 중요한 작업일수록 이런 현상이 두드러진다.
상황에 따라 나눠서 접근하는 것이 좋다.
- 영어가 유리한 경우: 코드 생성, 구조화된 데이터 변환, 일반적인 지시 (번역, 포맷 변환 등)
- 한국어가 유리한 경우: 한국어 문서 분석, 한국어 뉘앙스가 중요한 작업, 한국어 사용자 대상 콘텐츠 생성
토큰 몇 개 아끼려고 영어로 썼다가 결과물 품질이 떨어지면, 재시도에 더 많은 토큰을 쓰게 된다. 토큰 효율과 응답 품질 사이의 균형이 중요하다.
그런데 모델마다 한국어 효율이 다르다
앞에서 살펴봤듯이, 모든 모델이 한국어에 불리한 것은 아니다. Gemini(Gemma3)나 최신 모델들은 한국어에서도 영어와 비슷한 수준의 토큰 효율을 보이는 경우가 많다.
| 토크나이저 | Vocab 크기 | 한국어 효율 |
|---|---|---|
| OpenAI p50k_base | ~50K | 낮음 (한국어 토큰이 적음) |
| OpenAI cl100k_base | ~100K | 보통 |
| OpenAI o200k_base | ~200K | 양호 |
| Gemini | 262K | 높음 (한국어 토큰이 풍부) |
vocab 크기가 크면 한국어 음절이나 단어가 통째로 하나의 토큰으로 등록될 확률이 높아진다.
Gemini는 262K라는 대규모 vocab을 가지고 있고, 학습 데이터에 다국어(한국어 포함)가 충분히 포함되어 있어서
한국어 텍스트도 적은 토큰으로 처리할 수 있다.
반면 OpenAI의 초기 인코딩인 p50k_base는 vocab이 50K에 불과하고, 영어 중심으로 학습되었기 때문에 한국어 한 글자가 2~3개 토큰으로 쪼개지는 경우가 빈번하다.
결국 "한국어는 토큰을 많이 먹는다"는 말은 어떤 모델의 토크나이저를 쓰느냐에 따라 달라지는 이야기다.
Gemini 계열을 쓰고 있다면 한국어 프롬프트의 토큰 비용을 크게 걱정하지 않아도 된다.
구조화가 토큰도 줄이고 품질도 올린다
여러 방법 중에서 가장 효과가 좋은 건 구조화된 포맷으로 프롬프트를 작성하는 것이다.
산문으로 장황하게 쓰는 것보다 Markdown이나 bullet point로 정리하면 토큰도 줄고 LLM의 이해도도 올라간다.
[비효율적] 산문형
"결과물은 제목이 있어야 하고, 그 아래에 요약이 있어야 하고, 그 다음에 상세 내용이 있어야 합니다.
제목은 20자 이내여야 하고, 요약은 2줄 이내여야 합니다."
[효율적] 구조화
"출력 포맷:
- 제목: 20자 이내
- 요약: 2줄 이내
- 상세 내용"
컨텍스트 데이터 정제가 의외로 효과가 크다
프롬프트 문구를 다듬는 것보다, 컨텍스트에 넣는 데이터를 정제하는 것이 토큰 절약 효과가 훨씬 크다.
RAG로 문서를 검색해서 넣거나, 긴 로그를 분석할 때 원본 그대로 넣으면 토큰 낭비가 심하다.
HTML 태그, CSS 스타일, 반복 헤더, 불필요한 메타데이터를 제거하고 핵심 내용만 넣으면 같은 컨텍스트 윈도우에 더 많은 정보를 담을 수 있다.
[비효율적] HTML 원본
"<div class='container'><h2 style='color:red'>제목</h2><p>내용입니다.</p></div>"
[효율적] 정제
"## 제목\n내용입니다."
시스템 프롬프트도 마찬가지다. 매 요청마다 소비되는 토큰이므로, 장황하게 쓰면 누적 비용이 상당하다. 핵심 규칙만 간결하게 기술하는 습관이 필요하다.
응답 토큰을 줄이는 방법
앞에서 살펴봤듯이 출력 토큰은 입력 토큰보다 2~4배 비싸다.
입력은 직접 통제할 수 있는 반면, 출력은 LLM이 생성하는 것이라 통제가 어렵다고 느낄 수 있다.
실제로는 프롬프트 작성 방식으로 응답 토큰을 상당히 줄일 수 있다.
1. 응답 형식을 명시적으로 제한한다
LLM은 지시하지 않으면 친절하게 서론, 본론, 결론을 갖춘 장문으로 답하려는 경향이 있다.
응답 형식을 구체적으로 지정하면 불필요한 토큰을 크게 줄일 수 있다.
[응답이 길어지는 프롬프트]
"이 코드의 문제점을 분석해주세요."
→ LLM이 배경 설명, 문제 분석, 해결 방안, 추가 권장사항까지 장문으로 응답
[응답을 제한하는 프롬프트]
"이 코드의 문제점을 bullet point로 정리. 각 항목 1줄 이내."
→ 핵심만 간결하게 응답
2. max_tokens 파라미터를 활용한다
API 호출 시 max_tokens를 설정하면 응답 길이에 상한을 둘 수 있다.
응답이 중간에 잘릴 수 있으므로, 프롬프트에서 "간결하게 답하라"는 지시와 함께 사용하는 것이 좋다.
// 응답을 최대 500 토큰으로 제한
const response = await client.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 500,
messages: [{ role: "user", content: "..." }]
});
3. 구조화된 출력 포맷을 지정한다
JSON이나 특정 포맷으로 응답을 요청하면, LLM이 부가 설명 없이 지정된 구조만 출력한다. 산문형 응답 대비 토큰이 크게 줄어든다.
[산문형 응답 유도]
"이 에러 로그를 분석해줘."
→ 에러 배경, 원인 분석, 해결 방법을 장문으로 서술
[구조화된 응답 유도]
"이 에러 로그를 분석해서 아래 JSON 포맷으로 응답.
{ \"error\": \"에러 요약\", \"cause\": \"원인\", \"fix\": \"해결 방법\" }"
→ 지정된 필드만 채워서 응답
4. "설명하지 마라"고 명시한다
의외로 효과가 좋은 방법이다. LLM은 기본적으로 답변에 대한 맥락 설명을 붙이려는 경향이 있는데, "설명 없이 결과만", "부가 설명 불필요", "코드만 출력" 같은 지시를 추가하면 응답이 훨씬 짧아진다.
실제로 응답 토큰을 줄이는 것이 입력 토큰을 줄이는 것보다 비용 절감 효과가 클 때가 많다. 입력에서 토큰 몇 개 아끼는 것보다, 장문 응답을 간결한 응답으로 바꾸는 것이 토큰 수백~수천 개 차이를 만든다.
결국은 균형의 문제
토크나이저를 들여다보고 나면 토큰 수에 민감해지는데, 그렇다고 토큰 수만 최적화하면 안 된다.
프롬프트를 작성할 때 다음과 같은 우선순위를 두면 좋다.
- 명확성 - LLM이 의도를 정확히 이해할 수 있는가
- 간결성 - 같은 의미를 더 적은 문장으로 전달할 수 있는가
- 토큰 효율 - 언어 선택, 포맷, 데이터 정제로 토큰을 줄일 수 있는가
토큰을 아끼겠다고 프롬프트를 너무 줄이면 의도가 모호해지고, 의도를 명확히 하겠다고 장황하게 쓰면 토큰이 낭비된다. 이 균형을 찾는 감각은 결국 여러 번 써보면서 체득하는 수밖에 없다.
마무리
관심이 생겨서 토크나이저 코드를 뜯어보고, AI Agent의 도움을 받아 돌려보면서 "텍스트가 어떻게 숫자로 변환되는가"에 대한 이해가 훨씬 깊어졌다.
글의 처음에 던졌던 질문으로 돌아가 보자.
"Hello"는 1토큰인데, "안녕하세요"는 왜 여러 토큰으로 쪼개질까?
이제 어느 정도는 답할 수 있을 것 같다.
BPE는 학습 데이터에서 자주 등장하는 바이트 조합을 우선적으로 병합한다.
영어 중심으로 학습된 토크나이저(예: OpenAI p50k)에서는 "Hello"가 통째로 vocab에 등록되어 있지만,
한국어 "안녕하세요"는 각 음절이 UTF-8로 3바이트씩이고, 충분히 학습되지 않았기 때문에 여러 조각으로 쪼개진다.
하지만 Gemini처럼 vocab 크기가 262K로 거대하고 다국어 데이터를 충분히 학습한 토크나이저에서는
"안녕하세요" 같은 한국어 문자열도 더 큰 단위로 병합될 확률이 높아져, 토큰 효율이 크게 개선된다.
결국 "한국어가 토큰을 많이 먹는다"는 토크나이저의 설계와 학습 데이터에 따라 달라지는 이야기인 것이다.
같은 "BPE"라 해도 전처리, 바이트 매핑, merge 방식의 차이로 인해 각 프로바이더의 토크나이저는 꽤 다르게 동작한다.
LLM 비용 산정이나 프롬프트 최적화를 고민하고 있다면, 토크나이저 레벨까지 내려가 보는 것을 권한다.
그리고 이번에 가장 크게 느낀 건, 결국 기본기가 중요하다는 것이다.
LLM 토크나이저라는 최신 기술의 속을 열어보니,
1994년 압축 알고리즘(BPE), 1993년 유니코드 인코딩(UTF-8), 1950년대 오토마타 이론(정규식), 해시맵 같은 자료구조 기초가 그대로 들어있었다.
새로운 기술은 결국 이미 있던 근본적인 개념들의 조합이고, 그 근본을 알면 새로운 것이 나와도 본질을 빠르게 꿰뚫을 수 있다.
관심이 생겨서 가볍게 코드를 뜯어본 것뿐인데, 돌고 돌아 기본기의 중요성을 다시 확인하게 되었다.
📚 Reference
공식 문서
- OpenAI Tokenizer - 텍스트가 토큰으로 분할되는 과정을 시각화하는 공식 웹 도구
- OpenAI Token Counting Guide - API에서 토큰 수를 계산하는 방법 가이드
- OpenAI Models - 모델별 컨텍스트 윈도우, 최대 출력 토큰, 가격 비교
- Anthropic Token Counting API - Claude의 count_tokens 엔드포인트 레퍼런스
- Anthropic Context Windows - Claude의 컨텍스트 윈도우 개념과 활용
- Google Gemini Tokens Guide - Gemini API의 토큰 이해 및 카운팅 가이드
- Google Gemini Models - Gemini 모델별 사양 및 컨텍스트 윈도우
토크나이저 구현 참고
- tiktoken (GitHub) - OpenAI 공식 BPE 토크나이저 라이브러리
- eottabom/toolkit - tokenizer-core - 이 글에서 다룬 오프라인 토크나이저 예제 코드
- HuggingFace Tokenizers (GitHub) - Rust 기반 고속 토크나이저 라이브러리
- HuggingFace Tokenizer Summary - BPE, WordPiece, Unigram 등 토크나이저 알고리즘 요약
- SentencePiece (GitHub) - Google의 비지도 학습 기반 토크나이저
- Xenova/claude-tokenizer (HuggingFace) - Claude 토크나이저 데이터 (비공식)
- unsloth/gemma-3-1b-it (HuggingFace) - Gemma3 모델 및 토크나이저 데이터
논문