knowledge machine

knowledge > tech

LLM에게 질문하면 일어나는 거의 모든 일들

API 요청, 임베딩, 텐서, 트랜스포머, GPU, KV캐시, HBM, 토큰 생성을 거쳐 겨우 하나가 나오는 토큰의 대모험

2026년 9월 25일#llm#ai#inference

토마토는 과일이야?라는 질문을 LLM에게 보냈을 때 응답이 나오기까지의 과정을 정리했다. 각 단계에서 무엇이 입출력으로 쓰이고, 특정 처리는 왜 필요하며, 각 단계에 어떤 데이터와 메타데이터가 사용되는지 살펴본다. [1]1기존에 읽은 Reference: How an AI Token Travels Through a Data Center와 그 원문을 바탕으로 이 글의 서버 처리 흐름을 구성했다. 특정 서비스가 반드시 이 순서와 구조로 구현된다는 뜻은 아니다. ↩ ..

내가 공부해보려고 쓰는 목적이 가장 크고, 부수적으로는 LLM이 무슨 인격이나 취향을 가진 존재라기 보다는 걍 말을 받아서 숫자로 변환한뒤 가장 가능성이 높은 다음 말을 만들어서 내보내는 기계라는 사실을 생각해보고 싶기 때문이다.

언어뿐 아니라 더 많은 형태의 데이터를 처리할 수 있게 되면 될수록 인격이나 주관을 가진 존재로 오해받기 쉽지만, 그럴 때마다 동작 방식을 공부하고 상기하여 그저 기계로 봐야하지 않나 싶다. 컴퓨터 사이언스에 리터러시가 없는 사람들에게는 더욱 중요할 것이라 생각된다.

  1. 애플리케이션 클라이언트가 질문을 서버로 전송한다.
  2. 애플리케이션 서버가 벤더 API 요청을 구성한다.
  3. 벤더 서버가 요청을 접수하고 모델 입력을 준비한다.
  4. 토크나이저가 문장을 토큰 ID로 변환한다.
  5. GPU에서 함께 처리할 요청을 배치로 묶는다.
  6. 토큰 배열을 GPU 입력 텐서로 변환한다.
  7. GPU가 토큰 ID로 임베딩 벡터를 조회한다.
  8. 트랜스포머 레이어가 입력 벡터를 갱신한다.
  9. 각 층의 K와 V를 KV 캐시에 저장해 다음 토큰 생성에 재사용한다.
  10. 마지막 벡터로 다음 토큰 후보의 점수를 계산하고 하나를 고른다.
  11. 고른 토큰을 문자열로 복원해 화면에 보낸다.
  12. 방금 생성한 토큰을 다시 입력해 다음 토큰을 계산한다.
  13. 종료 토큰을 만나면 요청을 끝내고 사용한 자원을 정리한다.

0. 사전지식 - LLM은 앞 토큰으로 다음 토큰을 예측하는 기계다.

대규모 언어 모델(LLM, Large Language Model)은 앞의 토큰들을 바탕으로 다음 토큰 하나를 예측하는 자기회귀 언어 모델이다. 예를 들어 나는 사과를이라는 문장을 이어 쓸 때, 모델은 다음에 올 수 있는 토큰들에 점수를 매기고 그중 하나를 고른다. 이 예시에서는 먹었다가 선택됐다고 하자. 채팅 모델도 질문 뒤에 올 답변을 같은 방식으로 한 토큰씩 이어 쓴다.[2]2Hugging Face, Causal language modeling. 앞 토큰을 입력해 다음 토큰을 예측하는 자기회귀 언어 모델의 학습 방식을 설명한다. ↩ huggingface.co

모델은 완성된 답변을 미리 만들어 두지 않는다. 첫 토큰을 고른 뒤에는 그 토큰을 다음 반복의 입력에 포함해 다음 토큰을 고른다. 이 과정을 이어가며 답변을 만든다.

아래는 이어질 응답 시퀀스 설명에 나오는 용어들이자 개념들이다.

