[CloudNeta] Hands-On LLM Serving 2주차 - 토크나이저, forward, 생성 루프

이 문서는 2주차 part 1 - 모델 서빙 시스템 설계의 LLM 가공로직 자세히 보기를 분리한 글입니다. 요청이 워커에 도달하기까지의 경계와 큐는 request_id의 여정에서 다루고, 여기서는 워커 안에서 문자열이 실제 다음 토큰으로 바뀌는 과정에만 집중합니다.

SSE로 토큰의 문자열 조각을 리턴합니다

이 예제에서 SSE로 받는 token은 모델이 이미 선택해 생성한 다음 토큰의 문자열 조각입니다. 아직 생성되지 않은 초안이나 계산 중간값은 아닙니다. 다만 전체 답변은 계속 생성 중이므로, 클라이언트가 받은 것은 생성은 끝났지만 문장 전체로는 미완성인 조각입니다.

모델이 지금까지 확정한 토큰:  "The" + " quick" + " brown"
이번 forward가 새로 확정한 토큰: " fox"
클라이언트가 SSE로 받는 값:      {"token": " fox", ...}
전체 답변 상태:                  아직 미완성

보통의 자기회귀 생성에서는 이미 전송한 토큰을 뒤에서 고쳐 쓰지 않습니다. 새 토큰을 하나씩 뒤에 붙입니다. 클라이언트는 이 조각들을 도착 순서대로 이어 붙여 한 문장을 만듭니다.

다만 두 가지는 구분해야 합니다.

또한 상용 API의 text_delta 한 건이 언제나 토큰 정확히 하나라는 보장은 없습니다. 서버가 여러 토큰을 한 문자열 조각으로 묶을 수도 있습니다. 지금 보는 예제는 토큰 하나를 디코딩한 뒤 이벤트 하나로 보내므로 둘이 거의 일치합니다.

전체 지도: 트랜스포머 안과 밖

먼저 각 도구와 코드가 어디에 있는지 한 장에 놓아봅시다.

