Deep DiveSutskever·Vinyals·Le arXiv 1409.3215v3 원문과 arXiv 서지 직접 확인·Graves 2013의 LSTM 식 (7)~(11)과 Papineni et al. 2002 BLEU 원문 직접 확인·Bahdanau et al. fixed-length bottleneck 원문 직접 확인·Python 3.9.6 표준 라이브러리로 Graves peephole LSTM encoder와 decoder·softmax·EOS·beam search를 직접 실행·source 순서에 따른 state 변화·softmax 합 1·beam width 2와 완전 탐색 최적 결과 일치 확인

문장을 한 벡터에 접어 넣고 다시 풀어내면 번역이 되는 이유

Sutskever·Vinyals·Le의 2014년 Sequence to Sequence Learning with Neural Networks 원 논문을 12살 독자 기준으로 해부한다. 길이가 다른 문장을 어떻게 조건부 확률로 번역하는지, LSTM encoder와 decoder, cell state와 gate, EOS, source reversal, 정답 prefix 확률 계산과 beam search, WMT14 실험·384M parameter·8 GPU 병렬화·BLEU의 경계, Python 표준 라이브러리 toy LSTM 직접 재현, fixed vector 병목과 attention·Transformer로 이어지는 이유를 사실과 해석으로 분리한다.

3단계 세 번째 논문. 이 글은 Ilya Sutskever, Oriol Vinyals, Quoc V. Le의 Sequence to Sequence Learning with Neural Networks 원문 PDF를 직접 읽고 썼다. arXiv 기록은 2014-09-10 제출, 2014-12-14 개정 v3를 보여 준다.arXiv 서지 기록 **Sequence to Sequence, Seq2Seq(시퀀스에서 시퀀스로)**는 길이가 다른 source sequence(입력 순서열)를 한 LSTM encoder(인코더)가 고정 길이 상태로 읽고, 다른 LSTM decoder(디코더)가 그 상태를 조건으로 target sequence(출력 순서열)를 한 token씩 생성하는 방법이다.

이 글에서 얻을 답과 범위

LLM을 먼저 보면 token embedding은 code의 첫 layer이고 next-token prediction은 당연해 보인다. 그러나 번역처럼 입력 문장 길이와 출력 문장 길이가 다른 문제를 신경망이 어떻게 정의하는지는 잘 보이지 않는다. 이 논문은 그 질문을 큰 model 없이도 잡게 해 준다.

이 글을 읽으면 다음을 직접 설명할 수 있어야 한다.

  1. 고정 길이 vector만 받는 일반 신경망이 왜 문장 입력과 문장 출력을 바로 처리하기 어려운가
  2. encoder와 decoder가 각각 무엇을 저장하고, 번역 확률이 왜 다음 token 확률들의 곱인가
  3. Long Short-Term Memory, LSTM(장단기 메모리)의 cell state, hidden state, input gate, forget gate, output gate가 무엇을 하는가
  4. end-of-sentence, EOS(문장 끝) token이 왜 출력 길이를 정하고, source reversal(입력 문장 역순)이 무엇을 바꾸는가
  5. training에서 정답 문장 확률을 계산하는 일과 serving에서 beam search(빔 탐색)로 문장을 고르는 일이 왜 다른가
  6. WMT14 English to French 실험의 34.81 BLEU가 어떤 model과 decoding 조건의 수치이며, 무엇이 아닌가
  7. fixed-length vector의 병목 때문에 attention과 Transformer가 왜 뒤에 나왔는가

범위는 원 논문 arXiv 1409.3215v3의 LSTM encoder-decoder, source reversal, likelihood 학습, beam search, WMT14 실험, 병렬화, BLEU 평가다. attention 논문이나 Transformer 논문을 대신하지 않는다. LSTM의 상세 gate 식은 원 논문이 사용했다고 밝힌 Alex Graves의 2013 LSTM formulation까지 내려간다. 논문에 없는 저자 구현 API, modern framework의 정확한 tensor layout, training data 재현은 사실처럼 채우지 않는다.

먼저 잡을 한 문장

Seq2Seq는 입력 문장을 읽어 만든 상태를 decoder의 출발점으로 주고, decoder가 이전에 나온 출력과 그 상태를 이용해 다음 token 확률을 반복해서 내도록 만든 조건부 언어 모델이다.

왜 필요한가와 해결하려는 문제

길이가 다른 두 문장을 표 하나로 잇기 어렵다

영어 source가 I eat apples . 네 token이고 프랑스어 target이 Je mange des pommes . 다섯 token이라고 하자. 문장마다 길이가 다르고, 단어가 같은 위치에 1대1로 대응하지도 않는다.

source             I | eat | apples | .
target            Je | mange | des | pommes | .

입력 길이 4
출력 길이 5
대응 위치도 완전히 같지 않다

일반 feed-forward neural network(순방향 신경망)는 보통 입력 vector의 칸 수와 출력 vector의 칸 수를 미리 정한다. 문장 길이가 매번 바뀌면 빈칸을 채우는 padding(패딩)을 넣어도, 어디까지가 실제 문장인지와 출력이 언제 끝나는지는 model이 별도로 배워야 한다. 2014년 원 논문은 speech recognition, machine translation, question answering처럼 길이를 미리 알 수 없는 sequence 문제를 일반적으로 다룰 방법이 필요하다고 문제를 잡았다.원 논문 1절

다음 단어 확률의 곱으로 바꾸면 출력 길이를 고정할 필요가 없다

문장 전체를 한 번에 맞히려 하지 않는다. decoder는 첫 target token, 둘째 token, 셋째 token을 차례로 낸다.

