DevOps··3분 읽기·16

2. 입력 처리: 텍스트가 숫자가 되기까지

LLM에 텍스트가 들어가면 가장 먼저 일어나는 일. 토크나이저가 문자열을 정수로, 임베딩이 정수를 벡터로 변환하는 과정.

글꼴

1편에서 "OOM killer가 nginx를"이라는 문장이 모델 안에서 거치는 4단계 여행을 한 바퀴 돌았다. 토크나이저 → 임베딩 → 트랜스포머 레이어 → 출력. 전체 그림은 봤으니까, 이번 편부터 각 단계를 파고든다.

첫 번째 질문. 문자열이 어떻게 숫자가 되는 걸까?


토크나이저 — 모델과 별개인 프로그램

토크나이저는 모델 안에 있는 게 아니다. 모델 학습 전에 미리 만들어두는 별도 프로그램이다. CPU에서 실행되고, 마이크로초 단위로 끝난다. 병목이 될 일은 없다.

하는 일은 단순하다. 텍스트를 정수 배열로 바꾸는 것. 근데 "어떤 기준으로 쪼개느냐"가 꽤 중요하다.


BPE — 어휘 사전은 이렇게 만들어진다

대부분의 최신 모델이 쓰는 방식이 BPE(Byte Pair Encoding)다. 자주 나오는 문자 조합을 반복적으로 합쳐서 어휘 사전을 구축한다.

BPE 구축 과정:
 
1단계: 모든 글자를 개별 토큰으로
  [n] [g] [i] [n] [x]
 
2단계: 자주 붙어 나오는 쌍을 합침
  "ng" 빈도 높음 → [ng] [i] [n] [x]
  "in" 빈도 높음 → [ng] [in] [x]
  ...반복...
  "nginx" 충분히 빈도 높음 → [nginx]  ← 하나의 토큰으로 등록
 
최종 어휘 사전: ~15만 개 토큰

원리는 이게 전부다. 학습 데이터에서 빈도를 세고, 자주 같이 나오는 조합을 하나로 합치는 걸 반복한다.

자주 나오는 단어("nginx", "the", "error")는 통째로 하나의 토큰이 된다. 모르는 단어("xyzqwerty")는 서브워드로 분해된다. 최악의 경우 개별 바이트까지 쪼개지니까, 처리 못하는 입력은 없다. 이모지도 되고 바이너리도 된다.

한국어가 영어보다 토큰 효율이 낮은 이유도 여기에 있다. 영어 학습 데이터가 압도적으로 많으니까 영어 단어는 통째로 토큰이 되는 경우가 많고, 한국어는 한 글자가 여러 토큰으로 쪼개지는 경우가 잦다.

실제로 체감하면 이렇다.

한국어 기준:
  "OOM killer가 nginx를 종료시켰다" ≈ 12 토큰
  A4 1장 (~500자) ≈ 200~300 토큰
  로그 100줄 ≈ 500~1500 토큰
  코드 100줄 ≈ 300~800 토큰
 
영어는 같은 의미 대비 60~70% 토큰으로 처리됨 (더 효율적).

한국어로 긴 로그를 분석하려면 영어보다 토큰을 더 많이 먹는다는 뜻이다. 컨텍스트 윈도우가 128K라고 해도, 한국어 기준으로는 영어의 60~70% 수준의 텍스트만 넣을 수 있다.


토크나이저와 모델은 짝이 맞아야 한다

이건 실무에서 가끔 실수하는 부분이다.

Qwen2.5의 토크나이저:  "nginx" → 28712
Llama 3의 토크나이저:  "nginx" → 52341
 
Qwen 토크나이저 + Llama 모델 = 엉망
  → Llama 모델은 52341번에 "nginx" 지식을 학습했는데
  → 28712번을 넣으면 엉뚱한 벡터가 나옴
 
→ 모델 배포 시 토크나이저를 항상 함께 묶어서 배포.
  ollama가 모델을 받을 때 토크나이저도 같이 받는 이유.

모델을 받으면 실제로 이런 파일들이 들어 있다.

qwen2.5:7b/
├── model.bin            ← 파라미터 (W 전부). 7B Q4 기준 ~4.4GB
├── tokenizer.json       ← 토크나이저 (어휘 사전 + BPE 규칙). ~7MB
├── tokenizer_config.json ← 특수 토큰 설정 (<|im_start|>, <eos> 등)
└── config.json          ← 모델 구조 (hidden_dim, num_layers, num_heads 등)

4.4GB짜리 model.bin이 파라미터 전부고, 7MB짜리 tokenizer.json이 토크나이저다. 이 둘은 항상 같이 다닌다.


임베딩 — 정수를 벡터로 바꾸는 테이블

토크나이저가 텍스트를 정수 배열로 바꿨다. 다음은 임베딩이다. 15만 행 × 2048열짜리 거대한 숫자표에서 해당 행을 그대로 복사해오는 게 전부다.

임베딩 테이블 (15만행 × 2048열의 숫자표):
 
토큰ID     [0]      [1]      [2]      ...    [2047]
────────  ───────  ───────  ───────  ─────  ───────
0          0.012   -0.034    0.056   ...     0.023
1         -0.045    0.078   -0.012   ...    -0.056
...
15832      0.823    0.154   -0.431   ...     0.551  ← "OOM"
...
28712      0.652   -0.283    0.371   ...     0.124  ← "nginx"
...
151935     0.034   -0.067    0.089   ...    -0.045
 