flowchart LR
    Text["사람의 문자열"]
    TokE["Tokenizer.encode
문자열 → token IDs"] Emb["Token + Position
Embedding"] Blocks["Decoder Transformer Blocks
Causal Self-Attention + FFN"] Head["LM Head
hidden state → vocabulary logits"] Select["Sampling
temperature + softmax + multinomial"] TokD["Tokenizer.decode
token ID → 문자열 조각"] SSE["SSE
클라이언트에 즉시 전달"] Text --> TokE --> Emb --> Blocks --> Head --> Select --> TokD --> SSE subgraph Model["self.model(...) 내부"] Emb Blocks Head end

경계를 정확히 나누면 다음과 같습니다.

이 구분만 잡아도 tokenizer(), model(), model.generate(), SSE를 한 덩어리로 생각하는 혼란이 대부분 사라집니다.

등장인물 셋: 토크나이저, PyTorch, 모델

세 이름이 각각 어느 층을 맡는지 먼저 겹쳐 놓고 보겠습니다.

PyTorch, transformers, vLLM이 각각 소유한 API와, 수동 경로가 각각 어디까지 직접 제어하는지 살펴봄

토크나이저

토크나이저는 문자열과 모델 어휘 사전의 정수 ID를 양방향으로 변환합니다.

"The quick brown"
    ↓ encode
[2, 133, 2119, 6219]
    ↓ decode
"The quick brown"

토큰은 반드시 단어나 글자 하나와 일치하지 않습니다. 공백이 붙은 조각, 서브워드, 바이트 조각일 수 있습니다. 그래서 토큰 하나를 디코딩한 결과가 " fox", "ing", 문장부호 또는 빈 문자열처럼 보일 수 있습니다.

토크나이저가 돌려주는 핵심 값은 두 개입니다.

토큰 ID가 곧 임베딩 벡터는 아닙니다. 모델 안의 임베딩 테이블이 ID를 행 번호로 사용해 벡터를 조회합니다.

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

PyTorch

PyTorch는 이 코드의 수치 계산 실행 환경입니다.

PyTorch가 언어를 이해하거나 문장을 생성하는 것은 아닙니다. 학습된 모델이 정의한 계산을 텐서 연산으로 실행하는 엔진에 가깝습니다.

self.model(...)

self.model(...)은 모델의 forward 한 번입니다.

입력 시퀀스를 임베딩하고, 여러 트랜스포머 블록을 통과시키고, 각 위치의 hidden state를 LM 헤드로 보내 어휘 전체의 logits를 계산합니다. 출력 shape을 기호로 쓰면 다음과 같습니다.

input_ids:       [B, T]
outputs.logits:  [B, T, V]

B = batch size
T = 현재 입력 시퀀스 길이
V = vocabulary size

model()은 다음 토큰 하나를 직접 선택해주지 않습니다. 각 위치마다 어휘 V개에 대한 점수를 돌려줍니다.

self.model.generate(...)

model.generate()는 forward를 이용해 문장을 만드는 고수준 함수입니다. 내부적으로 대략 다음 일을 반복합니다.

while not finished:
    outputs = model(input_ids)
    next_token_logits = outputs.logits[:, -1, :]
    next_token = select(next_token_logits)
    input_ids = append(input_ids, next_token)

실제 generate()는 여기에 EOS 검사, 최대 길이, beam search나 sampling 설정, KV 캐시, 배치별 종료 처리 등을 더합니다.

ModelWorker.generate()를 한 줄씩 읽기

이 메서드는 여러 프롬프트를 하나의 배치로 토큰화한 뒤, model.generate()가 내부 생성 루프를 끝낼 때까지 기다리고 완성된 결과를 한 번에 돌려줍니다.

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

1. 배치에서 문자열과 식별자를 분리한다

prompt_texts = [prompt.prompt for prompt in prompts]
request_ids = [prompt.id for prompt in prompts]

2. 문자열 배치를 직사각형 텐서로 만든다

inputs = self.tokenizer(
    prompt_texts,
    return_tensors="pt",
    padding=True,
    truncation=True,
    max_length=512,
).to(self.device)

프롬프트 길이는 서로 다르지만 GPU의 배치 연산은 직사각형 텐서를 원합니다. 가장 긴 프롬프트 길이에 맞춰 짧은 행을 패딩합니다.

원래 토큰 길이:  [5], [8], [3]
padding 이후:     [3, 8]
attention_mask:   실제 토큰 1, 패딩 0

return_tensors="pt"는 결과를 PyTorch 텐서로 만들고, .to(self.device)는 모델과 같은 CPU 또는 GPU로 옮깁니다.

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

3. generate()가 자기회귀 루프를 맡는다

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,
    )

이름이 닮은 마스크가 하나 더 있어서 자주 섞입니다. 가리는 대상과 사는 곳이 다릅니다.

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

여기서 가장 중요한 교정은 이것입니다.

/generate가 model.generate()를 한 번 호출하는 것은 맞지만, 생성 루프가 한 번만 도는 것은 아닙니다. generate() 내부에서 forward와 다음 토큰 선택이 최대 50번 반복됩니다.

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

4. 토큰 ID 전체를 문자열로 되돌린다

generated_texts = self.tokenizer.batch_decode(
    outputs,
    skip_special_tokens=True,
)

outputs는 선택이 완료된 토큰 ID들의 행렬입니다. batch_decode()가 각 행을 문자열로 되돌립니다. 이 예제의 결과에는 생성된 부분뿐 아니라 입력 프롬프트도 포함됩니다.

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

5. 요청 ID와 결과를 다시 묶는다

results = []
for request_id, generated_text in zip(request_ids, generated_texts):
    results.append({
        "request_id": request_id,
        "generated_text": generated_text,
    })
return results

배치 안에서 한 번에 계산했지만 HTTP 요청이나 프롬프트의 소유자는 서로 다릅니다. request_id가 모델 계산 결과를 원래 요청으로 돌려주는 우편번호 역할을 합니다.

이 메서드만 따로 더 보기