[ p(y_1,\ldots,y_{T'}\mid x_1,\ldots,x_T)

\prod_{t=1}^{T'} p(y_t\mid v,y_1,\ldots,y_{t-1}) \tag{1} ]

기호
(x_1,\ldots,x_T)길이 (T)인 source token sequence
(y_1,\ldots,y_{T'})길이 (T')인 target token sequence
(v)encoder가 source를 다 읽은 뒤 만든 고정 길이 representation
(p(y_t\mid\cdots))t번째 target token의 조건부 확률
(T), (T')서로 달라도 되는 source와 target 길이

원 논문의 식 (1)이다. 앞 target들이 달라지면 다음 확률도 달라지고, EOS가 나오면 곱을 멈춘다. 그래서 output vector의 칸을 다섯 칸이나 열 칸으로 미리 정하지 않아도 된다.

12살 비유로 보면 한 사람이 읽고 다른 사람이 받아쓰는 구조다

encoder는 외국어 문장을 끝까지 읽는 사람이다. 읽는 동안 머릿속 메모를 계속 바꾼다. decoder는 그 메모를 받은 번역가다. 번역가는 첫 단어를 쓰고, 방금 쓴 단어와 받은 메모를 보면서 다음 단어를 쓴다. EOS를 쓰면 끝이다.

source token
  → encoder의 계속 바뀌는 메모
  → 마지막 메모 v
  → decoder의 시작 상태
  → 첫 target 확률
  → 이전 target과 새 상태
  → 다음 target 확률
  → EOS에서 종료

이 비유의 한계도 중요하다. 마지막 메모 하나가 사람의 완전한 문장 이해를 뜻하지 않는다. 이 논문은 translation likelihood를 높이는 parameter를 학습할 뿐이다.

무엇인가와 하지 않는 일

두 개의 LSTM을 잇는 조건부 language model이다

원 논문은 입력과 출력을 위해 서로 다른 두 LSTM을 썼다. 하나를 공유하는 것보다 parameter가 늘고, 여러 language pair를 학습하기에도 자연스럽다는 저자 설명이다. encoder는 source의 마지막 상태를 (v)로 만들고, decoder LSTM의 initial hidden state(초기 은닉 상태)를 (v)로 두어 target language model을 source에 조건화한다.원 논문 2절

encoder LSTM
  source x1 → x2 → ... → xT → v

decoder LSTM
  v와 시작 입력 → p(y1)
  v와 y1      → p(y2)
  v와 y1,y2   → p(y3)
  ...
  EOS를 선택하면 종료

원문은 네 layer LSTM이 sentence를 8,000 real number로 represent한다고 썼다. 이것은 당시 model의 고정 길이 문장 병목을 아주 크게 만든 선택이다. 논문 본문은 decoder API에서 cell state와 hidden state를 어떤 tensor로 넘겼는지까지 명시하지 않는다. 따라서 이 글은 paper fact인 “last hidden state representation으로 decoder를 초기화한다”와, 아래 toy code에서 교육 목적으로 ((h,c))를 넘기는 구현을 구분한다.

Seq2Seq가 하는 일

하는 일어떻게
variable-length input 읽기encoder가 token 하나마다 state를 갱신
variable-length output 만들기decoder가 EOS를 낼 때까지 token 확률을 반복
source에 맞는 output 확률 부여decoder 시작 state를 encoder representation에 조건화
정답 번역의 우도 높이기정답 target prefix에 대한 log probability를 최대화
후보 문장 찾기left-to-right beam search로 근사적 argmax 탐색

Seq2Seq가 하지 않는 일

하지 않는 일이유
source 각 단어를 decoder가 매 순간 직접 다시 읽기이 기본 model은 source 전체를 fixed vector 하나에 압축한다
vocabulary 밖 단어를 정상적으로 복원원 논문은 source 160,000개·target 80,000개 고정 vocabulary와 UNK를 썼다
모든 가능한 번역을 정확히 탐색길이가 무한히 가능하므로 beam search는 근사다
문법·사실성·안전성을 논리적으로 증명likelihood가 높은 token sequence를 낼 뿐이다
Transformer와 같은 구조LSTM recurrence와 fixed vector를 쓰며 self-attention이 없다
RAG처럼 외부 문서를 검색retrieval component가 전혀 없다

선행 개념을 재귀적으로 닫기

token과 vocabulary와 embedding은 각각 다르다

token은 model이 한 step에 읽거나 쓰는 단위다. 이 논문은 word-level vocabulary를 사용했다. vocabulary는 model이 아는 token 목록이고, ID는 그 목록의 주소다. embedding(임베딩)은 ID를 신경망이 계산할 수 있는 실수 vector로 바꾸는 table 행이다. vocabulary 밖 단어는 out-of-vocabulary, OOV(어휘집 밖)라고 하며, 원 논문의 special UNK token은 unknown(알 수 없는 단어)을 한 표식으로 합친 것이다.

apples라는 문자열
  → vocabulary ID 4217
  → embedding table의 4217번 행
  → [0.13, -0.42, ...] 같은 실수 vector
  → LSTM 입력

원 논문 WMT14 English to French 설정은 source의 자주 나온 160,000 words, target의 자주 나온 80,000 words를 vocabulary로 두고, vocabulary 밖 모든 word를 UNK token으로 치환했다. 따라서 34.81 BLEU 결과는 unknown target word가 reference에 있으면 불리한 조건이었다.원 논문 3.1절과 1절

recurrent state는 이전 계산을 다음 step으로 들고 가는 값이다

일반 RNN(순환 신경망)은 현재 입력 (x_t)와 이전 hidden state (h_{t-1})를 섞어 현재 state (h_t)를 만든다.

[ h_t=\sigma(W_{xh}x_t+W_{hh}h_{t-1}) ]

(W)는 학습되는 weight matrix(가중치 행렬), (\sigma)는 비선형 함수다. 같은 matrix를 모든 시간 step에서 재사용하므로 길이가 달라도 반복할 수 있다. 하지만 긴 문장을 거꾸로 미분하면 앞쪽 정보의 gradient가 반복 곱에서 작아지거나 커질 수 있다. 이를 vanishing gradient(기울기 소실)와 exploding gradient(기울기 폭발) 문제라고 부른다.

LSTM은 기억 상자와 세 개의 조절문을 더한다

이 논문은 Alex Graves의 LSTM formulation을 사용한다고 명시한다. Graves의 Generating Sequences With Recurrent Neural Networks 원문 식 (7)~(11)은 input gate (i_t), forget gate (f_t), output gate (o_t), cell state (c_t), hidden state (h_t)를 다음처럼 둔다.

[ \begin{aligned} i_t &= \sigma(W_{xi}x_t+W_{hi}h_{t-1}+W_{ci}c_{t-1}+b_i) \ f_t &= \sigma(W_{xf}x_t+W_{hf}h_{t-1}+W_{cf}c_{t-1}+b_f) \ g_t &= \tanh(W_{xc}x_t+W_{hc}h_{t-1}+b_c) \ c_t &= f_t\odot c_{t-1}+i_t\odot g_t \ o_t &= \sigma(W_{xo}x_t+W_{ho}h_{t-1}+W_{co}c_t+b_o) \ h_t &= o_t\odot\tanh(c_t) \end{aligned} ]

12살 설명값 범위와 역할
(c_t)오래 들고 갈 메모 상자실수 vector이며 기억을 더하거나 남긴다
(i_t)새 메모를 얼마나 쓸지 정하는 문sigmoid 뒤라 각 칸이 0과 1 사이
(f_t)옛 메모를 얼마나 지울지 정하는 문1이면 많이 보존하고 0이면 많이 잊는다
(g_t)새로 써 볼 메모 후보tanh 뒤라 각 칸이 -1과 1 사이
(o_t)메모 중 얼마를 밖으로 보일지 정하는 문hidden state를 조절한다
(h_t)다음 step과 다른 layer에 보이는 작업 메모(c_t) 전체와 다를 수 있다
(\odot)같은 위치끼리 곱하기vector의 각 칸을 독립적으로 조절한다

(W_{ci},W_{cf},W_{co})는 Graves formulation의 peephole connection(피프홀 연결)이다. cell의 같은 위치 값이 gate 판단에 들어간다. Graves는 이 행렬들이 diagonal이라고 설명한다. 즉 첫 memory cell은 첫 gate 칸만 볼 뿐, 모든 cell을 섞지는 않는다. Seq2Seq 원문은 이 식을 다시 인쇄하지 않고 Graves formulation을 참조하므로, 이 식은 Seq2Seq가 사용했다고 밝힌 하위 LSTM 정의다.

softmax와 NLL은 다음 token 하나를 학습 가능한 숫자로 만든다

decoder는 vocabulary 각 후보의 logit(정규화 전 점수) (z_j)를 만든다. softmax는 이를 합이 1인 확률로 바꾼다.

[ p_j=\frac{\exp(z_j)}{\sum_{k=1}^{|V_{\text{target}}|}\exp(z_k)} ]

정답이 j번 token일 때 loss는 (-\log p_j)다. 이를 negative log likelihood, NLL(음의 로그 우도)이라고 한다. 확률 1에 가까우면 loss는 0에 가깝고, 정답 확률이 매우 작으면 loss가 크게 벌어진다. 한 target 문장에는 모든 step의 NLL을 더한다. paper의 log likelihood 최대화와 code의 NLL 최소화는 부호만 반대인 같은 목표다.

BPTT는 같은 LSTM을 시간 축으로 펼쳐 미분하는 방법이다

backpropagation through time, BPTT(시간을 펼친 역전파)는 t=1, 2, 3에서 재사용한 같은 LSTM weight를 시간 축으로 복사한 계산 graph로 보고, 마지막 NLL의 gradient를 반대 방향으로 보낸다. 실제 parameter는 복사본마다 따로 있지 않다. 각 time step에서 온 gradient를 같은 (W_{xi}), (W_{hh}) 같은 parameter에 더한 뒤 update한다. Graves 원문은 LSTM의 full gradient를 BPTT로 계산할 수 있다고 설명한다.Graves 원문 2.1절

forward
  x1 → state1 → x2 → state2 → x3 → state3 → NLL

backward through time
  NLL → state3 → state2 → state1
  각 step의 weight gradient를 같은 parameter에 누적

training prefix와 serving prefix를 섞지 않는다

식 (1)의 (p(y_t\mid v,y_{<t}))는 정답 target 문장 (y)가 주어졌을 때 각 step 확률을 곱하는 방식이다. training에서는 이 정답 prefix로 likelihood를 계산할 수 있다. 이 글은 이를 teacher-forced conditioning(정답 prefix 조건화)이라고 부르지만, 원 논문은 그 framework 이름이나 scheduled sampling 같은 구현 정책을 명시하지 않는다.

serving에서는 정답 (y_{<t})가 없다. model이 이전에 선택한 token을 prefix로 써야 한다. training과 inference의 입력이 달라지는 이 경계를 놓치면 decoder가 이미 낸 오답을 다음 step에서 어떻게 이어 가는지 설명할 수 없다.

밑바닥 원리와 알고리즘을 단계별로 따라가기

source를 읽어 fixed vector를 만드는 단계

source가 A B C라면 원 논문 그림은 EOS까지 포함해 encoder가 sequence를 읽는 모습을 보인다. 단순한 forward order는 아래와 같다.

초기 상태 h0, c0
A      → h1, c1
B      → h2, c2
C      → h3, c3
EOS    → h4, c4
마지막 representation v

deep LSTM이면 이 흐름이 layer마다 쌓인다. layer 1의 (h_t)가 같은 시간의 layer 2 입력이 되고, 네 layer의 state가 source 정보를 압축한다. 원 논문 3.4절의 8,000 real number는 작은 context vector 하나라는 비유보다 훨씬 큰 state였지만, source 길이가 10이든 100이든 끝에는 같은 크기다.

decoder가 target probability를 차례로 곱하는 단계

target이 W X EOS라면 개념상 다음을 계산한다.

[ p(W,X,\text{EOS}\mid v)

p(W\mid v) \times p(X\mid v,W) \times p(\text{EOS}\mid v,W,X) ]

decoder 출발 상태 v
  → 첫 분포에서 W의 확률
  → W를 prefix에 넣은 다음 분포에서 X의 확률
  → W, X를 prefix에 넣은 다음 분포에서 EOS의 확률
  → 세 확률을 곱한다

곱은 아주 작은 수가 되기 쉬우므로 구현은 log probability를 더한다.

[ \log p(W,X,\text{EOS}\mid v)

\log p(W\mid v) +\log p(X\mid v,W) +\log p(\text{EOS}\mid v,W,X) ]

원 논문도 training에서 correct translation (T)가 source (S)에 조건부로 나올 log probability를 최대화한다고 썼다. 논문의 (S)는 이 절에서 training set과 source sentence 양쪽 의미로 사용되어 혼동되기 쉽다. 이 글에서는 dataset을 (D), source를 (x), target을 (y)로 분리한다.

[ \max_\theta \frac{1}{|D|} \sum_{(x,y)\in D}\log p_\theta(y\mid x) ]

EOS가 길이를 만드는 이유와 BOS의 정확한 경계

원 논문은 각 문장이 EOS로 끝나야 모든 가능한 길이 sequence의 분포를 정의할 수 있다고 명시한다. EOS의 확률이 높아지면 decoder는 끝난다. EOS가 없으면 다음 token은 정의해도 언제 멈추는가를 정하지 못한다.

원문은 EOS를 명시하지만 decoder 첫 입력을 어떤 BOS, beginning-of-sentence(문장 시작) token API로 넣었는지는 상세히 쓰지 않는다. 아래 직접 검증 code는 첫 decoder step을 명시적으로 만들기 위해 BOS를 둔다. 이것은 교육용 toy의 구현 선택이며, paper가 BOS라는 정확한 문자열을 썼다는 주장 아니다.

source reversal은 번역 결과를 뒤집는 일이 아니다

원 논문의 매우 특이한 선택은 target은 그대로 두고 source word order만 뒤집는 것이다.

원래 학습 쌍
source  a b c
target  α β γ

논문에서 학습한 쌍
encoder 입력  c b a
decoder 정답  α β γ

encoder가 c, b, a를 순서대로 읽으면 a는 encoder 마지막 가까이에 온다. decoder 첫 target α는 encoder가 끝난 직후 예측한다. 원문 저자들의 해석은 원래 첫 source a와 첫 target α 사이의 minimal time lag(최소 시간 간격)가 작아져, backpropagation이 source와 target 사이의 communication을 만들기 쉬워진다는 것이다. source와 target의 평균 거리는 같아도, 가장 짧은 의존성이 많아지는 것이 핵심이다.원 논문 2절과 3.3절

이것은 자연어의 의미를 더 잘 표현하는 일반 규칙이 아니다. 그 논문의 LSTM fixed-vector translation과 그 optimization 조건에서 확인한 data transform이다. Transformer encoder-decoder에 무조건 source reversal을 적용해야 한다는 뜻이 아니다.

decoding은 argmax가 아니라 탐색 문제다

학습이 끝난 뒤 목표는 가장 확률 높은 문장을 찾는 것이다.

[ \hat T=\arg\max_T p(T\mid S) \tag{2} ]

하지만 (T)의 길이와 각 위치 token 후보가 모두 다양하므로 모든 문장을 완전 탐색할 수 없다. 원 논문은 left-to-right beam search를 썼다.

beam B개 빈 prefix로 시작
각 prefix의 다음 token 후보를 vocabulary 전체로 확장
각 후보의 누적 log probability를 더함
EOS가 붙은 후보는 complete 집합으로 이동
나머지 중 상위 B개만 다음 step에 남김
완료 후보 중 높은 score를 선택

beam width (B=1)은 greedy decoding(그때그때 가장 높은 다음 token 하나만 선택)이다. (B)가 커질수록 더 많은 prefix를 보지만 output softmax와 state 갱신을 더 한다. 원 논문 표 1의 caption은 ensemble 5개와 beam 2가 single LSTM의 beam 12보다도 싸다고 적고, 표의 결과는 ensemble 5와 beam 2에서 34.50 BLEU, ensemble 5와 beam 12에서 34.81 BLEU다. 이 결과는 해당 ensemble·dataset·metric에서의 수치다.

내부 구조와 실제 실행 흐름

2014년 실제 model 설정을 숫자까지 분해한다

원 논문 3.4절의 깊은 LSTM 설정은 다음과 같다.

항목원 논문 설정
encoder와 decoder서로 다른 LSTMsource와 target에 parameter를 공유하지 않음
depth각각 4 layers같은 시간에도 네 번의 recurrent 계산이 쌓임
cells각 layer 1,000 cells각 layer의 state 폭
word embedding1,000 dimensionstoken ID를 LSTM 입력 vector로 변환
source vocabulary160,000 wordsEnglish 입력 후보 수
target vocabulary80,000 wordsFrench 출력 softmax 후보 수
sentence representation8,000 real numbers논문이 적은 deep LSTM state 표현 크기
전체 parameter384Mrecurrent connection 64M 포함
output80,000-way naive softmax매 decoder step의 정확한 vocabulary normalization

384M float32 weight만 계산하면 약 1.43 GiB다. 이 값은 논문 수치 384M에 4 bytes를 곱하고 (1024^3)으로 나눈 내 계산이며, gradient, optimizer state, activation, communication buffer는 포함하지 않는다.

data에서 GPU까지 한 batch가 지나가는 순서

parallel corpus sentence pair
  → tokenizer와 fixed vocabulary
  → OOV를 UNK로 치환
  → 같은 길이대 sentence를 한 minibatch로 묶음
  → source를 역순으로 encoder 4 layers에 통과
  → 마지막 state로 decoder를 조건화
  → 정답 target prefix별 80k softmax와 NLL 계산
  → BPTT로 encoder와 decoder를 거꾸로 미분
  → gradient norm clipping
  → SGD update

원 논문은 길이가 비슷한 sentence끼리 minibatch를 만들어 padding 때문에 버려지는 계산을 줄였고 약 2배 speedup을 보고했다. batch는 128 sequences, learning rate는 0.7, 첫 5 epoch 뒤 매 반 epoch마다 half, 총 7.5 epoch였다. momentum 없는 stochastic gradient descent, SGD(확률적 경사 하강법)를 썼다. batch로 나눈 gradient의 L2 norm, 즉 gradient vector의 길이가 5를 넘으면 (g\leftarrow5g/\lVert g\rVert_2)로 줄이는 hard clipping도 썼다.원 논문 3.4절

gradient clipping은 gradient를 없애기 아니다. 방향은 유지한 채 너무 큰 step의 길이를 상한 5로 자르는 안전장치다. LSTM이 vanishing gradient를 줄이는 구조여도 exploding gradient는 남을 수 있다는 것이 원문 설명이다.

논문의 병렬화는 layer와 softmax를 쪼갠 방식이다

원 논문은 single Graphics Processing Unit, GPU(그래픽 처리 장치) C++ implementation에서 약 1,700 words per second를 보고했고, 8 GPU machine에서는 약 6,300 English와 French words per second를 보고했다. 네 GPU는 네 LSTM layer에 하나씩 배치했고, 나머지 네 GPU는 softmax를 나눠 각각 1,000×20,000 matrix multiplication을 맡았다. minibatch 128에서 training에 약 10일이 걸렸다는 보고다.원 논문 3.5절

이것은 2014년 특정 C++·GPU·model·batch의 throughput이다. 현재 GPU나 framework 속도로 일반화하면 안 된다. 다만 target vocabulary softmax가 큰 matrix이고, depth가 늘면 layer pipeline과 output projection이 병목이 된다는 구조적 사실은 현재 model에서도 이해할 수 있다.

직접 검증으로 encoder state와 decoder 확률을 관찰하기

검증 범위와 환경

직접 실행한 사실이다. macOS에서 Python 3.9.6 표준 라이브러리만 썼다. 2차원 hidden state와 cell state, 3개 source token, 4개 target token의 고정 toy parameter를 만들었다. LSTM cell은 Seq2Seq 원문이 참조한 Graves 식 (7)~(11)의 peephole term까지 넣었다.

이것은 원 논문의 4 layer·1,000 cell·384M parameter·WMT14 experiment 재현이 아니다. 확인하는 범위는 다음 네 가지다.

  1. source 순서를 바꾸면 recurrent final state가 실제로 달라진다.
  2. decoder의 softmax 확률 합이 1이다.
  3. W EOS sequence 확률이 두 조건부 확률의 곱이고 NLL이 음의 log 합이다.
  4. 이 작은 search space에서 beam width 2의 최고 후보가 complete enumeration의 최고 후보와 같다.

아래 내용을 verify_seq2seq.py로 저장한 뒤 실행했다.

python3 --version
python3 verify_seq2seq.py
#!/usr/bin/env python3
import itertools
import math

SOURCE = ["A", "B", "<EOS>"]
TARGET = ["<BOS>", "W", "X", "<EOS>"]
sid = {word: i for i, word in enumerate(SOURCE)}
tid = {word: i for i, word in enumerate(TARGET)}

def sig(x):
    return 1.0 / (1.0 + math.exp(-x))

def mv(matrix, vector):
    return [sum(a * b for a, b in zip(row, vector)) for row in matrix]

def add(*vectors):
    return [sum(values) for values in zip(*vectors)]

def softmax(logits):
    maximum = max(logits)
    values = [math.exp(logit - maximum) for logit in logits]
    total = sum(values)
    return [value / total for value in values]

def parameters(scale):
    return {
        "Wxi": [[.40*scale,-.10*scale],[.20*scale,.30*scale]],
        "Whi": [[.10*scale,.20*scale],[-.20*scale,.10*scale]],
        "Wxf": [[.30*scale,.20*scale],[-.10*scale,.40*scale]],
        "Whf": [[.20*scale,-.10*scale],[.10*scale,.20*scale]],
        "Wxc": [[.50*scale,-.30*scale],[.20*scale,.40*scale]],
        "Whc": [[.20*scale,.10*scale],[-.10*scale,.30*scale]],
        "Wxo": [[.30*scale,.10*scale],[.20*scale,-.20*scale]],
        "Who": [[.10*scale,-.20*scale],[.30*scale,.10*scale]],
        "Wci": [.05*scale,-.04*scale], "Wcf": [.02*scale,.03*scale],
        "Wco": [.04*scale,-.02*scale], "bi": [.10,-.05],
        "bf": [.20,.15], "bc": [.00,.05], "bo": [.10,.00],
    }

ENC, DEC = parameters(1.0), parameters(.8)
SRC_E = [[.40,-.20],[-.30,.50],[.10,.10]]
TGT_E = [[.00,.00],[.50,.10],[-.10,.40],[.20,-.20]]
OUT_W = [[.00,.00],[.60,-.10],[-.20,.30],[.20,.50]]
OUT_B = [-9.0,.05,.00,-.15]

def step(x, h_previous, c_previous, p):
    peephole_i = [a*b for a, b in zip(p["Wci"], c_previous)]
    peephole_f = [a*b for a, b in zip(p["Wcf"], c_previous)]
    i = [sig(z) for z in add(mv(p["Wxi"], x), mv(p["Whi"], h_previous), peephole_i, p["bi"])]
    f = [sig(z) for z in add(mv(p["Wxf"], x), mv(p["Whf"], h_previous), peephole_f, p["bf"])]
    g = [math.tanh(z) for z in add(mv(p["Wxc"], x), mv(p["Whc"], h_previous), p["bc"])]
    c = [forget*old + write*candidate for forget, old, write, candidate in zip(f, c_previous, i, g)]
    peephole_o = [a*b for a, b in zip(p["Wco"], c)]
    o = [sig(z) for z in add(mv(p["Wxo"], x), mv(p["Who"], h_previous), peephole_o, p["bo"])]
    h = [read*math.tanh(cell) for read, cell in zip(o, c)]
    return h, c, (i, f, g, o)

def encode(words):
    h, c, gates = [0.0,0.0], [0.0,0.0], None
    for word in words:
        h, c, gates = step(SRC_E[sid[word]], h, c, ENC)
    return h, c, gates

def decode_once(previous, h, c):
    h, c, _ = step(TGT_E[tid[previous]], h, c, DEC)
    logits = [bias + sum(a*b for a, b in zip(row, h)) for row, bias in zip(OUT_W, OUT_B)]
    return h, c, softmax(logits)

def score(words, h, c):
    previous, logp, probabilities = "<BOS>", 0.0, []
    for word in words:
        h, c, distribution = decode_once(previous, h, c)
        probability = distribution[tid[word]]
        logp += math.log(probability)
        probabilities.append(probability)
        previous = word
    return logp, probabilities

def beam_search(h, c, width=2, maximum=3):
    active, complete = [([], 0.0, h, c, "<BOS>")], []
    for _ in range(maximum):
        expanded = []
        for prefix, logp, old_h, old_c, previous in active:
            next_h, next_c, distribution = decode_once(previous, old_h, old_c)
            for word in ("W", "X", "<EOS>"):
                candidate = prefix + [word]
                candidate_logp = logp + math.log(distribution[tid[word]])
                if word == "<EOS>":
                    complete.append((candidate, candidate_logp))
                else:
                    expanded.append((candidate, candidate_logp, next_h, next_c, word))
        expanded.sort(key=lambda item: item[1], reverse=True)
        active = expanded[:width]
    return sorted(complete, key=lambda item: item[1], reverse=True)[:width]

def exhaustive(h, c):
    candidates = []
    for length in range(1, 4):
        for prefix in itertools.product(("W", "X"), repeat=length-1):
            sentence = list(prefix) + ["<EOS>"]
            candidates.append((sentence, score(sentence, h, c)[0]))
    return sorted(candidates, key=lambda item: item[1], reverse=True)

def shown(values):
    return ",".join(f"{value:.6f}" for value in values)

forward_h, forward_c, _ = encode(["A", "B", "<EOS>"])
reverse_h, reverse_c, gates = encode(["B", "A", "<EOS>"])
after_bos_h, after_bos_c, first_distribution = decode_once("<BOS>", reverse_h, reverse_c)
target_logp, target_probabilities = score(["W", "<EOS>"], reverse_h, reverse_c)
exact, beam = exhaustive(reverse_h, reverse_c), beam_search(reverse_h, reverse_c)

print("encoder_forward_h=" + shown(forward_h))
print("encoder_reversed_h=" + shown(reverse_h))
print("encoder_forward_c=" + shown(forward_c))
print("encoder_reversed_c=" + shown(reverse_c))
print("last_input_gate=" + shown(gates[0]))
print("last_forget_gate=" + shown(gates[1]))
print("last_output_gate=" + shown(gates[3]))
print("source_order_changes_state=" + str((forward_h,forward_c) != (reverse_h,reverse_c)))
print("decoder_after_BOS_h=" + shown(after_bos_h))
print("decoder_after_BOS_c=" + shown(after_bos_c))
print("softmax_sum=" + f"{sum(first_distribution):.12f}")
print("p_W_given_v_BOS=" + f"{first_distribution[tid['W']]:.9f}")
print("p_EOS_given_v_W=" + f"{target_probabilities[1]:.9f}")
print("p_W_EOS_given_v=" + f"{math.exp(target_logp):.9f}")
print("nll_W_EOS_given_v=" + f"{-target_logp:.9f}")
print("exhaustive_best=" + ",".join(exact[0][0]) + f" logp={exact[0][1]:.9f}")
print("beam2_best=" + ",".join(beam[0][0]) + f" logp={beam[0][1]:.9f}")
assert abs(sum(first_distribution)-1.0) < 1e-12
assert (forward_h,forward_c) != (reverse_h,reverse_c)
assert exact[0] == beam[0]

내 실행 결과는 다음과 같다.

Python 3.9.6
encoder_forward_h=-0.010396,0.060875
encoder_reversed_h=0.026832,0.052776
encoder_forward_c=-0.019560,0.122713
encoder_reversed_c=0.050282,0.105306
last_input_gate=0.536191,0.498625
last_forget_gate=0.563002,0.548480
last_output_gate=0.534078,0.503019
source_order_changes_state=True
decoder_after_BOS_h=0.016833,0.043270
decoder_after_BOS_c=0.032155,0.086354
softmax_sum=1.000000000000
p_W_given_v_BOS=0.358469977
p_EOS_given_v_W=0.301220273
p_W_EOS_given_v=0.107978424
nll_W_EOS_given_v=2.225823847
exhaustive_best=<EOS> logp=-1.206681609
beam2_best=<EOS> logp=-1.206681609

W EOS의 확률 0.107978424는 0.358469977과 0.301220273의 곱이다. NLL 2.225823847은 두 확률 log의 음수를 더한 값이다. forward source와 reversed source의 (h,c)가 다른 것도 확인된다. 이것은 source order가 state를 바꾼다는 계산 사실일 뿐, 고정 hand parameter의 reversed order가 번역 성능을 높인다는 증명은 아니다.

이 toy의 첫 step에서는 W 확률 0.358469977이 EOS 확률보다 높다. 그런데 W 뒤에 EOS를 붙인 전체 확률은 곱셈 때문에 0.107978424가 된다. 그래서 길이 최대 3의 전체 후보를 raw log probability로 비교하면 EOS 한 token이 가장 높아 complete enumeration과 beam width 2가 모두 EOS를 골랐다. 이 결과는 translation 품질 예시가 아니라 EOS 처리·sequence length 편향·beam pruning을 작은 후보 공간에서 점검한 것이다.

값을 하나 바꾸고 실패를 읽는 방법

source encoder 입력을 A B EOS에서 B A EOS로 바꾸면 마지막 (h,c)가 바뀐다. target vocabulary row order나 embedding table row order를 바꾸고 ID mapping을 안 바꾸면 더 심각하다. 확률은 합 1로 보이지만 다른 word의 embedding을 읽는 조용한 corruption(손상)이 된다.

softmax에서 maximum logit을 빼지 않으면 큰 positive logit에서 exp overflow가 날 수 있다. EOS를 target vocabulary에서 빼면 decoder는 종료 event를 배울 수 없다. gradient clipping을 지우면 긴 unroll에서 exploding gradient가 update를 망가뜨릴 수 있다. 디버깅 순서는 다음이 좋다.

  1. source와 target vocabulary artifact의 version·row order·UNK rule을 확인한다.
  2. EOS와 decoder 시작 입력이 batch마다 정확히 들어가는지 확인한다.
  3. softmax 확률 합, target probability, NLL이 유한한지 확인한다.
  4. source 순서를 바꿨을 때 encoder state가 예상대로 바뀌는지 trace한다.
  5. gradient norm, clipping 비율, sequence length bucket별 loss와 EOS rate를 본다.
  6. beam result가 greedy와 다를 때 누적 log probability와 종료 후보를 함께 기록한다.

성능과 트레이드오프

계산 비용은 LSTM만이 아니라 output vocabulary와 beam에도 있다

한 LSTM layer의 한 time step은 네 gate에 input과 previous hidden state를 곱한다. input width를 (d), hidden width를 (h), layer 수를 (L), source length를 (T), target length를 (T'), target vocabulary를 (V_y)라고 하자.

구간대략적 계산 비용왜 커지는가
encoder(O(LT(dh+h^2)))source가 길고 layer·hidden이 클수록 반복 matrix multiply 증가
decoder state(O(LT'(dh+h^2)))target token 하나마다 LSTM state 갱신
decoder softmax(O(T'V_yh))모든 target vocabulary logit과 분모 계산
beam width Bdecoder와 softmax 비용에 대략 B배살아 있는 prefix마다 다음 후보 분포 필요
fixed context storage(O(Lh)) statesource 길이와 무관하게 마지막 state만 넘김

실제 runtime은 GPU matrix kernel, batch padding, memory bandwidth, vocabulary partition, beam 후보 중복에 따라 달라진다. Big-O notation(입력 크기가 커질 때 비용이 얼마나 빨리 커지는지 나타내는 표기)은 cost가 어디서 시작되는지 보기 위한 근사다. 원 논문 80,000-way naive softmax를 네 GPU로 나눈 사실은 output layer가 이미 큰 비용이었다는 직접 근거다.

원 논문 WMT14 결과를 조건과 함께 읽는다

원 논문은 Workshop on Machine Translation 2014, WMT14 English to French의 clean selected subset 12M sentence pairs를 사용했다고 썼다. French 348M words, English 304M words이며 cased BLEU를 tokenized prediction과 ground truth에 multi-bleu.pl로 계산했다.원 논문 3.1절과 3.6절

방법ntst14 BLEU반드시 붙여 읽을 조건
phrase-based statistical machine translation, SMT baseline33.30논문이 비교한 baseline
single forward LSTM beam 1226.17source reversal 없음
single reversed LSTM beam 1230.59source만 역순
ensemble 5 reversed LSTMs beam 234.50다섯 model ensemble
ensemble 5 reversed LSTMs beam 1234.81논문 abstract의 direct translation 최고치
baseline 1000-best를 ensemble 5로 rescoring36.5LSTM 단독 생성 결과가 아니라 SMT 후보 목록 재순위

34.81을 LSTM 한 대가 34.81을 냈다고 하면 틀린다. 표 1은 ensemble of 5와 beam size 12 결과라고 명시한다. 36.5는 LSTM이 모든 번역을 새로 생성한 score가 아니라 baseline이 만든 1000-best list를 rescore한 표 2 결과다.

BLEU는 무엇을 재고 무엇을 못 재는가

BLEU, Bilingual Evaluation Understudy(이중언어 평가 대리 지표)는 reference translation과 candidate translation의 clipped n-gram precision(잘라낸 n-그램 정밀도) geometric mean(기하 평균)에 brevity penalty(짧은 문장 벌점)를 곱한 corpus metric이다.

[ \operatorname{BLEU}

\operatorname{BP} \cdot \exp\left(\sum_{n=1}^{N}w_n\log p_n\right) ]

Papineni 등 원문은 baseline에서 (N=4), uniform weight를 쓰고 candidate corpus 길이 (c), effective reference length (r)로 아래 brevity penalty를 정의한다.BLEU 원문 2.3절

[ \operatorname{BP}= \begin{cases} 1 & c>r \ \exp(1-r/c) & c\le r \end{cases} ]

BLEU가 확인하는 것BLEU만으로 확인 못 하는 것
reference와 겹치는 1~4 gram과 길이source 사실을 정확히 옮겼는지
corpus 수준의 비교문장 하나의 완전한 품질
같은 tokenization·script에서 system 상대 비교다른 script·case·tokenization 결과의 공정한 직접 비교
짧은 output을 어느 정도 벌점자연스러운 동의어·전문 용어·안전성

BLEU 34.81은 model이 문장을 이해했다는 점수가 아니다. 원 논문이 정한 WMT14 test set, cased BLEU script, tokenization, vocabulary OOV penalty에서 reference n-gram overlap이 그 정도였다는 보고다.

실패와 운영에서 지켜야 할 기준

fixed vector는 길이와 정보량의 병목이다

원 논문은 reversed source로 long sentence에서 의외로 잘 되었다고 보고했다. Figure 3에서 35 word 미만 degradation이 없고 가장 긴 sentence에 minor degradation이라고 썼다. 그러나 한 state에 모든 source 정보를 넣는 구조적 병목이 사라진 것은 아니다. 길이와 정보가 늘어도 decoder가 직접 받는 것은 고정 크기 (v) 하나다.

Bahdanau, Cho, Bengio의 Neural Machine Translation by Jointly Learning to Align and Translate 원문은 바로 이 fixed-length vector를 bottleneck이라고 지적하고, decoder가 source annotation sequence의 서로 다른 위치를 target step마다 soft search하도록 확장했다. 다음 attention 논문에서 깊게 다룰 주제다.

OOV와 vocabulary drift는 숫자가 아니라 data contract 문제다

원 논문의 UNK는 vocabulary 밖 word를 한 token으로 합친다. named entity, product code, 신규 약어가 많은 업무 domain에서는 중요한 정보가 하나의 UNK로 무너진다. training vocabulary, tokenizer, normalization, casing, EOS, UNK rule은 model checkpoint와 같이 versioning해야 한다.

Not a Number, NaN(숫자가 아님)은 floating-point 계산이 정의할 수 없는 결과라는 표식이다. loss가 NaN이면 좋은 loss가 아니라 model 계산이 이미 망가졌다는 뜻이다.

증상흔한 원인먼저 볼 지표
번역이 UNK를 많이 냄domain drift 또는 vocabulary coverage 부족source와 target UNK rate
너무 짧은 outputEOS logit 과대 또는 decoding 설정target length와 EOS position histogram
빈 output 또는 무한 반복EOS 처리·beam 종료·token mapping 오류complete hypothesis 수와 max-length 종료율
loss가 NaNoverflow 또는 exploding gradientmax logit, gradient norm, clipping count
offline BLEU와 실제 사용 품질 괴리metric이 domain goal과 다름human review, terminology accuracy, task KPI
재배포 뒤 품질 급락vocab row order 또는 preprocessing 불일치model artifact hash와 data contract diff

training과 serving의 state를 관측한다

DB transaction처럼 단일 영속 상태를 복구하는 문제가 아니라, sequence model은 encoder state·decoder state·prefix·beam score가 request마다 잠깐 생기고 사라진다. 운영에서 저장하거나 trace할 값은 원문 자체가 아니라 최소한 길이·UNK count·EOS position·beam score gap·latency·failure code여야 한다. 민감한 source text와 target text를 무분별하게 log에 남기면 privacy 문제가 된다.

model update에서 vocab을 바꾼 경우에는 old checkpoint와 new tokenizer를 섞어 rollback하면 안 된다. checkpoint, vocabulary, preprocessing, decoder configuration, evaluation corpus version을 하나의 release unit으로 묶어야 한다.

대안과 선택 기준

같은 번역 문제라도 병목 위치가 다르다

방법source 정보 전달강점한계와 선택 기준
phrase-based SMTphrase table과 alignment·여러 component당시 OOV·규칙·후보 탐색에 강한 baselinecomponent가 많고 end-to-end representation 학습은 아님
Neural Probabilistic Language Model, NPLM최근 고정 window embeddinglocal next-word probability의 최소 바닥source sentence를 조건으로 한 variable output을 직접 만들지 못함
2014 fixed-vector Seq2Seqencoder 마지막 LSTM state 하나end-to-end variable input to variable outputlong source를 한 vector에 압축하고 recurrence가 순차적
attention encoder-decodersource state sequence의 step별 weighted contextfixed vector 병목 완화와 alignment 관찰attention score 계산과 더 복잡한 state
Transformer decoderself-attention과 cross-attention긴 context의 병렬 학습과 강한 scalingquadratic attention cost와 큰 data·compute 필요
Retrieval-Augmented Generation, RAGmodel context에 검색 결과 추가최신·사내 지식 groundingretrieval 품질·권한·citation 검증이 별도 문제

RAG와 graph retrieval augmented generation, GraphRAG(그래프 검색 증강 생성)를 만들고 싶은 backend engineer에게 이 논문의 핵심은 LSTM을 지금 production에 써라가 아니다. 입력 정보를 어떤 state로 압축하고, decoder가 그 state를 언제 읽으며, output search와 data contract가 전체 품질을 어떻게 제한하는가를 보는 연습이다. RAG의 retrieved chunk도 decoder가 읽는 conditioning information이며, fixed vector 하나보다 source token states나 retrieved evidence에 다시 접근하는 설계가 왜 필요해지는지 연결된다.

흔한 오해와 최초 질문의 답

오해 하나 Seq2Seq는 Transformer의 다른 이름이다

아니다. Seq2Seq는 variable-length input을 variable-length output으로 보내는 문제와 factorization의 이름으로 넓게 쓸 수 있다. 이 논문은 그 문제를 two LSTM과 one fixed vector로 푼 2014 architecture다. Transformer도 encoder-decoder Seq2Seq task에 쓸 수 있지만, recurrence·LSTM gate·source reversal이 핵심인 이 논문과 내부 구조가 다르다.

오해 둘 source reversal은 번역문을 거꾸로 출력한다

아니다. source만 역순으로 encoder에 넣고, target은 원래 순서 그대로 decoder가 학습한다. 목적은 target 첫 부분과 원래 source 첫 부분의 shortest gradient path를 만드는 optimization trick이다. service input을 무조건 뒤집으라는 일반 규칙이 아니다.

오해 셋 LSTM이면 긴 문장을 무한히 기억한다

아니다. gate가 long-range dependency를 다루기 쉽게 만들지만 memory capacity·training data·optimization·fixed vector bottleneck은 남는다. 원 논문은 그 설정에서 long sentence에 잘 되었다고 보고했지만, 후속 attention 논문은 fixed-length context가 특히 긴 source에서 문제라고 설명한다. 두 주장은 서로 모순이 아니라 dataset·architecture·측정 경계가 다르다.

오해 넷 beam size를 계속 키우면 정답 번역을 찾는다

아니다. beam search는 전체 search tree의 일부만 남기는 근사다. 더 큰 beam은 비용을 늘리고 raw log probability의 짧은 문장 선호 같은 문제도 만들 수 있다. 원 논문은 그 설정에서 beam 2가 대부분 benefit을 준다고 보고했지, beam search가 exact argmax라고 주장하지 않았다.

오해 다섯 36.5 BLEU가 model 단독 번역 성능이다

아니다. 36.5는 phrase-based baseline이 먼저 만든 1000-best candidate를 LSTM ensemble이 rescore한 결과다. direct LSTM translation의 최고 표 1 수치는 ensemble 5, beam 12의 34.81이다. 실험 숫자는 model 수, beam, candidate source, metric script, vocabulary OOV 처리를 떼고 말하면 의미가 바뀐다.

처음 질문에 대한 답

top-down으로 RAG·agent·data pipeline부터 만들고, 왜 model이 token을 하나씩 내는지와 왜 retrieved context가 필요한지에서 멈추면 이 논문으로 내려오면 된다. 읽을 순서는 다음이 효율적이다.

문제
  variable input과 variable output을 어떻게 확률로 정의하는가

구조
  encoder state와 decoder prefix가 무엇인가

학습
  NLL과 BPTT와 clipping이 어디에 붙는가

생성
  EOS와 beam search가 왜 필요한가

한계
  fixed vector가 왜 attention으로 이어지는가

이 글의 code를 직접 돌려 source 순서가 (h,c)를 바꾸고, decoder가 EOS까지 조건부 확률을 곱하며, beam이 prefix를 버린다는 것을 확인했다면 다음 Bahdanau attention 논문을 읽을 준비가 된 것이다.

출처

1차 자료

  1. Ilya Sutskever, Oriol Vinyals, Quoc V. Le. Sequence to Sequence Learning with Neural Networks PDF, arXiv 1409.3215v3, 2014-12-14. 이 글의 식 (1)·(2), LSTM encoder-decoder, source reversal, WMT14 data·hyperparameter·parallelization·표 1·2·Figure 3의 1차 근거다.
  2. arXiv article record. 제출·개정일과 abstract 검증 경로다.
  3. Alex Graves. Generating Sequences With Recurrent Neural Networks, arXiv 1308.0850v5, 2014. Seq2Seq 원문이 사용한다고 밝힌 LSTM formulation의 식 (7)~(11), peephole term, softmax와 BPTT·gradient clipping 설명에 썼다.
  4. Kishore Papineni, Salim Roukos, Todd Ward, Wei-Jing Zhu. BLEU A Method for Automatic Evaluation of Machine Translation, ACL 2002. modified n-gram precision, geometric mean, brevity penalty, corpus-level metric 경계의 1차 근거다.
  5. Dzmitry Bahdanau, KyungHyun Cho, Yoshua Bengio. Neural Machine Translation by Jointly Learning to Align and Translate, ICLR 2015 version. fixed-length context bottleneck과 attention alternative 경계에 썼다.

직접 실행한 검증

  • 2026-07-24 macOS에서 Python 3.9.6과 표준 라이브러리만 사용했다.
  • Graves 식 (7)~(11)의 peephole LSTM으로 2차원 encoder·decoder toy model을 실행했다.
  • A B EOS와 B A EOS의 final hidden·cell state가 서로 달라 source order가 state를 바꾼다는 것을 확인했다.
  • decoder softmax 합은 1.000000000000이었고, W EOS 확률 0.107978424와 NLL 2.225823847을 조건부 확률 곱과 log 합으로 직접 확인했다.
  • target 후보 길이 최대 3인 작은 search space에서 beam width 2의 최고 후보와 exhaustive search 최고 후보가 일치했다. 이 검증은 2014 WMT14 benchmark 재현이나 번역 품질 증명이 아니다.
대화

댓글

0
댓글을 불러오는 중입니다.