[CloudNeta] Hands-On LLM Serving 2주차 - 추적: generate()의 세 단계 (transformers가 맡는 영역)
이 문서는 보강 - 토크나이저, forward, 생성 루프에서 갈라져 나온 곁가지입니다. 그 글이 두 메서드를 나란히 읽는다면, 여기서는 한쪽만 그림 위주로 더 파고듭니다. 다른 한쪽은 해체한 자기회귀 루프에서 다룹니다.
ModelWorker.generate() 의 본체는 스무 줄이 안 됩니다. 짧은 이유는 하는 일이 적어서가 아니라 transformers 가 대신 해주는 영역이 넓기 때문입니다. 그 영역을 열어 보면 문자열이 정수가 되었다가 다시 문자열로 돌아오는 왕복 하나가 들어 있습니다.
읽는 위치
llm/model_worker.py:19 의 generate() 는 프롬프트 배치 하나를 통째로 받아 완성된 문자열 목록을 돌려줍니다. 앞선 글에서 본 프로세스 경계의 안쪽, 토큰 ID가 존재하는 유일한 구역이 바로 여기입니다.
모델은 문자열을 읽지 못하고 어휘 사전의 번호만 압니다. 그래서 이 메서드가 하는 일은 셋뿐입니다. 번호로 바꾸고, 번호를 늘리고, 다시 글자로 되돌립니다.
# llm/model_worker.py:30 ---- 1단계: 토큰화
inputs = self.tokenizer(
prompt_texts,
return_tensors="pt",
padding=True,
truncation=True,
max_length=512,
).to(self.device)
# llm/model_worker.py:41 ---- 2단계: 생성
with torch.no_grad():
outputs = self.model.generate(
inputs.input_ids,
attention_mask=inputs.attention_mask,
max_new_tokens=50,
num_return_sequences=1,
pad_token_id=self.tokenizer.eos_token_id,
)
# llm/model_worker.py:51 ---- 3단계: 디코딩
generated_texts = self.tokenizer.batch_decode(outputs, skip_special_tokens=True)
모델과 토크나이저는 llm/model_manager.py:18 에서 AutoModelForCausalLM.from_pretrained("facebook/opt-125m") 로 함께 올라옵니다. 아래 그림에 적힌 숫자는 전부 그 모델을 실제로 돌려서 나온 값입니다.
왕복: 문자열이 정수가 되었다가 문자열로 돌아온다
프롬프트 4개를 한 배치로 넣고, 그중 가장 긴 것이 토큰 7개인 경우입니다. 들어갈 때 [4, 7], 나올 때 [4, 57] 입니다. 57 은 7 에 50 을 더한 값이고, 그 50 이 max_new_tokens=50 입니다.
가운데 두 칸에만 정수가 삽니다. 토큰 ID는 워커 프로세스 밖으로 나가지 않습니다. 양쪽 끝의 문자열은 사람이 읽는 표현이고, 두 표현 사이를 오가는 변환기가 토크나이저입니다.
1단계: 토큰화
토크나이저는 문자열을 어휘 사전의 정수 ID 나열로 바꾸는 변환기입니다.
>>> tokenizer("Hello world")
{'input_ids': [2, 31414, 232], 'attention_mask': [1, 1, 1]}
"Hello world" 가 정수 3개가 됐습니다. 이것이 토큰화의 전부입니다. 1주차 part 2에서 본 대로 토큰은 단어가 아니라 서브워드 조각이고, 토크나이저는 텍스트와 토큰 ID를 양방향 변환하는 장치입니다.
문제는 배치입니다. 프롬프트 4개의 길이가 제각각인데 텐서는 직사각형이어야 합니다. padding=True 가 그 빈 자리를 메우고, 메운 자리가 어디인지는 attention_mask 가 따로 기록합니다.
padding=True 는 가장 긴 행에 맞춰 뒤를 채웁니다. 512 라는 숫자는 여기에 등장하지 않습니다. truncation=True, max_length=512 는 512토큰을 넘는 프롬프트가 들어왔을 때만 작동하는 안전선입니다.
옵션별 의미는 다음과 같습니다.
| 옵션 | 하는 일 |
|---|---|
return_tensors="pt" |
파이썬 리스트 대신 PyTorch 텐서로 반환한다 |
padding=True |
배치 안에서 가장 긴 것에 맞춰 짧은 것 뒤를 패딩 토큰으로 채운다. 로그에 찍히는 Batch input shape 가 이 결과다 |
truncation=True / max_length=512 |
512토큰을 넘으면 자른다 |
.to(self.device) |
만들어진 텐서를 GPU 메모리로 옮긴다 |
여기서 나오는 것은 벡터가 아니다
흔한 오해 하나를 짚고 갑니다. 토크나이저의 출력은 정수 인덱스이지 벡터가 아닙니다. 벡터가 되는 것은 그 다음, 모델 안의 임베딩 계층에서입니다. 임베딩의 실체는 큰 행렬 하나이고 조회는 행 인덱싱이므로, 23602 라는 숫자는 좌표가 아니라 몇 번째 행이냐는 번호입니다.
경계가 여기 있습니다. 왼쪽은 토크나이저의 세계라 번호만 있고, 오른쪽은 모델의 세계라 벡터가 있습니다. generate() 코드에서 임베딩이 보이지 않는 이유는 그 일이 모델 안에서 벌어지기 때문입니다.
2단계: 생성
"토큰 50개짜리 답변은 모델 실행 50번"이라는 말과, generate() 한 줄이 토큰 50개를 돌려준다는 사실은 모순이 아닙니다. model.generate() 가 바로 그 자기회귀 루프를 내부에서 최대 50바퀴 돌아주는 함수이기 때문입니다. 루프가 사라진 것이 아니라 함수 안으로 들어갔을 뿐입니다.
오른쪽 사다리의 막대가 매 바퀴 한 칸씩 길어집니다. 이번 바퀴에 나온 토큰이 다음 바퀴의 입력이 됩니다. 이 되먹임이 자기회귀의 정의이고, generate() 는 그 되먹임을 대신 돌려주는 함수입니다.
인자별 의미는 다음과 같습니다.
| 인자 | 하는 일 |
|---|---|
torch.no_grad() |
PyTorch는 기본적으로 역전파용 계산 그래프를 기록해 둔다. 추론은 파라미터를 고칠 일이 없으니 그 기록 자체가 메모리와 시간 낭비다. 블록을 나가면 원상복구되어야 하므로 컨텍스트 매니저로 관리한다 |
max_new_tokens=50 |
단어가 아니라 토큰 기준이고, "최대"이므로 EOS 토큰이 먼저 나오면 50개를 채우지 않고 멈춘다 |
attention_mask |
패딩으로 채운 가짜 토큰을 어텐션 계산에서 빼라는 표시다. 배치 처리와 패딩의 짝꿍 개념이다 |
pad_token_id=eos_token_id |
전용 패딩 토큰이 없는 토크나이저를 위해 EOS를 대신 쓰라고 지정한다 |
스트리밍 경로의 generate_forward_batch() 와 나란히 놓으면 차이가 선명합니다. 그쪽은 model() 을 직접 불러 루프의 한 바퀴만 돌고, 루프 자체는 바깥의 서빙 코드가 돌립니다. 같은 자기회귀 루프를 누가 돌리느냐가 두 경로의 본질적인 차이입니다. 이 대비는 해체한 자기회귀 루프에서 이어집니다.
곁다리: 이름이 닮은 마스크 두 개
마스크드 어텐션의 인과 마스크와 방금 본 attention_mask 는 이름이 비슷해서 자주 섞입니다. 가리는 대상이 다르고, 사는 곳도 다릅니다.
왼쪽 삼각형은 모델 구조에 붙박이라 배치와 무관하게 늘 같은 모양입니다. 오른쪽은 이번 배치의 사정이라 프롬프트 조합이 바뀌면 매번 다시 만들어집니다. 코드에서 인자로 건네는 것은 오른쪽 하나뿐입니다.
| 인과 마스크 | attention_mask (패딩 마스크) |
|
|---|---|---|
| 가리는 대상 | 미래 토큰 | 배치 맞춤용 가짜 토큰 |
| 사는 곳 | 모델 구조에 내장 | 입력 인자로 전달 |
| 배치에 따라 | 바뀌지 않는다 | 매번 다시 만들어진다 |
3단계: 디코딩
model.generate() 가 내놓는 것은 벡터가 아니라 토큰 ID의 나열입니다. hidden states 에서 LM 헤드가 어휘 전체에 대한 logits 를 뽑고 그중 다음 토큰을 고르는 과정은 이미 generate() 안에서 끝났고, 밖으로 나오는 것은 선택이 완료된 정수들입니다. batch_decode() 는 1단계의 역변환입니다.
outputs 에는 입력 프롬프트의 토큰까지 포함되어 있습니다. generate() 가 입력 텐서 뒤에 토큰을 이어붙이는 방식으로 동작하니 당연한 결과이고, /generate 응답이 전부 프롬프트로 시작하는 이유가 이것입니다.
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained("facebook/opt-125m")
enc = tok(["Hello world", "Hi"], return_tensors="pt", padding=True)
print(enc.input_ids) # 정수 ID 텐서. "Hi" 쪽 뒤에 패딩 ID가 보임
print(enc.attention_mask) # 패딩 위치만 0
print(tok.batch_decode(enc.input_ids, skip_special_tokens=True)) # 왕복 복원
말로 풀면
| 단계 | 무슨 일 | 누가 하는가 |
|---|---|---|
| 1 토큰화 | 문자열 목록을 직사각형 정수 텐서로 만든다. 패딩으로 길이를 맞추고, 채운 자리를 마스크로 기록한다 | transformers 토크나이저 |
| 2 생성 | 자기회귀 루프를 최대 50바퀴 돈다. 매 바퀴 forward 한 번, 마지막 위치에서 토큰 하나를 골라 입력 뒤에 붙인다 | transformers 의 generate() |
| 3 디코딩 | 선택이 끝난 정수들을 다시 글자로 되돌린다. 입력 프롬프트까지 포함해서 되돌린다 | transformers 토크나이저 |
이 메서드가 짧은 이유는 세 줄이 각각 큰 덩어리를 대신 처리하기 때문입니다. tokenizer() 가 문자열을 직사각형 정수 텐서로 만들고, generate() 가 자기회귀 루프를 최대 50바퀴 돌고, batch_decode() 가 그 정수를 다시 글자로 되돌립니다. 서빙 코드가 직접 쓴 것은 이 셋을 이어붙인 배관뿐입니다.