비둘기 유도 미사일

최근에 Claude, Cursor 같은 Large Langugue Model을 활용한 엔지니어링이 엄청나게 빠른 속도로 발전하고 있다. 이직한 회사에서도 Claude Code를 최고사양으로 구독할수 있게 해줘서 나름 하네스 엔지니어링도 이것저것 시도해보고, 잘 사용해보고 있다. 근데 이걸 사용하면서 느낀점은 “기존에 LLM모델이 로컬환경의 API(Bash, Curl등)를 부르는 방법을 추가하고, 부를수 있도록 LLM을 학습시켜놓은것이지, 근본적으로 인공지능 기술이 발전한것은 아니지 않나?” 라는 생각이 들었다.

세계2차대전 당시에 비둘기 유도 미사일이라는게 있었다고 한다. 당시 컴퓨터나 전자기술의 한계로 원하는 수준의 미사일 유도 시스템을 만들기 힘들었었는데, 누군가 비둘기에게 목표물을 인지시키고 그것을 가르키게만하고, 기계가 그 방향으로 나아가도록 조절한다면, 미사일도 유도할 수 있을것이라고 아이디어를 냈다고 한다. 이 시스템에서 비둘기는 화면에서 배를 쪼도록 학습되고, 미사일은 화면상에서 비둘기가 쪼은 반대방향으로 움직이면 된다.

비둘기 미사일

사실 현재 LLM이 처한 환경도 미사일속의 비둘기와 비슷하다고 생각한다. LLM은 학습한대로 output을 생성하고, 그 output에 따라 하네스가 api를 호출하고 그 결과를 LLM의 input으로 피드백하는것이다. 우리 모두가 시대에 의해 자의든 타의든 비둘기 사육사가 된 지금, 비둘기를 잘 알고, 돌보기위해서는 LLM자체를 기본적으로 이해하는게 중요한 것 같다.

물론 저렇게 거창하고 헛소리로 LLM공부를 시작한건 아니고 “Transformer의 연산량은 입력된 토큰시퀸스 길이의 제곱에 비례하는데, 토큰사용량은 입력의 길에에 비례할까?”라는 궁금증에 시작했다.



1. 트랜스포머의 동작원리

텍스트 → 토큰화 → [임베딩+위치] → ┌─ Self-Attention ─┐
                            │   (×L개 블록)      │ → 마지막 표현 → 다음 토큰 1개 예측
                            └─ FFN ────────────┘                  │
                                     ▲                            │
                                     └──── 생성한 토큰을 끝에 붙여 반복 ◀┘

LLM모델의 거의 대부분은 트랜스포머의 구조를 기반으로 동작한다. 즉 사용자로부터 입력이 주어지면 이를 통해 다음에 나올 토큰 1개를 예측하고, 이를 사용자 입력에 붙여서 다시 해당 입력을 기반으로 토큰 1개를 예측하기를 반복하는 자기피드백형 루프 구조이다. 여기서 가장 큰 연산량을 차지하는 부분은 Self-Attention구조지만, 저 항목에 입력으로 들어가는 포맷이 어떻게 되는지부터 정리하면서 확인해보려고 한다.



2. 텍스트가 벡터가 되기까지

먼저 사용자의 입력을 컴퓨터가 알아먹을 수 있는 값으로 바꿔야 한다. 해당 과정은 아래 그림과 같이 진행되는데, 하나씩 수식과 함께 살펴보자.

"사과는 배야"
   │ ① 토큰화
   ▼
[15234, 891, 4420, 77]              ← 정수 ID 시퀀스
   │ ② 임베딩 lookup (행렬 E)
   ▼
[[0.2, -1.1, 0.7, …],  (d차원)
 [ … ], [ … ], [ … ]]              ← 토큰마다 벡터 1개
   │ ③ 위치 정보 더하기/회전
   ▼
최종 입력 벡터 시퀀스

2.1 토큰화, 임베딩

가장 먼저 토큰화는 아래 수식처럼 문장을 정수 ID 시퀀스로 변환하는 과정이다.

\[\text{"사과는 배야"}\;\xrightarrow{\text{tokenize}}\ [x_1, x_2, x_3, x_4] = [15234,\ 891,\ 4420,\ 77] \quad (N=4)\]

이후 변환된 토큰별 정수ID $x_i$를 기반으로 임베딩 벡터$E_{i}$로 변환한다. $E_{i}$는 d차원 공간에서 사전에 학습된, 각 토큰의 위치를 나타내는 임베딩 행렬 $\mathbf{E}\in\mathbb{R}^{V\times d}$의 한 row이다. 즉 아래 수식과 같이 하나의 토큰은 고유한 id를 갖고, 각 id로 행렬에서 해당 행을 꺼내게 된다.

