지난 글에서 BPE와 WordPiece가 말뭉치를 훑어 서브워드 어휘를 만드는 알고리즘을 봤다. 알고리즘을 알고 나면 남는 질문은 하나다 — 그래서 실제로는 무엇을 설치해 무엇을 부르는가. 이 글은 그 자리를 채우는 두 라이브러리를 한자리에서 다룬다. SentencePiece는 어휘를 직접 학습하는 쪽이고, tiktoken은 이미 학습된 어휘로 토큰을 세는 쪽이다.
둘을 한 편에 묶는 이유가 있다. 실무에서 이 둘은 서로 다른 두 질문에 답하는데, 그 두 질문이 같은 프로젝트 안에서 나란히 나온다. 하나는 「내 데이터에 맞는 어휘를 어떻게 만드는가」이고, 다른 하나는 「남이 정해 둔 어휘에서 내 텍스트가 몇 토큰인가」다. 앞의 것을 모르면 한국어 모델을 학습시킬 수 없고, 뒤의 것을 모르면 API 비용도 컨텍스트 한계도 계산할 수 없다.
학습기와 인코더
알고리즘과 구현
BPE도 WordPiece도 Unigram도 알고리즘의 이름이지 파일 이름이 아니다. 논문의 의사코드를 그대로 옮겨 적으면 몇십 줄로 돌아가기는 하지만, 실제 모델이 쓰는 어휘를 만들려면 수십 기가바이트의 말뭉치를 견디는 구현과, 학습 결과를 담아 배포할 파일 형식과, 그 파일을 다시 읽어 똑같이 쪼개 주는 인코더가 함께 있어야 한다. 라이브러리가 채우는 자리가 여기다.
그리고 실무에서 마주치는 상황은 딱 둘로 갈린다. 어휘가 아직 없어서 만들어야 하는 자리와, 어휘가 이미 정해져 있어서 그것에 맞춰야 하는 자리다. 앞쪽은 자체 모델을 학습시키거나 도메인 특화 어휘가 필요할 때이고, 뒤쪽은 남의 API나 공개 체크포인트를 쓸 때다. 두 자리에서 손에 잡는 도구가 다르다.
산출물과 API
SentencePiece는 Google의 Kudo와 Richardson이 2018년에 공개한 라이브러리로, 학습기와 인코더를 둘 다 갖고 있다. 말뭉치 텍스트 파일을 넣으면 .model 파일이 나오고, 그 파일을 다시 읽어 문자열을 토큰과 ID로 바꾼다. T5, LLaMA 2, mT5, ALBERT, XLNet 같은 공개 모델이 어휘를 실어 나르는 형식이 바로 이 .model 파일이다.
tiktoken은 OpenAI가 공개한 라이브러리인데 학습기가 없다. OpenAI가 이미 정해 둔 어휘와 병합 규칙을 그대로 재현하는 인코더만 있다. 산출물을 만드는 도구가 아니라 상수를 읽는 도구다. 핵심 연산이 Rust로 구현되어 있고 Python 바인딩을 통해 부르는 구조다.
이 차이가 두 라이브러리의 API 모양을 그대로 결정한다. SentencePiece에는 SentencePieceTrainer가 있고 하이퍼파라미터를 열 개 넘게 받는다. tiktoken에는 그런 것이 없고, 대신 「어느 인코딩을 쓸 것인가」를 고르는 함수 두 개가 있을 뿐이다.
검색 파이프라인
문서 검색 파이프라인을 하나 만든다고 하자. 문서를 잘라 조각으로 나누고, 각 조각을 임베딩해 저장하고, 질문이 오면 관련 조각을 붙여 모델에 보낸다. 조각의 크기를 정할 때 「1,000자」로 자르면 안 된다. 임베딩 모델이든 생성 모델이든 받아들이는 단위는 문자가 아니라 토큰이고, 한국어는 문자 하나가 토큰 하나가 아니기 때문이다. OpenAI 모델을 쓴다면 이 지점에서 tiktoken이 나온다.
같은 프로젝트에서 문서가 특정 도메인에 몰려 있고 자체 소형 모델을 파인튜닝하기로 했다면, 이번에는 그 도메인 텍스트로 어휘를 새로 학습할 차례다. 이 지점에서 SentencePiece가 나온다. 두 도구가 하는 일이 겹치지 않으므로 「둘 중 무엇을 쓸까」가 아니라 「지금 어느 질문에 답하는 중인가」가 실제 물음이다.
사전 토크나이제이션
언어별 전처리
사전 토크나이제이션(pre-tokenization)은 서브워드 알고리즘을 돌리기 전에 텍스트를 단어 비슷한 조각으로 미리 갈라 두는 단계다. 지난 글의 BPE 학습 절차가 「말뭉치를 단어별로 세어」로 시작했던 것을 기억하면 된다. 그 「단어별로」를 누군가 해 줘야 하는데, 그 방법이 언어마다 다르다.
영어는 공백으로 자르면 대충 된다. 중국어는 공백이 아예 없어 문자 단위로 자르거나 별도 분사기를 돌린다. 일본어는 MeCab 같은 형태소 분석기가 필요하고, 한국어도 마찬가지다. 문제는 이 단계가 세 가지를 한꺼번에 끌고 들어온다는 것이다. 언어마다 다른 코드를 유지해야 하고, 형태소 분석기의 사전과 버전이 바뀌면 같은 문장이 다르게 갈라지며, 다국어 말뭉치에서는 문장마다 어느 언어인지를 먼저 판별해야 한다.
특히 마지막이 고약하다. 「서울特별시 Tokyo 2024年」처럼 한 문장에 세 언어가 섞이면 어느 규칙을 적용할지부터 정할 수 없다. 다국어 모델을 만들려는 사람에게 이 단계는 알고리즘보다 먼저 무너지는 자리였다.
▁ 기호
SentencePiece는 이 단계를 통째로 없앤다. 텍스트를 유니코드 문자의 스트림으로만 취급하고, 공백조차 ▁(U+2581, Lower One Eighth Block) 기호로 치환해 다른 문자와 똑같이 다룬다. 알고리즘이 보는 것은 「단어들의 목록」이 아니라 「문자들의 줄」 하나뿐이고, 그 줄에는 언어가 몇 개 섞여 있든 상관이 없다.
이 치환 하나가 두 가지 일을 한다. 첫째, 역변환이 완벽해진다. 토큰을 다시 이어 붙이고 ▁를 공백으로 되돌리면 원본 문자열이 그대로 나온다. 둘째, 단어의 시작이 명시된다. BPE가 </w>로 단어의 끝을 표시하고 WordPiece가 ##으로 「이것은 단어 중간이다」를 표시했다면, SentencePiece는 반대로 「여기가 단어의 시작이다」를 접두사로 표시한다.
import sentencepiece as spm
sp = spm.SentencePieceProcessor()
sp.load("llama2.model")
tokens = sp.encode("Hello, World!", out_type=str)
# ['▁Hello', ',', '▁World', '!']
ids = sp.encode("Hello, World!")
# [15043, 29892, 2787, 29991]
▁Hello와 Hello가 어휘에서 별개의 항목이라는 점을 눈여겨볼 만하다. LLaMA 2 어휘에서 앞의 것은 15043번이고 뒤의 것은 10994번이다. 앞에 공백이 있으면 ▁Hello가 되고 (Hello처럼 앞 글자에 붙어 있으면 Hello가 되므로, 프롬프트를 조립할 때 공백 하나 차이로 토큰 수와 ID가 함께 달라진다. 문장 첫머리는 학습기가 ▁를 하나 얹어 주므로 공백 뒤와 같은 대접을 받는다.
무손실 복원
역변환이 완벽하다는 말은 조건부로 읽어야 한다. 정확히는 공백 정보가 데이터에서 사라지지 않는다는 뜻이다. 비교해 보면 분명해진다. WordPiece가 ['한국', '##어']를 내놓았을 때 ##이 없는 첫 토큰 앞에 공백이 있었는지 없었는지는 토큰만 봐서는 알 수 없다. 문장 첫머리였을 수도 있고 앞 단어와 붙어 있었을 수도 있다. 그래서 디코더가 「첫 토큰 앞에는 공백을 넣지 않는다」 같은 규칙을 따로 들고 있어야 하고, 그 규칙이 틀리는 자리가 생긴다. ▁는 그 정보를 데이터 안에 넣어 두므로 규칙이 필요 없다.
다만 학습기의 기본 설정에는 텍스트 정규화가 들어 있다. 유니코드 정규화를 적용하고 연속된 공백을 하나로 줄이는 것이 기본값이라, 원문에 전각 문자나 이어진 공백이 있으면 복원 결과가 원문과 글자 단위로 같지 않을 수 있다. 코드나 로그처럼 공백 하나까지 의미가 있는 텍스트를 다룰 때는 정규화 규칙과 여분 공백 제거를 꺼야 한다. 「무손실」은 알고리즘의 성질이고, 그 앞에 붙은 전처리는 설정의 문제다.
모드와 어휘 학습
BPE와 Unigram
SentencePiece는 어휘를 만드는 알고리즘으로 두 가지를 지원한다. BPE 모드는 지난 글에서 본 바이트 쌍 병합을 사전 분리 없이 유니코드 수준에서 돌린다. LLaMA 2가 이 방식이다.
Unigram 모드는 방향이 반대다. 병합으로 쌓아 올리는 대신, 크고 넉넉한 초기 어휘에서 시작해 말뭉치 전체의 우도를 가장 적게 떨어뜨리는 토큰부터 지운다. 목표 어휘 크기에 닿을 때까지 깎아 내는 것이다. T5, ALBERT, XLNet이 Unigram 모드를 쓴다. BPE가 아래에서 위로 붙이고 Unigram이 위에서 아래로 깎는다고 보면 된다.
이 차이가 남기는 것이 있다. BPE의 산출물은 병합 규칙의 순서 있는 목록이라 인코딩할 때 규칙을 순서대로 적용하면 분할이 하나로 정해진다. Unigram의 산출물은 각 조각과 그 확률의 표다. 규칙이 아니라 점수를 들고 있으므로, 같은 문자열을 어휘로 쪼개는 방법이 여럿일 때 무엇을 고를지는 인코딩 시점에 따로 정해야 한다.
Viterbi 분할과 서브워드 정규화
Unigram은 분할 의 확률을 조각 확률의 곱으로 정의한다.
가능한 분할 중 이 값이 가장 큰 것을 고르면 되는데, 분할의 가짓수는 문자열 길이에 지수적으로 늘어나므로 전부 세어 볼 수 없다. 대신 Viterbi 알고리즘을 쓴다 — 문자열의 각 위치까지의 최적 분할을 왼쪽부터 차례로 채워 나가는 동적 계획법이고, 길이에 대해 선형에 가깝게 끝난다.
여기서 Unigram만의 쓸모가 하나 더 나온다. 최적 분할 하나만 쓰는 대신 확률이 높은 여러 분할 중에서 뽑아 쓰는 것이다. 이것이 서브워드 정규화(subword regularization)다. 같은 문장이 매 에폭 조금씩 다르게 쪼개지므로 데이터 증강과 같은 효과가 나고, 모델이 특정 분할에 과하게 의존하지 않게 된다. 학습할 때만 켜고 추론할 때는 최적 분할 하나로 되돌린다.
학습 파라미터
한국어 전용 모델이나 도메인 특화 어휘가 필요하면 직접 학습한다. 텍스트 파일 하나와 학습기 호출 한 번이면 된다.
spm.SentencePieceTrainer.train(
input="corpus.txt",
model_prefix="my_model",
vocab_size=32000,
model_type="unigram",
character_coverage=0.9999,
)
손잡이는 사실상 셋이다. vocab_size는 어휘 크기이고 한국어 전용이면 16K32K, 다국어라면 64K128K가 흔한 범위다. character_coverage는 말뭉치에 나온 문자 중 몇 퍼센트를 어휘에 담을지를 정한다. 1.0이면 딱 한 번 나온 희귀 한자까지 전부 넣고, 0.9999면 상위 99.99%만 담고 나머지는 알 수 없는 토큰으로 흘린다. 문자 종류가 적은 영어에서는 1.0으로 둬도 그만이지만, 한글 음절과 한자가 섞이는 말뭉치에서는 0.9995~0.9999를 권한다 — 오타나 깨진 인코딩에서 온 문자 하나까지 어휘 자리를 차지하는 것을 막기 위해서다.
user_defined_symbols는 통째로 한 토큰이어야 하는 문자열을 지정한다. 특수 토큰이 대표적이고, 태그나 구분자처럼 절대 쪼개지면 안 되는 것도 여기에 넣는다. 그리고 pad_id, unk_id, bos_id, eos_id로 특수 토큰의 ID를 못 박을 수 있는데, 이 자리를 학습기에 맡기지 말고 명시하는 편이 낫다. 나중에 학습 코드나 서빙 코드에서 이 숫자를 상수로 쓰게 되기 때문이다.
어휘 크기와 한국어
모델별 어휘와 커버리지
어휘를 어떻게 만들었는지는 모델 카드에 잘 적히지 않지만, 어휘 크기와 알고리즘만 봐도 한국어를 얼마나 감당하는지 짐작이 간다.
| 모델 | 어휘 크기 | 알고리즘 | 한국어 커버리지 |
|---|---|---|---|
| T5 | 32,000 | Unigram | 낮음 |
| LLaMA 2 | 32,000 | BPE | 낮음 |
| mT5 | 250,000 | Unigram | 높음 |
| SOLAR 10.7B | 32,000 | BPE | 낮음 |
| EXAONE | 102,400 | BPE | 높음 |
읽는 법은 단순하다. 어휘가 32,000이고 학습 말뭉치가 영어 중심이면 그 32,000자리의 대부분은 영어 조각이 차지한다. LLaMA 2의 어휘 파일을 열어 세어 보면 한글이 든 조각이 111개뿐이고, 그마저 전부 낱 음절이며 ▁가 붙은 것은 하나도 없다. 한글로 된 단어 조각이 어휘에 없다는 뜻이다. 같은 셈을 mT5에 하면 4,041개가 나온다. mT5의 250,000이나 EXAONE의 102,400은 그 자리를 늘려 확보한 것이다.
바이트 폴백
같은 문장을 두 토크나이저에 넣어 보면 차이가 눈에 보인다.
from transformers import AutoTokenizer
llama = AutoTokenizer.from_pretrained("meta-llama/Llama-2-7b-hf")
llama.tokenize("한국어 처리도 됩니다")
# ['▁', '한', '국', '어', '▁', '<0xEC>', '<0xB2>', '<0x98>', '리', '도',
# '▁', '<0xEB>', '<0x90>', '<0xA9>', '니', '다'] ← 16토큰
mt5 = AutoTokenizer.from_pretrained("google/mt5-base")
mt5.tokenize("한국어 처리도 됩니다")
# ['▁', '한국어', '▁', '처리', '도', '▁', '됩니다'] ← 7토큰
앞쪽 출력에 <0xEC> 같은 조각이 섞인 것은 「처」와 「됩」이 어휘에 아예 없기 때문이다. 바이트 폴백(byte fallback)은 어휘에 없는 문자를 알 수 없는 토큰으로 버리는 대신 UTF-8 바이트로 쪼개 담는 장치다. 덕분에 복원은 되지만 한글 한 글자에 토큰 셋이 든다.
같은 아홉 글자가 16토큰과 7토큰이다. 2.3배 차이인데, 이 배수가 세 곳에 그대로 곱해진다. 첫째, 컨텍스트다. 4,096 토큰 창에 들어가는 한국어 원문이 앞쪽 모델에서는 뒤쪽 모델의 절반도 안 된다. 둘째, 비용이다. 토큰 단위로 과금하는 API라면 같은 문서를 넣는 값이 2.3배다. 셋째, 생성 속도다. 자기회귀 생성은 토큰을 하나씩 뱉으므로 같은 분량의 한국어 답변을 쓰는 데 2.3배의 스텝이 든다.
여기에 품질 문제가 하나 더 얹힌다. 「한국어」가 한 + 국 + 어로 갈리면 모델은 「한」이라는 조각에서 문맥을 시작해야 한다. 「한 번」의 「한」, 「한국」의 「한」, 「한계」의 「한」이 전부 같은 ID를 받는다는 뜻이다. 바이트로 떨어진 「처」는 더하다 — 그 세 토큰은 문자로서의 뜻조차 들고 있지 않다. 의미의 단위와 토큰의 단위가 어긋난 만큼 모델이 문맥으로 메꿔야 할 일이 늘어난다.
어휘 확장
고치는 방법은 둘이다. 처음부터 한국어를 충분히 포함한 말뭉치로 어휘를 학습하는 쪽과, 기존 어휘를 그대로 두고 한국어 조각만 덧붙이는 어휘 확장(vocabulary extension) 쪽이다. 이미 사전학습된 영어 모델을 한국어로 이어 학습할 때는 뒤쪽이 현실적이다.
덧붙이는 데는 값이 있다. 어휘가 늘면 임베딩 행렬도 같이 늘어난다. 32,000개 어휘에 한국어 14,000개를 더해 46,000으로 만들고 은닉 차원이 4,096이라면, 늘어나는 임베딩 파라미터는 개다. 입력 임베딩과 출력 층이 가중치를 공유하지 않는 구조라면 그 두 배가 든다. 7B 모델에 견주면 앞쪽은 1%가 채 안 되고 뒤쪽도 2%를 넘지 않으니 감당할 만한 값이다. 대신 토큰 길이가 얼마나 줄어드는지는 어떤 말뭉치로 무엇을 덧붙였는가에 달렸으므로, 늘리기 전과 후를 같은 문장으로 재 보고 남는 장사인지 판단한다.
다만 새로 붙인 행은 학습된 적이 없는 난수라는 점을 잊으면 안 된다. 그대로 추론에 쓰면 그 토큰이 나오는 자리마다 엉뚱한 결과가 나온다. 보통은 새 행을 기존 어휘의 평균 벡터나 그 토큰을 쪼갠 옛 조각들의 평균으로 초기화한 뒤 이어 학습한다.
인코딩과 토큰 수
토큰 수와 컨텍스트 한계
이제 반대쪽이다. 어휘는 이미 정해져 있고 바꿀 수 없다. 그래도 반드시 알아야 하는 것이 둘 있다.
토큰 수다. 대부분의 API가 입력과 출력의 토큰 수에 비례해 과금하므로, 요청을 보내기 전에 몇 토큰인지 모르면 비용을 예측할 수 없다. 문자 수로 어림잡는 방법이 있기는 한데 언어에 따라 크게 빗나간다. 영어는 대략 네 글자에 한 토큰이지만 한국어는 그 비율이 전혀 다르고, 앞에서 봤듯 같은 한국어라도 인코딩에 따라 두 배 넘게 갈린다.
컨텍스트 한계다. 모델마다 한 번에 받을 수 있는 토큰 수의 상한이 있고, 이를 넘기면 요청 자체가 거절된다. 긴 문서를 다루는 앱은 넣기 전에 재어 보고 자르거나 요약해야 한다. 재는 일을 정확히 하려면 그 모델이 실제로 쓰는 인코딩이 필요한데, 그것을 그대로 재현해 주는 것이 tiktoken이다.
인코딩 이름과 모델 매핑
여기서 인코딩(encoding)이라는 말을 짚고 간다. 어휘와 병합 규칙, 그리고 사전 분리 정규식을 한 벌로 묶은 것이 인코딩이고, 이름이 붙어 있다. 모델과 인코딩은 다대일 관계라 여러 모델이 한 인코딩을 공유한다.
| 인코딩 | 어휘 크기 | 쓰는 모델 |
|---|---|---|
r50k_base |
50,257 | GPT-2, 구형 GPT-3(davinci·curie 등) |
p50k_base |
50,281 | text-davinci-003, Codex 계열 |
cl100k_base |
100,277 | GPT-4, GPT-3.5-turbo, text-embedding-3 |
o200k_base |
200,019 | GPT-4o·GPT-4.1 계열, o 시리즈 |
부르는 방법은 두 가지다.
import tiktoken
enc = tiktoken.encoding_for_model("gpt-4o") # 모델 이름으로 고르기
enc = tiktoken.get_encoding("o200k_base") # 인코딩 이름으로 고르기
ids = enc.encode("tiktoken은 매우 빠릅니다.")
len(ids) # 토큰 수
enc.decode(ids) # 원문 복원
앞쪽은 모델 이름과 인코딩의 대응표를 라이브러리가 들고 있다가 골라 준다. 편하지만, 새로 나온 모델 이름이 그 표에 아직 없으면 오류가 난다. 뒤쪽은 표를 거치지 않으므로 그런 일이 없는 대신 어느 인코딩인지를 사람이 알아야 한다.
인코딩별 토큰 ID
가장 자주 밟는 함정은 여기다. 같은 텍스트라도 인코딩이 다르면 토큰 ID가 다르다. cl100k_base의 ID 1234와 o200k_base의 ID 1234는 아무 관계가 없는 별개의 토큰이다. 어휘 크기가 다르니 당연하지만, 겹치는 앞쪽 번호대에서도 대응이 성립하지 않는다.
이것이 조용히 사고를 내는 자리가 있다. 전처리 결과를 캐시에 넣을 때 토큰 ID 배열을 저장해 두는 경우다. 모델을 GPT-4에서 GPT-4o로 올리면서 인코딩을 함께 바꾸면, 캐시에 남아 있던 ID 배열은 전부 다른 문장을 가리키게 된다. 오류는 나지 않고 결과만 이상해진다. 캐시에는 원문을 넣고 ID는 매번 다시 만들거나, 캐시 키에 인코딩 이름을 넣어 두는 것이 안전하다.
o200k와 아시아 언어
두 인코딩의 차이는 어휘 크기만이 아니다. 사전 분리에 쓰는 정규식이 다르다 — 숫자를 몇 자리씩 묶는지, 대소문자와 축약형을 어떻게 다루는지가 인코딩마다 따로 정해져 있다. o200k_base는 여기에 더해 한자·한글·가나를 더 큰 덩어리로 묶도록 어휘가 짜여 있다.
cl100k = tiktoken.get_encoding("cl100k_base")
o200k = tiktoken.get_encoding("o200k_base")
text = "서울特별시 Tokyo 2024年"
len(cl100k.encode(text)) # 12
len(o200k.encode(text)) # 9
열두 토큰이 아홉 토큰이 된다. 갈라지는 자리를 보면 이유가 보인다 — cl100k_base는 「울」과 「별」을 한 토큰으로 들고 있지 못해 UTF-8 바이트 조각 둘씩으로 떨어뜨리는데, o200k_base는 「서울」을 통째로 한 토큰에 담는다. 앞에서 본 한글 쪼개짐이 인코딩만 바뀌어 그대로 재현되는 셈이다. 한국어 문장을 몇 개 재 보면 줄어드는 폭이 3~4할 언저리이고, 같은 자를 영어 문장에 대면 거의 차이가 없다. 앞 절의 논리를 그대로 되짚으면, 이 감소는 비용과 컨텍스트와 생성 속도에 한꺼번에 반영된다. 한국어 서비스에서 모델을 고를 때 벤치마크 점수만 보고 인코딩을 안 보면 놓치는 차이다.
tiktoken 실전 패턴
메시지 토큰 수
채팅 API에 보내는 것은 문자열 하나가 아니라 역할과 내용이 붙은 메시지의 목록이다. 그래서 각 메시지의 내용을 인코딩해 더하는 것만으로는 실제 청구되는 토큰 수가 나오지 않는다. 메시지를 하나의 시퀀스로 이어 붙일 때 역할 표시와 구분자가 들어가고, 응답을 시작시키는 프라이머도 붙기 때문이다.
def num_tokens_from_messages(messages, model="gpt-4o"):
enc = tiktoken.encoding_for_model(model)
total = 3 # 응답 프라이머
for msg in messages:
total += 3 # 역할 + 구분자
for key, value in msg.items():
total += len(enc.encode(value))
if key == "name":
total += 1
return total
메시지당 3, 프라이머 3이라는 숫자는 채팅 포맷에서 온 상수이지 인코딩의 성질이 아니다. 포맷이 바뀌면 이 값도 바뀌므로, 정확한 회계가 필요하면 응답에 실려 오는 사용량 필드와 한 번 맞춰 보고 상수를 확정하는 편이 낫다. 짧은 메시지를 많이 보내는 앱에서는 이 오버헤드가 무시할 수 없다 — 열 글자짜리 메시지 100개면 내용이 아니라 포맷에만 300 토큰이 든다.
토큰 단위 절단
긴 텍스트를 한계에 맞춰 자를 때 text[:n]을 쓰면 안 되는 이유가 둘이다. 첫째, 문자 수와 토큰 수가 비례하지 않아 몇 자를 남겨야 몇 토큰이 되는지 정할 수 없다. 둘째, 토큰 하나가 여러 문자를 걸치는 자리에서 잘리면 남은 부분이 다시 인코딩될 때 다른 토큰으로 갈라져 미리 세어 둔 수와 어긋난다. 토큰으로 바꿔 자르고 다시 되돌리면 두 문제가 함께 사라진다.
def safe_truncate(text: str, max_tokens: int, model="gpt-4o") -> str:
enc = tiktoken.encoding_for_model(model)
ids = enc.encode(text)
if len(ids) <= max_tokens:
return text
return enc.decode(ids[:max_tokens])
주의할 것은 이 함수가 문장 경계를 지키지 않는다는 점이다. 토큰 수는 정확히 맞지만 마지막 문장이 중간에서 끊긴다. 게다가 앞에서 본 바이트 조각 탓에 자르는 자리가 한글 한 글자의 한가운데에 떨어질 수 있고, 그러면 마지막 글자가 �로 나온다. 검색 조각을 만드는 용도라면 이 함수로 상한을 재기만 하고, 실제 자르는 자리는 문단이나 문장 경계에서 고르는 편이 낫다.
특수 토큰 화이트리스트
<|im_start|> 같은 특수 토큰은 어휘에 들어 있지만, 기본 설정에서는 인코딩할 때 그 문자열을 만나면 오류가 난다. 쓰려면 허용 목록에 명시해야 한다.
enc = tiktoken.get_encoding("o200k_base")
ids = enc.encode(
"<|im_start|>user\nHello<|im_end|>",
allowed_special={"<|im_start|>", "<|im_end|>"},
)
번거로워 보이지만 이것은 안전장치다. 사용자 입력을 그대로 인코딩하는 앱을 생각해 보자. 특수 토큰이 자동으로 해석된다면 사용자가 입력창에 <|im_end|>를 적어 넣는 것만으로 대화의 경계를 위조할 수 있다. 자기 차례를 끝내고 시스템 메시지를 새로 여는 시늉을 하는 것이다. 기본을 「오류」로 두면 개발자가 이 자리를 지나칠 수 없다. 사용자 입력에는 허용 목록을 열지 않고, 시스템이 조립한 템플릿에만 여는 것이 원칙이다.
도구 선택
선택 기준
| 상황 | 쓰는 것 |
|---|---|
| OpenAI API 비용·컨텍스트를 재야 한다 | tiktoken |
| 자체 모델의 어휘를 새로 만든다 | SentencePiece |
| 공개 체크포인트를 그대로 쓴다 | 그 모델이 싣고 온 토크나이저 |
| 한국어 비중이 높아 어휘를 늘린다 | SentencePiece로 재학습·확장 |
| 여러 언어가 섞인 말뭉치를 다룬다 | SentencePiece |
표의 셋째 줄이 실은 가장 흔하다. HuggingFace transformers로 모델을 불러오면 AutoTokenizer가 그 모델이 싣고 온 어휘 파일을 알아서 읽으므로, SentencePiece가 뒤에서 돌고 있어도 직접 부를 일이 없다. 앞의 LLaMA 2와 mT5 예제가 그랬다. SentencePiece를 직접 부르는 것은 어휘를 만들거나 고칠 때이고, tiktoken을 직접 부르는 것은 남의 어휘를 재야 할 때다.
인코딩 속도
tiktoken의 핵심 연산이 Rust로 짜인 덕에 순수 Python 구현보다 크게 빠르고, encode_batch로 여러 문자열을 스레드에 나눠 넘길 수도 있다. 다만 이 이점을 과대평가하지 않는 편이 좋다. HuggingFace의 fast 토크나이저도 Rust 구현이라 둘의 격차는 순수 Python과의 격차보다 훨씬 좁고, 정확한 배수는 텍스트의 성격과 배치 크기, 코어 수에 따라 달라진다. 직접 재 보지 않은 숫자를 근거로 삼을 자리가 아니다.
정작 속도가 문제가 되는 지점은 따로 있다. API를 한 번 호출할 때 토큰을 한 번 세는 정도라면 인코딩 시간은 네트워크 왕복에 묻힌다. 반면 수백만 문서를 전처리해 조각으로 나누는 파이프라인이라면 토큰화가 전체 시간의 상당 부분을 차지하고, 여기서는 배치 인코딩 여부가 눈에 띄는 차이를 낸다. 한 번에 하나씩 부르는 반복문이 있으면 그것부터 배치로 바꾸는 것이 라이브러리를 갈아 끼우는 것보다 먼저다.
다음 걸음
토크나이저 이야기는 여기서 닫힌다. 텍스트가 어떻게 정수의 열이 되는지, 그 열의 길이가 무엇에 매여 있는지, 그리고 그 어휘를 만드는 쪽과 세는 쪽에서 각각 무엇을 손에 쥐는지를 봤다. 이제 남은 것은 그 정수의 열을 받아 처리하는 쪽이다.
다음 글부터는 그 위층으로 올라간다. 파라미터를 키우는 것만으로 왜 질적으로 다른 능력이 나타나는지, 「창발」이라 부르는 현상이 정확히 무엇을 가리키는지, 그리고 모델의 크기와 데이터와 컴퓨팅이 어떤 관계로 묶여 있는지를 본다. 토큰은 그 모델이 세상을 보는 단위였고, 지금부터는 그 단위로 무엇을 하는지가 주제다.
읽어주셔서 감사합니다. 😊