위 다섯 단계를 그림 위주로 다시 훑은 문서가 있습니다. 패딩이 만드는 격자, 인과 마스크와 패딩 마스크의 차이, 그리고 generate() 가 도는 50바퀴를 사다리로 펼친 그림까지 담았습니다.

generate()의 세 단계: transformers가 맡는 영역

generate_forward_batch()를 한 줄씩 읽기

이 메서드는 generate() 내부의 반복 루프 중 한 바퀴만 바깥으로 꺼낸 코드입니다. 활성 프롬프트를 배치로 묶어 각 프롬프트의 다음 토큰 하나씩을 만든 뒤 즉시 반환합니다.

1. 토큰화와 forward

encoded = self.tokenizer(
    prompt_texts,
    return_tensors="pt",
    padding=True,
    truncation=True,
    max_length=512,
).to(self.device)

with torch.no_grad():
    outputs = self.model(
        input_ids=encoded.input_ids,
        attention_mask=encoded.attention_mask,
        use_cache=False,
    )

self.model(...)을 직접 불렀으므로 forward 한 번만 실행됩니다. 아직 어떤 토큰도 선택하지 않았습니다. outputs.logits에는 각 배치, 각 위치, 각 어휘 토큰의 점수가 들어 있습니다.

예를 들어 배치 4개, 현재 길이 7, 어휘 50,272개라면:

encoded.input_ids.shape = [4, 7]
outputs.logits.shape     = [4, 7, 50272]

2. 왜 마지막 위치의 logits만 쓰는가

next_token_logits = outputs.logits[:, -1, :]

인과적 언어 모델은 각 위치에서 그 위치까지의 prefix를 보고 다음 토큰을 예측하도록 학습됩니다.

입력: [The, quick, brown]

위치 0의 logits: "The" 다음은 무엇인가
위치 1의 logits: "The quick" 다음은 무엇인가
위치 2의 logits: "The quick brown" 다음은 무엇인가  ← 지금 필요한 것

입력 네 토큰 각각에 대해 모델이 5만 2백여 개의 점수를 내놓지만, 추론에서는 마지막 자리의 점수만 쓰고 앞의 세 벌은 버린다는 그림

학습 때는 모든 위치의 예측을 한꺼번에 사용해 손실을 계산합니다. 추론 때는 현재 문장 전체 다음에 올 토큰만 필요하므로 마지막 위치만 꺼냅니다.

한 번의 forward가 내놓는 같은 출력을 학습은 T벌 전부 쓰고 추론은 마지막 한 벌만 쓴다는 비교도

슬라이싱 결과 shape은 다음처럼 줄어듭니다.

[B, T, V]
  ↓ [:, -1, :]
[B, V]

배치의 각 행마다 어휘 V개에 대한 다음 토큰 점수표 하나가 남습니다.

3. logits를 확률로 바꾸고 하나를 뽑는다

next_token = torch.multinomial(
    torch.softmax(next_token_logits / 0.7, dim=-1),
    num_samples=1,
).squeeze(-1)

안쪽부터 읽으면 됩니다.

logits 슬라이스에서 시작해 0.7로 나누고 softmax를 거쳐 multinomial로 뽑은 뒤 squeeze로 축을 없애는 다섯 단계와 각 단계의 텐서 모양

next_token_logits / 0.7

logits는 확률이 아니라 정규화되지 않은 점수입니다. temperature가 0.7처럼 1보다 작으면 점수 차이가 확대되어 높은 후보에 더 집중합니다. 반대로 1보다 크면 분포가 평평해져 다양한 후보가 선택될 가능성이 커집니다.

temperature 1.0, 0.7, 0.3에서 다음 토큰 상위 다섯 개와 나머지 전부의 확률을 비교한 막대 그래프

facebook/opt-125m 에 "The quick brown" 을 넣고 실제로 재어 본 값입니다.

토큰 T = 1.0 T = 0.7 T = 0.3
'ie' 0.2339 0.6412 0.9790
'ies' 0.0727 0.1209 0.0200
나머지 50267개 0.6371 0.1855 0.0003