용어의미역할
토큰(token)LLM의 문자열 처리 단위. 한 단어 전체일 수도 있고, 단어의 일부나 문장부호일 수도 있다. 역할 구분이나 종료를 나타내는 특수 토큰도 있다.모델은 토큰의 순서를 입력받아 다음 토큰을 예측한다. 입력과 출력의 길이도 보통 토큰 수로 센다.
토크나이저(tokenizer)문자열을 토큰 ID로 변환하는 프로그램. 모델에 정해진 어휘표와 분할 규칙을 사용하며, 생성된 ID를 다시 문자열로 해석할 수도 있다.입력 문자열을 모델이 읽을 수 있는 정수 배열로 바꾸고, 모델이 고른 출력 ID를 사람이 읽을 수 있는 글로 되돌린다.
토큰 ID토큰을 식별하는 번호. 토크나이저의 어휘표에서 어떤 한 토큰을 가리키는 정수모델은 이 번호로 임베딩을 조회하고, 출력할 번호를 고른 뒤에는 토크나이저를 통해 해당 문자열로 바꾼다.
임베딩(embedding)토큰 ID마다 미리 정해진 숫자 묶음이다. 예를 들어 이 글의 가상 모델에서는 ID 7342에 [0.8, 0.3, 0.1, -0.2]가 대응한다.토큰 ID를 받으면 임베딩 표에서 그 ID에 대응하는 숫자 묶음을 꺼낸다. 모델은 이 숫자들을 첫 계산의 입력으로 사용한다.
텐서(tensor)모델이 숫자를 저장하고 계산하는 배열이다. shape은 배열의 각 방향에 값이 몇 개 있는지, 자료형(dtype)은 숫자 하나를 어떤 형식으로 저장하는지 나타낸다.[3]3PyTorch, Tensors. 텐서의 형태(shape), 자료형(dtype), 저장 장치와 배열 연산을 설명한다. ↩ docs.pytorch.org토큰 ID나 토큰별 벡터를 정해진 형태로 묶어 전달한다. CPU나 GPU는 그 형태에 맞춰 여러 숫자를 함께 계산한다.
벡터(vector)텐서의 한 종류. 숫자를 한 줄로 나열한 1차원 배열을 벡터라고 부르고, 텐서는 차원 수와 관계없이 숫자를 담은 배열을 가리킨다. 예를 들어 [0.2, -0.1, 0.7, 0.4]는 길이가 4인 벡터다.토큰 하나의 상태를 여러 수치로 표현한다. 모델의 층은 이 수치들을 계산해 다음 층에 전달할 표현으로 바꾼다.
가중치(weight)모델이 가지고 있는 입력 숫자를 계산 결과에 얼마나, 어떤 방향으로 반영할지 정하는 숫자. 추론할 때 입력 벡터에 적용한다.입력 벡터를 변환하는 계산에 사용한다. 일반적인 추론에서는 가중치를 유지한 채 입력에 따라 계산 결과를 만든다.
트랜스포머 레이어(Transformer layer)토큰별 숫자 묶음을 한 번씩 갱신하는 계산 단계다. 어텐션으로 다른 토큰의 정보를 합하고, 피드포워드 신경망(FFN, Feed-Forward Network)으로 각 토큰의 숫자들을 다시 계산한다.토큰마다 숫자 묶음 하나를 받아 갱신된 묶음 하나를 다음 층에 넘긴다. 여러 층을 거치면서 질문의 앞뒤 관계가 마지막 토큰의 계산 결과에 반영된다.
어텐션(attention)현재 토큰의 벡터를 계산할 때 자신과 앞 토큰의 정보를 어느 정도 반영할지 정하는 계산이다.앞 토큰에서 가져온 정보를 현재 토큰의 계산에 반영한다. 뒤에 이어질 토큰은 미리 참고하지 않는다.
은닉 상태(hidden state)모델이 토큰마다 현재까지 계산한 숫자 묶음이다. 처음에는 임베딩에서 시작하고, 트랜스포머 층을 하나 통과할 때마다 묶음 안의 숫자가 달라진다.앞 토큰의 정보를 반영한 중간 결과를 다음 층으로 넘긴다. 마지막 입력 토큰의 최종 숫자 묶음으로 다음 토큰 후보들의 점수를 계산한다.
로짓(logits)다음에 올 수 있는 토큰마다 하나씩 매긴 점수다. 모델이 출력할 수 있는 토큰이 10,000종이라면 점수도 10,000개 나온다. 아직 확률로 바꾸기 전의 점수다.마지막 토큰의 은닉 상태로 토큰별 점수를 만든다. 점수에서 선택 규칙에 따라 다음 출력 토큰 하나를 고른다.
키·값 캐시(KV cache)이미 처리한 토큰에서 어텐션 계산에 필요한 키(key)와 값(value)이라는 숫자 묶음을 보관한 메모리다. 트랜스포머 층마다 따로 보관한다.다음 토큰을 만들 때 앞 토큰들의 키와 값을 다시 계산하지 않고 꺼내 쓴다. 새 토큰을 처리하면 그 토큰의 키와 값도 보관한다.

위 용어를 사용해 언어 질의를 컴퓨터가 계산할 수 있는 데이터로 바꾸는 흐름은 다음과 같다.

flowchart LR
    Q["대화 입력<br/>시스템·이전 대화·질의"]
    I["토큰 → 토큰 ID<br/>토마토는 → 7342"]
    E["임베딩 벡터<br/>[0.8, 0.3, 0.1, -0.2]"]
    H["층별 은닉 상태<br/>형태 [1, 16, 4]"]
    L["로짓 → 출력 ID (반복)<br/>4512, 27, 9311, 4"]
    R["응답 문자열<br/>네, 과일입니다."]

    Q --> I --> E --> H --> L --> R

이 데이터 순서도를 바탕으로 각 데이터를 처리하는 책임을 지닌 레이어들을 중간에 끼워넣으면 다음과 같은 순서도가 나온다.

flowchart TD
    subgraph FIRST["요청·모델 입력 준비"]
        direction LR
        A["애플리케이션 클라이언트"] -->|"질문·모델·대화 ID"| B["애플리케이션 서버"]
        B -->|"messages·옵션"| C["벤더 API<br/>대화 형식·토큰화"]
        C -->|"토큰 ID 텐서 [1, 16]"| D["임베딩 조회"]
    end

    subgraph SECOND["모델 계산·응답"]
        direction LR
        E["트랜스포머·어텐션"] -->|"최종 은닉 상태 [1, 4]"| F["로짓 계산·토큰 선택<br/>로짓 [1, 10000]"]
        F -->|"출력 ID → 응답 문자열"| G["문자열 복원·응답 표시"]
    end

    FIRST -->|"임베딩 벡터 [1, 16, 4]"| SECOND

모델 이름, 토큰 분리, ID, 벡터 값과 서버 이름은 설명을 위한 가상 값이며 실제 API나 특정 모델의 실행 기록이 아니다. 각 단계의 입출력 데이터도 설명용 JSONC로 나타냈다.

1. 애플리케이션 클라이언트가 질문을 서버로 전송

사용자가 입력창에 토마토는 과일이야?라고 적고 모델을 선택한다. 이 예시의 클라이언트는 화면에서 받은 질문과 모델 이름을 애플리케이션 서버에 보낸다.

{
  "input": "토마토는 과일이야?",
  "model": "example-llm"
}

2. 애플리케이션 서버가 벤더 API 요청을 구성

