신고 티켓
#02 · 청구서
서점의 신고
"물레책방 챗봇 대화를 한국어 질문과 영어 질문으로 나눠 봤어요. 뜻은 똑같은 질문인데 한국어 쪽에 요금이 더 붙어요." (보라 매니저, 이번 달 API 청구서와 대화 기록을 들고. 서점의 신고가 아니라 회사 안의 신고)
오늘 추적할 것
청구서가 세는 단위인 토큰이 글자·단어와 어떻게 다른지 확인한다. 글자 밑의 바이트(UTF-8)를 보고, 자주 붙어 나오는 쌍을 합쳐 토큰을 만드는 BPE를 직접 돌린다. 같은 뜻의 문장이 언어마다 다른 토큰 수가 되는 까닭을 찾고, 내 LLM의 토크나이저를 짠다.
도란 사무실 · 옛 인쇄소 건물 3층
같은 주 수요일 아침, 보라 매니저가 출력한 종이 두 장을 하은의 책상에 내려놓습니다. 한 장은 이번 달 API 청구서, 다른 한 장은 물레책방 챗봇의 대화 기록입니다. 물레책방은 대학가 골목에 있어 유학생 손님이 많고, 지난달부터 챗봇이 영어 질문도 받습니다. 보라 매니저가 형광펜으로 줄을 그어 둔 곳을 가리킵니다. "오늘 몇 시에 문을 닫나요?"와 "What time do you close today?" 뜻이 같은 질문인데, 한국어로 물은 쪽에 요금이 더 붙어 있습니다. "한국어라서 돈을 더 받는 거예요?"
하은이 먼저 추측합니다. "한국어 글자가 복잡해서 그런가요? 아니면 글자 수로 받는데… 아, 그런데 한국어 문장이 글자 수는 더 적네요." 아래층에서 올라오는 인쇄기 냄새 사이로 정 선임이 의자를 끌고 옵니다. "추측하지 말고 찍어 보자. 청구서에 적힌 단위부터 봐."
청구서의 줄마다 붙은 단위는 글자도 단어도 아닌 토큰입니다. '입력 토큰', '출력 토큰'. 정 선임이 말합니다. "모델은 글자를 보지 않아. 토큰을 봐. 월요일에 만든 바이그램은 글자 하나가 한 칸이었지? 진짜 모델의 한 칸에는 글자 조각이 들어가. 그 조각을 어디서 자르는지가 오늘 일이야. 자르는 규칙을 우리가 직접 만들어 보면, 왜 한국어가 더 나오는지도 보여."
이번 교시에서는 글자, 바이트, 토큰을 나란히 세어 보고, 글자 밑에 있는 UTF-8 바이트를 확인합니다. 이어서 가장 자주 붙어 나오는 쌍을 하나씩 합치는 BPE를 실습대에서 한 단계씩 돌리고, 그 규칙이 어떤 글로 배웠느냐에 따라 같은 뜻의 문장이 다른 토큰 수가 된다는 것을 직접 잽니다. 마지막에 내 LLM의 토크나이저(mylm/bpe.py)를 짭니다.
대형 언어 모델을 API로 부르면, 응답과 함께 사용량이 돌아옵니다. 대개 입력 토큰 수(우리가 보낸 질문과 앞선 대화)와 출력 토큰 수(모델이 쓴 답)이고, 청구서는 이 두 수에 각각의 단가를 곱해 더합니다. 그러니 "왜 비싼가"는 "왜 토큰이 많은가"와 같은 질문입니다.
토큰(token) 은 모델이 한 칸으로 다루는 글 조각입니다. 1교시에서 언어 모델은 '다음 토큰'의 확률을 냈습니다. 그 토큰이 무엇인지 아래 실습대에서 직접 세어 봅시다. 실습대의 토큰은 참고 모델(1교시에서 정 선임이 보여 준, 단편 86편으로 학습한 작은 모델)의 토크나이저(tokenizer), 즉 글을 토큰으로 자르는 규칙이 자른 것입니다. 이 토크나이저는 하은의 모델과 같은 한국 단편소설 말뭉치로 배웠습니다.
글자 (칸 하나 = 글자 하나)
바이트 (16진수, 글자마다 묶음)
토큰 (칸 하나 = 토큰 하나, 누르면 자세히)
참고 모델을 불러오는 중…
토큰 칸을 누르면 그 토큰의 번호와, 말뭉치에서 몇 번 나온 조각인지 보입니다.
첫 문장을 세 가지로 세면 이렇습니다.
| 세는 단위 | 개수 | 나뉜 모습 |
|---|---|---|
| 단어(띄어쓰기 덩어리, 어절) | 6 | 오늘 / 저녁은 / 무엇을 / 먹을까 / 하고 / 생각하다가 |
| 글자(공백 포함) | 23 | 오 / 늘 / ␣ / 저 / 녁 / … |
| 토큰(참고 토크나이저) | 12 | ▁오늘 / ▁저녁 / 은 / ▁무엇 / 을 / ▁먹 / 을 / 까 / ▁하고 / ▁생각 / 하 / 다가 |
토큰은 단어도 글자도 아닙니다. '저녁은'은 '▁저녁'과 '은' 두 토큰이 되고, '오늘'은 한 토큰, '생각하다가'는 '▁생각 / 하 / 다가' 세 토큰입니다. 맨 앞의 ▁는 '여기서 낱말이 시작한다(앞에 공백이 있었다)'는 표시입니다. 이 표시 덕분에 토큰을 다시 이어 붙일 때 띄어쓰기를 되살릴 수 있습니다. 1교시 실습대의 막대 이름에 보이던 ␣가 바로 이 ▁입니다(1교시 화면은 빈칸이 눈에 띄게 ␣로 그렸습니다).
모델이 실제로 받는 것은 조각의 글자가 아니라 토큰 번호입니다. 첫 문장은 [1873, 2088, 1090, 1853, 1092, 1678, 1092, 183, 1638, 1652, 1483, 1636]이라는 번호 12개가 되어 모델로 들어갑니다. 번호는 어휘 목록 안의 자리일 뿐, 번호 자체에 뜻은 없습니다(뜻을 담는 일은 3교시의 임베딩이 합니다).
그러면 조각은 어디서 자를까요? 사람이 사전을 보고 정한 것이 아닙니다. 말뭉치에서 자주 붙어 나오는 글자끼리 묶는다는 규칙 하나로 기계가 정했습니다. 그 규칙(2-3절)을 보기 전에, 글자 밑에 무엇이 있는지부터 봅니다.
컴퓨터는 글자를 바이트(0–255 사이의 수)의 줄로 저장합니다. 그 사이에 두 단계가 있습니다.
| 글자 | 코드 포인트 | UTF-8 바이트 (16진수) | 바이트 수 |
|---|---|---|---|
| A | U+0041 | 41 | 1 |
| é | U+00E9 | C3 A9 | 2 |
| 가 | U+AC00 | EA B0 80 | 3 |
| 물 | U+BB3C | EB AC BC | 3 |
영문 알파벳과 숫자, 공백은 1바이트, 한글 음절은 3바이트입니다(이모지 같은 글자는 4바이트). 그래서 첫 문장은 글자 23개인데 바이트는 59개입니다. 한글 18자 × 3 + 공백 5개 × 1 = 59.
손으로 한 번 적어 봅시다. '가'의 번호 AC00을 2진수로 쓰면 1010 1100 0000 0000(16비트)입니다. 3바이트 UTF-8의 틀은 1110xxxx 10xxxxxx 10xxxxxx이고, x 자리 16개에 위 비트를 앞에서부터 4·6·6개씩 나눠 넣습니다.
| 바이트 | 틀 | 채운 비트 | 16진수 |
|---|---|---|---|
| 1 | 1110xxxx | 1110 1010 | EA |
| 2 | 10xxxxxx | 10 110000 | B0 |
| 3 | 10xxxxxx | 10 000000 | 80 |
파이썬으로 확인하면 같습니다.
>>> hex(ord("가"))
'0xac00'
>>> "가".encode("utf-8")
b'\xea\xb0\x80'
>>> s = "물레책방"
>>> len(s), len(s.encode("utf-8"))
(4, 12)
>>> len("book".encode("utf-8"))
4토크나이저는 어디서부터 묶기 시작할지 정해야 합니다. 방법은 두 가지입니다.
<unk>(모르는 글자) 하나, 모두 1,585개가 기본 어휘입니다. 3번 미만 나온 글자는 <unk> 하나로 뭉갭니다. 이 말뭉치는 한국 소설이라 영어 소문자는 c, i, l, n, o 다섯 개뿐이고, 실습대에서 본 것처럼 영어 문장은 대부분 <unk>가 됩니다. 그런 토큰은 decode해도 원래 글로 돌아오지 않습니다.<unk>가 생기지 않습니다. 대신 처음 보는 글자는 바이트 조각으로 잘게 쪼개지고, 한글 한 글자는 처음에 세 조각입니다. 여러 언어를 받아야 하는 대형 모델의 토크나이저는 대개 이 방식을 씁니다.정 선임의 한마디
<unk>가 보이면 토크나이저가 그 글을 본 적이 없다는 뜻이야. 바이트에서 시작하면 <unk>는 사라지지만 공짜는 아니야. 처음 보는 글은 바이트 조각으로 잘게 잘려서 토큰이 늘어나. 청구서가 느는 길이 바로 거기야.
BPE(byte pair encoding, 바이트 쌍 인코딩) 는 토큰을 만드는 가장 널리 쓰이는 방법입니다. 규칙은 하나입니다. 이웃한 두 조각 중 가장 자주 붙어 나오는 쌍을 하나로 합친다. 이것을 정한 횟수만큼 되풀이한다.
아래 실습대는 물레책방에 손님이 오가는 짧은 글로 이 과정을 한 단계씩 보여 줍니다.
낱말과 지금의 조각 (×횟수 = 글에 나온 횟수)
토큰 수 70 → 70 · 어휘 24개 (기본 글자 24 + 병합 0)
이웃한 쌍 세기 (많은 순)
아직 없음
낱말마다 ▁(낱말 시작 표시)를 붙이고 한 글자씩 쪼갠 상태입니다. 이웃한 두 조각의 쌍을 모두 세면 가장 많은 쌍은 주황으로 칠한 '다 .'(5번)입니다. '다음'을 누르면 합칩니다.
실습대에서 본 것을 정리합니다.
작은 숫자로 손 계산을 한 번 해 봅시다. 글이 "책방 책방 책방 책"이면 낱말은 ▁책방이 3번, ▁책이 1번입니다.
| 단계 | 낱말의 조각 (횟수) | 쌍 세기 | 합칠 쌍 | 토큰 수 |
|---|---|---|---|---|
| 0 | ▁ 책 방 (3), ▁ 책 (1) | (▁, 책) 3 + 1 = 4, (책, 방) 3 | ▁ + 책 | 3×3 + 2 = 11 |
| 1 | ▁책 방 (3), ▁책 (1) | (▁책, 방) 3 | ▁책 + 방 | 2×3 + 1 = 7 |
| 2 | ▁책방 (3), ▁책 (1) | 쌍 없음 | 멈춤 | 3 + 1 = 4 |
어휘는 기본 글자 '▁', '방', '책'과 <unk>에 병합으로 생긴 '▁책', '▁책방'이 더해져 6개가 됩니다.
파이썬으로는
2번 단계(쌍 세기)는 collections.Counter 하나면 됩니다. 낱말을 조각의 튜플로 두고, 그 낱말의 횟수만큼 더합니다.
>>> from collections import Counter
>>> words = {("▁", "책", "방"): 3, ("▁", "책"): 1}
>>> pairs = Counter()
>>> for sym, c in words.items():
... for a, b in zip(sym, sym[1:]):
... pairs[a, b] += c
>>> pairs
Counter({('▁', '책'): 4, ('책', '방'): 3})
>>> pairs.most_common(1)
[(('▁', '책'), 4)]zip(sym, sym[1:])이 이웃한 두 조각을 차례로 짝지어 줍니다. 동점이면 most_common(1)은 먼저 센 쌍을 돌려줍니다. 실습대와 '오늘의 조립'도 같은 규칙이라, 같은 글을 넣으면 같은 병합 표가 나옵니다.
참고 토크나이저는 한국 단편소설 86편(1,617,462자)으로 이 과정을 1,000번 되풀이했습니다. 병합 표의 처음은 이렇습니다.
| 순서 | 병합 | 무엇인가 |
|---|---|---|
| 1 | 다 + . | 문장 끝 '다.' |
| 2–13 | ▁ + 그, ▁ + 하, ▁ + 이, ▁ + 아, ▁ + 것 … | 낱말 첫 글자에 ▁ 붙이기 |
| 14 | 었 + 다. | 과거형 문장 끝 '었다.' |
| 15 | 으 + 로 | 조사 '으로' |
새 글을 토큰으로 자를 때(encode) 는 배운 병합을 배운 순서대로 적용합니다. 낱말을 한 글자씩 쪼갠 뒤, 이웃한 쌍 가운데 병합 표에서 가장 앞에 있는 규칙부터 하나씩 합칩니다. 아래 실습대가 참고 토크나이저의 병합 1,000개로 직접 입력한 낱말을 자릅니다.
참고 모델을 불러오는 중…
병합을 몇 번 할지는 사람이 정합니다. 병합을 늘리면 어휘가 커지고, 같은 글이 더 적은 토큰이 됩니다. 참고 토크나이저의 병합 표를 앞에서부터 k개만 써서 말뭉치 전체를 잘라 보았습니다.
| 병합 수 | 어휘 크기 | 말뭉치 토큰 수 | 토큰 하나에 글자 |
|---|---|---|---|
| 0 (글자 그대로) | 1,585 | 1,650,436 | 0.98 |
| 100 | 1,685 | 1,334,295 | 1.21 |
| 200 | 1,785 | 1,237,212 | 1.31 |
| 500 | 2,085 | 1,096,490 | 1.48 |
| 1,000 | 2,585 | 989,089 | 1.64 |
병합 0번이면 토큰이 글자보다 많습니다(토큰 하나에 0.98자). 낱말마다 ▁가 한 칸씩 따로 붙기 때문입니다. 처음 100번의 병합이 토큰을 31만 개 넘게 줄이고, 마지막 500번은 10만 개 남짓 줄입니다. 자주 나오는 쌍부터 합치므로 뒤로 갈수록 효과가 작아집니다.
그러면 병합을 무한히 늘리면 좋을까요? 저울의 양쪽은 이렇습니다.
| 어휘를 키우면 좋은 점 | 어휘를 키우면 드는 값 |
|---|---|
| 같은 글이 더 적은 토큰: 계산이 줄고, 요금이 줄고, 문맥 창에 더 많은 글이 들어간다 | 임베딩 표(어휘 크기 × d)와 출력층이 커진다 |
| 자주 쓰는 낱말이 한 조각이 되어 모델이 다루기 쉽다 | 드물게 나오는 조각은 학습 예가 적어 잘 배우지 못한다 |
참고 모델은 어휘 2,585개에 d = 128이라 임베딩 표가 2,585 × 128 = 330,880개의 숫자입니다. 모델 전체 매개변수 1,140,608개의 29%입니다. 작은 모델일수록 이 비중이 커서, 어휘를 함부로 키울 수 없습니다.
이제 신고로 돌아갑니다. 바깥 대형 모델의 토크나이저 속은 볼 수 없습니다. 그래서 정 선임은 원리가 같은 작은 토크나이저를 직접 만들어 보자고 합니다. 하은이 서점 챗봇에 들어올 법한 질문 44개를 한국어와 영어로 나란히 썼습니다. 38쌍은 토크나이저를 가르치는 데 쓰고, 6쌍은 배울 때 쓰지 않고 토큰 수를 재는 데만 씁니다. 토크나이저는 바이트 256개에서 시작하는 BPE입니다. 배울 글에서 영어와 한국어를 얼마나 섞을지는 손잡이로 바꿀 수 있습니다.
같은 뜻 문장 쌍 (배울 때 쓰지 않은 문장)
한국어 이 근처에 그림책을 파는 서점이 있나요? · 46토큰
영어 Is there a bookstore near here that sells picture books? · 25토큰
6쌍 합계
6쌍 합계: 한국어 233토큰, 영어 122토큰 → 한국어가 영어의 1.91배
영어가 대부분인 글에서 배우면 병합이 영어 조각(' the', 'ook' 같은)에 먼저 쓰입니다. 영어 문장은 빨리 줄고, 한국어 문장은 바이트 조각으로 남습니다.
작은 바이트 BPE: 서점 질문 38쌍(한국어·영어로 나란히 쓴 문장)을 비율대로 섞어 이 화면에서 바로 배웠습니다. 바이트 256개에서 시작해 낱말 앞에 공백 바이트를 붙입니다. 숫자는 이 작은 토크나이저의 것이고, 실제 서비스의 토크나이저와는 다릅니다.
실습대의 숫자를 표로 모았습니다(시험용 6쌍의 합계, 병합 100개).
| 한국어 | 영어 | 한국어 ÷ 영어 | |
|---|---|---|---|
| 글자 | 118 | 262 | 0.45 |
| UTF-8 바이트 | 284 | 262 | 1.08 |
| 토큰: 병합 0 (바이트 + 낱말마다 공백 1) | 290 | 268 | 1.08 |
| 토큰: 영어 90%로 배운 병합 100 | 233 | 122 | 1.91 |
| 토큰: 반반으로 배운 병합 100 | 143 | 152 | 0.94 |
| 토큰: 한국어 90%로 배운 병합 100 | 109 | 226 | 0.48 |
두 가지가 겹쳐서 차이가 납니다.
그러니 "한국어는 원래 비싼 언어"가 아닙니다. 토크나이저가 어떤 글로 배웠느냐가 토큰 수를 정합니다.
실제 서비스로 돌아가면 이렇습니다. API로 부르는 대형 모델의 토크나이저는 대개 바이트에서 시작하는 BPE 계열이고, 학습에 쓴 글에서 영어의 비중이 큰 경우가 많습니다. 그래서 같은 뜻이라도 한국어가 토큰을 더 쓰는 경향이 있습니다. 다만 얼마나 더 쓰는지는 토크나이저마다 다릅니다. 위 표의 1.91배는 우리가 만든 작은 토크나이저의 숫자이지 바깥 모델의 숫자가 아닙니다. 하은은 이 표를 보고서에 넣는 대신, 같은 질문 몇 개를 한국어와 영어로 API에 보내고 응답에 찍힌 입력 토큰 수를 직접 비교하기로 합니다. 대화 기록에서 확인한 대로 한국어 쪽 입력 토큰이 더 많았습니다.
토큰 수는 요금만 정하지 않습니다.
챗봇은 대화가 이어질 때마다 앞선 대화를 다시 입력으로 보냅니다. 앞부분의 토큰이 매번 다시 세어지는 셈입니다. 이 문제는 8교시(KV 캐시와 문맥 창)에서 다시 만납니다.
정 선임의 한마디
요금을 줄이고 싶으면 먼저 재. 응답에 찍힌 사용량을 한 달만 모아 보면, 매번 붙여 보내는 긴 안내문 같은 데서 토큰이 새는 게 보일 거야. 언어를 바꾸는 것보다 그런 걸 줄이는 게 먼저야.
mylm/bpe.py를 엽니다. words_of(글 → ▁가 붙은 낱말 목록), base_vocab(기본 어휘), save·load(JSON으로 저장·읽기)는 이미 들어 있습니다. 채울 함수는 다섯 개입니다.
def count_pairs(words):
"""words: {글자 조각 튜플: 그 낱말이 나온 횟수}. 이웃한 두 조각의 쌍마다 몇 번 나왔는지 센다.
>>> count_pairs({("▁", "책", "방"): 3, ("▁", "책"): 2})
Counter({('▁', '책'): 5, ('책', '방'): 3})
"""
raise NotImplementedError("2교시에서 채웁니다")
def merge_pair(sym, a, b):
"""조각 튜플 sym에서 이웃한 (a, b)를 왼쪽부터 모두 a+b 하나로 합친다.
>>> merge_pair(("▁", "책", "방", "책"), "▁", "책")
('▁책', '방', '책')
"""
raise NotImplementedError("2교시에서 채웁니다")
def train(text, n_merges, min_count=1):
"""병합 규칙을 배운다. 돌려주는 값: (vocab, merges)
vocab: 기본 어휘 + 병합으로 생긴 조각(병합한 순서대로). 토큰 번호 = vocab 안의 자리.
merges: [(a, b), ...] 병합한 순서대로.
한 번에 가장 많이 나온 쌍 하나를 합친다(동점이면 먼저 센 쌍: Counter.most_common(1)).
가장 많은 쌍도 2번 미만이면 일찍 멈춘다.
>>> vocab, merges = train("책방 책방 책방 책", 2)
>>> merges
[('▁', '책'), ('▁책', '방')]
>>> vocab
['▁', '방', '책', '<unk>', '▁책', '▁책방']
"""
raise NotImplementedError("2교시에서 채웁니다")
def encode(text, vocab, merges):
"""글 → 토큰 번호 목록. 낱말마다 글자로 쪼갠 뒤, 배운 순서가 빠른 병합부터 적용한다.
>>> vocab, merges = train("책방 책방 책방 책", 2)
>>> encode("책방 책 방", vocab, merges)
[5, 4, 0, 1]
"""
raise NotImplementedError("2교시에서 채웁니다")
def decode(ids, vocab):
"""토큰 번호 목록 → 글. 조각을 이어 붙이고 ▁를 공백으로 되돌린다.
>>> vocab, merges = train("책방 책방 책방 책", 2)
>>> decode([5, 4, 0, 1], vocab)
'책방 책 방'
"""
raise NotImplementedError("2교시에서 채웁니다")몇 가지 도움말입니다.
train은 collections.Counter(words_of(text))로 낱말을 세고, base_vocab(counts, min_count)로 기본 어휘를 만든 뒤, 낱말마다 글자 튜플을 만들어 시작합니다(어휘에 없는 글자는 UNK). 그다음은 2-3절의 1–4 그대로입니다. 합친 뒤에는 모든 낱말에 merge_pair를 적용하고, 결과가 같아진 낱말은 횟수를 더해 하나로 둡니다.encode는 {조각: 번호}와 {(a, b): 병합 순서} 사전을 먼저 만들어 두면 쉽습니다. 낱말마다 "이웃한 쌍 중 병합 순서가 가장 빠른 쌍"을 찾아 합치기를, 더 합칠 쌍이 없을 때까지 되풀이합니다.decode는 조각을 이어 붙인 뒤 줄바꿈 바로 뒤의 ▁는 지우고("\n▁" → "\n"), 나머지 ▁는 공백으로 바꾸고, 맨 앞 공백 하나를 뗍니다.다 채웠으면 확인합니다. data/corpus.txt가 있으면 말뭉치 앞 5만 자로 한 번 더 확인합니다(초는 컴퓨터마다 다릅니다).
$ python checks/check_02.py
통과 count_pairs: 쌍마다 낱말 횟수를 더해 센다
통과 merge_pair: 왼쪽부터 겹치지 않게 모두 합친다
통과 train: 짧은 예(병합 2번)
통과 train: 물레책방 글에서 병합 3번
통과 train: 물레책방 글에서 병합 10번 뒤 멈춤, 어휘 34개
통과 encode: 물레책방 글 → 토큰 39개 (글자 69개)
통과 encode: 어휘에 없는 글자는 <unk>
통과 decode: 토큰 번호 → 원래 글
통과 왕복: decode(encode(글)) == 글 (줄바꿈 포함)
통과 말뭉치 앞 5만 자: 병합 50번이 기준과 같다 (0.5초), 앞 5천 자 → 토큰 4,251개
모두 통과 (10개). 내 BPE 토크나이저가 완성되었습니다.
이어서: python -m mylm.bpe 200 → 말뭉치에서 병합 200번을 배워 data/tokenizer.json에 저장합니다.물레책방 글의 결과(병합 10번, 토큰 39개)는 2-3절 실습대와 같습니다. 이어서 말뭉치 전체로 병합 200번을 배워 data/tokenizer.json에 저장합니다. 말뭉치에서는 3번 미만 나온 글자를 <unk>로 두는 min_count=3을 씁니다.
$ python -m mylm.bpe 200
병합 200번, 어휘 1,785개, 59.5초
처음 2만 자 → 토큰 15,233개 (토큰당 1.31자)
data/tokenizer.json에 저장했습니다.우리 데스크톱에서 병합 200번은 1분쯤, 1,000번은 4분 안팎 걸렸습니다. python -m mylm.bpe 1000처럼 1,000번을 다 돌리면 참고 토크나이저와 똑같은 병합 1,000개와 어휘 2,585개가 나옵니다. 같은 규칙, 같은 말뭉치이기 때문입니다. 기다리기 어렵다면 200번으로 저장한 것으로 다음 교시를 이어 가도 됩니다.
| 기호 | 읽는 법 | 모양 | 코드에서는 |
|---|---|---|---|
| ▁ | 낱말 시작 표시(앞에 공백이 있었다) | 글자 하나 | SPACE |
<unk> | 어휘에 없는 글자 | 토큰 하나 | UNK |
| V | 어휘 크기(토큰 종류의 수) | 수 하나, 참고 모델 2,585 | len(vocab) |
| 병합 표 | 배운 순서대로의 (a, b) 목록 | 길이 = 병합 수, 참고 모델 1,000 | merges |
| 토큰 번호 | 어휘 안의 자리 | 0 이상 V 미만의 정수 | vocab.index(조각), id_of[조각] |
| T | 글 하나의 토큰 수 | 정수 목록의 길이 T | len(encode(text, vocab, merges)) |
| 바이트 | UTF-8로 적은 0–255의 수 | 한글 한 글자 = 3개 | text.encode("utf-8") |
| 참고 모델 | 대형 언어 모델 (자릿수) | |
|---|---|---|
| 어휘 크기 | 2,585 (10³) | 수만–수십만 (10⁴–10⁵) |
| 시작 단위 | 글자(+ <unk>) | 대개 바이트(<unk> 없음) |
| 토크나이저를 배운 글 | 한국 단편소설 162만 자 | 여러 언어가 섞인 아주 큰 글 묶음 |
| 문맥 창 | 128토큰 | 수천–수십만 토큰 이상 |
규모는 자릿수로 한두 단계에서 서너 단계까지 차이 나지만 규칙은 같습니다. 자주 붙어 나오는 쌍을 합치고, 배운 순서대로 자릅니다. 어휘가 큰 토크나이저는 한 언어만이 아니라 여러 언어의 조각을 함께 담을 자리가 있어서, 언어 사이의 차이가 우리 실습대보다 작을 수 있습니다. 실제 차이는 쓰는 모델에서 직접 재는 수밖에 없습니다.
train("책방 책방 책 방", 5)로 확인할 수 있습니다.오늘의 조립 풀이: mylm/bpe.py
mylm/bpe.py의 다섯 함수 정답입니다. 내 코드가 확인 스크립트를 통과했다면 다르게 써도 괜찮습니다.
def count_pairs(words):
pairs = collections.Counter()
for sym, c in words.items():
for a, b in zip(sym, sym[1:]):
pairs[a, b] += c
return pairs
def merge_pair(sym, a, b):
out, j = [], 0
while j < len(sym):
if j < len(sym) - 1 and sym[j] == a and sym[j + 1] == b:
out.append(a + b)
j += 2
else:
out.append(sym[j])
j += 1
return tuple(out)
def train(text, n_merges, min_count=1):
counts = collections.Counter(words_of(text))
vocab = base_vocab(counts, min_count)
known = set(vocab)
words = {}
for w, c in counts.items():
sym = tuple(ch if ch in known else UNK for ch in w)
words[sym] = words.get(sym, 0) + c
merges = []
for _ in range(n_merges):
pairs = count_pairs(words)
if not pairs:
break
(a, b), n = pairs.most_common(1)[0]
if n < 2:
break
merges.append((a, b))
vocab.append(a + b)
new = {}
for sym, c in words.items():
if a in sym: # a가 없는 낱말은 그대로 둔다(빨라진다)
sym = merge_pair(sym, a, b)
new[sym] = new.get(sym, 0) + c
words = new
return vocab, merges
def encode(text, vocab, merges):
id_of = {v: i for i, v in enumerate(vocab)}
rank = {p: i for i, p in enumerate(merges)}
ids = []
for w in words_of(text):
sym = [ch if ch in id_of else UNK for ch in w]
while len(sym) > 1:
# 이웃한 쌍 가운데 가장 먼저 배운 병합(rank가 가장 작은 쌍)을 찾는다
best = min(range(len(sym) - 1), key=lambda j: rank.get((sym[j], sym[j + 1]), float("inf")))
if (sym[best], sym[best + 1]) not in rank:
break
sym[best:best + 2] = [sym[best] + sym[best + 1]]
ids += [id_of[s] for s in sym]
return ids
def decode(ids, vocab):
s = "".join(vocab[i] for i in ids)
s = s.replace("\n" + SPACE, "\n").replace(SPACE, " ")
return s[1:] if s.startswith(" ") else sdocstring은 '오늘의 조립'의 빈칸과 같아 뺐습니다. train이 if a in sym으로 a가 없는 낱말을 건너뛰는 것은 속도 때문입니다. 빼도 결과는 같습니다.
추적 보고서
청구서는 글자나 단어가 아니라 토큰을 센다. 토큰은 토크나이저가 자른 글 조각이고, 모델이 받는 것은 그 번호다.
한글 한 글자는 UTF-8로 3바이트, 영문 한 글자는 1바이트다. 그래서 같은 뜻의 한국어 문장은 글자가 훨씬 적어도 바이트로는 영어와 비슷해진다.
BPE는 가장 자주 붙어 나오는 쌍을 하나씩 합친다. 어떤 조각이 한 토큰이 되는지는 토크나이저가 배운 글이 정한다.
직접 만든 작은 바이트 BPE에서, 영어 90%로 배우면 한국어가 1.91배의 토큰을 썼고 한국어 90%로 배우면 0.48배로 뒤집혔다. 원인은 언어가 아니라 배운 글의 비율이다.
바깥 대형 모델의 실제 차이는 토크나이저마다 다르다. 같은 질문을 두 언어로 보내 응답의 입력 토큰 수를 비교했고, 한국어 쪽이 많았다. 요금을 줄이려면 매번 다시 보내는 긴 안내문부터 줄인다.
내 LLM: mylm/bpe.py(count_pairs, merge_pair, train, encode, decode)를 채우고 check_02를 통과했다. 말뭉치로 병합 200번을 배워 data/tokenizer.json에 저장했다.
보고서를 받은 보라 매니저가 고개를 끄덕이다가, 금요일 아침에 다시 하은의 자리로 옵니다. 이번에는 항구 앞에 있는 '항구서점'의 신고입니다. "손님이 배 타고 가는 여행책을 찾아서 사장님이 챗봇에 물었는데, 과일 배 얘기를 늘어놨대요." 하은은 참고 토크나이저로 '배'를 잘라 봅니다. 어느 문장의 '배'든 같은 토큰 번호 하나입니다. 번호만으로는 배와 배를 구별할 방법이 없습니다. 3교시에서는 토큰 번호가 숫자 목록(임베딩)이 되는 과정을 따라가고, 그 목록만으로는 왜 아직 세 가지 '배'를 가를 수 없는지 확인합니다.
새 교시·새 강의 소식 받기
이 강의에 새 교시가 더해지거나 이어지는 강의가 열리면 이메일로 알려 드려요.
R
ReflexKR
눌러 보며 배우는 파이썬과 Reflex. 모든 예제는 Reflex 0.10.0에서 직접 실행해 확인했습니다.
ReflexKR은 배너 광고 없이 무료로 운영돼요. 도움이 되셨다면 커피 한 잔으로 응원해 주세요.
© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
질문과 답변
이 교시에서 궁금한 점을 남겨 주세요. 모든 언어 페이지의 질문이 함께 모입니다.