나눗셈이 순위를 바꾸지는 않습니다. 1등은 나눠도 1등입니다. 바뀌는 것은 1등과 나머지의 격차뿐이고, 그 격차가 곧 다른 후보가 뽑힐 여지입니다.

softmax(..., dim=-1)

각 행의 어휘 V개 점수를 합이 1인 확률 분포로 바꿉니다. dim=-1은 마지막 차원, 즉 어휘 차원을 뜻합니다.

logits:        [2.1, 1.4, -0.3, ...]
softmax 결과:  [0.58, 0.29, 0.04, ...]

torch.multinomial(..., num_samples=1)

확률 분포에서 행마다 후보 하나를 무작위 추출합니다. 가장 높은 확률을 무조건 고르는 argmax와 달리, 낮지만 0이 아닌 후보도 선택될 수 있습니다.

.squeeze(-1)

multinomial 결과 [B, 1]의 마지막 크기 1인 차원을 제거해 [B]로 만듭니다. 이제 배치의 각 프롬프트마다 다음 토큰 ID 하나가 있습니다.

4. 토큰 ID를 문자열 조각으로 바꾸고 요청별로 포장한다

results = []
for i, prompt_data in enumerate(prompts):
    token = self.tokenizer.decode(
        next_token[i].unsqueeze(0),
        skip_special_tokens=True,
    )
    results.append({
        "request_id": prompt_data["request_id"],
        "token": token,
        "is_finished": token == self.tokenizer.eos_token,
    })
return results

이 for 루프는 프롬프트마다 모델을 다시 실행하는 루프가 아닙니다. 무거운 모델 연산은 배치 전체에 대해 이미 한 번 끝났습니다. 여기서는 [B]에 담긴 토큰 ID를 하나씩 문자열로 바꾸고, 원래 request_id와 묶을 뿐입니다.

반환 dict는 SSE 규격이 아닙니다. 워커 프로세스와 스케줄러 사이에서 결과를 운반하기 위한 내부 메시지 계약입니다.

이후 스케줄러는 token을 현재 프롬프트 뒤에 붙이고, 끝나지 않았다면 다음 배치에 다시 태웁니다.

현재 prompt
  → forward
  → 다음 token 선택
  → token을 클라이언트에 SSE로 전달
  → prompt = prompt + token
  → 다음 forward

따라서 문장 생성 루프가 사라진 것이 아닙니다. model.generate() 안에 있던 루프를 서빙 코드의 requests_processing_loop()가 대신 돌리는 것입니다.

이 메서드만 따로 더 보기

손으로 쓴 네 줄이 왜 필요해졌는지, 그리고 그 아래 PyTorch 와 transformers 와 vLLM 이 각각 무엇을 맡는지 정리한 문서입니다. 이 저장소가 손으로 만든 조각들이 vLLM 안의 무엇에 해당하는지 표로 대응시켜 두었습니다.

해체한 자기회귀 루프: PyTorch, transformers, vLLM

/generate와 /generate_stream을 다시 정확히 말하면

처음의 두 문장을 다음처럼 고치면 정확합니다.

경로 정확한 설명
/generate 여러 프롬프트를 배치로 묶을 수 있고, 워커가 model.generate()를 한 번 호출합니다. 하지만 그 호출 내부에서는 프롬프트마다 최대 50개의 새 토큰을 만들기 위한 자기회귀 루프가 반복됩니다. 완성 결과를 한 번에 돌려줍니다.
/generate_stream HTTP 요청 하나에는 프롬프트 하나가 들어오지만, 스케줄러는 여러 사용자의 활성 요청을 최대 배치 크기만큼 함께 forward할 수 있습니다. forward 한 번마다 요청별 다음 토큰 하나를 얻고, 그 조각을 SSE로 즉시 보낸 뒤 다시 반복합니다.

