DevOps··3분 읽기·4

3. 어텐션: 새 토큰이 문맥을 얻는 과정

LLM 어텐션의 실제 계산 과정을 숫자로 추적한다. Q·K 내적이 주목도를 만들고, V의 가중합이 문맥을 만드는 과정. 멀티 헤드가 필요한 이유, 스케일링(√d), 잔차 연결까지.

글꼴

2편에서 "OOM killer가 nginx를"이 5개의 2048차원 벡터가 되는 과정을 봤다. 각 벡터에는 위치 정보까지 반영되어 있다. 하지만 아직 각 토큰은 서로의 존재를 모른다. "OOM" 벡터는 뒤에 "killer"가 온다는 걸 모르고, "killer" 벡터는 앞에 "OOM"이 있다는 걸 모른다.

이 벡터들에 문맥을 만들어주는 게 어텐션이다.

2~4편에서는 부품의 동작 원리를 이해하는 데 집중한다. 여기서는 "OOM"이라는 토큰 하나가 어텐션을 통과하면 어떻게 되는지, 그리고 "killer"라는 두 번째 토큰이 추가되면 무슨 일이 벌어지는지까지 본다. 토큰이 5개로 늘어나는 건 5편에서 다룬다.


어텐션이 하는 일

한마디로 정리하면 이렇다.

인풋:  토큰의 벡터 (2048차원)
아웃풋: 같은 차원이지만, 이전 토큰들의 문맥이 반영된 벡터
 
어텐션의 역할:
  새 토큰이 들어올 때마다, 이전에 있던 토큰들과의 관계를 계산해서
  새 토큰의 벡터에 문맥 정보를 반영해주는 것.
 
  레이어 1 어텐션을 거치면 → 바로 앞 토큰들과의 관계가 반영됨.
  레이어 2 어텐션을 거치면 → 이미 1차 문맥이 담긴 벡터들 사이에서
                              더 깊은 관계가 반영됨.
  ...
  레이어 36까지 반복 → 풍부한 문맥.

1편에서 "어텐션은 토큰 간 정보를 교환한다"고 했다. 이번 편에서 그 계산 과정을 실제 숫자로 추적한다.


Q, K, V — 세 벡터를 만드는 이유

어텐션에 들어온 벡터에 학습된 행렬(W)을 곱해서 세 종류의 벡터를 만든다.

각 토큰의 벡터 × 학습된 행렬 → 세 종류의 벡터:
 
  Q (Query):  "나를 완성하려면 어떤 맥락이 필요하지?"
  K (Key):    "나는 이런 종류의 정보를 갖고 있어."
  V (Value):  "가져갈 수 있는 내 실제 정보."
 
                    ×W_Q         ×W_K         ×W_V
                 ┌────────┐  ┌────────┐  ┌────────┐
"OOM" 벡터  ───→ │ Q_OOM   │  │ K_OOM   │  │ V_OOM   │
                 └────────┘  └────────┘  └────────┘
 
"killer" 벡터 ─→ │ Q_killer│  │ K_killer│  │ V_killer│
                 └────────┘  └────────┘  └────────┘
 
  ...5개 토큰 전부 동일하게.
 
W_Q, W_K, W_V: 각각 2048×2048 행렬 = 약 400만 개의 학습된 파라미터.

왜 세 개가 필요한가? 같은 토큰이라도 "질문할 때"와 "답할 때" 다른 측면이 부각되어야 한다. "nginx"가 맥락을 찾을 때(Q)는 "나는 프로세스인데 뭔 일이 생겼지?" 방향이고, 다른 토큰에 정보를 줄 때(K/V)는 "나는 웹 서버야" 방향이다. 하나의 벡터로 두 역할을 동시에 잘 수행하기는 어렵다.


첫 번째 토큰: "OOM" — 참조할 문맥이 없다

"OOM"은 시퀀스의 첫 토큰이다. 앞에 아무것도 없다.

OOM의 Q가 참조할 수 있는 K: OOM의 K뿐.
 
  OOM의 Q · OOM의 K = 어떤 점수
  → softmax → 1.00 (유일한 후보니까 100%)
  → OOM의 새 벡터 = 1.00 × OOM의 V = OOM의 V 그대로
 