애플리케이션 서버는 시스템 지침과 이전 대화를 읽고, 방금 받은 사용자 질문을 끝에 추가한다. 서버는 이 메시지와 생성 길이 제한, 스트리밍 옵션 등 API 설정을 선택한 벤더의 규격에 맞춰 전송한다. 생성 설정에는 온도(temperature)와 최대 출력 토큰 수(max_output_tokens) 등이 포함될 수 있다.

여기서는 요청마다 필요한 대화 내역을 다시 보내는 무상태 채팅 API 방식을 가정한다. 이 방식에서는 벤더 API가 이전 요청의 대화를 자동으로 이어 붙여 주지 않으므로 애플리케이션 서버가 이번 계산에 필요한 대화 맥락을 구성해야 한다.[4]4Anthropic, Messages 가이드. 각 요청에서 messages 배열에 대화 내용을 전달하는 Claude Messages API의 요청 방식을 설명한다. 본문은 이 형태를 무상태 API의 예시로 삼았다. ↩ platform.claude.com 반면 이전 응답 ID나 대화 ID로 서버 측 상태를 이어 주는 API도 있으므로, 모든 벤더 API가 무상태인 것은 아니다.[5]5OpenAI, 대화 상태 가이드. 이전 응답 ID나 대화 ID를 통해 대화 상태를 이어 가는 방식을 설명한다. ↩ developers.openai.com

아래는 시스템 지침을 system 메시지로 담는 가상 채팅 API 요청이다. 실제 벤더마다 필드 이름과 배치 방식이 다르다. 예를 들어 Claude Messages API는 시스템 지침을 messages 안의 system 역할이 아니라 최상위 system 필드에 둔다.[6]6Anthropic, Messages API 문서. Claude Messages API에서 시스템 지침을 최상위 system 필드로 전달하는 방식을 설명한다. ↩ platform.claude.com

{
  "model": "example-llm",
  "messages": [
    {
      "role": "system",
      "content": "짧게 답하세요."
    },
    {
      "role": "user",
      "content": "안녕"
    },
    {
      "role": "assistant",
      "content": "안녕하세요"
    },
    {
      "role": "user",
      "content": "토마토는 과일이야?"
    }
  ],
  "max_output_tokens": 16,
  "stream": true
}

3. 벤더 서버가 API 요청을 접수하고 모델 입력을 준비

벤더 서버 API는 요청 형식, 인증 정보와 권한, 사용 한도 등을 검사한다. 통과한 요청에 이 글에서는 req_7f3a라는 ID를 부여하고 처리 상태를 저장한다고 가정한다. 이 식별자를 통해 응답 연산 후 요청과 짝을 맞춘다.

이 글에서는 벤더가 messages를 모델이 학습에 사용한 대화 형식으로 바꾼다고 가정한다. 아래 prompt_text는 결과를 읽기 쉽게 나타낸 가상 문자열이다. 실제 구현에서는 별도 문자열을 만들지 않고 바로 토큰 ID로 바꿀 수도 있다.

{
  // 벤더 API가 요청 상태를 ID로 찾는 가상 저장소
  "request_states": {
    // 이 키로 이번 요청의 상태 조회
    "req_7f3a": {
      // 아직 모델 계산을 기다리는 상태
      "status": "queued",
      "max_output_tokens": 16,
      // 모델별 대화 형식을 적용한 설명용 문자열
      "prompt_text": "<system>짧게 답하세요.</system><user>안녕</user><assistant>안녕하세요</assistant><user>토마토는 과일이야?</user><assistant>"
    }
  }
}

4. 토크나이저가 문장을 토큰 ID로 변환

토크나이저는 준비한 입력을 토큰 단위로 나누고 각각에 대응하는 ID를 반환한다. 가상 어휘표에서 시스템 지침, 이전 대화, 이번 질문과 응답 시작 표시를 다음 열여섯 토큰으로 나눈다고 가정한다.

{
  "request_id": "req_7f3a",
  // 시스템 지침과 이전 대화의 역할 표시까지 포함
  "tokens": ["<system>", "짧게 답하세요.", "</system>", "<user>", "안녕", "</user>", "<assistant>", "안녕하세요", "</assistant>", "<user>", "토마토는", " 과일", "이야", "?", "</user>", "<assistant>"],
  // 토큰 순서에 맞춘 정수 ID
  "token_ids": [10, 201, 11, 1, 610, 2, 3, 611, 12, 1, 7342, 55, 188, 9, 2, 3],
  // 특수 토큰까지 포함한 입력 길이
  "input_length": 16
}

5. GPU 배치 정하기

요청을 처리할 모델 복제본이 여러 개라면 어떤 서버가 계산할지 정해야 한다. 로드 밸런서(load balancer)와 추론 라우터(inference router)는 실행 가능한 서버, 부하, 모델과 캐시 상태 등을 고려해 요청을 배정한다. 스케줄러(scheduler)는 배정된 요청들 가운데 이번 GPU 실행에 포함할 대상을 고른다.

GPU에 한 요청씩 보내는 대신 여러 요청을 배치로 묶으면 함께 계산할 수 있다. 사용자가 서로 다른 시점에 질문해도 하나의 GPU를 나눠 사용할 수 있다.

{
  // 실행 대상으로 선택된 가상 서버
  "replica": "llm-07",
  // 이번 GPU 계산에 넣을 요청들
  "batch": [
    {
      // 서빙 프로그램이 요청 상태와 입력 데이터를 연결하는 키
      "request_id": "req_7f3a",
      // 이번 배치에서 우리 요청에 대응하는 항목 번호
      "batch_index": 0,
      // 아직 입력 전체를 처리하기 전임
      "phase": "prefill",
      // 우리 요청에 포함된 입력 토큰 수
      "token_count": 16
    }
  ]
}

숫자를 추적하기 쉽도록 이후에는 배치에 이 요청 하나만 있다고 가정한다.

