본문 바로가기
TYLER SONGBlog
← Blog index
LLM Research8 min read

Attention 직관: Query가 참고할 정보를 고르고 Value를 섞는 방법

Attention은 현재 토큰이 문맥 속 어떤 토큰을 얼마나 참고할지 계산하는 방법이다. Query와 Key의 유사도를 softmax 가중치로 바꾼 뒤, 여러 Value를 가중합해 새로운 표현을 만든다. 작은 벡터로 계산 과정을 직접 구현하고 히트맵으로 참고 비율의 변화를 관찰한다.

By 송민성

오늘의 목표

Attention이 “현재 토큰은 주변 토큰 중 무엇을 얼마나 참고해야 하는가?”를 계산하는 과정임을 그림처럼 이해한다. 작은 숫자로 Query·Key·Value를 직접 계산해 Attention 출력이 만들어지는 흐름을 확인한다.

개념과 직관

문맥에서 필요한 정보를 찾기

다음 문장을 보자.

> 철수는 사과를 샀다. 그는 그것을 먹었다.

“그것”의 의미를 이해하려면 앞에 나온 “사과”를 참고해야 한다. 모든 단어를 똑같이 참고하는 대신, 현재 처리하는 단어와 관련이 큰 단어에 더 많은 비중을 줄 수 있다.

Attention은 이 참고 대상을 선택하고 비중을 정하는 계산이다.

그림으로 표현하면 다음과 같다.

text
현재 토큰: "그것"
        │
        │ 관련도 질문
        ▼
이전 토큰: 철수   사과   샀다   그는
참고 비중: 0.05  0.70  0.10  0.15
                 ▲
          가장 많이 참고

Query·Key·Value

각 토큰은 계산 과정에서 세 가지 역할의 벡터로 바뀐다. 벡터(vector)는 여러 숫자를 한 줄로 모아 표현한 값이다.

  • 쿼리(Query): 현재 토큰이 “어떤 정보를 찾고 있는가”를 나타내는 벡터
  • 키(Key): 각 토큰이 “어떤 정보와 관련 있는가”를 나타내는 벡터
  • 값(Value): 선택되었을 때 실제로 전달할 내용을 나타내는 벡터

도서관 검색에 비유할 수 있다.

text
Query: 찾고 싶은 책의 조건
Key:   각 책의 검색용 정보
Value: 책 안에 담긴 실제 내용

Query는 여러 Key와 비교된다. 잘 맞는 Key일수록 해당 Value가 Attention 출력에 더 많이 반영된다.

Query·Key·Value는 단어 문자열 자체가 아니다. 모델이 토큰의 숫자 표현을 서로 다른 방식으로 변환해 만든 벡터다.

유사도 계산

Query와 Key가 얼마나 비슷한지 내적(dot product, 같은 위치의 숫자를 곱한 뒤 모두 더하는 계산)으로 측정할 수 있다.

text
Query = [1, 0]
Key A = [1, 0]
Key B = [0, 1]

Query · Key A = 1×1 + 0×0 = 1
Query · Key B = 1×0 + 0×1 = 0

Query는 Key A와 같은 방향이므로 더 높은 점수를 얻는다.

점수가 높다고 바로 Value를 선택하지는 않는다. 모든 점수를 softmax(여러 점수를 합이 1인 양수 비율로 바꾸는 함수)에 넣어 가중치로 바꾼다.

text
유사도 점수: [1.0, 0.0]
softmax 결과: 약 [0.73, 0.27]

가중치의 특징은 다음과 같다.

  • 각 가중치는 0보다 크다.
  • 전체 가중치의 합은 1이다.
  • 점수가 큰 대상에 더 높은 가중치가 주어진다.
  • 하나만 선택하지 않고 여러 대상을 서로 다른 비율로 참고한다.

Value의 가중합

가중합(weighted sum)은 각 값에 가중치를 곱한 뒤 더하는 계산이다.

text
가중치 = [0.73, 0.27]

Value A = [10, 0]
Value B = [0, 20]

출력
= 0.73 × Value A + 0.27 × Value B
= [7.3, 5.4]

Attention 출력에는 Value A와 Value B의 정보가 모두 들어간다. 다만 Query와 더 잘 맞았던 Value A의 영향이 더 크다.

전체 흐름은 다음과 같다.

text
Query와 각 Key를 비교
        ↓
유사도 점수 계산
        ↓
softmax로 가중치 변환
        ↓
각 Value에 가중치 적용
        ↓
가중합으로 출력 생성

간단히 적으면 다음과 같다.

text
점수 = Q · K의 전치
가중치 = softmax(점수)
출력 = 가중치 · V

전치(transpose)는 행과 열을 맞바꾸는 연산이다. 여러 Key를 한 번에 Query와 비교하려고 사용한다.

Self-Attention

셀프 어텐션(self-attention, 같은 토큰 묶음 안에서 서로를 참고하는 Attention)은 한 문장의 토큰들이 같은 문장 안의 다른 토큰을 참고하게 만든다.

