NOTES / 06 / 2026.10.20

실제 요청 하나를 끝까지 따라가기.

지난 편

5편에서 입력 전체를 한꺼번에 계산하는 prefill, 새 토큰 하나씩 계산하는 decode, 앞의 계산 결과를 저장해 다시 쓰는 KV 캐시를 봤다.

한 줄 결론

요청 하나는 접수와 대기를 거쳐 토큰으로 잘린다. 서버는 prefill로 첫 토큰을 고른 뒤 decode로 한 토큰씩 이어 간다. 고른 토큰은 그때그때 보내고, <EOS>가 나오면 멈춘 뒤 KV 캐시 메모리를 비운다.

쉽게 보기

1~5편의 예시 요청에 "stream": true만 더해 보낸다고 하자. 보낸 순간부터 답이 다 나올 때까지를 순서대로 놓으면 이렇다.

단계                                      이 시점의 데이터 (가상 값)

[클라이언트]
  1. 질문을 보낸다                        "토마토는 과일이야?"
[앱 서버]
  2. 요청을 만들어 API로 보낸다           system + messages + "stream": true
[API 서버]
  3. API 키를 확인하고 차례를 기다린다
[LLM 서버]
  4. 토큰으로 자른다                      토큰 ID 16개
  5. 다른 요청과 묶어 GPU에 올린다
  6. prefill: 입력 16개를 한꺼번에 계산   [16, 3584] -> 캐시 16
  7. 첫 토큰을 고른다                     네 (4512)
  8. 고른 토큰을 바로 보낸다              "네"
  9. decode: 한 토큰씩 반복               , -> 과일입니다. -> <EOS>  캐시 17 -> 18 -> 19
 10. <EOS>가 나오면 멈춘다                stop_reason: "end_turn"
 11. 자원을 정리한다                      KV 캐시 19토큰 분량을 비운다

API 서버와 LLM 서버를 나눈 것은 설명용이다. Claude의 내부 구성, 입력 형식, 벡터 길이 같은 설정값은 공개되어 있지 않다. 그래서 서버 내부 동작(특히 5단계와 11단계)은 vLLM 같은 일반적인 서빙 엔진을, 입력 형식과 설정값은 공개 모델을 기준으로 설명한다.

1~2. 요청 만들기 (2편). 앱 서버가 질문에 시스템 지침을 붙여 요청을 만든다. 2편의 요청에 stream만 더했고 temperature 같은 값은 넣지 않았다.

{
  "model": "claude-opus-5-5",
  "max_tokens": 1024,
  "system": "짧게 답하세요.",
  "messages": [
    { "role": "user", "content": "토마토는 과일이야?" }
  ],
  "stream": true
}

3. 접수. API 서버는 API 키를 확인한다. 요청이 몰려 있으면 계산을 시작하기 전에 차례를 기다린다.

4. 토큰으로 자르기 (2편). 시스템 지침과 질문을 정해진 형식으로 잇고, 토크나이저가 토큰 16개로 잘라 토큰 ID로 바꾼다. ID는 2편의 예시 토큰 표 그대로다. 잇는 형식은 공개 모델의 흔한 형태를 따랐다.

[5, 21873, 1240, 6150, 703, 2981, 13, 6, 7, 7342, 9180, 412, 1785, 30, 8, 9]

5. 다른 요청과 묶기 (5편). 서버는 이 요청을 그 순간 들어와 있는 다른 요청과 묶어 GPU에서 함께 계산한다. 요청끼리 내용이 섞이지는 않는다. 이 요청의 KV 캐시를 둘 GPU 메모리가 남아 있어야 묶음에 들어간다.

6. prefill (3편, 5편). 토큰 ID 16개로 임베딩 표에서 벡터 16개를 꺼낸다. [토큰 수, 벡터 길이] 모양으로는 [16, 3584]다. 3,584는 2편에서 본 Qwen2.5-7B의 벡터 길이다. 이 벡터들은 레이어를 차례로 지나며 어텐션으로 앞 문맥을 섞는다. 레이어를 지나도 모양은 [16, 3584] 그대로다. 그사이 레이어마다 위치 0~15의 값이 KV 캐시에 쌓인다.

7. 첫 토큰 고르기 (4편). 위치 15의 마지막 벡터로 로짓을 내고 확률 분포로 바꾼다. 요청에 temperature가 없으니 API 기본값 1.0이 적용된다. 4편의 가상 값에서는 네가 0.70, 아니가 0.30이고, 여기서는 네(4512)가 뽑혔다.

8. 바로 보내기 (1편). LLM 서버는 4512를 글자 네로 바꿔 곧바로 보낸다. 앱 서버는 받은 조각을 클라이언트에 넘긴다. 첫 글자가 보이기까지 1~7단계를 모두 거쳐야 한다. 스트리밍을 켰으니 그 뒤로는 토큰이 나오는 대로 화면에 붙는다.

event: content_block_delta  ... "text": "네"
event: content_block_delta  ... "text": ","
event: content_block_delta  ... "text": " 과일입니다."
event: message_delta        ... "stop_reason": "end_turn"
event: message_stop