6. 토큰 배열을 GPU 입력 텐서로 변환

4단계에서 나온 토큰 ID를 GPU가 계산할 수 있는 입력 텐서로 준비하고 그것은 16개의 정수다. [10, 201, 11, 1, 610, 2, 3, 611, 12, 1, 7342, 55, 188, 9, 2, 3] 모델 입력에는 토큰의 순서와 계산에 포함할 토큰도 표시한다.

토크나이저가 텐서를 바로 반환할 수도 있고, 벤더 서버에서 모델을 돌리는 프로그램이 수행하기도 한다.

{
  // 요청 하나 안의 토큰 열여섯 개
  "input_ids": [[10, 201, 11, 1, 610, 2, 3, 611, 12, 1, 7342, 55, 188, 9, 2, 3]],
  // 각 토큰의 입력 내 위치
  "position_ids": [[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]],
  // 이 예시에서는 실제 토큰이 1, 패딩이 0. 이번 입력에는 패딩 없음
  "attention_mask": [[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]],
  // 토큰 ID가 든 안쪽 배열이 1개이고, 그 배열에 ID가 16개 있다는 설명용 메타데이터
  "shape": [1, 16],
  // 이 예시에서 배열 안 숫자 하나를 저장하는 형식
  "dtype": "int64"
}

토큰 ID만으로는 입력에서 몇 번째인지 알 수 없으므로 위치 정보도 계산에 반영한다. position_ids는 이 예시에서 위치를 명시적으로 표현한 값이다. 위치 정보를 적용하는 방법은 모델 구조에 따라 다르다.

attention_mask는 실제 토큰과 패딩(padding)을 구분한다. 예를 들어 토큰이 세 개이고 나머지 두 칸을 패딩으로 채웠다면, 마스크는 [1, 1, 1, 0, 0]이다. 이번 입력에는 패딩이 없어 값이 모두 1이다. 이 마스크는 각 칸에 실제 토큰이 있는지 표시한다.

shape과 dtype은 텐서의 모양과 숫자 저장 형식을 설명하는 메타데이터다. 예를 들어 [[0.8, 0.3, 0.1, -0.2], [0.2, 0.0, 0.4, 0.1]]의 shape은 [2, 4]다. 값이 두 줄이고 각 줄에 네 개씩 있다는 뜻이다. dtype: float32는 숫자 하나를 32비트 부동소수점 형식으로 저장한다는 뜻이다.

7. GPU가 ID로 임베딩 벡터를 조회

서빙 프로그램은 입력 텐서를 모델이 실행되는 GPU로 전달한다. GPU 메모리에는 모델의 가중치가 준비돼 있어야 한다. PyTorch의 임베딩 연산은 토큰 ID를 인덱스로 사용해 임베딩 표의 해당 벡터를 조회한다.[7]7PyTorch, Embedding 문서. 정수 토큰 ID를 인덱스로 사용해 임베딩 테이블에서 해당 벡터를 조회하는 연산을 설명한다. ↩ docs.pytorch.org 트랜스포머 논문은 입력 임베딩과 출력층의 관계를 설명한다.[8]8Vaswani 외, Attention Is All You Need, §3.4. 입력 임베딩과 출력층의 가중치 연결을 설명한다. 원 논문은 인코더–디코더 구조를 다루며, 현대 LLM이 모두 이 구조를 그대로 사용한다는 뜻은 아니다. ↩ arxiv.org

여기서부터는 주로 GPU에서 모델 계산이 이뤄진다.

{
  // 입력 토큰 16개에 대응하는 임베딩 벡터들
  "embeddings": [[
    [0.1, 0.0, 0.2, 0.1],
    [0.4, 0.1, 0.0, 0.3],
    [0.1, 0.1, 0.1, 0.0],
    [0.2, 0.0, 0.1, 0.2],
    [0.5, 0.2, 0.0, 0.1],
    [0.1, 0.1, 0.0, 0.7],
    [0.3, 0.2, 0.0, 0.1],
    [0.2, 0.4, 0.1, 0.3],
    [0.1, 0.2, 0.1, 0.0],
    [0.2, 0.0, 0.1, 0.2],
    [0.8, 0.3, 0.1, -0.2],
    [0.6, 0.4, 0.2, 0.1],
    [0.0, 0.2, 0.5, 0.3],
    [0.1, 0.1, 0.0, 0.6],
    [0.1, 0.1, 0.0, 0.7],
    [0.3, 0.2, 0.0, 0.1]
  ]],
  // 요청 하나, 토큰 열여섯 개, 토큰마다 숫자 네 개
  "shape": [1, 16, 4],
  "dtype": "float32"
}

모델의 임베딩 층은 각 토큰 ID에 연결된 벡터를 조회한다. 이 벡터 열여섯 개가 트랜스포머의 첫 입력이다. 아직 답변 토큰은 나오지 않았고, 벡터에는 앞 대화의 정보가 반영되지 않았다. 이 예시에서 10번 위치의 토마토는은 [0.8, 0.3, 0.1, -0.2], 마지막 15번 위치의 <assistant>는 [0.3, 0.2, 0.0, 0.1]에 대응한다.

8. 입력 벡터를 트랜스포머 레이어마다 갱신

7단계에서 토큰 ID로 임베딩 표를 조회해, 토큰마다 숫자 네 개로 된 벡터를 얻었다. 이제 트랜스포머는 이 벡터들을 가지고 계산한다. 정말 많고 복잡한 계산을 한다.

당연한 이야기를 한 번 짚자면 모델 학습은 이미 되어있는 것이다. 모델을 미리 만들 때 문장의 앞부분으로 다음 토큰을 예측하고, 실제로 이어지는 토큰에 더 높은 점수를 주도록 임베딩 표와 가중치를 조정한다. 예를 들어 학습 문장에서 토마토는 다음에 과일이 이어졌다면, 이 문맥에서 과일에 더 높은 점수를 주도록 값을 조정한다.