text
입력 토큰 ─┬─ Query 생성
           ├─ Key 생성
           └─ Value 생성

예를 들어 “그것”에서 만든 Query가 “사과”에서 만든 Key와 높은 유사도를 얻으면, “사과”의 Value가 “그것”의 새 표현에 크게 반영된다.

모든 토큰이 각자의 Query를 가지므로 Attention 가중치는 하나가 아니라 표 형태가 된다.

text
             참고 대상 Key
현재 Query    철수   사과   샀다
철수          0.6   0.2   0.2
사과          0.1   0.7   0.2
그것          0.1   0.8   0.1

각 행은 한 Query가 모든 Key를 참고하는 비율이며, 행마다 합이 1이다.

코드로 직접 해보기

1단계: 내적 계산하기

NumPy는 숫자 배열과 행렬 계산을 돕는 파이썬 라이브러리다.

python
import numpy as np query = np.array([1.0, 0.0]) keys = np.array([ [1.0, 0.0], [0.0, 1.0], [0.8, 0.2], ]) scores = query @ keys.T print(scores)

출력:

text
[1.  0.  0.8]

@는 행렬 곱 연산자다. keys.T는 Key 배열의 행과 열을 바꾼다. Query와 방향이 비슷한 첫 번째 Key와 세 번째 Key가 높은 점수를 얻는다.

2단계: softmax 직접 구현하기

python
import numpy as np def softmax(numbers): shifted = numbers - np.max(numbers) exp_numbers = np.exp(shifted) return exp_numbers / np.sum(exp_numbers) scores = np.array([1.0, 0.0, 0.8]) weights = softmax(scores) print(weights) print(weights.sum())

np.exp()는 지수 함수를 계산한다. 최댓값을 먼저 빼는 이유는 점수가 클 때 지수 계산 결과가 너무 커지는 문제를 막기 위해서다.

가중치의 합은 부동소수점 오차 범위에서 1이 된다.

3단계: Value의 가중합 계산하기

python
import numpy as np query = np.array([1.0, 0.0]) keys = np.array([ [1.0, 0.0], [0.0, 1.0], [0.8, 0.2], ]) values = np.array([ [10.0, 0.0], [0.0, 20.0], [8.0, 4.0], ]) scores = query @ keys.T shifted = scores - np.max(scores) exp_scores = np.exp(shifted) weights = exp_scores / np.sum(exp_scores) output = weights @ values print("유사도 점수:", scores) print("Attention 가중치:", weights) print("가중치 합:", weights.sum()) print("Attention 출력:", output)

계산되는 배열의 모양은 다음과 같다.

text
query:  (2,)
keys:   (3, 2)
scores: (3,)
values: (3, 2)
output: (2,)

Query 하나가 Key 세 개와 비교되어 점수 세 개를 만든다. 그 가중치로 Value 세 개를 섞으면 길이 2인 출력 벡터 하나가 만들어진다.

4단계: 여러 Query를 한 번에 계산하기

python
import numpy as np def softmax_rows(matrix): shifted = matrix - np.max(matrix, axis=1, keepdims=True) exp_matrix = np.exp(shifted) return exp_matrix / np.sum(exp_matrix, axis=1, keepdims=True) queries = np.array([ [1.0, 0.0], [0.0, 1.0], [0.5, 0.5], ]) keys = np.array([ [1.0, 0.0], [0.0, 1.0], [0.8, 0.2], ]) values = np.array([ [10.0, 0.0], [0.0, 20.0], [8.0, 4.0], ]) scores = queries @ keys.T weights = softmax_rows(scores) outputs = weights @ values print("유사도 점수:\n", scores) print("\nAttention 가중치:\n", weights) print("\n각 행의 합:", weights.sum(axis=1)) print("\nAttention 출력:\n", outputs)

행 하나가 Query 하나에 해당한다. softmax도 각 행에 따로 적용해야 각 Query의 가중치 합이 1이 된다.

오늘의 실험

Query를 바꾸며 어떤 Key의 가중치가 커지는지 히트맵(heatmap, 숫자의 크기를 색의 진하기로 보여주는 그림)으로 확인한다.

python
import numpy as np import matplotlib.pyplot as plt def softmax_rows(matrix): shifted = matrix - np.max(matrix, axis=1, keepdims=True) exp_matrix = np.exp(shifted) return exp_matrix / np.sum(exp_matrix, axis=1, keepdims=True) queries = np.array([ [1.0, 0.0], [0.0, 1.0], [0.5, 0.5], ]) keys = np.array([ [1.0, 0.0], [0.0, 1.0], [0.8, 0.2], ]) query_names = ["Q1", "Q2", "Q3"] key_names = ["사과", "사람", "과일"] scores = queries @ keys.T weights = softmax_rows(scores) plt.imshow(weights, cmap="Blues", vmin=0, vmax=1) plt.colorbar(label="Attention 가중치") plt.xticks(range(len(key_names)), key_names) plt.yticks(range(len(query_names)), query_names) plt.xlabel("Key") plt.ylabel("Query") plt.title("Query별 Attention 가중치") for row in range(weights.shape[0]): for col in range(weights.shape[1]): plt.text( col, row, f"{weights[row, col]:.2f}", ha="center", va="center", ) plt.tight_layout() plt.show()