첫 토큰은 어텐션을 거쳐도 거의 변하지 않는다.
참조할 문맥이 없으니까 당연하다.

아무런 감흥이 없는 결과다. 어텐션의 진짜 가치는 두 번째 토큰부터 나온다.


두 번째 토큰: "killer" — 여기서 문맥이 생긴다

"killer"가 추가되면 비로소 어텐션이 의미 있는 일을 한다.

killer의 Q가 참조할 수 있는 K: OOM의 K + killer의 K.
(미래 토큰인 "가", "nginx", "를"의 K는 볼 수 없다.)
 
killer의 Q · OOM의 K
= (0.512 × -0.215) + (0.223 × 0.438) + (-0.341 × 0.612) + ...
  (128번의 곱셈 → 합산. 헤드 1개 = 128차원)
= 14.7
 
÷ √128 = 14.7 / 11.3 = 1.30
  ↑ 스케일링: 차원이 클수록 내적 값이 커지므로
    √차원으로 나눠서 softmax가 안정적으로 동작하도록 맞춘다.
 
killer의 Q · killer의 K
= ... = 11.2
÷ √128 = 0.99
 
softmax로 정규화 (합이 1이 되도록):
  OOM:     e^1.30 / (e^1.30 + e^0.99) = 0.58
  killer:  e^0.99 / (e^1.30 + e^0.99) = 0.42
 
→ killer는 OOM에 58%, 자기 자신에 42% 주목.

Q·K 내적 후에 √128로 나누는 과정이 있다. 이걸 스케일링이라고 하는데, 차원이 클수록 내적 값이 커져서 softmax가 한쪽에 극단적으로 몰리는 걸 방지한다. 실질적으로 softmax의 안정성을 위한 장치다.

이 주목도(0.58, 0.42)로 V를 가중합한다.

killer의 새 벡터 = 0.58 × V_OOM  +  0.42 × V_killer
 
= 0.58 × [0.127, -0.334, 0.556, ...]
+ 0.42 × [-0.423, 0.167, 0.712, ...]
──────────────────────────────────────
= [-0.104, -0.124, 0.621, ...]
 
→ "killer" 벡터에 "OOM" 정보가 58% 섞여 들어왔다.
→ 이제 이 벡터는 "OOM과 관련된 killer"라는 문맥을 담고 있다.

여기서 핵심은 "killer"라는 단독 의미가 "OOM과 관련된 killer"라는 문맥을 가진 의미로 바뀌었다는 거다. "가", "nginx", "를"이 순서대로 추가되면, 매번 같은 과정이 반복된다. 새 토큰의 Q가 이전 모든 토큰의 K와 내적을 구하고, V의 가중합으로 새 벡터를 만든다. 이 과정이 5개, 100개, 10,000개로 어떻게 확장되는지는 5편에서 자세히 다룬다.


멀티 헤드 — 하나의 내적으로는 부족하다

위에서 "killer가 OOM에 58% 주목"이라고 했다. 이건 하나의 내적이 포착한 하나의 관계다. 그런데 "killer"와 "OOM" 사이에는 여러 종류의 관계가 동시에 존재한다.

관계 1: 의미적 관계  — "OOM killer"라는 복합어
관계 2: 문법적 관계  — 주어-동사 구조의 일부
관계 3: 인접 관계    — 바로 앞 토큰

Q·K 내적 하나로는 이 세 관계 중 하나만 포착할 수 있다. 나머지는 놓친다. 그래서 헤드를 여러 개 만든다.

해결: 헤드를 여러 개 만든다.
 
  3B 모델: 16개 헤드
  7B 모델: 28개 헤드
 
  각 헤드가 독립적인 W_Q, W_K, W_V를 갖는다.
  → 같은 입력을 받아도 서로 다른 관계를 학습하게 된다.
 
  핵심: 왜 헤드마다 다른 관계를 포착하는가?
    → W_Q, W_K, W_V가 헤드마다 다른 파라미터이기 때문이다.
    → 학습 과정에서 "헤드 1은 문법을 잘 잡고, 헤드 2는 의미를 잘 잡으면
       전체 손실이 줄어든다"는 방향으로 자연스럽게 역할이 분화된다.
    → 사람이 설계한 게 아니라 학습의 결과.