"OOM" (ID: 15832) → 15832번째 행을 그대로 복사:
[0.823, 0.154, -0.431, 0.672, -0.210, ..., 0.551]
 ←──────────── 2048개의 실수 ──────────────→

이 테이블 자체가 파라미터다. 15만 × 2048 = 약 3억 개의 숫자. 학습 전에는 랜덤이었지만, 학습이 끝나면 비슷한 의미의 토큰이 가까운 벡터를 가지게 된다.

벡터의 차원은 모델 크기에 따라 다르다. 3B = 2048차원, 7B = 3584차원. 차원이 크다는 건 하나의 토큰을 더 많은 축으로 표현할 수 있다는 뜻이다.

이 시점에서 각 벡터는 개별 토큰의 의미만 담고 있다. "OOM"은 "Out of Memory와 관련된 무언가" 정도는 알지만, 뒤에 "killer"가 온다는 건 아직 모른다. 문맥을 만들어주는 건 다음 편의 어텐션이다.


위치 인코딩 — "너는 몇 번째야"

임베딩만으로는 부족한 게 하나 있다.

임베딩만으로는 부족한 것:
  "OOM killer가 nginx를"과 "nginx를 OOM killer가"는
  같은 토큰 5개로 구성. 임베딩 벡터도 동일.
  → 순서가 다른데 구분을 못한다.
 
해결: 각 토큰에 "너는 몇 번째야"라는 위치 정보를 넣어준다.
 
초기 방식 (GPT-2): 절대 위치 임베딩
  위치 0~1023에 고정 벡터 할당 → 1024 토큰이 한계.
 
현재 방식 (RoPE): 상대 위치 인코딩
  Q, K 벡터를 위치에 따라 회전시킴.
  토큰 간 "상대 거리"가 내적에 반영됨.
  → 원리상 무한 확장 가능.
  단, 학습에서 본 적 없는 거리는 성능 저하.
 
모델별 최대 컨텍스트:
  GPT-2 (2019)       1,024
  Llama 2 (2023)     4,096
  Llama 3.1 (2024)  128,000
  Qwen2.5 (2024)    128,000
  Claude            200,000

"OOM killer가 nginx를"과 "nginx를 OOM killer가"는 같은 토큰 5개인데 순서가 다르다. 임베딩만으로는 이 차이를 구분할 수 없다. 위치 인코딩이 각 토큰에 "너는 몇 번째 자리에 있다"는 정보를 넣어줘서 해결한다.

초기에는 위치 0~1023에 고정 벡터를 할당하는 방식이었는데, 이러면 1024 토큰이 한계다. 요즘 모델들은 RoPE라는 방식을 써서 토큰 간 "상대 거리"를 내적에 반영한다. 원리상 무한 확장이 가능하지만, 학습에서 본 적 없는 거리에서는 성능이 떨어진다.

다음 편의 어텐션에서 "killer가 OOM에 주목"하는 것도, 위치 인코딩 덕분에 "바로 앞 토큰"이라는 정보가 내적에 반영된 결과다.


서빙 시 전체 흐름

지금까지 나온 것들을 서빙 관점에서 이어 붙이면 이렇다.

사용자 입력

    │  ① CPU에서 실행 (마이크로초)

┌────────────┐
│ 토크나이저   │  텍스트 → 토큰 ID 배열
│ (CPU, 7MB)  │
└─────┬──────┘
      │  [토큰 ID 배열]  → GPU로 전송

┌────────────┐
│   모 델     │  임베딩 → 36개 레이어 → 출력 확률
│ (GPU, 4.4GB)│  시간의 99.9%가 여기
└─────┬──────┘
      │  [다음 토큰 ID]  → CPU로 반환

┌────────────┐
│ 토크나이저   │  토큰 ID → 텍스트 (역변환)
│ (CPU, 역변환) │
└─────┬──────┘


  사용자에게 출력 → 생성된 토큰을 다시 GPU로 보내서 반복

시간의 99.9%는 GPU에서 모델이 돌아가는 부분이다. 토크나이저는 CPU에서 마이크로초 단위로 끝나고, 병목은 전부 모델 쪽에 있다.

여기까지가 "텍스트 → 숫자 → 벡터"의 여정이다. "OOM killer가 nginx를"은 이제 5개의 2048차원 벡터가 되었고, 각 벡터에는 위치 정보까지 반영되어 있다. 하지만 아직 각 토큰은 서로의 존재를 모른다. "OOM" 벡터는 뒤에 "killer"가 온다는 걸 모르고, "killer" 벡터는 앞에 "OOM"이 있다는 걸 모른다.

이 벡터들에 문맥을 만들어주는 게 어텐션이다. 다음 편에서 "killer"의 벡터가 "OOM"의 정보를 흡수하는 과정을 실제 숫자로 추적해본다.

이 글이 어떠셨나요?

이 글이 도움이 되셨나요?
공유:

관련 포스트

뉴스레터 구독

새 글이 올라오면 이메일로 알려드려요.

댓글

댓글을 불러오는 중...