\[E_i = \mathbf{E}[\,x_i\,] \in \mathbb{R}^{d}, \qquad E_1 = [\,0.2,\ -1.1,\ 0.7,\ \ldots\,]\] \[\mathbf{E} = \begin{bmatrix} E_1 \\ E_2 \\ \vdots \\ E_N \end{bmatrix} = \begin{bmatrix} 0.2 & -1.1 & 0.7 & \cdots \\ \vdots & & & \\ \vdots & & & \\ \vdots & & & \end{bmatrix} \in \mathbb{R}^{N\times d}\]

미리 학습된 각 토큰의 관계는 $E_왕 + E_{여자}\approx E_{여왕}$과 같이 학습된다. 행렬을 만드는 자세한 과정은 알아서 찾아보자.

2.2 위치 주입

앞의 과정까지 진행 후, 생성된 임베딩벡터 시퀸스는 그냥 하나의 토큰에 대해 대응되는 벡터로 변환해놨을 뿐이다. 이후 설명하겠지만 이후에 진행될 Self-Attention에서는 변환된 벡터들을 이용해 계산할 때, 위치정보에 대하여 전혀 고려를 하지 않는다. 아래 수식과 같이 동일 토큰의 다른 순서로 이루어진 입력에 대하여 동일한 연산을 한다는 것이다. 그렇기 때문에 self-attention을 진행하기 전에 각 벡터에 위치정보를 더해줘야 한다.

\[\text{"사과는 배야"} \;\equiv\; \text{"배야 사과는"} \quad(\text{위치 정보가 없으면})\]

이번 포스트에서는 간단하게 위치 벡터를 더하는 방식만 알아보자. 임베딩 벡터를 회전시키는 방법도 있는데, 이는 attention단계에서 하는 방법이라 해당 내용에서 설명을 해야한다.

Sinusoidal

해당 방식은 단순히 임베딩 벡터 $E_i$에 위치벡터 $PE_i$를 더하여 Self-Attention 루프의 입력 $X_i$로 만든다.

\[X_i = E_i + PE_i \qquad\Longleftrightarrow\qquad \mathbf{X} = \mathbf{E} + \mathbf{PE} \in \mathbb{R}^{N\times d}\]

이때 $PE$는 아래와 같이 정의되는데, sin/cos가 bounded($[-1,1]$)라 $X_i$가 기존 임베딩벡터 $E_i$ 근방에 머물고, $PE$ 벡터 자체는 위치에 따라 나선(helix)을 그리며 배치된다.

\[PE_{(pos, 2i)} = \sin\left(\frac{pos}{10000^{\frac{2i}{d_{model}}}}\right) \\ PE_{(pos, 2i+1)} = \cos\left(\frac{pos}{10000^{\frac{2i}{d_{model}}}}\right)\]

이와 같은 방식으로 구한 위치벡터를 사용할 경우 아래와 같은 이점이 있다.

  • 여러 주파수: 차원 $k$가 커질수록 $10000^{2k/d}$가 기하급수로 커져 파장이 길어짐 → 낮은 차원=빠른 진동(가까운 위치 구분), 높은 차원=느린 진동(먼 범위). 이진 카운터처럼 위치마다 고유 “지문”.
  • 선형: 삼각함수 덧셈정리로 $PE_{pos+m} = R_m \cdot PE_{pos}$ ($R_m$=거리 $m$에만 의존하는 회전행렬)이 성립 → 모델이 상대 위치를 선형으로 학습하기 쉬움.

위 내용을 공부하며, 위치 벡터를 더하면 의미 벡터 값이 바뀌는데, 다른 의미가 되는 거 아닌가? 라는 의문이 있었는데, GPT-2의 위치 임베딩 분석에 따르면, 워낙 임베딩 행렬이 고차원이라서 위치벡터와 임베딩벡터 사이의 코사인 유사도가 거의 0에 가깝고, sin/cos는 값이 $[-1,1]$사이라 위치가 커져도 크기가 커지지 않아, 의미정보를 압도하지 못한다고 한다.



마치며

이번 글에서는 간단하게 LLM에서 어떻게 사용자 입력을 처리 가능한 데이터로 변환하는지 정리해보았다. 다음 글에서는 이렇게 위치까지 입힌 벡터가 Self-Attention으로 어떻게 문맥에 따라 의미가 섞이는지 확인해보려고 한다.