즉 /generate_stream이 개별 HTTP 요청을 받도록 설계된 것과 모델이 그 요청 하나만 단독 계산한다는 것은 다른 이야기입니다. 사용자는 각자 연결 하나를 갖지만 GPU에서는 여러 사용자가 한 배치에 합승할 수 있습니다.

그리고 SSE를 택한 이유는 결과가 완료되자마자 한 번 반환하기 위해서가 아닙니다. 완료되기 전부터 생성된 조각을 계속 반환하기 위해서입니다.

왜 굳이 한 토큰씩 바깥에서 돌리는가

model.generate()로 완성본을 만든 다음 문자열을 잘라 보내면 겉보기 스트리밍은 만들 수 있습니다. 하지만 사용자는 모델 계산이 모두 끝날 때까지 첫 글자를 받지 못합니다.

반대로 한 토큰씩 제어권을 돌려받으면 서빙 계층이 토큰 사이마다 다음 일을 할 수 있습니다.

즉 수동 한 토큰 생성은 모델 기능을 흉내 내기 위한 목적보다, 서빙 스케줄러가 생성 도중에 개입할 제어 지점을 얻기 위한 구조입니다.

generate 경로는 자기회귀 루프가 transformers 함수 안에서 돌고, generate_forward_batch 경로는 같은 루프가 서빙 코드 쪽으로 나와 매 바퀴 프로세스 경계를 왕복한다는 비교도

이 코드는 prefill도 KV 캐시도 쓰지 않는다

지금까지 읽은 generate_forward_batch()는 실제 서빙 엔진이 쓰는 두 가지 최적화를 둘 다 빼고 있습니다. 무엇을 뺐는지, 그래서 얼마를 손해 보는지 직접 재 보겠습니다.

개념은 1주차에서 이미 깔았습니다

prefill과 decode가 왜 서로 다른 단계인지, KV 캐시가 무엇을 저장하고 왜 Query는 저장하지 않는지, 인과 마스크 덕분에 앞 위치의 K/V가 왜 안 바뀌는지는 1주차 part 3 - LLM 서빙에서 그림과 함께 다뤘습니다. 아래는 그 개념을 이 저장소 코드에 대보고 직접 재보는 자리입니다.

캐시를 끄는 두 줄

캐시가 없는 이유는 코드 두 곳에 나뉘어 있습니다. 하나는 워커에 있습니다.

outputs = self.model(
    input_ids=encoded.input_ids,
    attention_mask=encoded.attention_mask,
    use_cache=False,      # K/V를 만들고 나서 버린다
)

다른 하나는 스케줄러 쪽 WorkloadManager.update_sequence_output()에 있습니다.

sequence.output.append(token)
sequence.prompt += token      # 프롬프트 문자열 자체가 길어진다
sequence.token_count += 1

토큰 ID를 이어붙이는 것이 아니라 프롬프트 문자열에 생성된 조각을 그대로 덧붙입니다. 그리고 다음 바퀴에 requests_processing_loop()이 그 길어진 seq.prompt를 다시 워커로 보냅니다. 두 줄이 합쳐지면 매 바퀴 이렇게 됩니다.

  1. 지금까지의 문자열 전체를 처음부터 다시 토큰화한다
  2. 그 전체를 모델에 넣어 모든 위치의 K/V를 다시 계산한다
  3. 마지막 위치의 logits만 쓰고 나머지는 버린다
  4. K/V도 버린다
실제 서빙 엔진 이 저장소
prefill 프롬프트를 한 번 처리해 K/V를 캐시에 채운다 첫 바퀴가 프롬프트 전체를 처리하긴 하지만 K/V를 남기지 않는다
decode 새 토큰 1개만 넣고 캐시된 K/V와 대조한다 매 바퀴 누적 문자열 전체를 다시 토큰화해 다시 forward한다
KV 캐시 레이어마다 헤드마다 K/V를 쌓아둔다 없다. use_cache=False
한 바퀴 비용 길이와 무관하게 거의 일정 지금까지 생성한 길이에 비례해서 계속 커진다

즉 이 코드에는 prefill과 decode의 구분 자체가 없습니다. 매 바퀴가 전부 prefill입니다.