이렇게 학습 중 조정하는 값 하나하나를 매개변수(parameter)라고 부른다. 임베딩 표에서 토큰 ID 하나를 조회했을 때 [0.2, -0.5, 0.8, 0.1]이 나온다면, 이 네 값이 각각 매개변수 하나다. 가중치 행렬에 저장된 값도 각각 매개변수다. 10B 모델은 이런 매개변수가 약 100억 개 있다는 뜻이다. 지금 질문에 답하는 추론(inference)에서는 이 값을 고정해 사용하고, 입력에 따라 중간 계산 결과와 다음 토큰 점수를 만든다.[9]9Hugging Face, Causal language modeling. 다음 토큰 예측에서 정답 토큰과 예측을 비교하고 모델 매개변수를 조정하는 학습 과정을 설명한다. ↩ huggingface.co

처음 입력한 토큰 전체를 트랜스포머의 모든 층에 통과시키는 단계를 프리필(prefill)이라고 한다. 토큰마다 벡터를 갱신해 다음 층에 넘긴다. 여기서는 두 층인 설명용 모델을 가정한다. 두 층을 모두 지난 뒤 마지막 입력 토큰의 벡터로 첫 답변 토큰을 고른다.

아래 도식은 한 층의 입력 벡터가 결과 벡터로 바뀌고, 그 결과가 다음 층으로 넘어가는 흐름을 나타낸다.

flowchart LR
    A["입력 토큰의 벡터 16개"] --> B["첫 번째 층의 Q·K·V"]
    B -->|"어텐션·FFN 등의 계산"| C["결과 벡터 16개"]
    C --> D["두 번째 층의 Q·K·V"]
    D -->|"어텐션·FFN 등의 계산"| E["최종 벡터 16개"]

각 층은 입력 벡터에 학습된 가중치 행렬을 적용해 쿼리(query, Q), 키(key, K), 값(value, V)이라는 중간 벡터를 만든다. 현재 토큰의 Q와 자신 및 앞 토큰들의 K를 비교해 토큰별 점수를 얻는다. 소프트맥스는 이 점수들을 합이 1인 비율로 바꾼다. 모델은 그 비율로 각 토큰의 V를 반영해 어텐션 결과를 만든다. 즉 V 자체가 결과 벡터인 것은 아니다.

층 내부에서는 이 Q, K, V를 사용해서 스케일 조정 내적 어텐션, 헤드 결과 결합, 피드포워드 신경망, 잔차 연결, 정규화를 해서 어텐션 결과 벡터를 만든다고 한다. 말이 너무 어렵다. 나도 이거 보면서 이해해보려고 했는데 걍 포기했다. 그냥 이런저런 계산이 일어나고, 저 연산들을 마치면 입력 토큰마다 결과 벡터가 하나씩 나온다는 점만 기억하면 된다.[10]10Vaswani 외, Attention Is All You Need, §3.1–3.4. Q·K·V, 스케일 조정 내적 어텐션, 위치별 피드포워드 신경망, 잔차 연결과 정규화를 설명한다. 원 논문은 인코더–디코더 구조를 설명하며, 본문은 연산 원리를 참고한다. ↩ arxiv.org

이 결과 벡터가 그 층의 은닉 상태다. 첫 번째 층에서 나온 벡터 열여섯 개를 두 번째 층이 받아, 자기 층의 가중치로 다시 계산한다. 층 하나를 지났다고 답변 토큰이 하나 나오는 것은 아니다. 모든 층을 통과한 뒤 마지막 입력 토큰의 최종 벡터를 사용해 다음 출력 토큰을 고른다.

트랜스포머에 여러 층을 두면 앞 층의 계산 결과를 다음 층이 다시 활용할 수 있다.[11]11Elhage 외(2021), A Mathematical Framework for Transformer Circuits, ‘Three Kinds of Composition’. 앞 층의 계산 결과가 뒤 층의 Q·K·V 계산에 활용될 수 있는 구조를 분석한다. 본문의 ‘민수와 사과’는 이 원리를 설명하기 위한 예시다. ↩ transformer-circuits.pub 이게 앞에서 말한 LLM의 자기회귀적인 작동방식의 핵심인데 예를 들어 민수는 사과를 샀다. 그는 그것을 먹었다.에서 앞 층이 그것을과 사과의 관계를 반영했다면, 다음 층은 그 결과를 받아 문장 속 관계를 더 계산할 수 있다. 각 층은 서로 다른 가중치를 사용한다. 층을 많이 쌓는 것만으로 결과가 반드시 좋아지는 것은 아니다. 그러니까 빈칸 채우기 문제를 푸는데 같은 문장의 앞뒤를 여러번 보면서 답이 뭘까 추론하고 고민하는 과정과 비슷하다.

큰 LLM은 이 예시보다 훨씬 긴 벡터를 더 많은 층에서 계산한다. 답변을 이어 쓸 때도 새 토큰을 모든 층에 통과시켜 다음 토큰을 고른다. 많은 가중치를 읽으며 행렬 연산을 반복하기 때문에 모델 굴리는 게 컴퓨팅 파워와 메모리를 어마무지하게 잡아먹는 것이다.

아래는 층별 최종 결과 중 마지막 입력 토큰의 벡터만 발췌한 가상 예시다. 아직 답변 토큰은 선택하지 않았다. 9단계에서는 각 층의 K, V를 보관하는 방법을 살펴보고, 10단계에서는 마지막 층의 벡터로 첫 답변 토큰을 고른다.

