지난 글에서 MoE가 희소 활성화로 거대 모델을 효율적으로 만드는 방법을 살펴봤다. 이제 시선을 바꿔, LLM에 텍스트가 어떻게 입력되는지 가장 근본적인 질문부터 시작한다. LLM은 문자열을 직접 처리하지 않는다. 텍스트를 정수 ID의 시퀀스로 바꾼 뒤에야 임베딩 층을 거쳐 모델에 들어간다. 이 변환을 맡는 것이 토크나이저다.
토크나이저는 모델 바깥의 전처리처럼 보이지만, 실제로는 모델의 일부처럼 행동한다. 요금이 토큰으로 매겨지고, 문맥 창의 크기도 토큰으로 정해지며, 모델이 숫자를 잘 못 세거나 한 글자가 스트리밍 중에 깨져 나오는 것도 토큰 경계에서 생긴다. 이 글은 토큰이 무엇인지에서 출발해, 그 단위가 돈과 버그와 보안에 어떻게 이어지는지를 실제로 세어 본 값으로 따라간다. 토크나이저가 어휘를 어떻게 만드는지는 다음 글의 몫이다.
분할 단위
문자와 단어
텍스트를 숫자로 바꾸려면 먼저 어디서 자를지 정해야 한다. 초기 NLP 모델은 두 극단 중 하나를 골랐다. 글자 하나를 한 단위로 삼으면 알파벳 언어의 어휘는 100개 남짓으로 끝나지만, 문장 하나가 수백 단위로 길어진다. 어텐션 비용이 길이의 제곱으로 늘어나는 트랜스포머에게는 치명적이고, 모델이 글자를 모아 낱말을 만드는 일부터 배워야 해서 학습도 느리다.
단어 하나를 한 단위로 삼으면 문장은 짧아지지만 어휘가 수십만 개로 불어난다. 더 큰 문제는 OOV(Out-of-Vocabulary), 곧 어휘에 없는 낱말이다. 학습 때 못 본 신조어, 오타, 제품 이름이 들어오면 모두 「모르는 단어」 한 칸으로 뭉개진다. 한국어처럼 조사와 어미가 붙어 형태가 끝없이 바뀌는 언어에서는 이 문제가 훨씬 심하다. 「먹었다」·「먹었는데」·「먹었으니」가 전부 다른 단어가 되기 때문이다.
서브워드
오늘날 LLM이 쓰는 서브워드 토크나이저는 이 둘 사이를 고른다. 자주 나오는 글자 조합은 통째로 한 토큰으로 두고, 드문 낱말은 더 작은 조각으로 쪼갠다. 영어의 「the」·「ing」처럼 흔한 것은 한 토큰이 되고, 「categorization」처럼 드문 것은 「c + ategor + ization」처럼 여러 조각이 된다.
요즘 토크나이저는 여기에 한 가지를 더한다. 글자가 아니라 바이트에서 출발하는 것이다. 모든 텍스트는 UTF-8 바이트의 나열이므로, 바이트 256종을 기본 어휘로 깔아 두면 어떤 언어·기호·이모지가 와도 최소한 바이트 조각으로는 표현된다. OOV가 원리적으로 사라지는 대신, 어휘에 없는 글자는 바이트 단위까지 잘게 쪼개진다는 값을 치른다. 뒤에서 볼 한국어 토큰 효율 문제와 스트리밍 중 깨지는 글자가 모두 이 선택에서 나온다.
토큰과 어휘
토큰
토큰은 토크나이저가 자른 조각 하나이고, 모델이 보는 최소 단위다. 영어에서는 흔히 「토큰 하나가 대략 단어 0.75개」라고 어림하지만, 이는 영어 산문의 평균일 뿐이다. 긴 전문 용어, 코드, 숫자, 그리고 영어가 아닌 언어는 이 어림에서 크게 벗어난다.
아래는 GPT-4가 쓰는 cl100k 토크나이저로 실제로 나눈 결과다. 공식 tiktoken 대신 Hugging Face에 올라와 있는 호환 토크나이저로 셌다.
import tiktoken
enc = tiktoken.get_encoding("cl100k_base") # GPT-4 기준
print(enc.encode("cat")) # [4719] — 1토큰
print(enc.encode("category")) # [5588] — 1토큰
print(enc.encode("categorization")) # [66, 7747, 2065] — c|ategor|ization
print(enc.encode("def forward(self):")) # 4토큰 — def|·forward|(self|):
print(enc.encode("2024")) # [2366, 19] — 202|4
print(enc.encode("12345")) # [4513, 1774] — 123|45
토큰 하나에는 정수 ID가 하나 붙는다. 이 ID가 모델로 들어가는 실제 입력이고, 모델은 임베딩 행렬에서 그 번호의 줄을 꺼내 벡터로 바꾼다. 토크나이저와 임베딩 행렬이 같은 번호표를 공유하는 셈이라, 둘 중 하나만 바꾸면 번호가 엉뚱한 줄을 가리키게 된다.
위 결과에서 눈여겨볼 것이 둘 있다. 「(self」가 괄호와 낱말이 붙은 채 한 토큰이라는 점은, 토크나이저가 파이썬 코드를 대량으로 보고 만들어졌다는 흔적이다. 코드에서 자주 나오는 덩어리는 사람이 보기에 어색한 경계라도 통째로 어휘에 들어간다. 그리고 「category」는 한 토큰인데 「categorization」은 「c」에서 끊긴다. 흔한 낱말의 앞머리가 오히려 쪼개지는 이 모양은, 어휘가 뜻이 아니라 빈도로 만들어진다는 것을 보여 준다.
어휘 크기와 파라미터
어휘는 토크나이저가 아는 토큰 전체의 집합이고, 그 수가 어휘 크기다.
| 모델 | 어휘 크기 | 토크나이저 |
|---|---|---|
| GPT-2 | 50,257 | 바이트 BPE |
| LLaMA 2 | 32,000 | SentencePiece BPE |
| GPT-4 (cl100k) | 100,277 | tiktoken BPE |
| LLaMA 3 | 128,256 | tiktoken BPE |
| GPT-4o (o200k) | 200,019 | tiktoken BPE |
| EXAONE (LG) | 102,400 | BPE |
어휘가 크면 같은 문장이 더 적은 토큰으로 표현된다. 대신 모델의 입구와 출구가 무거워진다. 입구의 임베딩 행렬과 출구에서 다음 토큰 확률을 내는 출력 행렬이 둘 다 「어휘 크기 × 은닉 차원」이기 때문이다. LLaMA 3 8B는 은닉 차원이 4,096이고 두 행렬을 따로 두므로, 128,256 × 4,096 × 2 ≈ 10억 5천만 개가 어휘에 쓰인다. 전체 80억 개의 13%다. 같은 계산을 LLaMA 2 7B에 하면 어휘 32,000개로 약 2억 6천만 개, 전체의 4%다.
작은 모델일수록 이 비중이 커진다. 은닉 차원이 작아도 어휘는 줄이지 않기 때문이다. 어휘가 25만 개가 넘는 Gemma 2 2B는 두 행렬을 하나로 묶어 쓰는데도 어휘 몫이 전체의 20%를 넘는다. 거꾸로 70B 같은 큰 모델에서는 어휘 몫이 3% 안팎으로 작아진다. 어휘를 넓히는 결정이 작은 모델에서 더 비싼 이유다. 출력 쪽에서는 매 토큰 어휘 전체에 대해 확률을 계산해야 하므로, 어휘가 두 배가 되면 이 마지막 층의 계산도 두 배가 된다.
토큰 경제
요금과 문맥 창
토큰은 LLM의 회계 단위다. 대부분의 API는 입력 토큰과 출력 토큰에 따로 단가를 매겨 100만 토큰당 얼마로 받는다. 문맥 창도 토큰으로 정해진다. 「128K 문맥」은 입력과 출력을 합쳐 토큰 12만 8천 개까지라는 뜻이지, 글자 수나 단어 수가 아니다. 속도도 초당 토큰 수로 잰다.
그래서 같은 내용을 더 적은 토큰으로 표현하면 세 가지가 동시에 좋아진다. 돈이 덜 들고, 긴 문서를 더 많이 넣을 수 있고, 답이 빨리 나온다. 반대로 한 언어가 다른 언어보다 토큰을 두 배 쓰면, 그 언어 사용자는 같은 서비스를 두 배 가격에, 절반의 문맥으로, 절반의 속도로 쓰는 셈이다. 이 차이가 모델 선택의 기준이 될 만큼 크다는 것을 다음 소절에서 직접 잰다.
어림을 하나 가지고 있으면 편하다. 아래 소절에서 잰 문장들로 보면 한글 한 글자(공백 제외)가 cl100k에서는 토큰 1.21.9개, o200k에서는 0.60.9개쯤이다. 1만 자짜리 한국어 보고서라면 cl100k로 1만 2천1만 9천 토큰, o200k로 6천9천 토큰이다. 같은 문서를 요약시키는 데 드는 입력 비용이 토크나이저 하나로 두 배 가까이 갈리고, 128K 문맥 창에 한 번에 넣을 수 있는 보고서 수도 그만큼 갈린다.
한국어 토큰 효율
한국어는 영어 위주로 만들어진 토크나이저에서 손해를 본다. 한글 한 글자는 UTF-8로 3바이트인데, 어휘에 그 음절이 통째로 들어 있지 않으면 바이트 조각 두세 개로 쪼개진다. 같은 뜻의 문장 세 쌍을 cl100k와 o200k로 세어 봤다.
| 문장 | cl100k | o200k |
|---|---|---|
| The weather is really nice today. | 7 | 7 |
| 오늘 날씨가 정말 좋네요. | 18 | 8 |
| The capital of South Korea is Seoul. | 8 | 8 |
| 대한민국의 수도는 서울입니다. | 15 | 8 |
| Measure Korean token efficiency directly. | 6 | 6 |
| 한국어 토큰 효율을 직접 재 본다. | 25 | 12 |
cl100k에서 한국어 문장은 영어의 1.94.2배 토큰을 썼다. 어휘를 두 배로 넓힌 o200k에서는 1.02.0배로 줄었다. 「대한민국의 수도는 서울입니다」는 cl100k에서 「대·한·(바이트)·(바이트)…」로 부서졌지만, o200k에서는 「대한·민국·의·수도·는·서울·입니다」로 거의 낱말 단위로 잘렸다. 같은 회사의 토크나이저라도 세대에 따라 한국어 비용이 절반으로 바뀐 것이다. 영어 문장은 두 토크나이저에서 토큰 수가 똑같았다는 점도 함께 봐 둘 만하다 — 이 표본에서는 어휘를 넓혀 얻은 이득이 전부 영어 밖의 언어로 갔다.
이 표에서 가져갈 것은 특정 배수가 아니라 방법이다. 문장 몇 개로 잰 값은 문체에 따라 크게 흔들리므로, 모델을 고를 때는 자기 서비스의 실제 입력 수백 건을 후보 모델의 토크나이저로 세어 평균을 낸다. 단가표의 숫자가 같아도 토큰 수가 두 배면 실제 비용은 두 배다. HyperCLOVA X, EXAONE 같은 한국어 중심 모델이 어휘에 한국어 음절과 형태소를 넉넉히 넣는 것도 이 비용을 줄이기 위해서다.
경계가 만드는 버그
숫자 분해
토큰 경계는 사람이 보기에 자연스러운 곳에 그어지지 않는다. 숫자가 대표적이다. cl100k는 숫자를 왼쪽부터 세 자리씩 끊는다. 「2024」는 「202|4」가 되고, 「1234567」은 「123|456|7」이 된다. 세 자리 묶음이 오른쪽 끝, 곧 일의 자리 기준이 아니라 왼쪽 기준이라서 같은 자리의 숫자가 수마다 다른 토큰 위치에 놓인다.
덧셈을 생각해 보면 문제가 드러난다. 사람은 일의 자리끼리 맞춰 더하는데, 모델에게 「12345」와 「678」은 「123|45」와 「678」이라 끝자리가 서로 다른 토큰 안에 들어 있다. 자리 맞춤부터 스스로 배워야 하는 것이다. GPT-2의 토크나이저는 이보다 더 불규칙했다. 숫자에 자릿수 규칙 없이 BPE가 합친 대로 두어서, 「2019」는 한 토큰인데 「2023」은 「20|23」, 「8231」은 「8|231」로 갈렸다. 학습 데이터에 자주 나온 연도는 통째로 어휘에 들어가고 나머지는 제각각 쪼개진 것이다. 최근 모델 중 일부는 숫자를 한 자리씩 끊는 쪽을 택했다. 토큰은 늘지만 자리가 일정해진다.
글자 세기
「strawberry에 r이 몇 개인가」는 한동안 LLM의 대표적 실패 사례였다. 이유는 토큰을 보면 바로 보인다. cl100k에서 이 낱말은 「str|aw|berry」 세 토큰이다. 모델은 「berry」라는 토큰 하나를 벡터 하나로 받을 뿐, 그 안에 r이 두 개 있다는 사실을 직접 볼 수 없다. 철자 정보는 학습 중에 간접적으로만 배운다.
한국어에서는 이 문제가 다른 모양으로 나온다. 「딸기」는 두 토크나이저 모두에서 세 토큰인데, 「딸」이라는 글자가 바이트 조각 두 개로 갈라진다. 글자 수를 세거나, 받침을 바꾸거나, 초성만 뽑는 일처럼 글자 안쪽을 다루는 작업이 모델에게 유난히 어려운 까닭이다. 이런 작업이 필요하면 모델에게 시키지 말고 코드로 처리하거나, 모델이 코드를 실행하는 도구를 쓰게 하는 편이 확실하다.
스트리밍 중 깨진 글자
바이트 단위 토크나이저에서는 토큰 경계가 글자 한가운데를 지날 수 있다. cl100k로 「안녕하세요」를 나누면 다섯 토큰인데, 「안」의 3바이트가 첫 토큰에 둘, 둘째 토큰에 하나로 갈린다.
답을 한 토큰씩 받아 화면에 바로 찍는 스트리밍 코드가 토큰마다 디코딩하면, 첫 토큰은 완성되지 않은 바이트라 깨진 문자(�)로 찍힌다. 대부분의 SDK와 서빙 엔진은 이를 막으려고 바이트를 쌓아 두었다가 완성된 글자가 되었을 때만 내보낸다. 직접 스트리밍을 구현할 때도 같은 원리로, 바이트 버퍼를 두고 UTF-8 디코더가 끝난 글자만 넘겨주게 한다. 영어로만 시험하면 이 버그가 절대 안 보이고, 한국어 사용자가 붙는 날 처음 드러난다.
공백
공백도 토큰의 일부다. cl100k에서 「Hello」와 앞에 공백이 붙은 「 Hello」는 서로 다른 토큰(9906과 22691)이다. 프롬프트를 공백으로 끝내면 모델은 「공백 다음에 올 토큰」을 골라야 하는데, 학습 데이터에서 공백은 대개 다음 낱말 토큰에 붙어 있었으므로 어색한 분포에 놓인다. 프롬프트 끝의 불필요한 공백이 답의 품질을 떨어뜨리는 경우가 있는 까닭이다. 코드에서는 반대로 공백이 절약의 대상이다. GPT-2 토크나이저는 들여쓰기 공백을 한 칸에 한 토큰씩 썼지만, cl100k부터는 연속된 공백 여러 칸을 한 토큰으로 묶어 파이썬 코드의 토큰 수가 크게 줄었다.
특수 토큰
채팅 템플릿
토크나이저에는 일반 텍스트와 별개로, 구조를 알리는 특수 토큰이 있다. 문장의 시작과 끝, 대화에서 역할이 바뀌는 자리, 한 턴이 끝나는 자리를 표시한다.
# LLaMA 3 특수 토큰 예시
tokenizer.bos_token # "<|begin_of_text|>" — 문장 시작
tokenizer.eos_token # "<|end_of_text|>" — 문장 끝
tokenizer.pad_token # None — 배치 패딩용은 직접 정한다
# 채팅 템플릿 토큰
"<|start_header_id|>" # 역할 헤더 시작
"<|end_header_id|>" # 역할 헤더 끝
"<|eot_id|>" # 턴 종료
대화형 모델은 시스템 지시, 사용자 발화, 모델 답을 이 토큰들로 감싼 한 줄의 텍스트로 받는다. 이 감싸는 규칙을 채팅 템플릿이라 부르며, 모델마다 다르다. 학습 때 쓴 템플릿과 추론 때 쓴 템플릿이 조금만 달라도 모델은 역할을 헷갈리거나 답을 끝내지 못한다. 턴 종료 토큰을 멈춤 조건으로 등록하지 않으면 모델이 사용자 역할까지 스스로 지어내며 대화를 이어 쓰는 사고가 흔히 난다. 그래서 템플릿은 손으로 짜지 말고 토크나이저에 딸려 오는 것을 그대로 쓴다. 손으로 짜지 않아도 겹치는 사고는 남는다. 템플릿을 적용해 이미 시작 토큰이 붙은 문자열을 다시 토크나이저에 넣으면, 토크나이저가 기본값으로 시작 토큰을 한 번 더 붙여 문장 시작이 두 번 들어간다. 모델은 대개 그럭저럭 답하지만 품질이 미묘하게 떨어져 원인을 찾기 어렵다. 템플릿이 만든 문자열을 토큰으로 바꿀 때는 특수 토큰 자동 추가를 끄고, 첫 몇 개 토큰 ID를 찍어 확인하는 습관이 가장 싸다.
특수 토큰 주입
특수 토큰은 보안 문제이기도 하다. 사용자가 입력창에 <|eot_id|> 같은 문자열을 써 넣었을 때, 이것이 일반 텍스트로 쪼개지지 않고 진짜 특수 토큰으로 바뀌면 사용자가 턴 경계를 위조할 수 있다. 자기 턴을 닫고 시스템 역할 머리를 열어 가짜 지시를 끼워 넣는 식이다.
그래서 특수 토큰은 텍스트에서 만들어지지 않게 막는 것이 원칙이다. tiktoken은 기본값으로 입력에 특수 토큰 문자열이 있으면 오류를 내고, 허용하려면 따로 인자를 줘야 한다. Hugging Face 토크나이저는 기본값이 반대라서, 텍스트 속 특수 토큰 문자열을 특수 토큰으로 바꾼다. 사용자 입력을 넣을 때는 특수 토큰을 쪼개도록 하는 옵션을 켜거나, 입력에서 그 문자열을 걸러 낸 뒤 템플릿에 넣는다. 이는 프롬프트 인젝션을 막는 여러 겹 중 가장 아래 겹이다 — 이 겹이 뚫리면 위의 어떤 지시문 방어도 소용이 없다.
토크나이저 교체
어휘 확장
영어 중심 모델을 한국어에 쓰려고 토큰 효율을 올리고 싶을 때, 어휘에 한국어 토큰을 더하는 어휘 확장을 고려하게 된다. 한국어 말뭉치에서 자주 나오는 음절과 형태소를 골라 새 토큰으로 추가하고, 임베딩 행렬과 출력 행렬에 그만큼 줄을 늘린다.
문제는 새 줄의 값이다. 모델은 이 토큰을 본 적이 없으므로 새 줄은 비어 있다. 무작위로 채우면 모델이 그 토큰을 쓰레기로 취급한다. 흔한 방법은 새 토큰이 원래 토크나이저로 쪼개졌을 때의 조각들, 곧 바이트 토큰들의 임베딩 평균으로 초기화하는 것이다. 그러면 새 토큰이 적어도 「원래 이렇게 읽히던 것」과 비슷한 곳에서 출발한다. 그다음 한국어 말뭉치로 추가 사전학습을 해서 새 줄들을 제자리에 앉힌다. 이 추가 학습이 적게 잡아도 수십억 토큰 규모라서, 어휘 확장은 파인튜닝 한 번이 아니라 사전학습을 조금 더 하는 일에 가깝다.
그래서 이득과 비용을 견줘 보고 정한다. 한국어 토큰이 절반으로 줄면 같은 문맥 창에 두 배를 넣고 생성도 빨라지지만, 앞에서 본 것처럼 어휘가 는 만큼 입출력 행렬이 커지고 추가 학습 비용이 든다. 추가 학습이 모자라면 새 토큰 주변에서 문장이 어색해지고, 영어 능력이 조금씩 깎이는 일도 흔하다. 요즘은 처음부터 다국어 어휘가 넉넉한 모델이 많아, 어휘 확장보다 그런 모델을 고르는 편이 싼 경우가 많다.
깨지는 것들
토크나이저를 바꾸면 그 위에 쌓인 것들이 함께 흔들린다. 먼저 번호표가 바뀌므로, 이전 모델용으로 만든 LoRA 어댑터처럼 토큰 번호에 묶인 산출물을 그대로 쓸 수 없다. 특정 토큰 ID의 확률을 올리거나 내리는 설정, ID로 적어 둔 멈춤 조건도 다시 확인해야 한다.
운영 쪽에서도 깨진다. 토큰 수로 잡아 둔 문맥 예산, 요청당 비용 추정, 문서를 자르는 청크 크기가 모두 토크나이저에 따라 달라진다. 같은 문서가 새 토크나이저에서 30% 적은 토큰이 되면 청크당 담기는 내용이 달라져 검색 결과까지 바뀐다. 프롬프트 캐시처럼 토큰 열이 같아야 적중하는 기능도 처음부터 다시 쌓인다. 모델을 새 세대로 올릴 때 토크나이저가 함께 바뀌었는지부터 확인하는 것이 좋은 습관인 까닭이다. 다음 글에서는 가장 널리 쓰이는 서브워드 알고리즘인 BPE와 WordPiece가 어휘를 어떻게 만드는지 살펴본다.
읽어주셔서 감사합니다. 😊

