제1부 기반: 언어모델과 트랜스포머
1.2 토큰화와 임베딩
모델은 글자를 읽지 않는다. 최신 모델조차 "strawberry에 r이 몇 개냐"를 곧잘 틀리는 것은 멍청해서가 아니다. 그 단어가 모델 눈에는 실제 글자 s·t·r·a·w·b·e·r·r·y로 들어오는 것이 아니라 st·raw·berry 같은 몇 개의 정수 묶음으로 들어오기 때문이다 — 묶음 안에 r이 몇 개 포함되어 있는지는 모델이 직접 셀 수 없는, 이미 봉인된 정보다. 텍스트가 신경망에 닿기 전, 아무도 거들떠보지 않는 한 부품이 문장 전체를 정수 열로 잘라 버린다. 그리고 그 부품에 내재된 설계 결정이 당신의 청구서, 유효 컨텍스트, 다국어 성능, 산술 능력을 조용히 좌우한다.
관문은 둘이다. 토큰화(tokenization) — 텍스트를 정수 ID 열로 자른다. 임베딩(embedding) — 각 ID를 벡터로 바꿔 신경망에 넣는다. 1.1에서 언어모델을 "다음 토큰을 예측하는 확률 기계"라 불렀는데, 그 토큰이 정확히 무엇이고 어떻게 만들어지는지, 그리고 정수가 어떻게 벡터가 되는지가 이 장의 전부다. 눈에 안 보이는 층이라 문제도 조용히 만든다. 한국어 서비스가 같은 기능에 영어보다 몇 배 비용이 큰 이유, 128k 컨텍스트라는데 실제로는 그만큼 안 담기는 이유, 회계 숫자를 자꾸 틀리는 이유 — 이 세 가지가 전부 여기서 갈라져 나온다.
전체 전처리 파이프라인은 이렇다. 이 장은 왼쪽 네 단계(텍스트→ID→임베딩)를 다루고, 오른쪽 트랜스포머는 1.4이 맡는다.
여기 나오는 어휘 크기·토큰 수·모델별 스펙은 버전과 시점에 따라 달라진다. 구체 수치는 개략 규모를 보여 주기 위한 예시일 뿐이고, 무게는 원리와 방향에 있다. 정확한 값은 예외 없이 그 모델의 실제 토크나이저로 직접 세어야 한다 — 이 문장은 이 장에서 여러 번 되풀이될 것이다.
1.2.1 왜 문자도 단어도 아닌가
토큰화 설계에는 긴장이 하나뿐이다. 어휘 크기 대 시퀀스 길이. 텍스트를 잘게 자를수록 종류(어휘)는 줄지만 조각의 개수(시퀀스 길이)가 늘고, 크게 자를수록 그 반대다. 이 절충의 양 끝을 보면 왜 중간 어딘가가 답인지가 드러난다.
한쪽 끝은 문자 또는 바이트 단위다. 어휘가 우습게 작다 — 영문 알파벳 수십 개, 유니코드 바이트라면 256개. "처음 보는 단어"라는 개념 자체가 없으니 미등록어(out-of-vocabulary, OOV)가 원천적으로 발생하지 않는다. 대신 같은 문장이 몹시 긴 토큰 열이 된다. 영어 단어의 평균 길이가 대략 4~5글자이니, 문자 단위로 자르면 시퀀스가 단어 단위보다 4~5배 길어진다. 이것이 왜 치명적인가. 셀프어텐션 비용이 시퀀스 길이의 제곱에 비례하기 때문이다(1.4). 시퀀스가 5배 길어지면 어텐션 연산은 $5^2 = 25$배로 증가한다. 게다가 모델은 "un", "believ", "able" 같은 의미 단위를 글자로부터 스스로 조립하는 데 표현력을 낭비한다 — 배우기도 전에 철자법부터 익혀야 하는 셈이다.
반대쪽 끝은 단어 단위다. 시퀀스는 짧다(단어 수 ≈ 토큰 수). 대신 어휘가 폭발한다. 자연어 어휘는 사실상 무한에 가깝다 — 고유명사, 신조어, 오타, 합성어가 끝없이 생긴다. 특히 한국어처럼 교착어·굴절어는 치명적 문제다. "가다"라는 동사 하나가 "간다, 갔다, 가겠다, 가더라, 가시는, 가셨겠더라고요"로 어미가 붙어 수십·수백 형태로 불어나고, 여기에 조사("책이, 책을, 책에게, 책으로부터")까지 곱해지면 한 어간에서 나오는 표면형이 기하급수로 늘어난다. 어휘를 아무리 키워도 학습 때 못 본 형태는 <UNK>(unknown)라는 쓰레기통 토큰으로 구분되지 않는다 — "가셨겠더라고요"를 못 봤으면 단순히 "모르는 단어"가 되어 버리고, "가다"와의 연결이 끊긴다. 그리고 어휘 크기 $V$는 임베딩·출력층 파라미터 전체를 $V \times d$만큼 늘린다(1.2.6에서 계산한다).
$$ \underbrace{\text{어휘 } V\ \downarrow,\quad \text{시퀀스 } n\ \uparrow}_{\text{문자·바이트}} \qquad\Longleftrightarrow\qquad \underbrace{\text{어휘 } V\ \uparrow,\quad \text{시퀀스 } n\ \downarrow}_{\text{단어}} $$
서브워드(subword)는 이 스펙트럼의 중간을 데이터로 학습해서 포착한다. 규칙은 한 줄이다 — 자주 나오는 묶음은 하나의 토큰으로 크게, 드문 것은 잘게 분할한다. 그래서 흔한 단어("the", "안녕")는 1토큰으로 짧게 표현되고, 희귀·신조어("antidisestablishmentarianism", "블록체인화")는 아는 조각들로 분해되어 OOV 없이 표현된다. "tokenization"을 학습에서 한 번도 못 봤어도 "token" + "ization"으로 조립할 수 있다는 것 — 이 일반화가 서브워드의 핵심 미덕이다. 어휘의 크기를 감당 가능한 수만~수십만 수준으로 묶으면서도, 표현 못 하는 문자열이 없다.
| 단위 | 어휘 크기 | 시퀀스 길이 | OOV | 형태 일반화 | 실무 |
|---|---|---|---|---|---|
| 문자·바이트 | 매우 작음(수십~256) | 매우 김 | 없음 | 모델이 스스로 조립(비효율) | 특수 목적·바이트 폴백 |
| 서브워드 | 중간(수만~수십만) | 중간 | 사실상 없음 | 조각 재조합으로 강함 | 현대 LLM 표준 |
| 단어 | 매우 큼(수십만+) | 짧음 | 심각(<UNK>) | 약함(형태마다 별개) | 구식·특수 용도 |
한 가지 착각을 여기서 깨자. "토큰 = 단어"가 아니다. 토큰은 흔하면 단어보다 크게(구·접미사까지 흡수하고), 드물면 단어보다 잘게 분할되는 통계적 조각이다. 영어 기준으로 "1토큰 ≈ 0.75단어 ≈ 4문자"라는 경험칙이 통용되지만, 이것은 영어와 특정 토크나이저에서 나온 개략치일 뿐이다. 언어·도메인마다 크게 다르고, 뒤에서 보듯 한국어는 이 비율이 훨씬 나쁘다. 예산을 이 경험칙으로 설정하지 말고 실제 토크나이저로 세라.
1.2.2 BPE — 최빈 쌍을 반복 병합해 어휘를 키운다
BPE(Byte-Pair Encoding)는 원래 데이터 압축 기법이다(Gage, 1994). 가장 자주 붙어 나오는 인접 바이트 쌍을 새 심볼로 치환하기를 반복해 데이터를 줄인다. Sennrich 등(2016)이 이것을 서브워드 분절에 가져왔고, 지금도 GPT 계열·Llama 계열을 비롯한 대다수 LLM 토크나이저의 골격이다. 작동은 두 단계로 나뉜다 — 학습(코퍼스로 어휘와 병합 규칙을 만든다)과 적용(그 규칙으로 새 문장을 자른다). 이번 절은 학습, 다음 절은 적용이다.
작은 코퍼스로 직접 실행해 보자. 단어별 빈도가 이미 집계돼 있다고 하고, 각 단어를 문자 + 단어끝 표시 </w>로 펼친다. 단어끝 표시를 붙이는 이유는 "est"가 단어 끝에 온 것(newest)과 중간에 온 것을 구별하고, 병합이 단어 경계를 넘어 병합하지 못하게 막기 위해서다.
| 펼친 형태 | 빈도 |
|---|---|
l o w </w> | 5 |
l o w e r </w> | 2 |
n e w e s t </w> | 6 |
w i d e s t </w> | 3 |
이제 인접 쌍의 빈도를 코퍼스 전체에서(단어 빈도로 가중해서) 센다. 1회차의 계산을 하나도 빼놓지 않고 전부 적어 보면 이렇다.
| 인접 쌍 | 어디서 (단어 빈도) | 합계 |
|---|---|---|
l o | low(5) + lower(2) | 7 |
o w | low(5) + lower(2) | 7 |
w </w> | low(5) | 5 |
w e | lower(2) | 2 |
e r | lower(2) | 2 |
r </w> | lower(2) | 2 |
n e | newest(6) | 6 |
e w | newest(6) | 6 |
e s | newest(6) + widest(3) | 9 |
s t | newest(6) + widest(3) | 9 |
t </w> | newest(6) + widest(3) | 9 |
w i | widest(3) | 3 |
i d | widest(3) | 3 |
d e | widest(3) | 3 |
최빈은 e s, s t, t </w>가 나란히 9로 동률이다. 동률은 정해 둔 규칙으로 해소한다(여기서는 사전순 등 결정적 규칙으로 e s를 택했다고 하자 — 중요한 건 구현이 매번 같은 선택을 하도록 규칙이 고정돼 있다는 점이다). e s를 하나의 새 토큰 es로 병합하면 newest는 n e w es t </w>, widest는 w i d es t </w>가 된다.
이제 이것이 왜 연쇄하는지가 보인다. es가 생기자 다음 회차에는 es t가 9번(newest 6 + widest 3)으로 최빈이 되고, 그것을 병합하면 est가 생겨 그다음엔 est </w>가 9번으로 최빈이 된다. 흔한 접미사가 이렇게 문자에서 시작해 한 조각씩 자라 오른다. 처음 다섯 병합을 순서대로 따라가면:
| 회차 | 최빈 쌍 (빈도) | 새 토큰 | 영향받는 단어 |
|---|---|---|---|
| 1 | e s (9) | es | newest, widest |
| 2 | es t (9) | est | newest, widest |
| 3 | est </w> (9) | est</w> | newest, widest |
| 4 | l o (7) | lo | low, lower |
| 5 | lo w (7) | low | low, lower |
3회차가 끝난 상태를 다시 세어 보면(이것이 4회차 선택의 근거다): newest → n e w est</w>(6), widest → w i d est</w>(3)이 되어 est 관련 쌍은 다 소진됐고, 이제 남은 최빈은 l o와 o w가 각각 7이다. low(5)와 lower(2)에 둘 다 들어 있어서다. 규칙으로 l o를 골라 병합하면 lo가 생기고, 5회차에는 lo w가 7로 최빈이 되어 low라는 온전한 단어가 하나의 토큰이 된다. 흔한 단어일수록 단어 전체가 한 토큰이 되어 가는 과정이다.
학습이 남기는 산출물은 둘이다 — ① 최종 어휘(토큰 목록)와 ② 순서 있는 병합 규칙 목록(e s → es, es t → est, …). 이 순서가 그대로 적용 규칙이 된다. 병합을 몇 번 할지, 즉 목표 어휘 크기 $V$가 유일한 하이퍼파라미터이고, 크게 설정할수록 흔한 표현이 더 긴 조각으로 묶인다(1.2.6).
1.2.3 못 본 단어를 아는 조각으로 자른다
학습이 끝났으니 규칙을 적용해 보자. 코퍼스에 한 번도 없던 단어 lowest를 잘라 보겠다. 문자로 펼친 뒤, 병합 규칙을 학습된 순서 그대로 적용 가능한 곳마다 적용한다.
lowest는 학습에서 못 본 단어인데도 아는 조각 low + est</w>, 단 2토큰으로 정확히 표현됐다. 이것이 1.2.1에서 말한 일반화의 실물이다 — 접두부 low와 접미부 est를 각각 다른 단어(low·lower / newest·widest)에서 배웠는데, 처음 보는 단어에서 둘이 재조합됐다.
일반화가 언제나 이렇게 매끄럽진 않다는 것을 보이려고 하나 더 잘라 보자. slowest다. 문자로 펼치면 s l o w e s t </w>. 같은 규칙을 순서대로 밟으면 e s → es, es t → est, est </w> → est</w>까지는 똑같이 진행되고, 그다음 l o → lo가 적용되어 s lo w est</w>, lo w → low로 s low est</w>가 된다. 결과는 s + low + est</w>, 3토큰이다. 학습 코퍼스에 s로 시작하는 병합이 없었으므로 맨 앞 s는 홀로 문자 조각으로 남았다. 모델은 "low"와 "est"라는 익숙한 형태소를 새 단어에서도 알아보되, 배우지 못한 자리는 문자 단위로 폴백한다 — 일반화는 학습된 병합이 닿는 곳까지만이다.
병합 규칙이 전혀 없는 극단, 이를테면 학습에서 한 번도 못 본 이상한 문자 조합이 오더라도 최소한 문자나 바이트 단위로는 떨어지므로 <UNK>가 나지 않는다. 문자를 base로 두면 base에 있는 문자는 다 표현되고, 바이트를 base로 두면(다음 절) 세상의 어떤 바이트 열도 표현된다. 실제 학습·적용은 라이브러리로 한다 — Hugging Face tokenizers, Google sentencepiece, OpenAI tiktoken. 위는 원리를 직접 따라간 것이고, 프로덕션 구현은 병합 후보를 우선순위 큐로 관리하는 식으로 최적화돼 있어 긴 텍스트도 빠르게 자른다.
1.2.4 바이트로 내려가면 OOV가 사라진다 — byte-level BPE
순수 BPE의 약점은 base가 유니코드 문자라는 데 있다. 세상의 문자는 종류가 방대하다 — 라틴·키릴·아랍·한글·한자·이모지까지 base에 다 넣으면 병합을 시작하기도 전에 어휘가 수만 개의 희귀 문자로 낭비되고, 그래도 못 넣은 문자가 오면 <UNK>가 난다. byte-level BPE(Radford 등, 2019, GPT-2)는 이 문제를 우아하게 없앤다. 텍스트를 문자로 보지 않고 먼저 UTF-8 바이트 열로 바꾼 다음, 그 바이트 위에서 BPE를 실행한다. base가 정확히 256개 바이트로 고정되므로 어떤 문자열이든 표현할 수 있고 — 실제 OOV가 0이다 — 어휘를 희귀 문자 저장이 아니라 실제로 유용한 병합에만 쓴다. GPT-2 이후의 GPT 계열과 상당수 오픈 모델이 이 방식을 쓴다.
무비용은 아니다. 대가를 이해하려면 UTF-8이 문자를 몇 바이트로 쓰는지를 알아야 하는데, 이것이 뒤에 나올 한국어 비용 문제의 근원이므로 직접 확인하고 가자. UTF-8은 가변 길이 인코딩이다.
| 유니코드 범위 | 바이트 수 | 무엇이 여기에 |
|---|---|---|
| U+0000 – U+007F | 1 | ASCII(영문·숫자·기본 문장부호) |
| U+0080 – U+07FF | 2 | 라틴 확장·그리스·키릴·아랍·히브리 |
| U+0800 – U+FFFF | 3 | 한글 음절·대다수 한자·일본어·기타 |
| U+10000 – U+10FFFF | 4 | 이모지·희귀 한자·일부 기호 |
영어 알파벳은 글자당 1바이트지만 한글 음절은 글자당 3바이트다. 한 글자를 실제로 분해해 보자. '안'은 유니코드 U+C548이다. 3바이트 UTF-8로 인코딩하면 이진수 1100 0101 0100 1000을 1110xxxx 10xxxxxx 10xxxxxx 틀에 4·6·6비트로 나눠 채워 11101100 10010101 10001000, 즉 EC 95 88 세 바이트가 된다. byte-level 토크나이저 입장에서 '안'은 처음부터 세 개의 바이트 심볼이다. 만약 학습 코퍼스에 한국어가 충분히 있어서 이 세 바이트를 하나로 묶는 병합이 어휘에 생겼다면 '안'은 1토큰으로 표현되지만, 병합이 없으면 이 한 글자가 최대 3토큰으로 흩어진다. 영어 글자 하나가 늘 1바이트 = (거의) 1토큰인 것과 대조적이다. 라틴 문자 중심으로 학습된 범용 토크나이저에서 한글·CJK가 토큰을 몇 배씩 소비하는 근본 원인이 바로 이 3바이트 성질과, 그 바이트를 묶어 줄 병합이 어휘에 부족하다는 사실의 결합이다(1.2.9에서 정면으로 다룬다).
1.2.5 WordPiece는 우도로 병합하고, Unigram은 위에서 쳐낸다
"서브워드"라는 우산 아래에는 BPE 말고도 몇 갈래가 더 있다. 쓰려는 모델의 토크나이저가 어느 계열인지 알면 그 모델의 분절 성질이 예측된다.
WordPiece는 BERT 계열이 쓴다(Schuster·Nakajima, 2012; Wu 등, 2016). BPE와 골격은 같은 bottom-up 병합인데 병합 기준이 다르다. 단순 최빈이 아니라 언어모델 우도를 가장 많이 높이는 쌍을 병합한다. 개략적으로 쌍 $(a,b)$의 점수를
$$ \text{score}(a,b) = \frac{\text{freq}(ab)}{\text{freq}(a)\,\text{freq}(b)} $$
로 두는데, 이 식의 의미가 중요하다. 분자만 보면 단순히 최빈(BPE)인데, 분모로 각 조각이 따로 등장한 빈도를 나눠 주니, "그냥 자주 붙는" 쌍이 아니라 "서로 특별히 잘 붙는" 쌍이 우대된다. 예를 들어 "the"는 the 자체로 워낙 흔해서 t·h·e가 자주 붙지만, "e"나 "th"가 다른 데서도 하도 흔하게 나오면 분모가 커져 점수가 작아진다. 반대로 "qu"처럼 q와 u가 거의 항상 붙어만 다니는 쌍은 분모가 작아 점수가 치솟는다. 단어 중간에 온 조각에는 ##를 붙여 표기하고(playing → play, ##ing), 적용할 때는 병합 순서를 되짚는 대신 최장 일치(greedy longest-match)로 왼쪽부터 어휘에 있는 가장 긴 조각을 떼어 낸다.
Unigram LM은 Kudo(2018)가 제안했고 방향이 정반대다. BPE·WordPiece가 작은 데서 키워 나가는 bottom-up이라면, Unigram은 큰 후보 어휘에서 시작해 쳐내는 top-down이다. 먼저 흔한 조각들로 과하게 큰 후보 어휘를 만들고, 각 토큰에 확률을 부여한 유니그램 언어모델을 세운 뒤, 그 토큰을 뺐을 때 전체 코퍼스 우도가 가장 덜 떨어지는 토큰들을 반복적으로 제거해 목표 크기에 맞춘다. 분절도 결정적이지 않다 — 한 문장을 자르는 방법이 여러 가지일 때 우도가 가장 높은 분절을 고르며, 학습 중에는 일부러 여러 분절을 샘플링하는 서브워드 정규화(subword regularization)로 견고성을 높일 수 있다. 같은 단어가 늘 같은 조각으로만 잘리지 않게 해 모델이 특정 분절에 과의존하지 않도록 하는 것이다.
여기서 자주 혼동되는 이름을 정리하자. SentencePiece는 알고리즘이 아니라 프레임워크다(Kudo·Richardson, 2018). BPE와 Unigram을 둘 다 구현하며, 그 실제 기여는 입력을 공백까지 포함한 원시 문자 스트림으로 다룬다는 점이다. 대다수 토크나이저는 언어별 규칙으로 먼저 단어를 띄어 놓는 전분절(pre-tokenization)을 하는데, 이 규칙은 언어마다 다르다 — 중국어·일본어는 애초에 공백이 없고, 한국어는 교착적이라 어절 경계가 형태소 경계와 다르다. SentencePiece는 공백 자체를 ▁(U+2581)라는 문자로 치환해 토큰 안에 넣어 버려서 언어별 전분절 규칙 없이 균일하게 실행된다. 덕분에 탈토큰화(detokenization)가 무손실이고 — ▁를 공백으로 되돌리면 원문이 정확히 복원된다 — 띄어쓰기 규칙이 제각각인 다국어에 잘 맞아 다국어 모델이 즐겨 쓴다. "SentencePiece로 학습했다"는 말은 대개 그 위의 Unigram이나 BPE를 뜻한다.
| 방식 | 어휘 구성 방향 | 병합·선택 기준 | 분절 성질 | 대표 계열 |
|---|---|---|---|---|
| BPE | bottom-up 병합 | 최빈 쌍 | 결정적, 병합 순서 적용 | NMT 원조·다수 LLM |
| byte-level BPE | bottom-up(바이트 base) | 최빈 쌍 | 결정적, OOV 0 | GPT 계열 등 |
| WordPiece | bottom-up 병합 | 우도 점수 | 결정적, 최장 일치 | BERT 계열 |
| Unigram(SentencePiece) | top-down 가지치기 | 유니그램 우도 | 확률적(정규화 지원) | 다국어·다수 오픈모델 |
"어느 방식이 항상 최고"라는 정설은 없다. 도메인·언어·어휘 크기·후속 학습에 따라 우열이 갈리고, 비교 연구조차 조건에 민감하다. 실무에서는 대개 이 논쟁을 할 일이 없다 — 쓰려는 사전학습 모델이 정해 놓은 토크나이저를 그대로 쓰는 것이 기본이기 때문이다. 토크나이저를 바꾸면 토큰 ID의 의미 전체가 달라져 임베딩을 새로 학습해야 하므로(1.2.15), 토크나이저를 처음부터 학습하는 것은 파운데이션 모델을 직접 만들 때의 이야기다(2.1).
1.2.6 어휘 크기의 U자형 절충
병합 횟수, 곧 목표 어휘 크기 $V$는 설계자가 조정하는 유일한 변수다. 크게 설정하면 흔한 표현이 더 긴 조각으로 묶여 시퀀스가 짧아지지만, 그 대가를 세 갈래로 치른다.
첫째, 파라미터와 메모리다. 임베딩 행렬과 출력층이 각각 $V \times d$개의 파라미터다(1.2.12). $d = 4096$일 때 $V$를 32k에서 256k로 키우면 임베딩만 $32{,}000 \times 4096 \approx 1.31 \times 10^8$에서 $256{,}000 \times 4096 \approx 1.05 \times 10^9$로, 1.3억에서 10.5억 파라미터로 불어난다. 큰 모델에서는 사소하지만 작은 모델에서는 전체의 상당 비중이다. 둘째, 출력 소프트맥스 비용이다. 매 스텝 $V$개의 로짓에 소프트맥스를 적용해야 하니 $V$가 크면 마지막 층의 연산·메모리가 커진다. 셋째, 뜻밖에 중요한 희소성 문제다. 어휘가 크면 드문 토큰은 학습 내내 거의 등장하지 않아 그 임베딩 행이 제대로 학습되지 않는다 — 특히 다국어 모델에서 저자원 언어의 토큰이 이 함정에 빠진다. 어휘에 자리는 차지했는데 그 벡터는 초기값에 가깝게 방치되는 것이다.
작게 설정하면 정반대의 대가다. 파라미터는 아끼지만 시퀀스가 길어져 어텐션 $O(n^2)$ 비용·컨텍스트 낭비·토큰당 과금이 모두 늘고, 단어가 잘게 분할되어 모델이 의미를 조립하는 부담이 커진다. 두 항을 한 식으로 놓으면 절충의 모양이 보인다.
$$ \text{총 비용} \approx \underbrace{V \times d}_{\text{어휘 파라미터, } V\uparrow \Rightarrow \uparrow} \;+\; \underbrace{c \times n(V)}_{\text{시퀀스 처리, } V\uparrow \Rightarrow n\downarrow} $$
$V$를 키우면 앞 항이 늘고 뒤 항이 준다 — 어느 극단도 최적이 아닌 U자형 절충이고, 최솟값은 중간 어딘가다. 현대 LLM 어휘는 대략 수만~수십만 범위에 자리 잡았고(초기 GPT-2가 약 5만, 최근 대형 다국어 모델이 10만~20만대인 식으로 커져 왔다), 큰 흐름은 어휘가 커지는 쪽이었다. 다국어·코드 지원을 넓히려면 더 많은 언어와 프로그래밍 언어의 흔한 조각을 토큰으로 확보해야 하고, 그러려면 어휘가 커야 하기 때문이다. 다만 어휘를 키운다고 저자원 언어의 효율이 자동으로 좋아지는 것은 아니다. 학습 데이터에 그 언어가 충분히 들어가 실제로 병합이 생겨야 그 언어의 토큰이 어휘에 자리 잡는다 — 자리만 늘린다고 채워지지 않는다.
1.2.7 LLM이 산술을 틀리는 토큰 차원의 이유
토크나이저가 조용히 만드는 첫 번째 함정은 숫자다. BPE는 코퍼스에서 자주 붙어 나온 숫자열을 묶음으로 병합한다. 그 결과 자릿수 분절이 일관되지 않는다 — "2020"은 학습에 하도 흔해 1토큰인데 "2021"은 20 + 21로, "1999"는 19 + 99로, "7"은 단순히 7로 갈리는 식이다. 어느 숫자가 어떻게 잘리는지는 그 숫자가 코퍼스에 얼마나 흔했는지에 달려 있어 예측 불가능하다.
왜 이것이 계산을 망치는지는 자릿셈의 원리를 떠올리면 분명해진다. 사람이 큰 수를 더할 때는 자릿수를 세로로 맞추고 일의 자리부터 올림을 처리한다. 그런데 토큰이 자릿수와 어긋나게 잘려 있으면 이 정렬 자체가 불가능하다. 예를 들어 1000 + 999를 시켰다고 하자. 1000이 1토큰, 999가 9+99로 2토큰이라면, 모델은 토큰 열 [1000]과 [9][99]를 받는다. 두 피연산자의 토큰 경계가 서로 어긋나 있으니 "천의 자리 대 천의 자리, 백의 자리 대 백의 자리"를 맞출 방법이 없다. 정답 1999를 내려면 자릿수마다 올림을 전파해야 하는데, 그 자릿수들이 애초에 토큰 안에 뭉쳐 숨어 있어 모델이 개별 자릿수에 손을 못 댄다. 사람으로 치면 숫자 전체를 "이미지"로만 보고 암산하라는 것과 비슷하다.
| 분절 방식 | 1234의 경향 | 산술에의 함의 |
|---|---|---|
| 묶음 병합 허용(초기 BPE) | 불규칙(123+4, 12+34, 1234 …) | 자릿수 정렬이 깨져 계산에 불리 |
| 고정 크기 그룹(예: 3자리씩) | 규칙적(123+4) | 그나마 일관 |
| 개별 자릿수 강제 분리 | 항상 1+2+3+4 | 자릿수 ↔ 토큰 1:1, 산술에 유리 |
그래서 최근 토크나이저 중에는 숫자를 아예 개별 자릿수로, 또는 고정 크기 그룹으로 강제 분리하는 것들이 있다. 자릿수를 토큰과 1:1로 묶어 두면 최소한 정렬 문제는 사라진다. 어느 모델이 정확히 어떤 규칙을 쓰는지는 그 토크나이저를 직접 열어 봐야 알 수 있다. 실무 결론은 분명하다 — LLM이 긴 산술·회계·정밀 수치를 자주 틀리는 이유의 상당 부분이 이 토큰화에 있고, 이것은 모델을 더 키운다고 근본적으로 낫지 않는다. 정확한 계산은 모델에게 시키지 말고 도구(계산기·코드 실행)로 넘겨라(6.5). 이 장 첫머리의 "strawberry에 r이 몇 개냐"도 같은 병이다 — 글자가 토큰 안에 봉인돼 있어 모델이 세지 못한다.
1.2.8 코드·공백·보이지 않는 경계
두 번째 함정은 코드와 공백이다. 자연어에서 공백은 그저 단어 사이 구분이지만, 파이썬 같은 언어에서는 들여쓰기가 문법이다. 초기 byte-level BPE는 연속 공백을 개별 토큰으로 처리해, 4단계 중첩된 함수의 16칸 들여쓰기가 16개 토큰을 소비하는 낭비를 일으켰다. 이를 개선한 토크나이저는 연속 공백·탭 런(run)을 하나의 토큰으로 묶는 항목을 어휘에 넣어(예: 4칸 들여쓰기를 1토큰으로) 코드 효율을 크게 높였다. 그래도 코드는 자연어보다 특수문자와 식별자 조합이 많아 분절이 예민하다 — 같은 변수명이 snake_case냐 camelCase냐에 따라 토큰 수가 달라진다. 코드용 토큰 예산을 자연어 감으로 설정하면 어긋난다. 실제로 세라.
그리고 눈에 잘 안 띄는 경계 함정 몇 가지가 있다. 가장 자주 문제가 되는 것이 선행 공백이다. 많은 토크나이저에서 "world"와 " world"(앞에 공백 하나)는 서로 다른 토큰이다. byte-level BPE는 공백을 앞 글자에 붙여 학습하고, SentencePiece는 ▁world처럼 공백을 토큰 안에 넣기 때문이다. 프롬프트를 프로그램으로 이어 붙일 때 공백을 잘못 처리하면 학습 때 본 적 없는 분절이 나와 성능이 조용히 변동한다. 비슷하게, 유니코드 정규화(NFC/NFKC)나 대소문자 접힘 여부에 따라 겉보기 같은 글자가 다르게 인식되고, 오타·희귀 문자·이모지는 byte-level이라 깨지진 않지만 토큰이 불어난다. 이 경계 효과들은 하나하나는 사소해 보여도, 프롬프트를 자동 생성하는 파이프라인에서 누적되면 원인을 찾기 어려운 품질 저하로 나타난다.
1.2.9 한국어·CJK는 왜 몇 배 비용이 큰가
세 번째 함정이 실무에 가장 크게 영향을 준다. 대다수 범용 토크나이저는 영어(라틴 문자) 중심 코퍼스로 학습됐고, 그래서 병합의 압도적 다수가 영어 조각에 쓰였다. 한글·한자·일본어 문자는 병합이 부족해 글자 하나가 여러 토큰으로, 심하면 바이트 조각으로 떨어진다. 근원은 1.2.4에서 직접 본 그것이다 — 한글 음절은 UTF-8에서 3바이트이고('안' = EC 95 88), 그 3바이트를 하나로 묶는 병합이 어휘에 없으면 한 글자가 최대 3토큰으로 흩어진다. 영어는 글자당 1바이트이고 흔한 단어 전체가 1토큰이니, 같은 뜻을 전하는 데 드는 토큰 수가 근본적으로 벌어진다.
$$ \text{같은 의미의 문장} \quad\Longrightarrow\quad n_{\text{한국어}} \;\gg\; n_{\text{영어}} \qquad(\text{흔히 } 2\text{–}3\times \text{ 이상}) $$
아래는 방향을 보이기 위한 개략 예시다. 정확한 수는 토크나이저·버전마다 다르니 반드시 실제로 세되, 비율의 개략 규모는 이렇다.
| 내용(같은 뜻) | 텍스트 | 문자 수 | 대략 토큰 수 | 문자당 토큰 |
|---|---|---|---|---|
| 영어 | Hello, nice to meet you. | 24 | 약 6–7 | 낮음(문자당 토큰 적음) |
| 한국어 | 안녕하세요, 만나서 반갑습니다. | 16 | 약 15–25 | 높음(문자·바이트당 토큰 많음) |
한국어는 글자 수가 오히려 적은데도 토큰은 두세 배 이상 든다. 이 비효율은 겉으로 안 보이지만 서비스에 세 가지 실질 손실로 곧장 직결된다. 비용이 오른다 — 과금이 토큰당이므로 같은 대화라도 한국어가 더 비용이 크다(1.2.10, 7.5). 유효 컨텍스트가 줄어든다 — 컨텍스트 창은 토큰 단위라, 같은 128k 창에 담기는 한국어 글자 수가 영어보다 적다. "128k 토큰이면 책 한 권"이라는 어림이 한국어에서는 훨씬 짧은 분량으로 쪼그라든다. 그리고 지연이 늘어난다 — 출력도 토큰당 순차 생성되므로(5.1), 같은 분량의 한국어 답변이 더 느리고 더 비용이 크다. 세 손실이 곱해지면 다국어 서비스의 단가 구조가 언어별로 크게 갈린다.
완화책은 근본적으로 하나다 — 한국어·다국어를 충분히 넣어 학습한 토크나이저·모델을 고르는 것. 어휘에 한글 조각(음절·형태소)이 많으면 같은 문장의 토큰 수가 크게 줄어 효율이 극적으로 개선된다. 한국어 특화 토크나이저를 쓰는 방법도 있지만, 모델과 토크나이저는 한 쌍이라 임의로 바꿀 수 없다는 장벽이 있다(1.2.15). 그래서 표준은, 다국어 서비스라면 모델 선택 단계에서 대상 언어의 토큰 효율(문자당 토큰 비)을 직접 벤치마크해 비용·컨텍스트·지연 산정에 반영하는 것이다. 이것은 나중에 조정할 수 있는 튜닝 파라미터가 아니라, 모델을 고르는 순간 사실상 확정되는 구조적 제약이다.
1.2.10 왜 과금이 토큰당인가 — 토큰 경제학
토큰은 모델이 일하는 자연 단위다. 그래서 API 가격도, 컨텍스트 한도도, 지연도 전부 토큰으로 매겨진다. 왜 하필 글자 수나 요청 횟수가 아니라 토큰인가 — 연산량이 토큰 수에 정확히 비례하기 때문이다. 밀집(dense) 트랜스포머에서 토큰 하나를 순전파로 처리하는 비용은 대략 파라미터 수 $N$에 비례한다. 경험식으로 순전파는 토큰당 약 $2N$ FLOPs다(가중치와의 곱·합이 각 파라미터마다 한 번씩, 1.4에서 유도한다).
$$ \text{총 연산량} \;\approx\; \underbrace{2N}_{\text{토큰당}} \times \underbrace{(L_{\text{in}} + L_{\text{out}})}_{\text{처리한 토큰 수}} \;+\; \underbrace{O(n^2 d)}_{\text{어텐션(길수록)}} $$
즉 처리하고 생성한 토큰 수가 그대로 비용이다. 글자 수는 언어마다 토큰과의 비가 달라 비용의 실제 동인과 어긋나고, 요청 횟수는 한 요청의 크기를 무시한다. 토큰은 연산량과 가장 잘 맞는 투명한 단위라서 과금 기준이 됐다.
입력과 출력의 가격이 다른 것도 이 물리에서 나온다. 보통 출력 토큰이 입력보다 비용이 크다. 입력(프리필, prefill)은 프롬프트 전체를 한 번의 순전파로 병렬 처리한다 — 연산 집약적이지만 토큰당으로는 상대적으로 저렴하다. 출력(디코드, decode)은 토큰을 하나씩 순차 생성하며 매 토큰마다 모델 전체를 다시 통과하고, 이 국면은 메모리 대역폭에 묶여 토큰당 비용이 크다(왜 그런지는 5.1에서 밀리초 단위로 계산한다). 그래서 같은 1000토큰이라도 그것이 입력이냐 출력이냐에 따라 원가가 다르다.
컨텍스트 길이는 이 비용을 세 겹으로 높인다 — 입력 토큰이 많아 프리필 비용이 오르고, KV 캐시가 토큰 수에 비례해 메모리를 차지하고(5.1), 어텐션이 $O(n^2)$로 자란다. 그래서 롱컨텍스트는 "그냥 많이 넣으면 되는" 무비용 기능이 아니라 비용·지연과의 거래다(5.6). 여기에 1.2.9의 다국어 페널티가 곱해진다는 것을 잊지 마라 — 한국어 서비스는 같은 기능에도 토큰이 더 들어 이 세 겹 비용을 전부 더 크게 부담한다. 절약의 수단은 원리 수준에서 이미 보인다 — 프롬프트를 짧고 구조적으로, 반복되는 앞부분은 프리픽스 캐시로 재사용(5.6), 불필요하게 긴 출력을 억제, 그리고 토큰 효율이 좋은 모델·토크나이저 선택. 상세한 유닛 이코노믹스는 7.5이 맡는다.
1.2.11 특수 토큰과 챗 템플릿
어휘에는 텍스트 조각만 있는 것이 아니다. 특수 토큰(special tokens)이 함께 들어간다. 텍스트가 아니라 구조·제어 신호를 나타내는 예약된 토큰이다. 문서의 시작과 끝(BOS/EOS), 패딩(PAD), 미등록(UNK — byte-level에서는 사실상 불필요) 같은 경계·제어 토큰이 있고, 대화형 모델에는 여기에 더해 화자와 턴 경계를 나타내는 역할 토큰이 있다. 정확한 형식은 모델마다 다르다 — ChatML 계열의 <|im_start|>·<|im_end|>, 일부 Llama 계열의 [INST]·<<SYS>> 또는 <|begin_of_text|>·<|start_header_id|>·<|eot_id|> 같은 식으로, 저마다 다른 토큰을 쓴다.
챗 템플릿(chat template)은 {역할, 내용} 메시지 목록을 모델이 학습 때 본 정확한 토큰 배열로 렌더링하는 규칙이다. 이것이 왜 중요한가. 대화형 모델은 단순히 텍스트를 이어 붙여 학습된 것이 아니라, 시스템·유저·어시스턴트 턴을 특정 특수 토큰으로 감싼 특정 포맷으로 사전·사후학습됐다. 추론 때도 정확히 같은 포맷을 써야 학습된 능력이 제대로 나온다. 논리 구조로 보면 이런 모양이다(실제 특수 토큰은 모델별로 다르다):
<|system|> 너는 친절한 한국어 도우미다. <|end|>
<|user|> 토큰이 뭐야? <|end|>
<|assistant|>
이 문자열이 토큰화되어 모델에 들어가고, 모델은 마지막 <|assistant|> 뒤부터 답을 생성한다. Hugging Face transformers에서는 이 규칙이 토크나이저에 Jinja 챗 템플릿으로 실려 있어 tokenizer.apply_chat_template(messages, add_generation_prompt=True)로 정확한 포맷을 얻는다. 직접 문자열을 조립하지 말고 이 함수를 쓰는 것이 함정을 피하는 길이다 — 특수 토큰 하나를 빠뜨리거나 순서를 틀리거나 역할 표기를 어긋나게 쓰면 성능이 조용히 나빠진다. 에러가 아니라 답 품질 저하로만 나타나기 때문에 발견이 늦다. 특히 파인튜닝할 때 학습과 추론의 템플릿이 어긋나면, 어렵게 학습한 모델이 추론에서 제 실력을 못 내는 원인이 되는데 로그에는 아무 이상이 검출되지 않는다. 여기서는 개관만 한다 — SFT의 대화 포맷과 손실 마스킹(답변 토큰에만 손실을 적용하는 것)은 3.2, 챗 제품의 대화 상태·스트리밍 설계는 7.1에서 깊이 다룬다.
1.2.12 임베딩 행렬 — 토큰 ID에서 벡터로
토큰화의 출력은 정수 ID 열이다. 신경망은 정수를 직접 다루지 못하니 각 ID를 연속 벡터로 바꿔야 한다 — 이것이 임베딩이다. 임베딩 행렬 $E$는 크기 $V \times d$의 학습 가능한 표다($V$는 어휘 크기, $d$는 모델 차원 $d_{\text{model}}$). 토큰 ID $i$의 임베딩은 단순히 $E$의 $i$번째 행이다. 개념적으로는 원-핫 벡터와 행렬의 곱으로 쓸 수 있지만,
$$ e_i \;=\; \mathbf{1}_i^\top E \;=\; E_{i,:} $$
실제 구현은 곱이 아니라 행 조회(lookup)다. $V$가 수만~수십만인데 원-핫 곱을 하면 대부분이 0인 벡터와의 곱에 연산을 낭비하므로, 단순히 $i$번째 행을 꺼내 온다. 이 벡터가 트랜스포머의 첫 입력이 되고(위치 정보는 1.5에서 더해진다), 이후 층들이 문맥을 섞어 문맥화된 표현으로 바꾼다.
이 표가 얼마나 큰지 직접 재 보면 어휘 크기의 무게가 실감 난다. $V = 128{,}000$, $d = 4096$이면 임베딩 행렬 하나가 $128{,}000 \times 4096 = 524{,}288{,}000$, 약 5.2억 파라미터다. fp16(원소당 2바이트)으로 저장하면 약 1 GB다 — 8B 모델이라면 임베딩 하나가 전체 파라미터의 6% 남짓을 차지하는 셈이고, 더 작은 모델에서는 이 비중이 훌쩍 커진다. 이 크기 때문에 어휘를 키우는 결정이 무비용이 아니고(1.2.6), 다음 절의 가중치 공유가 실제 절약이 된다.
1.2.13 언임베딩과 가중치 공유
출력 쪽에는 정확히 반대의 연산이 있다. 트랜스포머 마지막 층이 낸 은닉 벡터 $h \in \mathbb{R}^d$를 어휘 전체에 대한 로짓 $z \in \mathbb{R}^V$로 되돌려야 다음 토큰의 확률분포가 나온다(1.1). 이 사영을 하는 것이 언임베딩(unembedding) 행렬 또는 LM 헤드로, 크기가 $d \times V$다 — 임베딩과 방향만 반대인 같은 모양의 큰 행렬이다.
$$ z \;=\; h\,W_{\text{out}}, \qquad p \;=\; \mathrm{softmax}(z) \;\in\; \Delta^{V-1} $$
여기서 자연스러운 질문이 나온다. 입력에서 "토큰 $i$를 나타내는 벡터"($E$의 $i$번째 행)와, 출력에서 "은닉 상태가 토큰 $i$와 얼마나 닮았나를 재는 벡터"($W_{\text{out}}$의 $i$번째 열)가 왜 달라야 하나? 둘은 같아도 되지 않나? 이 착안이 가중치 공유(weight tying)다 — 입력 임베딩 $E$와 출력 사영을 같은 파라미터로 묶어 $W_{\text{out}} = E^\top$로 두고, 하나의 $V \times d$ 행렬을 양쪽에 재사용한다(Press·Wolf, 2017; Inan 등, 2016).
$$ z \;=\; h\,E^\top $$
이득은 둘이다. 첫째, 파라미터 전체를 아낀다 — $V \times d$개를 한 벌 덜 저장한다. 앞 절의 예($V = 128\text{k}$, $d = 4096$)라면 약 5.2억 파라미터, fp16으로 약 1 GB를 아끼는 것이고, 어휘가 크고 모델이 작을수록 이 절약이 결정적이다. 둘째, 입력과 출력에서 같은 토큰이 같은 벡터를 공유하니 일관성·정칙화 효과가 있다. 다만 모든 모델이 묶는 것은 아니다. 공유는 "입력 표현 공간과 출력 표현 공간이 같아야 한다"는 제약을 거는 셈인데, 대형 모델에서는 어휘 파라미터의 비중이 작아져 절약 이득이 줄고, 두 공간을 따로 학습하게 풀어 두는(untied) 편이 약간 유리하다는 견해도 있다. 묶느냐 푸느냐는 정답이 있는 문제가 아니라 모델 규모와 어휘 크기에 따른 설계 선택이다.
1.2.14 이 임베딩은 검색용 문장 임베딩이 아니다
여기서 반드시 갈라 둬야 할 혼동이 있다. 이 장의 임베딩은 모델 입력 임베딩 — 토큰 하나당 하나의 벡터를 주는, 문맥과 무관한 조회 테이블이다. "은행(bank)"이라는 토큰은 강둑이든 금융기관이든 무조건 같은 임베딩 행을 받는다. 의미의 구별은 그 뒤 트랜스포머 층들이 문맥을 섞으며 만들어 낸다. 그런데 사람들이 흔히 "임베딩"이라 부르는 것 — 문장·문서 전체를 한 벡터로 만들어 코사인 유사도로 검색하는 그것 — 은 이것과 다른 물건이다.
| 입력 임베딩(이 장) | 문장·검색 임베딩(6.2) | |
|---|---|---|
| 단위 | 토큰 1개 | 문장·문서 1개 |
| 문맥 | 무문맥(조회 테이블) | 문맥 반영(모델 통과 후 풀링) |
| 용도 | 트랜스포머의 첫 입력 | 의미 유사도·벡터 검색·RAG |
| 학습 | 언어모델 손실의 일부 | 대조학습 등 별도 목적 |
의미 검색·RAG에 쓰는 임베딩은 별도의 임베딩 모델과 별도의 목적함수(주로 대조학습)로 만든다 — 문장 전체를 모델에 통과시킨 뒤 그 출력을 풀링해 한 벡터로 만들고, 의미가 비슷한 문장끼리 벡터가 가깝도록 학습한다. 이것은 6.2에서 본격적으로 다룬다. 이 장의 $V \times d$ 조회 테이블과 혼동하지 마라 — 둘 다 "임베딩"이라 불릴 뿐, 단위도 문맥성도 학습 목적도 다르다.
1.2.15 토큰 세기와, 토크나이저 불일치라는 조용한 버그
실무의 제1 규칙은 짧다 — 그 모델의 토크나이저로 세라. 글자나 단어 수로 어림하지 마라. 앞에서 봤듯 언어·코드·숫자에 따라 편차가 크고, 한국어라면 그 편차가 몇 배에 이른다.
# OpenAI 계열: tiktoken (인코딩 이름은 모델마다 다르니 문서 확인)
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
ids = enc.encode("안녕하세요, 반갑습니다.")
print(len(ids), ids) # 토큰 수와 ID 열 (개략)
# Hugging Face 계열: AutoTokenizer (모델과 한 쌍)
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained("some-org/some-model") # 실제 모델 id로
print(len(tok("안녕하세요, 반갑습니다.")["input_ids"]))
# 챗 포맷은 수작업으로 조립하지 말고 템플릿 함수로:
msgs = [{"role": "user", "content": "토큰이 뭐야?"}]
prompt_ids = tok.apply_chat_template(msgs, add_generation_prompt=True)
토큰을 셀 때는 입력과 출력을 따로 세고(가격·컨텍스트 예산이 다르다 — 1.2.10), 턴마다 더해지는 특수 토큰·챗 템플릿 오버헤드까지 예산에 포함시켜라. 다국어 서비스라면 대상 언어별 토큰/문자 비를 미리 측정해 비용·컨텍스트를 산정하는 것이 표준이다. Anthropic 모델의 토큰 카운팅·컨텍스트 한도·특수 토큰은 기억으로 단정하지 말고 claude-api 스킬과 공식 문서를 출처로 삼아라 — 이런 값은 시점에 따라 바뀐다.
이 규칙을 어길 때 생기는 사고들은 하나같이 에러 없이 조용히 온다는 공통점이 있어 발견이 늦다. 그 근원은 하나다 — 모델과 토크나이저는 한 쌍이고, 토큰 ID가 곧 임베딩 행렬의 행 번호이므로, 다른 토크나이저의 ID를 넣으면 완전히 잘못된 벡터를 조회한다. 여기서 갈라져 나오는 흔한 실수들을 정리해 두면, 모델 A로 서비스하면서 모델 B의 토크나이저로 토큰 수를 추정해 예산·컨텍스트 계산이 어긋나는 경우, 파인튜닝과 추론의 챗 템플릿이 달라 성능이 저하되는 경우(1.2.11), 특수 토큰을 새로 추가하고 임베딩 크기를 안 늘려 새 토큰 ID가 임베딩 행 범위를 벗어나 오버플로하는 경우 — 토큰을 추가했다면 model.resize_token_embeddings(len(tokenizer))로 임베딩과 LM 헤드를 함께 리사이즈하고 새 행을 학습시켜야 한다 — 같은 모델군이라도 버전이 바뀌며 어휘·병합이 달라지는 드리프트(학습·평가·서빙에서 정확히 같은 토크나이저 아티팩트를 고정해야 한다), 그리고 잘림(truncation)을 문자 기준으로 처리해 토큰 경계 중간이 잘려 깨지는 경우가 있다. 이들 전부가 로그에는 드러나지 않고 답 품질로만 드러난다.
그래서 이 장을 한 문장으로 압축하면, 텍스트가 모델에 닿기 전 두 관문 — 정수로 자르는 토큰화와 벡터로 바꾸는 임베딩 — 에 내재된 결정이 이후 모든 층의 비용과 능력을 규정한다는 것이다. 서브워드는 어휘 폭발과 시퀀스 폭발 사이의 데이터 학습된 절충이고, BPE·byte-level·WordPiece·Unigram은 그 절충을 정하는 서로 다른 방법이며, 어휘 크기는 U자형 거래다. 눈에 안 보이는 이 층이 한국어의 비용을 몇 배로 높이고, 산술을 틀리게 하고, 챗 템플릿 불일치로 조용히 성능을 잠식한다. 임베딩 벡터가 트랜스포머에 들어간 뒤 순전파·역전파·경사하강으로 어떻게 학습되는지, 어텐션(1.4)을 읽기 위한 최소 배경을 다음 장에서 다진다.