{
  // 각 층의 전체 결과: 길이 4인 벡터 16개
  "hidden_state_shape": [1, 16, 4],
  // 아래에는 마지막 입력 토큰인 15번 위치의 결과만
  "sample_position": 15,
  // 첫 번째 층의 계산 결과이자 두 번째 층의 입력
  "layer_1_hidden_state": [0.5, 0.4, 0.2, 0.1],
  // 두 번째 층의 계산 결과. 10단계에서 출력 점수 계산에 사용
  "layer_2_hidden_state": [0.9, 0.1, 0.5, 0.2]
}

9. K와 V를 KV 캐시에 저장해 다음 토큰 생성에 재사용한다

KV 캐시는 입력 토큰을 바탕으로 이전에 계산한 K와 V를 저장해 두는 공간이며, 트랜스포머 층마다 가중치와 결과값이 다르므로 각각 따로 있다.[12]12Hugging Face, How caching works. 트랜스포머 층별 K·V 캐시의 구조를 설명한다. ↩ huggingface.co

{
  // 캐시를 이번 요청과 연결하는 키
  "request_id": "req_7f3a",
  // 처음 입력 토큰 처리를 마친 상태
  "phase": "prefill_complete",
  // 층마다 자기 계산값을 따로 보관
  // 실제 K·V 배열은 생략하고 저장된 토큰 수만 표시
  "cache_by_layer": {
    // 첫 번째 층에서 입력 토큰 16개로 계산한 K·V
    "layer_1": {
      // 0~15번, 총 16개 토큰
      "cached_token_count": 16
    },
    // 두 번째 층에서 입력 토큰 16개로 계산한 K·V
    "layer_2": {
      // 이 층에도 같은 16개 위치의 K·V
      "cached_token_count": 16
    }
  }
}

프리필이 끝나면 각 층의 캐시에는 처음 입력한 16개 토큰의 K와 V가 들어 있다. 첫 답변 토큰 네를 고른 후 다음 반복에서 네를 새 입력으로 각 층에 통과시키면, 층마다 과거 16개 토큰의 K와 V를 재사용하고 네의 K와 V를 새로 추가한다. 이때 캐시에는 17개 토큰의 값이 들어간다.[13]13Hugging Face, How caching works. 새 토큰을 처리할 때 과거 K·V를 재사용하고 새 K·V를 캐시에 추가하는 과정을 설명한다. ↩ huggingface.co 저장된 K와 V를 읽어 새 토큰의 어텐션을 계산하는 작업은 매번 필요하다.

접두 캐시(prefix caching)는 한 요청에서 계산한 K·V를 다른 요청에서도 재사용하는 방식이다. 같은 모델과 재사용 조건에서 새 요청의 시작 부분과 일치하는 캐시가 있으면 히트(hit)로 처리해 가져오고, 없으면 미스(miss)로 처리해 새로 계산하고 저장한다.[14]14vLLM, Automatic Prefix Caching. 요청 앞부분의 토큰이 일치하는 KV 블록을 재사용하는 방식을 설명한다. ↩ docs.vllm.ai

예를 들어 앞선 요청이 공손하게 답해. 토마토는 과일이야?였고, 새 요청이 공손하게 답해. 사과는 과일이야?라면 앞의 공손하게 답해.에 해당하는 K·V를 재사용할 수 있다. 그 캐시가 남아 있으면 히트이고, 없으면 이 부분도 새로 계산한다. 질문 부분은 두 요청에서 다르므로 새로 처리한다.

단어 하나나 뜻이 비슷한 문장이 있다는 것만으로는 재사용할 수 없다. 요청의 시작부터 토큰 ID가 일치하는 부분이 있어야 한다. 이 사실은 AI 애플리케이션 만들 때 비용 구조를 효율화하기 위해서 매우 중요한데 애플리케이션이 자주 바뀌는 날짜나 사용자 정보를 메시지 뒤쪽에 두면, 앞쪽의 고정된 지침을 캐시에서 재사용할 가능성이 높아진다.

KV 캐시는 이미 계산한 K와 V를 다시 만들지 않아 응답 속도와 처리량을 높이는 데 도움을 준다.[15]15NVIDIA, Optimizing Inference for Long Context and Large Batch Sizes with NVFP4 KV Cache. 긴 문맥과 큰 배치에서 KV 캐시의 메모리 사용과 읽기·쓰기 부담이 추론 성능에 미치는 영향을 설명한다. ↩ developer.nvidia.com 대신 메모리를 사용한다. 문맥이 길어지거나 동시에 처리하는 요청이 많아지면 층마다 저장하고 읽을 K와 V도 늘어난다. 그래서 GPU의 고대역폭 메모리(HBM)는 저장 용량과 읽기·쓰기 대역폭이 모두 중요하다.[16]16How an AI Token Travels Through a Data Center. GPU 메모리의 용량과 대역폭이 추론 성능에 미치는 영향을 설명한다. ↩ www.datagravity.dev 그래서 HBM이 LLM 구동의 핵심적인 부분으로 여겨지는 것이고, SK하이닉스가 돈을 많이 벌고 있는 것이다.

10. 최종 결과 벡터를 점수로 바꾼다

프리필을 마쳤다고 곧바로 문자가 나오는 것은 아니고 아직도 그냥 벡터 숫자뿐이고 컴퓨터만 알아먹는다. 이제 마지막 입력 토큰의 최종 벡터를 바탕으로 실제 문자열 토큰을 고르는 과정이 남았다.

앞에서 예로 든 모델이 출력할 수 있는 토큰 목록에는 10,000개의 항목이 있다고 가정한다. 목록에는 단어 전체, 단어 조각, 문장부호 등이 있고, 각 항목은 토큰 ID와 연결된다. 이 예시에서는 ID 4512가 네에 해당한다고 하자.

