LabHub
배우기 러닝패스 코스

트랜스포머 — 어텐션을 손으로 계산한다 · 토큰화와 어휘 · 이론

같은 문장인데 토큰 수가 다르다

LabHub 에서 이어서 보기

한 줄 요약

토큰은 글자도 낱말도 아니다. 어휘를 만들 때 정해지는 조각이고, 어휘가 모델마다 다르기 때문에 같은 문장의 토큰 수도 모델마다 다르다.

왜 이게 필요했나

요금표에도 문맥 한도에도 단위는 토큰이다. 그런데 화면에서 셀 수 있는 것은 글자뿐이라, 한동안은 "글자 수에 얼마쯤 곱하면 되겠지" 로 어림한다. 그 어림이 언제 깨지는지가 문제다.

깨지는 자리는 늘 같다. 한국어 문서를 넣었더니 예상보다 훨씬 비싸다. 같은 뜻의 영어 문서는 싸다. 로그를 그대로 넣었더니 짧은 글인데 토큰이 폭발한다. 이모지가 섞인 사용자 입력에서만 길이 계산이 어긋난다.

전부 같은 원인이다. 토큰은 글자 단위가 아니다. 어떤 조각을 하나로 셀지가 어휘에 적혀 있고, 그 어휘는 학습 자료를 보고 만들어졌다. 영어가 많은 자료로 만든 어휘라면 영어의 흔한 조각은 한 토큰이고 한국어는 잘게 쪼개진다.

여기서 더 나쁜 것은 이 차이가 조용하다는 점이다. 오류가 나지 않는다. 그저 청구서와 문맥 한도에서만 드러난다.

왜 바이트에서 시작하나

옛 방식은 낱말 사전을 두고 사전에 없는 낱말을 <UNK> 하나로 뭉갰다. 사전에 없는 이름, 오타, 새 유행어, 이모지가 전부 같은 토큰이 되어 버리니 되돌릴 수도 없었다.

지금 쓰는 방식은 바이트에서 시작한다. 글을 UTF-8 로 적으면 어떤 글자든 1부터 4까지의 바이트가 되고, 바이트는 0 부터 255 까지 256가지뿐이다. 그 256개를 처음 어휘로 두면 어휘 밖의 글자라는 것이 아예 생기지 않는다.

"A".encode("utf-8")      # b'A'          → 1바이트"가".encode("utf-8")     # b'\xea\xb0\x80' → 3바이트

여기에 한국어의 비용이 그대로 드러난다. 한글 음절 하나는 UTF-8 로 3바이트다. 어휘가 작아서 합칠 규칙이 몇 개 없으면 한글 한 글자가 토큰 세 개가 된다. 영어 알파벳은 1바이트라 같은 조건에서 한 글자가 한 토큰이다. 출발선이 세 배 차이라는 뜻이다.

같은 다섯 글자의 바이트 수를 견준 그림. 안녕 뒤에 빈칸과 h 와 i 를 붙인 다섯 글자는 UTF-8 로 3, 3, 1, 1, 1 바이트라 모두 9바이트이고 hello 는 모두 5바이트다. 병합 규칙이 하나도 없으면 바이트 하나가 곧 토큰 하나여서 9 토큰과 5 토큰이 된다

어떻게 어휘를 만드나

