[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 입니다.

문자열 4개가 토크나이저를 거쳐 4행 7열 정수 텐서가 되고, generate를 거쳐 4행 57열이 된 뒤 다시 문자열 4개로 돌아오는 전체 파이프라인

가운데 두 칸에만 정수가 삽니다. 토큰 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 가 따로 기록합니다.

길이가 다른 프롬프트 네 개가 패딩을 거쳐 4행 7열 정수 격자가 되고, 그 옆에 같은 모양의 0과 1로 된 어텐션 마스크가 만들어지는 그림

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 라는 숫자는 좌표가 아니라 몇 번째 행이냐는 번호입니다.

토크나이저가 내놓은 정수 인덱스가 모델 안 임베딩 행렬의 행 번호로 쓰여 768차원 벡터로 바뀌는 경계

경계가 여기 있습니다. 왼쪽은 토크나이저의 세계라 번호만 있고, 오른쪽은 모델의 세계라 벡터가 있습니다. generate() 코드에서 임베딩이 보이지 않는 이유는 그 일이 모델 안에서 벌어지기 때문입니다.

2단계: 생성

"토큰 50개짜리 답변은 모델 실행 50번"이라는 말과, generate() 한 줄이 토큰 50개를 돌려준다는 사실은 모순이 아닙니다. model.generate() 가 바로 그 자기회귀 루프를 내부에서 최대 50바퀴 돌아주는 함수이기 때문입니다. 루프가 사라진 것이 아니라 함수 안으로 들어갔을 뿐입니다.

generate 호출 한 번 안에서 forward, 마지막 위치 선택, 토큰 이어붙이기가 반복되는 자기회귀 루프와, 매 바퀴 입력 길이가 7에서 56까지 늘어나는 사다리

오른쪽 사다리의 막대가 매 바퀴 한 칸씩 길어집니다. 이번 바퀴에 나온 토큰이 다음 바퀴의 입력이 됩니다. 이 되먹임이 자기회귀의 정의이고, 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 는 이름이 비슷해서 자주 섞입니다. 가리는 대상이 다르고, 사는 곳도 다릅니다.

왼쪽은 미래 위치를 가리는 7행 7열 삼각형 모양의 인과 마스크, 오른쪽은 패딩 칸을 가리는 4행 7열 어텐션 마스크를 나란히 비교한 그림

왼쪽 삼각형은 모델 구조에 붙박이라 배치와 무관하게 늘 같은 모양입니다. 오른쪽은 이번 배치의 사정이라 프롬프트 조합이 바뀌면 매번 다시 만들어집니다. 코드에서 인자로 건네는 것은 오른쪽 하나뿐입니다.

인과 마스크 attention_mask (패딩 마스크)
가리는 대상 미래 토큰 배치 맞춤용 가짜 토큰
사는 곳 모델 구조에 내장 입력 인자로 전달
배치에 따라 바뀌지 않는다 매번 다시 만들어진다

3단계: 디코딩

model.generate() 가 내놓는 것은 벡터가 아니라 토큰 ID의 나열입니다. hidden states 에서 LM 헤드가 어휘 전체에 대한 logits 를 뽑고 그중 다음 토큰을 고르는 과정은 이미 generate() 안에서 끝났고, 밖으로 나오는 것은 선택이 완료된 정수들입니다. batch_decode() 는 1단계의 역변환입니다.

출력 텐서 첫 행의 앞 13칸 중 앞 7칸은 입력 프롬프트이고 나머지가 새로 생성된 토큰이며, 디코딩 결과 문자열이 프롬프트로 시작하는 것을 보여주는 그림

outputs 에는 입력 프롬프트의 토큰까지 포함되어 있습니다. generate() 가 입력 텐서 뒤에 토큰을 이어붙이는 방식으로 동작하니 당연한 결과이고, /generate 응답이 전부 프롬프트로 시작하는 이유가 이것입니다.

말로 풀면

단계 무슨 일 누가 하는가
1 토큰화 문자열 목록을 직사각형 정수 텐서로 만든다. 패딩으로 길이를 맞추고, 채운 자리를 마스크로 기록한다 transformers 토크나이저
2 생성 자기회귀 루프를 최대 50바퀴 돈다. 매 바퀴 forward 한 번, 마지막 위치에서 토큰 하나를 골라 입력 뒤에 붙인다 transformers 의 generate()
3 디코딩 선택이 끝난 정수들을 다시 글자로 되돌린다. 입력 프롬프트까지 포함해서 되돌린다 transformers 토크나이저
한 줄 요약

이 메서드가 짧은 이유는 세 줄이 각각 큰 덩어리를 대신 처리하기 때문입니다. tokenizer() 가 문자열을 직사각형 정수 텐서로 만들고, generate() 가 자기회귀 루프를 최대 50바퀴 돌고, batch_decode() 가 그 정수를 다시 글자로 되돌립니다. 서빙 코드가 직접 쓴 것은 이 셋을 이어붙인 배관뿐입니다.