트랜스포머의 마지막 입력 벡터는 길이 4이며, 형태는 [1, 4]다. 언어 모델 출력층(LM head)은 이 벡터에서 목록의 항목마다 점수를 계산해 [1, 10000] 형태의 점수 배열을 만든다. 예를 들어 ID 4512에 해당하는 값이 4.2라면, 네의 로짓(logit)은 4.2다. 각 칸이 토큰 ID 하나의 점수에 대응한다. 이 4.2는 네가 나올 확률 4.2%를 뜻하지 않는다.

소프트맥스(softmax)는 이 로짓들을 확률로 바꾼다. 확률은 현재 문맥 바로 다음에 각 토큰이 출력될 가능성을 나타내며, 전체를 더하면 100%가 된다. 예를 들어 소프트맥스 결과가 네 70%, 아니 30%라면, 샘플링(sampling)은 이 비율에 따라 다음 토큰 하나를 뽑는다. 네가 더 자주 뽑히지만 매번 반드시 선택되지는 않는다. 생성 설정에 따라 가장 높은 점수의 토큰을 고르거나, 확률 분포에서 샘플링한다. 온도(temperature)와 누적 확률 기준의 후보 선택(top-p)은 확률 분포나 선택에 영향을 준다. 여기서는 ID 4512의 네가 첫 토큰으로 선택됐다고 가정한다.[17]17Hugging Face, Generation strategies. 탐욕적 선택과 샘플링, 온도, top-p 같은 생성 설정을 설명한다. ↩ huggingface.co

{
  // 모델 출력 점수 배열의 형태
  // 실제 점수 10,000개는 생략하고 배열 형태만 표시
  "logits_shape": [1, 10000],
  // 이번 예시에서 고른 첫 출력 토큰 ID
  "selected_token_id": 4512,
  // ID를 사람이 읽을 문자열로 적은 값
  "selected_token_text": "네"
}

입력 열여섯 토큰을 조건으로 첫 출력 토큰 하나를 골랐다. 최종 응답 네, 과일입니다.의 첫 토큰은 네다.

11. 첫 토큰을 문자열로 복원해 화면에 보낸다

서빙 프로그램은 선택한 ID를 디토크나이저(detokenizer)에 전달해 문자열로 복원한다. 벤더의 응답 서버는 요청 ID로 연결된 애플리케이션 서버에 문자열 조각을 전송한다. 애플리케이션 서버는 이를 conv_42의 클라이언트에 전달한다. 스트리밍을 사용하면 전체 답변이 끝날 때까지 기다리지 않고 이 조각을 먼저 받을 수 있다.

{
  // 설명용 스트리밍 이벤트 이름
  "event": "text_delta",
  // 요청과 응답을 연결하는 ID
  "request_id": "req_7f3a",
  // 이번에 화면에 추가할 문자열
  "delta": "네"
}

클라이언트는 비어 있던 답변 영역에 네를 표시한다. 사용자가 처음 기다림이 끝났다고 느끼는 순간이다. 요청 이후 이 첫 토큰의 출력에 걸린 시간을 첫 토큰 지연 시간(TTFT, Time to First Token)이라고 한다. 당연하게도 모델의 프리필 과정 뿐 아니라 네트워크 전송, 서버 대기와 전처리 시간도 영향을 준다.

12. 디코드(decode)에서 방금 생성한 토큰을 다시 입력한다

첫 토큰 네를 화면에 보낸 뒤 다음 토큰을 계산한다. 네를 고른 시점에는 아직 이 토큰의 K·V가 캐시에 없다. 다음 반복에서 ID 4512를 새 입력으로 처리하고, 과거 입력 열여섯 토큰의 K·V는 캐시에서 재사용한다.

{
  // 이번 반복에서 새로 넣는 토큰: 직전에 고른 "네"
  "input_ids": [[4512]],
  // 처음 위치 0~15 다음, 새 위치는 16
  "position_ids": [[16]],
  // 캐시의 과거 16개와 새 토큰 1개를 함께 참고
  "attention_mask": [[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]],
  // 새로 계산할 입력 ID 텐서의 형태
  "input_shape": [1, 1],
  // 계산 전에 캐시에 들어 있던 토큰 수
  "past_token_count": 16
}

새 입력은 토큰 하나지만, 어텐션이 참고할 토큰은 캐시에 든 과거 16개와 새 토큰을 합쳐 17개다. 그래서 이 예시에서는 attention_mask에도 17개 값을 둔다. 모두 실제 토큰이므로 값은 1이다. 이 마스크는 패딩 여부를 표시하고, 뒤 토큰을 보지 못하게 하는 제한은 인과 마스크가 담당한다.

모델은 방금 고른 네를 임베딩으로 바꿔 모든 층에 통과시킨다. 각 층은 새 토큰의 벡터를 갱신하고, 과거 토큰의 K·V를 캐시에서 참고한다. 마지막 층의 벡터로 그다음 토큰을 고른다.

반복이번에 새로 처리하는 입력재사용하는 과거 토큰의 K·V처리 뒤 캐시의 토큰 수화면에 누적된 응답
프리필처음 입력한 16개 토큰이전 캐시 없음. 입력 16개를 처음 처리16네
디코드 1네 / 4512처음 입력한 16개 토큰의 K·V17네,
디코드 2, / 27처음 입력 16개 + 네의 K·V18네, 과일입니다.
디코드 3 과일입니다. / 9311처음 입력 16개 + 네, ,의 K·V19네, 과일입니다.

긴 문자열 과일입니다.을 한 토큰으로 둔 것도 가상 어휘표의 설정이다. 실제 토크나이저에서는 여러 토큰으로 나뉠 수 있으므로, 여기서는 설명 편의를 위해 한 토큰으로 가정했다.

표에서 고른 토큰은 다음 반복에서 새 입력으로 처리될 때 캐시에 추가된다. 그래서 프리필에서 네를 골라도 캐시는 16개로 남아 있고, 디코드 1에서 네를 처리한 뒤에 17개가 된다.