text 내용은 가상이고, 일부 이벤트는 생략했다. 조각 하나에 토큰 여러 개가 담길 수도 있다(1편 더 깊이).

9. decode 반복 (5편). 이제 방금 고른 토큰 하나만 [1, 3584] 모양으로 넣는다. 앞 위치의 값은 KV 캐시에서 꺼내 쓰고, 고른 토큰은 8단계처럼 바로 나간다. 표는 5편 그대로다.

단계이번에 계산하는 위치고른 토큰계산 뒤 캐시 토큰 수
prefill0~15 (입력 16개 전부)네16
decode 116 네,17
decode 217 ,과일입니다.18
decode 318 과일입니다.<EOS>19

10. 끝내기 (4편). decode 3에서 <EOS>가 뽑히면 반복이 멈춘다. <EOS>는 글자로 바꿔 보내지 않는다. 대신 API는 끝부분의 message_delta 이벤트에 stop_reason을 "end_turn"으로 담는다. Claude 문서는 end_turn이 응답을 자연스럽게 마쳤다는 뜻이라고 설명한다. end_turn이 <EOS>를 고른 결과라는 것도 일반 엔진 기준의 설명이다.

11. 자원 정리 (5편). 요청이 끝나면 서버는 KV 캐시 19토큰 분량의 GPU 메모리를 비워 다른 요청에 내준다. 다음 질문 그럼 수박은?은 앞 대화를 담은 새 요청이라 1단계부터 다시 시작한다.

응답 시간도 이 순서로 나눠 볼 수 있다.

  • 첫 글자까지: 네트워크를 오가는 시간, 3단계와 5단계의 대기, 6단계 prefill. 대기는 몰린 요청 수에, prefill은 입력 길이에 따라 늘어난다.
  • 첫 글자 뒤: 9단계 decode가 한 토큰씩 내놓는 속도. 출력이 길수록 늦게 끝난다.
더 깊이

서버가 여러 요청을 묶어 계산하는 이유.

이 절도 vLLM 같은 일반 엔진 기준이다. 5편 더 깊이에서 본 것처럼 decode는 단계마다 모델 가중치 전체를 메모리에서 읽는다. 요청 하나만 계산하면 읽은 가중치를 토큰 하나에만 쓰고 끝난다. 여러 요청을 묶으면 한 번 읽은 가중치를 묶인 요청 모두에 쓰므로 읽는 비용이 요청 수만큼 나뉜다. vLLM 논문 Efficient Memory Management for Large Language Model Serving with PagedAttention 2.3절이 같은 이유를 든다.

이렇게 묶어 한 번에 처리하는 단위를 배치라고 하고, 어느 요청을 언제 배치에 넣을지 정하는 부분을 스케줄러라고 한다. 같은 논문 4.5절에 따르면 vLLM은 먼저 온 요청을 먼저 처리한다(first-come-first-serve). 배치에 넣을 수 있는 요청 수의 상한은 KV 캐시를 둘 GPU 메모리 크기로 정해진다(같은 논문 3절, 5편 더 깊이 참고).

배치에서 빠지고 들어오는 방식: continuous batching.

요청마다 도착 시각과 출력 길이가 다르다. 배치를 한 번 짜서 모든 요청이 끝날 때까지 그대로 두면, 일찍 끝난 요청의 자리는 비어 있고 새 요청은 가장 긴 요청이 끝나야 들어갈 수 있다. 그래서 서빙 엔진은 대개 decode 반복이 한 번 끝날 때마다 배치를 다시 짠다. 같은 논문 2.3절의 설명대로 끝난 요청은 배치에서 빼고 기다리던 요청을 넣으므로, 새 요청은 반복 한 번만 기다리면 들어갈 수 있다. 논문은 이 방식을 Orca(Yu et al., OSDI 2022)의 iteration-level scheduling으로 소개한다. 길이가 다른 요청끼리 빈칸으로 길이를 맞추지 않고도 함께 처리할 수 있게 하는 GPU 계산 방식도 함께 언급한다. vLLM 저장소의 README는 이 기능을 continuous batching이라고 부른다.

토마토 예시로 보면, decode 1~3을 하는 동안 같은 배치의 다른 요청이 끝나서 빠지거나 새 요청이 들어올 수 있다. 이 요청이 decode 3에서 <EOS>를 고르면 다음 반복부터 배치에서 빠진다. 같은 논문 4.3절은 생성을 마친 요청의 KV 캐시 공간을 비워 다른 요청의 KV 캐시를 담는다고 설명한다. 11단계의 자원 정리가 이것이다.

배치를 반복마다 다시 짜므로 배치 크기도 반복마다 달라질 수 있다. 4편 더 깊이에서 temperature 0으로도 같은 답이 보장되지 않는 원인으로 배치 크기 변화를 들었다. 그 변화가 바로 여기서 생긴다.

출처

원글 LLM에게 질문하면 일어나는 일들을 읽고 다시 정리했다.

Notes 피드로 가기