얼마를 다시 계산하나

이 저장소가 쓰는 모델 그대로(facebook/opt-125m), 이 루프 모양 그대로 재 봤습니다. 프롬프트는 12토큰이고, LLMEngine의 self.max_tokens = 20이 기본값이라 20토큰까지 생성했습니다. 비교군은 같은 루프에 KV 캐시만 켠 것입니다.

항목 이 저장소 그대로 같은 루프 + KV 캐시 배수
모델에 들어간 토큰 누적 430 31 13.9배
어텐션 점수 칸 누적 9,910 562 17.6배
20토큰 생성 시간 1.48초 0.66초 2.2배

캐시를 켜면 누적 입력이 31토큰입니다. 프롬프트 12토큰을 한 번 넣고, 그 뒤로는 19바퀴 동안 한 번에 한 토큰씩만 넣기 때문입니다. 캐시가 없으면 12, 13, 14로 계속 늘어나며 다시 들어가서 430토큰이 됩니다.

같은 측정을 100토큰까지 늘리면 격차가 훨씬 벌어집니다.

항목 이 저장소 그대로 같은 루프 + KV 캐시 배수
모델에 들어간 토큰 누적 6,051 111 54.5배
어텐션 점수 칸 누적 447,613 6,282 71.3배
100토큰 생성 시간 9.35초 3.25초 2.9배

토큰 수는 54배, 어텐션 점수 칸은 71배 차이인데 시간은 2.9배밖에 차이가 안 납니다. opt-125m이 작은 모델이고 CPU에서 돌렸기 때문입니다. 파이썬 오버헤드와 커널 실행 비용이 상수처럼 깔려 있어서 연산량 차이가 그대로 시간 차이로 나타나지 않습니다. 모델이 커지고 컨텍스트가 길어질수록 이 상수가 묻히면서 시간 격차가 연산량 격차 쪽으로 붙습니다.

브라우저에서 보이는 증상: 뒤로 갈수록 느려진다

연산량보다 체감에 직접 닿는 지표는 토큰 사이 간격입니다. 100토큰을 생성하며 구간별 평균을 재면 이렇습니다.

생성 구간 캐시 없음 입력 토큰 캐시 없음 ITL 캐시 사용 입력 토큰 캐시 사용 ITL
첫 바퀴(prefill) 12 62ms 12 61ms
1~20번째 토큰 13~31 69ms 1 26ms
21~40번째 토큰 33~51 82ms 1 26ms
41~60번째 토큰 53~71 90ms 1 27ms
61~80번째 토큰 73~91 99ms 1 27ms
81~100번째 토큰 93~111 109ms 1 27ms

캐시가 없으면 문장이 길어질수록 다음 토큰까지의 간격이 같이 벌어집니다. 69ms에서 109ms로 1.6배가 됐습니다. 캐시를 켜면 27ms에 고정됩니다. 입력이 언제나 한 토큰이라 매 스텝 하는 일의 양이 같기 때문입니다.

이 저장소를 띄우고 /generate_stream에 긴 답을 요구해 보면 눈으로 확인할 수 있습니다. 처음엔 토큰이 또박또박 나오다가 뒤로 갈수록 뜸해집니다. 1주차 part 3에서 "TPS가 낮으면 decode 병목"이라고 했던 증상이 바로 이 모양이고, 여기서는 원인이 캐시가 없다는 것 하나뿐입니다.

결과가 달라지지는 않는다

캐시가 정확도를 깎는 근사는 아닌지 확인해 볼 필요가 있습니다. 샘플링의 무작위성을 빼고 항상 가장 확률 높은 토큰을 고르게(greedy) 한 뒤 두 경로를 돌리면, 20토큰이 문자 단위까지 완전히 같습니다.

캐시 없음:  runs away.\nI'm not sure if you're being sarcastic or not, but the brown fox
캐시 사용:  runs away.\nI'm not sure if you're being sarcastic or not, but the brown fox