13. 종료를 알리고 요청의 자원을 정리한다

마지막 반복에서 종료 토큰 <EOS>를 선택하면 서버는 이를 화면의 글자로 표시하지 않고 생성 종료로 처리한다. 길이 제한에 도달하거나 사용자가 중단한 경우에도 종료할 수 있다.

이번 예시에서 모델은 [4512, 27, 9311, 4]를 선택했다. 이 중 마지막 ID 4는 종료 표시다. 사용자에게 보여 준 문자열은 네, 과일입니다.다.

{
  // 생성 종료를 알리는 가상 이벤트
  "event": "completed",
  // 처음 접수한 요청과 같은 ID
  "request_id": "req_7f3a",
  // 이번엔 종료 토큰을 골라 멈춘 상태
  "finish_reason": "eos",
  // 화면에 표시한 최종 응답
  "text": "네, 과일입니다.",
  // 이 예시에서 고른 전체 ID 목록. 서비스별 과금 필드와는 별개
  "generated_token_ids": [4512, 27, 9311, 4]
}

벤더 서버는 요청 상태를 완료로 갱신하고 사용량과 지연을 기록한다. 애플리케이션 서버는 이번 사용자 질문과 완성된 어시스턴트 응답을 conv_42의 대화 내역에 저장해 다음 요청을 구성할 때 사용한다.

스케줄러는 완료된 요청을 다음 배치에서 제외한다. 더 이상 필요하지 않은 요청별 캐시와 실행 자원은 반환하거나 캐시 정책에 따라 관리한다. API 서버가 보관하던 연결과 요청 상태도 서비스의 보관 정책에 맞춰 정리한다.

주

  1. 1.기존에 읽은 Reference: How an AI Token Travels Through a Data Center와 그 원문을 바탕으로 이 글의 서버 처리 흐름을 구성했다. 특정 서비스가 반드시 이 순서와 구조로 구현된다는 뜻은 아니다. ↩..
  2. 2.Hugging Face, Causal language modeling. 앞 토큰을 입력해 다음 토큰을 예측하는 자기회귀 언어 모델의 학습 방식을 설명한다. ↩huggingface.co
  3. 3.PyTorch, Tensors. 텐서의 형태(shape), 자료형(dtype), 저장 장치와 배열 연산을 설명한다. ↩docs.pytorch.org
  4. 4.Anthropic, Messages 가이드. 각 요청에서 messages 배열에 대화 내용을 전달하는 Claude Messages API의 요청 방식을 설명한다. 본문은 이 형태를 무상태 API의 예시로 삼았다. ↩platform.claude.com
  5. 5.OpenAI, 대화 상태 가이드. 이전 응답 ID나 대화 ID를 통해 대화 상태를 이어 가는 방식을 설명한다. ↩developers.openai.com
  6. 6.Anthropic, Messages API 문서. Claude Messages API에서 시스템 지침을 최상위 system 필드로 전달하는 방식을 설명한다. ↩platform.claude.com
  7. 7.PyTorch, Embedding 문서. 정수 토큰 ID를 인덱스로 사용해 임베딩 테이블에서 해당 벡터를 조회하는 연산을 설명한다. ↩docs.pytorch.org
  8. 8.Vaswani 외, Attention Is All You Need, §3.4. 입력 임베딩과 출력층의 가중치 연결을 설명한다. 원 논문은 인코더–디코더 구조를 다루며, 현대 LLM이 모두 이 구조를 그대로 사용한다는 뜻은 아니다. ↩arxiv.org
  9. 9.Hugging Face, Causal language modeling. 다음 토큰 예측에서 정답 토큰과 예측을 비교하고 모델 매개변수를 조정하는 학습 과정을 설명한다. ↩huggingface.co
  10. 10.Vaswani 외, Attention Is All You Need, §3.1–3.4. Q·K·V, 스케일 조정 내적 어텐션, 위치별 피드포워드 신경망, 잔차 연결과 정규화를 설명한다. 원 논문은 인코더–디코더 구조를 설명하며, 본문은 연산 원리를 참고한다. ↩arxiv.org
  11. 11.Elhage 외(2021), A Mathematical Framework for Transformer Circuits, ‘Three Kinds of Composition’. 앞 층의 계산 결과가 뒤 층의 Q·K·V 계산에 활용될 수 있는 구조를 분석한다. 본문의 ‘민수와 사과’는 이 원리를 설명하기 위한 예시다. ↩transformer-circuits.pub
  12. 12.Hugging Face, How caching works. 트랜스포머 층별 K·V 캐시의 구조를 설명한다. ↩huggingface.co
  13. 13.Hugging Face, How caching works. 새 토큰을 처리할 때 과거 K·V를 재사용하고 새 K·V를 캐시에 추가하는 과정을 설명한다. ↩huggingface.co
  14. 14.vLLM, Automatic Prefix Caching. 요청 앞부분의 토큰이 일치하는 KV 블록을 재사용하는 방식을 설명한다. ↩docs.vllm.ai
  15. 15.NVIDIA, Optimizing Inference for Long Context and Large Batch Sizes with NVFP4 KV Cache. 긴 문맥과 큰 배치에서 KV 캐시의 메모리 사용과 읽기·쓰기 부담이 추론 성능에 미치는 영향을 설명한다. ↩developer.nvidia.com
  16. 16.How an AI Token Travels Through a Data Center. GPU 메모리의 용량과 대역폭이 추론 성능에 미치는 영향을 설명한다. ↩www.datagravity.dev
  17. 17.Hugging Face, Generation strategies. 탐욕적 선택과 샘플링, 온도, top-p 같은 생성 설정을 설명한다. ↩huggingface.co
Written by 김종혁이메일 보내기링크 복사하기X에 공유하기
← 글 목록으로 돌아가기이전글다음글랜덤