구조를 그려보면 이렇다.

각 헤드의 차원 = 전체 차원 / 헤드 수
  3B: 2048 / 16 = 128차원/헤드
 
입력 벡터 (2048차원)

     ├──→ 헤드 1 (128차원): Q₁·K₁→V₁ 가중합  "의미적 관계"
     ├──→ 헤드 2 (128차원): Q₂·K₂→V₂ 가중합  "문법적 관계"
     ├──→ 헤드 3 (128차원): Q₃·K₃→V₃ 가중합  "인접 관계"
     ├──→ ...
     └──→ 헤드16 (128차원): Q₁₆·K₁₆→V₁₆ 가중합  "장거리 의존"

                   ▼  16개 출력을 이어 붙임 (concat)
           [128 + 128 + ... + 128] = 2048차원

                   ▼  × W_O (2048×2048) — 합산 행렬
           최종 어텐션 출력 (2048차원)

빠르기 때문에 멀티 헤드를 쓰는 게 아니다. 2048차원 싱글 헤드 1개와 128차원 16헤드의 계산량은 비슷하다. 멀티 헤드를 쓰는 이유는 하나의 내적으로는 하나의 관계만 포착할 수 있기 때문이다. 여러 헤드가 각각 다른 관계를 동시에 포착하고, W_O로 합쳐서 하나의 벡터로 만든다.


잔차 연결 — 원본을 보존한다

어텐션의 마지막 단계다. 어텐션이 만든 결과를 원래 벡터에 더한다.

어텐션 최종 출력 = 원래 벡터 + 어텐션 결과
 
원래 killer 벡터:      [0.312, -0.721, 0.481, ...]  (어텐션에 들어온 입력)
+ 어텐션이 만든 벡터:   [-0.104, -0.124, 0.621, ...]  (문맥 정보)
────────────────────────────────────────────────────
= 새 killer 벡터:      [0.208, -0.845, 1.102, ...]  (원본 + 문맥)

왜 더하는가? 어텐션이 실수로 원본 정보를 날려버릴 수 있다. 더하면 최소한 원본은 보존된다. 36개 레이어를 거치면서 매번 "원본 + 이번 레이어에서 새로 얻은 정보"가 누적된다. 이 구조 덕분에 레이어를 깊게 쌓아도 학습이 안정적으로 된다.


어텐션 전체 흐름 요약

인풋: 토큰의 벡터 (2048차원)

    │  ① 벡터에서 Q, K, V 생성 (×W_Q, ×W_K, ×W_V)

    │  ② 새 토큰의 Q가 이전 토큰들의 K와 내적
    │     → ÷√d로 스케일링
    │     → 주목도 계산 (softmax)
    │     → V의 가중합으로 새 벡터 생성
    │     → 이걸 16개 헤드가 각각 독립적으로 수행

    │  ③ 16개 헤드의 출력을 이어 붙이고 W_O로 합산

    │  ④ 잔차 연결: 원래 벡터 + 어텐션 결과

아웃풋: 같은 차원의 벡터 (2048차원, 이전 토큰들의 문맥이 반영된 상태)

인풋과 아웃풋의 차원이 동일하다(2048). 이게 중요하다. 이 뒤에 FFN을 붙이고, 또 다음 레이어의 어텐션을 붙이고... 몇 개든 쌓을 수 있다는 뜻이다.

어텐션은 "토큰 간 관계를 파악"했다. 하지만 "이 관계가 무슨 의미인지"는 아직 모른다. "nginx가 OOM killer의 대상이다"까지는 파악했지만, "그래서 이게 서비스 장애라는 뜻이다"는 아직이다. 그 변환을 하는 게 다음 편의 FFN이다.

이 글이 어떠셨나요?

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

관련 포스트

뉴스레터 구독

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

댓글

댓글을 불러오는 중...