CS/Python

파이썬으로 Attention 찍먹하기

munsik22 2026. 8. 12. 17:28

🧩 Attention이란?

Attention 메커니즘은 딥러닝 모델이 입력 데이터에서 가장 관련성이 높은 부분을 우선시하도록 (또는 집중하도록) 유도하는 머신러닝 기법이다.(출처: IBM)

Attention은 모델이 문장을 처리할 때 현재 토큰을 이해하기 위해 다른 어떤 토큰을 얼마나 중요하게 참고해야 하는지 계산하는 메커니즘이다. 예컨데 각 단어가 다른 단어들을 같은 비중으로 보지 않고, 상황에 따라 중요한 단어에 더 큰 가중치를 주도록 한다.

'그는'이 누구인지 이해하려면 '철수'를 참고해야 한다.

Attention이 하는 일

"나는 오늘 SSAFY에 간다"라는 문장에서 "간다"라는 토큰을 이해할 때 모델은 앞 단어들을 참고한다.

토큰 가중치 설명
나는 0.1 누가 가는지와 관련
오늘 0.2 언제 가는지와 관련
SSAFY에 0.6 어디로 가는지와 직접 관련 있으므로 높은 비중
간다 0.1 자기 자신 정보

Attention은 이런 가중치를 자동으로 학습한다.

Attention의 출력

Attention은 다른 토큰들의 정보를 가중합(Weighted Sum)해서 새로운 표현 벡터를 만든다. 예를 들어 "간다" 토큰의 기존 벡터가 있고, 다른 토큰들의 정보도 있다고 한다면, 새로운 "간다" 벡터는 개념적으로 다음과 같이 만들어 진다.

토큰 기존 벡터 가중치 새로운 벡터
나는 $\vec{A}$ $0.1$ $0.1 \times \vec{A}$
오늘 $\vec{B}$ $0.2$ $0.2 \times \vec{B}$
SSAFY에 $\vec{C}$ $0.6$ $0.6 \times \vec{C}$
간다 $\vec{D}$ $0.1$ $0.1 \times \vec{D}$