당연한 결과입니다. 인과 마스크 때문에 앞 위치의 K/V는 뒤에 무엇이 붙어도 바뀌지 않으니, 다시 계산하든 꺼내 쓰든 같은 값입니다. KV 캐시는 같은 답을 더 싸게 얻는 방법이지 다른 답을 얻는 방법이 아닙니다.

그런데 왜 이렇게 짜여 있나

캐시를 켜는 순간 스케줄러가 할 일이 늘어나기 때문입니다. past_key_values를 요청마다 따로 들고 있어야 하고, 요청이 배치에 새로 들어오거나 빠져나갈 때 그 캐시도 같이 붙였다 떼야 합니다. 요청마다 길이가 제각각이니 이 메모리는 크기가 계속 변합니다. 이것이 PagedAttention이 푸는 문제이고, 1주차 part 3에서 "가변 크기 메모리를 어떻게 할당하고 관리할 것인가가 별도의 엔지니어링 문제"라고 했던 대목입니다.

이 예제는 그 앞 단계에서 멈춰 있습니다. 캐시도 없고 스케줄링도 없는 대신, 자기회귀 루프가 서빙 코드에 그대로 드러나 있어서 한 바퀴가 무엇인지 눈으로 볼 수 있습니다. 최적화를 하나씩 얹으면서 무엇이 왜 필요해지는지 따라가기에는 이 상태가 출발점으로 낫습니다.

덧붙여, 이 루프가 다시 하는 일은 모델 계산만이 아닙니다. 매 바퀴 누적 문자열 전체를 토크나이저에 다시 넣습니다. 100토큰을 생성하는 동안 토큰화한 문자 수가 누적 23,209자였습니다. 모델 forward에 비하면 작은 비용이지만, 실제 서빙 엔진이 문자열이 아니라 토큰 ID 리스트를 시퀀스 상태로 들고 다니는 이유가 여기 있습니다.

멀티모달 운영에서는 어디까지 이어지는가

텍스트와 이미지·영상은 사용자에게는 하나의 대화지만, 생성 결과의 성격이 다릅니다.

텍스트 이미지·영상
다음 토큰을 반복 생성 보통 이미지 또는 프레임 전체를 만드는 별도 모델·외부 API 작업
토큰 조각을 즉시 표시 가능 완료 전에는 진행 상태나 미리보기만 제공하는 경우가 많음
TTFT, ITL, tokens/s가 중요 큐 대기, 전체 생성시간, 성공률, 결과 파일 상태가 중요
SSE가 자연스러움 job 생성 후 폴링, 상태 SSE 또는 webhook이 자연스러움

두 경로를 한 채팅에 결합할 때 백엔드는 먼저 메시지와 미디어 placeholder의 순서를 확정하고, 각각의 상태를 비동기로 갱신할 수 있습니다. 텍스트는 성공했지만 이미지가 실패한 경우도 전체 요청 실패로 뭉개지 않고 partial 상태로 표현할 수 있어야 합니다.

여기서 토큰 생성 이해가 필요한 이유는 모델 연구 때문이 아닙니다. 텍스트가 왜 먼저 흐를 수 있는지, 왜 이미지와 같은 완료 계약으로 묶으면 사용자 경험이 나빠지는지, 비용과 타임아웃을 어디서 나눌지를 판단하기 위해서입니다.

마지막으로 머릿속에 남길 일곱 문장

  1. 토크나이저는 문자열을 토큰 ID로 바꾸며 트랜스포머 밖에 있다.
  2. 모델의 forward는 각 위치에 대해 어휘 전체의 logits를 만든다.
  3. 현재 문장 다음 토큰에는 마지막 위치의 logits만 사용한다.
  4. softmax와 sampling이 logits에서 다음 토큰 ID 하나를 고른다.
  5. model.generate()는 이 forward와 선택을 내부에서 반복한다.
  6. generate_forward_batch()는 한 바퀴만 실행하고 바깥 스케줄러가 반복한다.
  7. SSE는 이미 생성된 조각을 미완성 문장 상태에서 즉시 전달하는 운반 수단이다.