[BPE(Byte Pair Encoding)](https://arxiv.org/abs/1508.07909) 가 하는 일은 한 문장으로 적힌다 — 가장 자주 붙어 다니는 두 개를 하나로 합치는 일을, 어휘가 원하는 크기가 될 때까지 되풀이한다.

한 바퀴는 세 걸음이다.

1. 지금 목록에서 이웃한 두 개가 몇 번씩 붙어 나오는지 센다.
2. 가장 많이 나온 짝을 고른다.
3. 그 짝이 나오는 자리를 새 번호 하나로 바꾼다. 새 번호는 256부터 하나씩 올라간다.

ids = [104, 101, 108, 108, 111]      # "hello"counts = {(104,101):1, (101,108):1, (108,108):1, (108,111):1}# 전부 1회라 동점이다 — 동점을 어떻게 깰지 정해 두지 않으면# 같은 글로 돌려도 어휘가 매번 달라진다.

동점 처리는 사소해 보이지만 실제로는 중요하다. 학습이 결정적이지 않으면 어제 만든 어휘와 오늘 만든 어휘가 달라지고, 그러면 어제 부호화한 데이터를 오늘 모델이 다르게 읽는다. [Hugging Face 의 BPE 설명](https://huggingface.co/learn/llm-course/en/chapter6/5)도 같은 자리에서 규칙을 못 박는다.

멈추는 시점이 곧 어휘 크기다. 256에서 멈추면 병합이 하나도 없고, 5만에서 멈추면 병합 규칙이 4만 9천여 개다. 어휘 크기는 모델을 만들 때 정하는 값이고, 바꾸면 임베딩 표의 크기도 함께 바뀐다.

다만 어휘 크기는 상한일 뿐이다. 합칠 만한 쌍이 떨어지면 거기서 멈춘다. 한 번밖에 안 나오는 쌍을 합쳐 봐야 어휘 칸만 하나 쓰고 토큰은 하나도 줄지 않기 때문이다. 자료가 적으면 5만을 달라고 해도 몇천에서 끝난다 — 어휘 크기를 키우는 것과 실제로 어휘가 커지는 것은 다른 일이다.

부호화는 배운 순서대로

규칙을 다 배운 뒤 새 글을 부호화할 때, 배운 순서를 그대로 지켜야 한다.

이유는 뒤 규칙이 앞 규칙의 결과를 재료로 쓰기 때문이다. 256번을 만든 규칙이 먼저 돌아야 257번 규칙이 256번을 볼 수 있다. 순서를 뒤섞으면 같은 규칙 목록으로도 다른 토큰이 나오고, 그 상태로 학습한 모델은 서비스에서 다른 글을 읽게 된다.

복호화는 그 반대다. 새 번호를 두 개로 풀고, 그 둘 중에 또 새 번호가 있으면 다시 푼다. 남는 것이 없으면 바이트만 남고, 그것을 UTF-8 로 읽으면 원문이다. 원문과 한 글자도 다르면 안 된다 — 이 왕복이 깨지면 모델이 무엇을 읽었는지 아무도 되짚을 수 없다.

현장에서 만나는 모습

첫째, 한국어 서비스의 청구서가 예상의 두세 배로 나온다. 영어 기준으로 어림한 글자당 토큰 수를 그대로 적용해 견적을 냈기 때문이다. 고칠 방법은 하나뿐이다 — 자기 서비스의 실제 문장으로 재는 것이다.

둘째, 문맥 한도에 예고 없이 부딪힌다. 글자 수로는 한참 여유가 있는데 토큰으로는 이미 넘었다. 긴 문서를 자르는 코드가 글자 기준이면 어떤 언어에서는 너무 많이 자르고 어떤 언어에서는 못 자른다.

셋째, 같은 내용인데 형식만 바꿨더니 토큰이 늘어난다. 들여쓰기가 깊은 JSON, 줄바꿈이 많은 로그, 표를 공백으로 맞춘 문서가 그렇다. 공백과 줄바꿈도 바이트이고, 어휘에 그 조각이 없으면 하나하나가 토큰이 된다.

넷째, 이모지와 희귀 문자에서 길이 계산만 어긋나고 오류는 안 난다. 바이트 수준이라 깨지지는 않는다. 다만 이모지 하나가 4바이트라 어휘에 없으면 토큰 서넛을 먹는다.

다섯째, 토크나이저를 바꾸면 모델을 다시 학습해야 한다. 토크나이저는 모델 바깥의 전처리 도구가 아니라 모델의 일부다. 번호가 달라지면 임베딩 표의 줄이 달라지고, 그건 다른 모델이다.

실무에서 진짜 중요한 것

다음 실습에서 할 것

/root/work/tf-token/bpe.py 를 한 단계씩 키운다. 실제 모델의 토크나이저를 부르는 것이 아니라 같은 알고리즘을 표준 라이브러리만으로 직접 만든다 — 이 파드에는 transformers 도 tokenizers 도 tiktoken 도 없고, numpy 조차 /opt/onnx-lab/bin/python 안에만 있다. 그래서 여기서 나오는 숫자는 전부 여러분이 만든 어휘로 잰 것이다.

UTF-8 바이트로 여는 것에서 시작해 인접 쌍 세기, 한 쌍 합치기, 병합 규칙 학습, 배운 순서대로 부호화, 정확한 복호화까지 만든다. 그다음 어휘 크기를 바꿔 가며 같은 글의 토큰 수가 줄어드는 곡선을 잰다.

마지막 단계가 이 실습의 요점이다. 같은 내용을 담은 한국어 문단과 영어 문단을 두고 어휘를 두 벌 배운다 — 하나는 두 글을 함께 보고 배운 어휘, 다른 하나는 영어만 보고 배운 어휘다. 크기는 같다. 그리고 두 어휘로 같은 문단을 부호화해 토큰 수를 나란히 놓는다. 영어만 보고 배운 어휘에는 한글 바이트를 합치는 규칙이 하나도 없으므로 한국어는 바이트 수에서 거의 줄지 않는다. 그런데도 복호화는 정확하다. 손해와 튼튼함이 같은 구조에서 나온다는 것을 숫자로 보게 된다. 채점기는 여러분의 모듈을 실제로 불러 매번 다른 입력으로 함수를 직접 두드려 보고, 자기가 따로 계산한 값과 대조한다.