따라서 Attention 이후의 "간다" 벡터는 단순히 "간다"라는 단어 자체가 아니라 "나는 오늘 SSAFY에 간다"라는 문맥을 반영한 벡터가 되는데, 이를 문맥화된 임베딩(contextualized embedding이라고 한다.

Attention이 필요한 이유

단어의 의미는 주변 문맥에 따라 달라지기 때문이다. 아래 예시를 보면 두 문장 모두 "은행"이 있지만 그 의미가 다르다.

  • 5대 은행(銀行, Bank)이 SSAFY와 업무 협약을 맺었다.
  • 가을이 되기 시작하니 거리에 은행(銀杏, Ginkgo) 냄새가 진동하기 시작했다.

Attention은 "은행"이 주변 단어 중 무엇을 봐야 하는지 학습한다.

  • "업무 협약"에 높은 Attention → 금융기관 의미
  • "거리", "냄새"에 높은 Attention → 나무 의미

Query, Key, Value

구성 요소 비유 역할
Query (Q) 검색어 지금 필요한 정보는 무엇인가?
Key (K) 문서 제목/색인 각 토큰은 어떤 정보인가?
Value (V) 문서 본문 실제로 전달할 정보

현재 처리 중인 토큰이 Query를 만들고, 문장 내 모든 토큰의 Key와 비교한다.

$$\text{Attention}(Q, K, V) = \text{softmax}\left(\frac{Q K^T}{\sqrt{d_k}}\right) V$$

  • $Q \times K^T$: 현재 토큰과 다른 토큰 간의 관련성 계산
  • $\frac{...}{\sqrt{d_k}}$: 값이 너무 커지는 것을 방지
  • $\text{softmax}(...)$: 관련성을 0~1 사이의 비율로 변환
  • $... \times V$: 중요도가 높은 토큰의 정보를 많이 반영

[출처] ResearchGate, Junaid Qadir

여기서 Softmax는 AI가 여러 개 중 하나를 고르는 다중 클래스 분류 문제에서 여러 개의 출력 값을 0과 1 사이의 확률값으로 바꾸고 전체 합이 항상 1이 되도록 하는 활성화 함수다. Sigmoid 함수의 그래프와 비슷하게 생겼는데, Sigmoid는 이진 분류나 독립적인 다중 레이블 분류에서 각 출력값을 0과 1 사이의 값으로 독립적으로 변환하지만, Softmax는 다중 클래스 분류 문제에서 모든 출력값의 합이 1이 되는 확률 분포로 만든다는 차이가 있다.

PyTorch 예제

import math
import torch
import torch.nn.functional as F

# 토큰 수 = 3
# 각 토큰 벡터 차원 = 4
torch.manual_seed(42)

Q = torch.randn(3, 4)  # 현재 토큰이 찾고 싶은 정보
K = torch.randn(3, 4)  # 각 토큰의 특징/색인
V = torch.randn(3, 4)  # 각 토큰이 전달할 실제 정보

# 1. 각 Query와 Key의 유사도 점수
scores = Q @ K.transpose(0, 1)

# 2. 차원 크기에 따라 정규화
scores = scores / math.sqrt(K.size(-1))

# 3. 점수를 Attention 확률로 변환
weights = F.softmax(scores, dim=-1)

# 4. 중요도에 따라 Value를 가중합
output = weights @ V

print("Attention 점수:")
print(scores)

print("\nAttention 가중치:")
print(weights)

print("\nAttention 결과:")
print(output)
'''
Attention 점수:
tensor([[ 0.0725,  0.0990,  0.3250],
        [-1.8634, -1.4245, -1.4388],
        [ 0.1536, -0.0942,  0.6380]])

Attention 가중치:
tensor([[0.3017, 0.3098, 0.3884],
        [0.2451, 0.3801, 0.3748],
        [0.2938, 0.2293, 0.4769]])

Attention 결과:
tensor([[ 0.1212,  0.5156, -0.2394, -0.1912],
        [ 0.0999,  0.5376, -0.2558, -0.1143],
        [ 0.1348,  0.5492, -0.3327, -0.3267]])
'''

위 결과에서 첫 번째 토큰은 다음 비율로 정보를 가져온다.

$$\text{첫 번째 토큰의 새 표현} = 0.3017 \times \vec{v1} + 0.3098 \times \vec{v2} + 0.3884 \times \vec{v3}$$

즉 첫 번째 토큰은 자기 자신(또는 첫 번째)이나 두 번째 토큰보다 세 번째 토큰의 정보에 더 집중하고 있다.

GPT-2의 Attention

GPT-2는 입력된 텍스트를 바탕으로 확률적으로 가장 알맞은 다음 단어(토큰)를 예측하여 문장을 이어 나가는 자기회귀(Autoregressive) 방식의 언어 모델이다. GPT-2의 Attention은 현재 위치에서 과거 토큰들을 참고해서 다음 토큰을 예측하는 데 사용된다.

오른쪽이 Causal Mask를 적용한 이후다. [출처] Medium

미래 토큰을 예측하는데 미래 정보를 보면 컨닝을 하는 것이기 때문에 Causal Mask를 사용해 미래 정보를 볼 수 없게 한다.

현재 토큰은 과거와 현재만 볼 수 있고 미래 토큰을 볼 수 없다

구분 일반 Self-Attention GPT-2 Causal Self-Attention
과거 토큰 참고
현재 토큰 참고
미래 토큰 참고
문맥 방향 양방향 단방향
대표 모델 BERT GPT-2, GPT-3 등
주요 목적 문장 전체 이해 다음 토큰 생성

🧩 GPT-2 모델 구현하기

💡 GPT-2 구조를 공부하는 것은 현대 생성형 인공지능과 대형 언어 모델(LLM)의 핵심 작동 원리를 이해하는 가장 효율적인 디딤돌이 된다. 복잡한 최신 모델과 달리 구조가 비교적 단순하면서도 트랜스포머 디코더의 핵심 요소를 모두 담고 있어, AI 엔지니어링 기초를 다지기에 이상적이기 때문이다.

GPT 모델 아키텍처 [출처] ResearchGate, Steve D Yang

GPT-2 모델(왼쪽 패널)은 N개의 트랜스포머 Decoder 블록으로 구성된다. 각 Decoder 블록(가운데 패널)은 Multi-head Masked Attention 레이어, MLP 레이어, Layer Normalization 및 드롭아웃 레이어를 포함한다. 잔차 연결(residual connection, 덧셈 연산자로 연결되는 분기선)을 통해 블록은 이전 블록의 입력으로부터 학습할 수 있다. Multi-head Masked Attention 레이어(오른쪽 패널)는 Q, K, V 벡터를 사용하여 입력 시퀀스의 순차적 관계를 포착하는 어텐션 점수를 계산한다. (출처)

솔직히 적으면서도 뭔 소린지 모르겠다. 최대한 이해한 대로 풀어쓰자면 다음과 같다.

  • (1) Attention Head 블록: Q, K, V 벡터를 가지고 Attention 점수를 계산
  • (2) Multi-Head Attention 블록: (1)을 N개 가지고 있는 블록
  • (3) MLP(Feed Forward) 블록: (2)의 결과를 비선형 변환함
  • (4) Decoder 블록: (2) + Layer Norm + (3)
  • (5) Transformer 블록: (4)를 N개 가지고 있음

작은 단위부터 순서대로 구현해보자.

기본 설정

import math
from dataclasses import dataclass

import torch
import torch.nn as nn
import torch.nn.functional as F

@dataclass
class GPT2Config:
    vocab_size: int = 50257
    block_size: int = 1024
    n_embd: int = 768
    n_head: int = 12
    n_layer: int = 12
    dropout: float = 0.1

1. Attention Head

하나의 Attention Head는 Q, K, V를 만들고 Attention 결과를 계산한다.
class AttentionHead(nn.Module):
    def __init__(self, n_embd: int, head_dim: int, dropout: float):
        super().__init__()

        # 입력 벡터 C를 head_dim 크기의 Q, K, V로 변환
        self.query = nn.Linear(n_embd, head_dim, bias=False)
        self.key = nn.Linear(n_embd, head_dim, bias=False)
        self.value = nn.Linear(n_embd, head_dim, bias=False)

        self.dropout = nn.Dropout(dropout)
        self.head_dim = head_dim

    def forward(self, x, causal_mask):
        """
        x: (B, T, C)

        causal_mask: (1, 1, block_size, block_size)
        """
        B, T, C = x.shape

        # q, k, v: (B, T, head_dim)
        q = self.query(x)
        k = self.key(x)
        v = self.value(x)

        # k.transpose(-2, -1):
        # (B, T, head_dim) -> (B, head_dim, T)
        #
        # attention_score:
        # (B, T, head_dim) @ (B, head_dim, T)
        # -> (B, T, T)
        attention_score = q @ k.transpose(-2, -1)

        # head_dim이 커질수록 score가 너무 커지는 것을 방지
        attention_score = attention_score / math.sqrt(self.head_dim)

        # causal_mask 원래 shape:
        # (1, 1, block_size, block_size)
        #
        # Head에서는 (1, T, T)만 필요하므로 하나의 차원을 제거
        mask = causal_mask[:, 0, :T, :T]

        # 미래 토큰 위치를 -inf로 변경
        attention_score = attention_score.masked_fill(
            mask == 0,
            float("-inf")
        )

        # 마지막 차원 기준 softmax
        # attention_weight: (B, T, T)
        attention_weight = F.softmax(attention_score, dim=-1)
        attention_weight = self.dropout(attention_weight)

        # (B, T, T) @ (B, T, head_dim)
        # -> (B, T, head_dim)
        output = attention_weight @ v

        return output
  • 입력: (B, T, C)
  • 출력: (B, T, head_dim)

여기서 B는 배치 사이즈, T는 시퀀스 길이, C는 임베딩 차원(n_embd)을 의미한다. 예를 들어,

B = 2
T = 4
C = 768

x = torch.randn(B, T, C)

에서 x는 다음과 같은 의미를 가진다.

  • 문장 2개
  • 각 문장에 토큰 4개
  • 각 토큰은 길이 768의 벡터

n_embd=768, n_head=12 이므로,

head_dim = 768 / 12 = 64

입력 x:              (B, T, 768)
AttentionHead 출력:  (B, T, 64)

즉, Attention Head 하나는 전체 768차원 중 64차원짜리 관점 하나를 처리한다.

2. Multi-Head Attention

여러 Attention Head를 병렬로 실행하고 결과를 합친다.
class MultiHeadCausalAttention(nn.Module):
    def __init__(self, config: GPT2Config):
        super().__init__()

        assert config.n_embd % config.n_head == 0

        self.n_head = config.n_head
        self.head_dim = config.n_embd // config.n_head

        # AttentionHead를 n_head개 생성
        self.heads = nn.ModuleList([
            AttentionHead(
                n_embd=config.n_embd,
                head_dim=self.head_dim,
                dropout=config.dropout
            )
            for _ in range(config.n_head)
        ])

        # 여러 Head를 합친 뒤 다시 n_embd로 projection
        self.proj = nn.Linear(config.n_embd, config.n_embd)

        self.dropout = nn.Dropout(config.dropout)

        # (block_size, block_size) 하삼각 causal mask 생성
        mask = torch.tril(
            torch.ones(config.block_size, config.block_size)
        )

        # Multi-Head Attention score에 맞추기 위한 shape
        # (1, 1, block_size, block_size)
        mask = mask.view(
            1, 1,
            config.block_size,
            config.block_size
        )

        self.register_buffer("causal_mask", mask)

    def forward(self, x):
        """
        x: (B, T, C)
        """
        B, T, C = x.shape

        assert T <= self.causal_mask.size(-1)

        # 각 Head의 출력:
        # (B, T, head_dim)
        head_outputs = [
            head(x, self.causal_mask)
            for head in self.heads
        ]

        # 마지막 차원으로 이어 붙인다.
        #
        # [(B, T, 64), (B, T, 64), ..., (B, T, 64)]
        # -> (B, T, 64 * 12)
        # -> (B, T, 768)
        x = torch.cat(head_outputs, dim=-1)

        # (B, T, C) -> (B, T, C)
        x = self.proj(x)
        x = self.dropout(x)

        return x

입력으로 (B, T, 768)이 들어오면, 각각의 Head 12개는 (B, T, 64)를 출력하며, 이를 concat하면 (B, T, 768)이 된다.

(B, T, C)proj(x) 이후 그대로 (B, T, C)을 유지한다. 그러면 proj(x)는 대체 왜 한걸까? 단순히 concat을 하면 내부적으로는 아래처럼 그냥 붙어 있는 상태이다.

[Head1 결과 | Head2 결과 | Head3 결과 | ... | Head12 결과]

이 상태에서는 Head 1의 결과와 Head 7의 결과를 모델이 적극적으로 섞어서 새 feature를 만들 수 없기 때문에, Projection을 통해 이어 붙은 Head 결과들을 학습 가능한 방식으로 섞고 변환해야 한다.

3. Feed Forward(MLP) 블록

Attention이 토큰 사이의 관계를 처리한 뒤, MLP가 각 토큰 벡터를 변환한다.
# GPT-2 MLP 구조: C → 4C → GELU → C

class MLP(nn.Module):
    def __init__(self, config: GPT2Config):
        super().__init__()

        # (B, T, C) -> (B, T, 4C)
        self.fc1 = nn.Linear(
            config.n_embd,
            4 * config.n_embd
        )

        # (B, T, 4C) -> (B, T, C)
        self.fc2 = nn.Linear(
            4 * config.n_embd,
            config.n_embd
        )

        self.dropout = nn.Dropout(config.dropout)

    def forward(self, x):
        """
        x: (B, T, C)
        """

        x = self.fc1(x)
        # (B, T, C) -> (B, T, 4C)

        x = F.gelu(x, approximate="tanh")
        # shape 유지: (B, T, 4C)

        x = self.fc2(x)
        # (B, T, 4C) -> (B, T, C)

        x = self.dropout(x)

        return x

n_embd=768일 때,

입력:        (B, T, 768)
fc1 출력:    (B, T, 3072)
GELU 출력:   (B, T, 3072)
fc2 출력:    (B, T, 768)

[출처] Medium, Glomus

GELU(Gaussian Error Linear Unit)는 ReLU와는 달리 입력값의 크기에 확률적 중요도를 가중치로 부여하는 매끄러운 곡선 형태를 띠는 비선형 활성화 함수다.

4. LayerNorm 블록

Attention이나 MLP에 들어가기 전 각 토큰 벡터의 값 범위를 일정하게 맞춰서 다음 레이어가 안정적으로 처리하게 만든다.
class LayerNorm(nn.Module):
    def __init__(self, n_embd: int, eps: float = 1e-5):
        super().__init__()

        # 각 embedding 차원에 대한 학습 파라미터
        self.weight = nn.Parameter(torch.ones(n_embd))
        self.bias = nn.Parameter(torch.zeros(n_embd))

        self.eps = eps

    def forward(self, x):
        """
        x: (B, T, C)
        """

        # 마지막 차원 C에 대해서만 평균/분산 계산
        mean = x.mean(dim=-1, keepdim=True)
        var = x.var(dim=-1, keepdim=True, unbiased=False)

        # mean, var shape: (B, T, 1)
        x = (x - mean) / torch.sqrt(var + self.eps)

        # weight, bias: (C,)
        # broadcasting 적용 후 결과: (B, T, C)
        x = self.weight * x + self.bias

        return x

GPT-2는 토큰 벡터에 계속 Attention/MLP 결과를 더하는 구조라 블록이 쌓일수록 어떤 토큰 벡터는 값이 너무 커지거나 작아질 수 있다. LayerNorm은 그 상태를 정리해서 Attention과 MLP가 매번 지나치게 튀는 입력을 받지 않게 한다. 요컨대 그냥 현재 토큰 벡터의 수치 분포를 안정화하는 역할을 하는 것이다.

5. Decoder 블록

LayerNorm, Multi-Head Attention, MLP, Residual Connection을 묶어 토큰 정보를 업데이트한다.
class DecoderBlock(nn.Module):
    def __init__(self, config: GPT2Config):
        super().__init__()

        self.ln_1 = LayerNorm(config.n_embd)
        self.attn = MultiHeadCausalAttention(config)

        self.ln_2 = LayerNorm(config.n_embd)
        self.mlp = MLP(config)

    def forward(self, x):
        """
        x: (B, T, C)
        """

        # ln_1(x):        (B, T, C)
        # attn(...):      (B, T, C)
        # residual 결과:  (B, T, C)
        x = x + self.attn(self.ln_1(x))

        # ln_2(x):        (B, T, C)
        # mlp(...):       (B, T, C)
        # residual 결과:  (B, T, C)
        x = x + self.mlp(self.ln_2(x))

        return x

6. Transformer 모델

DecoderBlockn_layer개 쌓는다.
class GPT2Transformer(nn.Module):
    def __init__(self, config: GPT2Config):
        super().__init__()

        self.config = config

        # token id -> embedding vector
        self.token_embedding = nn.Embedding(
            config.vocab_size,
            config.n_embd
        )

        # position id -> position embedding vector
        self.position_embedding = nn.Embedding(
            config.block_size,
            config.n_embd
        )

        self.dropout = nn.Dropout(config.dropout)

        # DecoderBlock을 n_layer개 생성
        self.blocks = nn.ModuleList([
            DecoderBlock(config)
            for _ in range(config.n_layer)
        ])

        # 마지막 LayerNorm
        self.ln_f = LayerNorm(config.n_embd)

        # embedding vector -> vocabulary logits
        self.lm_head = nn.Linear(
            config.n_embd,
            config.vocab_size,
            bias=False
        )

        # GPT-2에서 흔히 쓰는 weight tying:
        # 입력 token embedding weight와 출력 lm_head weight 공유
        self.lm_head.weight = self.token_embedding.weight

    def forward(self, idx, targets=None):
        """
        idx: (B, T)
             각 위치에 token id가 들어 있음

        targets: (B, T), 선택 사항
                 학습 시 정답 token id
        """
        B, T = idx.shape

        assert T <= self.config.block_size, \
            "입력 길이가 block_size보다 큽니다."

        # position:
        # [0, 1, 2, ..., T-1]
        # shape: (T,)
        positions = torch.arange(T, device=idx.device)

        # token embedding:
        # idx: (B, T)
        # -> (B, T, C)
        token_emb = self.token_embedding(idx)

        # position embedding:
        # positions: (T,)
        # -> (T, C)
        position_emb = self.position_embedding(positions)

        # position_emb의 (T, C)는 batch 방향으로 자동 broadcast됨
        x = token_emb + position_emb
        # x: (B, T, C)

        x = self.dropout(x)

        # Decoder Block 반복
        for block in self.blocks:
            x = block(x)

        x = self.ln_f(x)
        # (B, T, C)

        logits = self.lm_head(x)
        # (B, T, vocab_size)

        loss = None

        if targets is not None:
            # logits:
            # (B, T, vocab_size)
            # -> (B*T, vocab_size)
            #
            # targets:
            # (B, T)
            # -> (B*T,)
            loss = F.cross_entropy(
                logits.view(B * T, self.config.vocab_size),
                targets.view(B * T)
            )

        return logits, loss

실행 확인

config = GPT2Config(
    vocab_size=50257,
    block_size=128,
    n_embd=768,
    n_head=12,
    n_layer=12,
    dropout=0.1
)

model = GPT2Transformer(config)

# batch 2개, 각 문장 길이 16
idx = torch.randint(
    low=0,
    high=config.vocab_size,
    size=(2, 16)
)

logits, loss = model(idx)

print(logits.shape) # torch.Size([2, 16, 50257])

(B, T, vocab_size) = (2, 16, 20257)은 다음을 의미한다.

  • 문장 2개
  • 각 문장 위치 16개
  • 각 위치마다 vocabulary 50257개 토큰의 점수

다음 토큰 예측하기

이렇게 구현한 GPT2Transformer 모델을 사용해서 입력 문장 뒤의 다음 토큰 하나를 예측해보자. 물론 학습을 하지 않았기 때문에 결과는 의미 있는 문장이 아니라 랜덤한 토큰이 나올 것이다. 그래도 GPT-2가 다음 토큰을 예측하는 코드 흐름 자체는 동일하다.

from transformers import GPT2TokenizerFast
from gpt2 import * # 구현한 Transformer 모델

tokenizer = GPT2TokenizerFast.from_pretrained("gpt2")

device = "cuda" if torch.cuda.is_available() else "cpu"

config = GPT2Config(
    vocab_size=50257,
    block_size=128,
    n_embd=768,
    n_head=12,
    n_layer=12,
    dropout=0.0,
)

model = GPT2Transformer(config).to(device)
model.eval() # Dropout 비활성화

@torch.no_grad()
def predict_next_token(model, input_ids, temperature=1.0):
    """
    input_ids: (B, T)

    반환:
        next_token_id: (B, 1)
    """

    # block_size보다 긴 입력은 최근 토큰만 사용
    input_ids = input_ids[:, -model.config.block_size:]

    # logits shape: (B, T, vocab_size)
    logits, _ = model(input_ids)

    # 마지막 위치의 logits만 가져온다.
    #
    # logits[:, -1, :]
    # shape: (B, vocab_size)
    #
    # "현재 문장 뒤에 올 다음 토큰"에 대한 점수
    next_token_logits = logits[:, -1, :]

    # temperature가 낮을수록 가장 높은 확률 토큰을 더 강하게 선택
    next_token_logits = next_token_logits / temperature

    # 가장 점수가 높은 token id 선택
    next_token_id = torch.argmax(
        next_token_logits,
        dim=-1,
        keepdim=True
    )

    # shape: (B, 1)
    return next_token_id

prompt = "Hello, my name is"

# 문자열 -> GPT-2 token id
encoded = tokenizer(
    prompt,
    return_tensors="pt"
)

input_ids = encoded["input_ids"].to(device)
print(input_ids.shape)

# 다음 토큰 예측
next_token_id = predict_next_token(model, input_ids)
print(next_token_id)

# 예측한 토큰 ID를 문자열로 변환
next_token_text = tokenizer.decode(
    next_token_id[0].tolist()
)
print("다음 토큰:", repr(next_token_text))

# 전체 문장에 붙여서 확인
output_ids = torch.cat([input_ids, next_token_id], dim=1)
output_text = tokenizer.decode(output_ids[0].tolist())
print(output_text)

아직 weight 정보가 학습되지 않았기 때문에 자기 자신의 유사도가 다른 토큰보다 가장 크게 나와 문장의 마지막 단어(토큰)만 반복해서 예측하는 것을 볼 수 있다.

class GPT2Transformer(nn.Module):
    def __init__(self, config: GPT2Config):
        ...

        # GPT-2에서 흔히 쓰는 weight tying:
        # 입력 token embedding weight와 출력 lm_head weight 공유
        # self.lm_head.weight = self.token_embedding.weight ← 주석 처리

위 코드에서 self.lm_head.weight 부분을 주석 처리하면 입력 "is"를 무조건 다시 예측하는 현상이 크게 줄어든다.

물론 학습하지 않은 모델이므로 결과는 여전히 의미 없는 랜덤 토큰이다.