다음 세 번의 실험을 진행한다.

  1. Q1 = [1.0, 0.0]을 [2.0, 0.0]으로 바꾼다. 같은 방향에서 크기가 커질 때 가중치가 더 한쪽으로 몰리는지 확인한다.
  2. Q3 = [0.5, 0.5]를 [0.9, 0.1]로 바꾼다. 가장 높은 가중치를 받는 Key가 어떻게 달라지는지 확인한다.
  3. 첫 번째 Key를 [1.0, 0.0]에서 [-1.0, 0.0]으로 바꾼다. 반대 방향의 Key가 얼마나 적게 참고되는지 확인한다.

결과는 다음 표로 기록한다.

실험바꾼 값가장 높은 가중치의 Key최고 가중치관찰
기본변경 없음
1Q1 크기 증가
2Q3 방향 변경
3Key 방향 반전

색이 가장 진한 칸만 보지 말고 다음 질문에 답한다.

  • Query의 방향이 바뀌면 어떤 Key가 선택되는가?
  • Query의 크기가 커지면 가중치 분포가 얼마나 뾰족해지는가?
  • 한 Key의 점수가 낮아지면 남은 Key들의 가중치는 어떻게 변하는가?
  • 각 행의 가중치 합은 여전히 1인가?

왜 이걸 배우나 (LLM / Post-training 연결)

LLM은 self-attention을 사용해 현재 토큰을 처리할 때 문맥의 어느 토큰을 얼마나 참고할지 계산한다. 이후 배우는 마스킹, 여러 Attention 헤드, Transformer 구조도 오늘 계산한 Q·K → softmax → V 가중합을 확장한 형태다.

LLM Research Engineer는 Post-training 데이터와 학습 방식이 모델의 지시 따르기, 긴 문맥 활용, 답변 형식에 어떤 변화를 만드는지 실험한다. Attention의 흐름을 이해하면 모델이 앞부분의 조건이나 예시를 어떻게 활용하는지 분석할 기반이 생긴다.

초보자가 막히는 지점

Query·Key·Value를 단어 자체로 생각하기

Query·Key·Value는 문자열이 아니라 숫자 벡터다. 같은 토큰에서 만들어져도 검색, 비교, 정보 전달이라는 서로 다른 역할을 맡는다.

가장 높은 Key 하나만 선택한다고 생각하기

Attention은 보통 하나만 고르지 않는다. softmax 가중치로 여러 Value를 섞는다. 가장 높은 가중치가 0.4라면 나머지 Value들도 합계 0.6만큼 출력에 참여한다.

유사도 점수와 가중치를 혼동하기

내적으로 구한 점수는 음수이거나 합이 1이 아닐 수 있다. softmax를 통과한 뒤의 값이 실제 가중치다.

keys.T를 빼서 행렬 곱 오류가 나기

Query의 길이가 2이고 keys의 모양이 (3, 2)라면 그대로 곱할 수 없다. keys.T로 (2, 3)을 만든 뒤 곱한다.

python
print(query.shape) # (2,) print(keys.shape) # (3, 2) print(keys.T.shape) # (2, 3)

softmax 결과 전체의 합만 확인하기

Query가 여러 개라면 모든 숫자의 총합이 아니라 각 행의 합이 1이어야 한다.

python
print(weights.sum(axis=1))

큰 점수에서 nan이 나오기

지수 함수에 큰 값을 넣으면 계산 범위를 넘을 수 있다. softmax를 계산하기 전에 각 행의 최댓값을 뺀다.

python
shifted = scores - np.max(scores, axis=1, keepdims=True)

최댓값을 빼도 softmax의 최종 비율은 바뀌지 않는다.

Value를 Key와 같은 값으로 고정해서 생각하기

Key는 관련도를 비교하는 값이고 Value는 실제로 전달할 값이다. 예제에서는 이해를 돕기 위해 비슷한 숫자를 사용할 수 있지만, 두 역할은 서로 다르다.

알고리즘 (병행 1시간)

LeetCode Medium 트리 문제 2개를 푼다.

  1. 102. Binary Tree Level Order Traversal
  2. 98. Validate Binary Search Tree

각 문제에 최대 30분을 사용한다. 막히면 정답 코드를 외우지 말고 트리 그림에 노드 방문 순서와 현재 저장해야 할 정보를 직접 적는다.

풀이 뒤에는 다음 내용을 기록한다.

  • 접근: 어떤 순서로 노드를 방문했는가
  • 시간복잡도: 노드 수를 n이라 할 때 연산량이 어떻게 늘어나는가
  • 틀린 이유: 빠뜨린 조건, 잘못된 값의 범위, 방문 순서 중 무엇이 원인이었는가