Hands-On LLM Serving and Optimization 책 스터디 1주차

2026. 8. 2. 13:56·ETC
반응형

1. Introduction to Model Serving and Optimization

Anatomy of a Model

엔지니어링 관점에서 모델은 정적 데이터 파일이 아니라 실행 가능한 프로그램이며, 아래와 같은 세 가지 구성 요소로 나뉜다.

  • 모델 데이터: 가중치 & 편향, 그리고 임베딩 & 레이블 클래스 & max_batch_size & 입출력 텐서 같은 구성(메타데이터)
  • 모델 아키텍처: 레이어의 종류와 개수, 레이어 간 연결, 수행 연산 등 모델의 구조와 설계
  • 모델 실행 코드: 아키텍처를 초기화하고 가중치를 로드해 예측을 실행하는 과정

아키텍처와 가중치를 별도 파일로 분리해 저장하면 유연성이 높아진다. 버전 간 구조가 바뀌어 '일치하지 않는 키'가 생겨도 부분 로딩이 가능해, 미세 조정이나 점진적 아키텍처 업데이트에 유리하다.

 

텐서(Tensor)란?
딥러닝에서는 다차원 숫자배열을 뜻한다. "[N, d] 형태 텐서"에서 N = 토큰 수 (11개) d = 은닉 크기 (896)이며, [11, 896] 텐서는 11줄 * 896칸짜리 표라는 뜻. 텐서 형태로 데이터를 저장하는 이유는 GPU가 행렬 연산에 특화 되었기 때문.
예) [11, 896] 텐서의 데이터 형태
토큰1 [-0.12, 0.55, 0.98, ... (896개 숫자) ]
토큰2 [ 0.31, -0.02, 0.44, ... (896개 숫자) ]
토큰3 [ 0.71, 0.13, -0.44, ... ]
... 토큰11 [ 0.05, 0.88, 0.21, ... ]

실습에서 아래와 같은 에러를 만날 수 있는데 해당 에러는 텐서의 shape(표의 가로세로 크기)가 맞지 않아서 발생하는 것.
RuntimeError: shape mismatch: got [4, 896] but expected [11, 896]

 

Model Lifecycle: From Training to Serving

ML 수명주기는 데이터 수집 → 학습·미세조정 → 평가 → 배포 → 서빙 → 최적화·반복으로 이루어져있는데, 이 책에서는 서빙에 집중한다. 학습된 모델을 운영 환경에 배포해 새로운 데이터에 대한 예측(추론)을 생성할 수 있게 만드는 것을 서빙(Serving)이라고 한다. 

 

What Is Model Serving?

서빙은 배포 위치에 따라 세 가지로 나뉜다.

  • 온디바이스: 창고 로봇의 탑재 컴퓨터에서 YOLO 같은 실시간 객체 감지 모델 실행
  • 온프레미스: 사내 클러스터에서 Sentence-BERT를 서빙해 내부 문서 임베딩 생성 (데이터 보안 & 규정 준수 유리)
  • 클라우드: 고객 지원 챗봇이 SageMaker Inference나 OpenAI 같은 원격 LLM 호출

서빙 엔지니어는 모델 내부(아키텍처 & 학습 방법 & 파일 형식)는 블랙박스로 두고 배포, 확장 & 가용성, 지연 시간, 모니터링, 버전 관리, 보안, 그리고 가장 결정적인 서빙 비용과 같은 운영에 집중하며, 이는 새로운 학습 알고리즘 개발보다 시스템 엔지니어링과 인프라 설계에 가깝다. 단 LLM은 예외로, 트랜스포머 등 알고리즘에 대한 이해가 최적화에 필수다.

 

Why Study Model Serving?

책에서는 모델 서빙을 공부해야하는지에 대한 의문에 다음과 같이 답하고 있다.

  • Q. 클라우드 벤더를 쓰면 되지 않나?
    • 옵션이 너무 많아 최적 선택이 어렵고, 기존 인프라와의 통합 & 튜닝에 추가 엔지니어링이 필요하다. 원리를 알아야 벤더 서비스도 제대로 활용할 수 있다.
  • Q. 파운데이션 LLM만 호출하면 되지 않나?
    • 비용(오픈소스 LLM을 EC2에서 돌리면 SageMaker 대비 30~60% 저렴, 예약 시 최대 70% 할인), 데이터 프라이버시, 미세조정 모델 지원 부재(2025년 초 기준 OpenAI는 미세조정 모델 서빙에 50% 추가 요금)가 걸린다. 실제 챗봇은 의도 분류 소형 모델 + 임베딩 모델 + LLM 조합으로 동작하게 된다.
  • Q. 자체 스택 유지 비용이 크지 않나?
    • 특수 하드웨어 초기 투자와 전문 인력이 필요한 건 사실이다. 아웃소싱은 좋은 출발점이지만, 사용량이 늘면 비용 & 보안 & 유연성 측면에서 내재화가 장기 경쟁 우위가 되는지 계속 재평가해야 한다.

 

Why Optimize Model Serving (Especially for LLMs)?

실제 환경에서 LLM 서빙은 부하가 걸리면 지연 시간이 늘고, 처리량은 하드웨어 용량보다 훨씬 낮은 수준에서 정체되며, 비용은 사용량에 비례하거나 그보다 빠르게 증가하는 문제가 발생하게 된다.

 

학습 프레임워크와 서빙 프레임워크는 목표가 다르다. 학습은 역전파와 가중치 업데이트를 반복하며 처리량 위주로 대량 병렬 처리하지만, 서빙은 순방향 전파만 수행하며 요청별 응답 지연을 최소화하는 것이 중요하다. 그래서 학습용 스택(HF Transformers, PyTorch 기본 실행)을 그대로 서빙에 쓰면 비효율적이고, vLLM & TensorRT-LLM & SGLang & NVIDIA Triton 같은 서빙 전용 프레임워크를 쓴다.

효과는 수치로 확인된다. 핵심은 성능 극대화 자체가 아니라, 지속 가능한 비용으로 지연 시간·처리량 SLA를 충족하는 것이다.

  • vLLM은 HF Transformers 대비 8.5~15배, TGI 대비 3.3~3.5배 높은 처리량을 달성했다. (Kwon et al., 2023)
  • DeepSeek R1 호스팅 실험에서는 FP8 MLA 커널 활성화와 배치 크기 증가만으로 처리량이 15배(38 → 600 TPS) 증가했다.
  • 알파벳 회장 존 헤네시는 LLM 요청 비용이 기존 키워드 검색보다 10배 비쌀 수 있다고 언급했다. (2023년 로이터 인터뷰)

 

Model Serving Paradigms

On-Device (Edge) Serving

스마트폰, 드론, 로봇, 카메라, VR 헤드셋 등 사용자 기기에서 직접 모델을 실행하는 옵션. 구성 요소는 모델 런타임(LiteRT, ONNX Runtime, CoreML 등 하드웨어 추상화 + delegate로 GPU 오프로드)과 Model Wrapper(전처리, 로드, 실행, 후처리를 캡슐화한 앱 개발자 구현체)다.

 

배포는 학습 형식에서 런타임 형식으로 변환(컴파일) → 수치 정확도 검증 → 성능 측정 → 앱에 포함 순으로 진행하며, 퀄컴 AI 허브 같은 도구가 이를 자동화한다.

  • 장점: 비용 효율(클라우드 API 요금 불필요), 효율(빠른 응답 & 전력 효율), 프라이버시(데이터가 기기를 벗어나지 않음), 개인화(기기 내 사용자 데이터로 지속 미세조정)
    • 적합한 케이스: 프라이버시 최우선 워크로드(생체 인증 & 개인 건강 데이터), 초저지연(제스처 인식& 로봇 제어 루프 & AR/VR), 연결이 불안정한 환경(산업 장비 & 원격 센서 & 드론), IoT & 스마트시티 & 로봇공학
  • 제약: 컴퓨팅 & 저장 한계, 전력 소비(배터리), 업데이트 & 유지보수 어려움, 기기별 하드웨어 편차(예: 애플 뉴럴 엔진 최적화 모델이 스냅드래곤에서는 비효율적임)

 

Single-Model Service

각 모델과 각 버전을 전용 웹 서비스로 배포하고 HTTP/gRPC로 예측(Prediction) API를 노출하는 고전적 마이크로서비스 패턴으로 구성된다. 서버 측 실행은 대부분 컨테이너(Docker, Kubernetes Pod) 안에서 이뤄진다.

 

컨테이너 내부는 세 가지로 구성된다.

  • 추론 백엔드(TensorFlow Serving, TorchServe, vLLM, TensorRT-LLM이 실제 실행)
  • 모델 관리(모델 다운로드 & 압축 해제 & 백엔드 로드, 신규 모델 감지 시 자동 갱신)
  • API 서버(외부 예측 요청 수신)

라우팅 시 라운드 로빈은 요청별 처리 시간 편차를 고려하지 않는 한계가 있다(예: 5,000토큰 요청이 100토큰보다 오래 걸리는데 순서대로 배분하면 병목 발생). 대안으로 가중 라운드 로빈, 최소 연결, 최소 응답 시간, 동적 부하 분산(CPU, GPU, 메모리 사용량이나 대기열 길이 기준)을 쓴다.

 

확장은 수평 확장(scale out)과 수직 확장(scale up)이 있다.

  • 수평 확장: 여러 머신에 서빙 인스턴스를 늘리는 것으로, Kubernetes HPA 같은 오토스케일링이 지표 임계값을 넘으면 인스턴스를 동적으로 생성한다.
  • 수직 확장: GPT-4나 Llama-2-70B처럼 단일 머신 GPU 메모리로 부족할 때 더 강력한 GPU(H100, A100)를 쓰거나 여러 GPU에 분산하는 것이다. 이때 노드 간 서빙은 네트워크 오버헤드로 동기화 지연과 복잡성이 늘기 때문에, 가능하면 한 머신에 여러 GPU를 쓰는 노드 내 서빙을 우선한다.

싱글 모델 서빙이 책에서 기본 출발점으로 삼는 방식이다. 리소스 경합이 없어 성능이 좋고, 모델별 독립 확장이 쉬우며, 로그·메트릭·업데이트가 분리돼 디버깅이 간단하고, 장애가 격리되며, 모델별 최적 하드웨어로 비용을 관리할 수 있다. 다만 고객 100명이 각 10개 모델을 올리면 1,000개 서빙을 유지해야 하고 미사용 모델이 리소스를 낭비한다는 확장성 한계가 있다.

 

Multi-Model Service

하나의 컨테이너에서 여러 모델을 공동 호스팅하고 GPU/CPU/메모리를 공유하며, 트래픽에 따라 모델을 동적으로 로드 & 언로드한다. 앞의 1,000개 모델 예시에서 전부를 메모리에 올리는 대신 요청이 올 때만 로드하는 온디맨드 방식을 쓸 수 있어 가장 비용 효율적이다.

 

컨테이너는 두 구성 요소로 이뤄진다.

  • 모델 서버 추론 백엔드: 모델을 블랙박스로 다루며 통합 예측 API를 제공한다. 내부에는 TensorFlow, ONNX, PyTorch 등 형식별 백엔드가 따로 있다. NVIDIA Triton이 대표적인 오픈소스 구현체다.
  • 모델 캐시 관리: 모델 저장소에서 백엔드로 모델을 로드하고, LRU(Least Recently Used) 캐시로 로드된 모델을 추적해 리소스 사용률이 높을 때 사용 빈도가 낮은 모델을 언로드한다.

 

요청 처리 흐름은 다음과 같다. 모델이 이미 로드돼 있으면 바로 실행 → 로드돼 있지 않으면 저장소에서 다운로드해 로드한 뒤 실행 → 메모리 & 디스크 사용량이 임계값(예: 80%)을 넘으면 캐시에서 사용량이 가장 적은 모델을 찾아 언로드하고 제거한다.

 

라우팅 과제는 두 가지다.

  • 요청을 해당 모델이 이미 로드된 컨테이너로 보내야 한다. 그러지 않으면 콜드 스타트(로드될 때까지 대기)나 모델 스와핑(기존 모델을 메모리에서 제거)으로 지연이 생긴다.
  • 모델마다 트래픽이 달라 자주 쓰이는 모델은 인스턴스를 더 확보해야 한다. 해법으로는 모델 메타데이터에 replica 속성을 두고, 라우팅 계층에 라우트 맵(모델 ↔ 호스트)을 도입해 어느 컨테이너로 보낼지 추적한다. 복제본 수는 트래픽에 맞춰 실시간 조정하며, 새 복제본을 배치할 컨테이너를 고르는 일은 빈 패킹(bin packing) 문제에 해당한다.

 

다중 모델 서빙이 어려움을 겪는 경우는 다음과 같다.

  • 모델이 너무 커서 하나의 GPU에 다 담기지 않음(잦은 언로딩 → 콜드 스타트 지연 증가)
  • 개별 모델에 높은 트래픽이 몰려 항상 로드된 상태가 필요(단일 모델 서빙이 적합)
  • 모델별 보안 정책이 다를 때
  • 캐시 관리 & 모델별 라우팅 & 종속성 충돌로 운영 복잡성이 높을 때

 

Model Serving Platforms

규모가 커지면 다음 두 가지 이유로 인해 단일 & 멀티 모델 서빙을 조합하는 것만으로는 부족해진다.

  • 여러 모델의 협업이 필요하다. 시리 같은 음성 비서는 한 번의 명령에 음성 인식, NLP, 추천, 텍스트 음성 변환을 엮어야 한다.
  • 플랫폼에서 돌아가는 AI 앱이 늘면서 GPU & CPU & 메모리 할당 최적화가 중요해진다.

이를 설계하려면 설계 리소스 그룹으로 앱 & 비즈니스 시나리오별 워크로드를 분리하고 각각에 CPU & GPU & 메모리 할당량을 배정한다. 그리고 그래프 실행 구성 요소(Airflow, Ray)가 다단계 추론 워크플로를 지원해, 앱 팀이 정의한 워크플로를 받아 각 단계마다 라우팅 구성 요소를 호출해 적절한 서빙 그룹을 찾는다. 실제 플랫폼에는 보안 & 접근 제어, 메트릭 & 모니터링, 배포 & DevOps 통합도 포함된다.

 

2. Large Language Model Serving

Inside the Mind of a Transformer

LLM Evolution

언어 모델의 발전은 Word2Vec(2013, 단어를 연속 공간의 밀집 벡터로 표현) → RNN(순차 특성 모델링) → LSTM, GRU(2014, 게이팅 메커니즘, 메모리 셀로 장기 의존성 개선)과 같이 이루어졌지만, RNN 계열은 입력을 한 단계씩 순차 처리해 병렬화가 어렵고 GPU 활용도가 낮았다.

 

2017년 구글의 “Attention Is All You Need”가 소개한 트랜스포머는 RNN의 순환 레이어를 셀프 어텐션과 위치 인코딩으로 대체해 병렬 처리를 가능하게 하고 장거리 의존성을 효율적으로 포착했다. 이는 BERT와 GPT 두 계열의 등장으로 이어졌는데, 핵심은 작업마다 처음부터 학습하는 방식에서 대규모 비레이블 텍스트로 사전 학습한 뒤 미세 조정하는 방식으로의 전환이었다.

 

BERT는 양방향 인코더로 텍스트를 양방향 동시에 읽어 문맥 이해와 분류에 강하고, GPT는 단방향 디코더로 이전 문맥에서 다음 토큰을 예측해 생성 작업에 적합하다.

 

또한, 모델 크기와 학습 데이터 규모를 함께 키우면 성능이 크게 향상된다는 사실을 발견해 추가 학습 없이 또는 최소한의 학습만으로 새 작업을 수행하는 퓨샷 & 제로샷 학습이 가능해졌다. GPT-1이 1억 1,700만 개였던 매개변수가 2년 만에 GPT-3에서 1,750억 개가 됐고, 최근 DeepSeek R1은 6,710억 개에 이르렀다. 이렇게 수십억~수천억 매개변수를 갖춘 사전 학습 모델을 가리켜 '대규모 언어 모델'이라는 용어가 등장했다.

 

이 책에서 LLM과 트랜스포머는 디코더 전용 트랜스포머를 뜻한다. 다만 LLM의 정의는 고정된 게 아니며 2018년에는 수억 파라미터의 BERT가 "대규모"였지만 지금은 수조 파라미터에 멀티모달까지 포함되며 기대치도 높아져 일관된 텍스트 생성을 넘어 복잡한 지시 수행과 도구 상호작용까지 포함된다.

 

The Autoregressive Nature of Transformers

LLM는 자기회귀적 특성을 가지고 있다. 각 토큰은 이전에 생성된 모든 토큰을 기반으로 예측되며, 한 번에 하나씩 텍스트가 만들어진다. 이 단계별 프로세스 덕분에 모델은 일관성과 문맥을 유지하고 각 단어가 이미 생성된 내용과 의미 있게 연결된다. 이는 언어가 자연스럽게 구성되는 방식, 다음과 같이 각 단어가 이전 문맥에 따라 점진적으로 쌓이는 방식을 반영한다.

  • 1단계: 모델이 “미국 수도에 대한 간략한 소개를 작성하세요”라는 초기 프롬프트를 받아 첫 출력 토큰 "Washington"을 생성
  • 2단계: 생성된 토큰이 원래 입력에 추가되고 업데이트된 시퀀스가 모델에 다시 입력되어 "D.C."를 출력
  • 3단계: 프롬프트에 Washington과 D.C.가 모두 포함된 상태에서 is를 생성
  • 4단계: is가 추가된 뒤 the를 생성

이 반복은 최대 길이 도달이나 특수 정지 토큰 생성 같은 정지 조건을 만날 때까지 계속되며, 출력 시퀀스를 토큰 단위로 점진적으로 구축한다.

 

Decoder-Only Transformer Architecture

대부분의 최신 LLM이 쓰는 디코더 전용 트랜스포머의 기본 아키텍처를 Qwen 2.5의 아키텍처를 분석하면서 살펴본다.

 

모델 아키텍처

모델은 개념적으로 토크나이저, 트랜스포머(디코더) 블록, 언어 모델링(LM) 헤드의 세 가지 핵심 구성 요소로 나뉜다.

  • 1단계(토크나이저와 임베딩): 원시 입력 텍스트를 받아 고정 어휘를 기반으로 개별 토큰으로 분해 → 토큰들을 숫자 토큰 ID로 변환 → 임베딩 레이어로 그 ID를 벡터 임베딩에 매핑해 트랜스포머 블록의 입력을 준비한다. (예: OpenAI 토크나이저 도구로 확인하면 “Write a short introduction about US capital city”는 11개 토큰으로 분해 → 각각 [1100, 10930, 261, 4022, 22575, 1078, 290, 2952, 9029, 5030, 693] 같은 ID → [-0.12, 0.55, 0.98, …] 형태의 임베딩 벡터에 매핑
  • 2단계(트랜스포머(디코더) 블록): 모델의 핵심은 디코더 블록들의 스택에 있으며, 모델 실행과 학습 과정에서 대부분의 계산이 여기서 이뤄진다. 블록은 12개, 24개 또는 그 이상 여러 층으로 쌓여, 다음 출력을 예측하기 전에 프롬프트와 이전 출력에 대한 풍부한 문맥적 이해를 토큰 단위로 구축한다. 출력은 은닉 상태, 즉 입력 토큰의 문맥화된 표현이며 [N, d] 형태의 텐서다. N은 입력 시퀀스의 토큰 수, d는 은닉 상태 크기(모델에 따라 768, 2048, 4096 등)를 뜻한다. 대부분의 경우 시퀀스 마지막 토큰의 최종 은닉 상태만 다음 토큰 예측에 쓰인다.
  • 3단계(LM 헤드): 먼저 트랜스포머 블록의 은닉 상태를 토큰 어휘 로짓(logit: 어휘에 있는 토큰들에 대한 확률 분포)으로 매핑하고, 다음으로 그 로짓을 기반으로 출력 토큰을 선택한다. 보통 확률이 가장 높은 토큰이 다음 출력이 된다. 예시에서는 LM 헤드가 Washington을 선택하고 London, New York, Cat 같은 대안들이 그 뒤를 잇는다.

실습에서는 Qwen 2.5의 아키텍처를 다음 코드(ch2_Inside_the_Mind_of_a_Transformer.ipynb)로 확인한다. 모델을 로드한 뒤 config 객체에서 아키텍처 파라미터와 토크나이저 파라미터를 출력하고, 마지막 루프에서 architectures, model_type, torch_dtype을 제외한 나머지 모델별 파라미터를 전부 나열한다.

from transformers import AutoModelForCausalLM
from pprint import pprint

model_name = "Qwen/Qwen2.5-0.5B"
model = AutoModelForCausalLM.from_pretrained(
    model_name,
    trust_remote_code=True,
    device_map="auto"
)

# Print all configuration parameters
config = model.config
print("\n=== Model Configuration Parameters ===")

# Architecture parameters
print("\nArchitecture Parameters:")
print(f"Hidden size: {config.hidden_size}")  # Size of the hidden layers
print(f"Number of layers: {config.num_hidden_layers}")  # Number of transformer blocks
print(f"Number of attention heads: {config.num_attention_heads}")  # Number of attention heads
print(f"Intermediate size: {config.intermediate_size}")  # Size of the MLP intermediate layer

# Tokenizer parameters
print("\nTokenizer Parameters:")
print(f"Vocabulary size: {config.vocab_size}")  # Size of the vocabulary
print(f"Maximum position embeddings: {config.max_position_embeddings}")  # Maximum sequence length

# Print model size
total_params = sum(p.numel() for p in model.parameters())
print(f"\nModel Size:")
print(f"Total parameters: {total_params:,}")

# Model-specific parameters
print("\nModel-specific Parameters:")
for key, value in config.to_dict().items():
    if key not in ['architectures', 'model_type', 'torch_dtype']:
        print(f"{key}: {value}")

# Free GPU memory
free_gpu(model)

 

config에는 레이어 수, 은닉 차원, 어텐션 헤드, 어휘 크기 등 아키텍처의 중요한 세부 정보가 담겨 있다. 이를 이해하면 필요한 컴퓨팅 리소스(예: GPU 메모리)를 예측하고, 적절한 서빙 전략(예: 양자화나 배치 처리)을 선택하며, 성능 최적화(예: 레이어별 병렬 처리나 모델 샤딩)를 계획할 수 있다.

=== Model Configuration Parameters ===

Architecture Parameters:
Hidden size: 896
Number of layers: 24
Number of attention heads: 14
Intermediate size: 4864

Tokenizer Parameters:
Vocabulary size: 151936
Maximum position embeddings: 32768

Model Size:
Total parameters: 494,032,768

Model-specific Parameters:
transformers_version: 5.13.1
output_hidden_states: False
return_dict: True
dtype: bfloat16
chunk_size_feed_forward: 0
is_encoder_decoder: False
id2label: {0: 'LABEL_0', 1: 'LABEL_1'}
label2id: {'LABEL_0': 0, 'LABEL_1': 1}
problem_type: None
vocab_size: 151936
hidden_size: 896
intermediate_size: 4864
num_hidden_layers: 24
num_attention_heads: 14
num_key_value_heads: 2
hidden_act: silu
max_position_embeddings: 32768
initializer_range: 0.02
rms_norm_eps: 1e-06
use_cache: True
tie_word_embeddings: True
rope_parameters: {'rope_theta': 1000000.0, 'rope_type': 'default'}
use_sliding_window: False
sliding_window: None
max_window_layers: 24
layer_types: ['full_attention', 'full_attention', 'full_attention', 'full_attention', 'full_attention', 'full_attention', 'full_attention', 'full_attention', 'full_attention', 'full_attention', 'full_attention', 'full_attention', 'full_attention', 'full_attention', 'full_attention', 'full_attention', 'full_attention', 'full_attention', 'full_attention', 'full_attention', 'full_attention', 'full_attention', 'full_attention', 'full_attention']
attention_dropout: 0.0
pad_token_id: None
bos_token_id: 151643
eos_token_id: 151643
_name_or_path: Qwen/Qwen2.5-0.5B
use_mrope: False
output_attentions: False

 

 

Transformer(Decoder) 블록

디코더 블록은 LLM 아키텍처에서 가장 중요하고 계산량이 많은 계층으로 두 가지 주요 요소로 이뤄진다.

  • 셀프 어텐션 레이어: 셀프 어텐션 레이어는 모델이 프롬프트의 이전 토큰들을 모두 고려하고, 다음 단어를 생성할 때 각 토큰에 서로 다른 중요도를 부여할 수 있게 하는 레이어.
  • 피드포워드 신경망(FFN): 주어진 토큰을 더 강력한 표현, 즉 토큰의 문맥적 의미를 담은 고밀도 고차원 벡터(일반적으로 768차원 또는 4,096차원)로 변환한다. 토큰별 변환을 적용하는 구성 요소로, 어텐션 레이어의 문맥 정보를 활용하고 학습 과정에서 얻은 지식을 통합해 예측 전에 정제된 토큰 표현을 제공한다.

다음 코드는 Qwen 2.5 모델의 디코더 레이어를 검사하는 방법을 보여준다. named_children()으로 모듈 트리를 재귀 순회하며 각 자식 모듈의 이름과 타입을 출력하되 _orig_mod, wrapped_model 같은 내부 모듈은 명확성을 위해 건너뛴다. 모듈 이름에 Qwen2Attention이 포함되면 어텐션 전용 속성을 hasattr로 확인해 추가로 출력한다.

import torch
from transformers import AutoModelForCausalLM
from pprint import pprint


# Load the model
model = AutoModelForCausalLM.from_pretrained(
    "Qwen/Qwen2.5-0.5B",
    trust_remote_code=True,
    device_map="auto"  # This will automatically handle device placement
)

print(f"\n=== {model_name} Architecture ===")
print("\nModel Configuration:")
# pprint(model.config.to_dict())

print("\nModel Structure:")
def print_module_structure(module, prefix=''):
    for name, child in module.named_children():
        # Skip certain internal modules for clarity
        if name in ['_orig_mod', 'wrapped_model']:
            continue

        # Print the current module
        print(f"{prefix}{name}: {type(child).__name__}")

        if "Qwen2Attention" in name.lower():
          print(f"\nFound attention module: {name}")
          print(f"Type: {type(module).__name__}")

          # Print attention-specific attributes
          if hasattr(module, 'num_heads'):
              print(f"Number of attention heads: {module.num_heads}")
          if hasattr(module, 'head_dim'):
              print(f"Head dimension: {module.head_dim}")
          if hasattr(module, 'hidden_size'):
              print(f"Hidden size: {module.hidden_size}")
          if hasattr(module, 'rotary_emb'):
              print(f"Has rotary embeddings: {module.rotary_emb is not None}")

        # If it's a container module (has children), recurse
        if list(child.children()):
            print_module_structure(child, prefix + '  ')

print_module_structure(model)

free_gpu(model)

 

출력에서 디코더 레이어의 구조를 명확하게 확인할 수 있다. 어텐션은 Qwen2Attention, MLP(Multi-Layer Perceptron, 다층 퍼셉트론)는 Qwen2MLP, 레이어 정규화는 Qwen2RMSNorm으로 나타난다.

Loading weights: 100%
 290/290 [00:00<00:00, 951.10it/s]

=== Qwen/Qwen2.5-0.5B Architecture ===

Model Configuration:

Model Structure:
model: Qwen2Model
  embed_tokens: Embedding
  layers: ModuleList
    0: Qwen2DecoderLayer
      self_attn: Qwen2Attention
        q_proj: Linear
        k_proj: Linear
        v_proj: Linear
        o_proj: Linear
      mlp: Qwen2MLP
        gate_proj: Linear
        up_proj: Linear
        down_proj: Linear
        act_fn: SiLUActivation
      input_layernorm: Qwen2RMSNorm
      post_attention_layernorm: Qwen2RMSNorm
    1: Qwen2DecoderLayer
      self_attn: Qwen2Attention
        q_proj: Linear
        k_proj: Linear
        v_proj: Linear
        o_proj: Linear
      mlp: Qwen2MLP
        gate_proj: Linear
        up_proj: Linear
        down_proj: Linear
        act_fn: SiLUActivation
      input_layernorm: Qwen2RMSNorm
      post_attention_layernorm: Qwen2RMSNorm
    2: Qwen2DecoderLayer
      self_attn: Qwen2Attention
        q_proj: Linear
        k_proj: Linear
        v_proj: Linear
        o_proj: Linear
      mlp: Qwen2MLP
        gate_proj: Linear
        up_proj: Linear
        down_proj: Linear
        act_fn: SiLUActivation
      input_layernorm: Qwen2RMSNorm
      post_attention_layernorm: Qwen2RMSNorm
...
    23: Qwen2DecoderLayer
      self_attn: Qwen2Attention
        q_proj: Linear
        k_proj: Linear
        v_proj: Linear
        o_proj: Linear
      mlp: Qwen2MLP
        gate_proj: Linear
        up_proj: Linear
        down_proj: Linear
        act_fn: SiLUActivation
      input_layernorm: Qwen2RMSNorm
      post_attention_layernorm: Qwen2RMSNorm
  norm: Qwen2RMSNorm
  rotary_emb: Qwen2RotaryEmbedding
lm_head: Linear

 

출력을 보면 embed_tokens(임베딩) 뒤에 layers가 ModuleList로 이어지고, 그 안에 Qwen2DecoderLayer가 0번부터 반복된다. 각 디코더 레이어는 셀프 어텐션(Q/K/V/O 프로젝션이 모두 Linear), MLP(gate/up/down 프로젝션과 SiLU 활성 함수), 그리고 어텐션 앞뒤의 두 정규화 레이어로 구성된다. 이 디코더 레이어 구조를 아는 것은 모델 최적화나 서빙을 계획할 때 매우 중요하다. 예를 들어 어텐션 사용량이 많은 블록은 융합 어텐션 커널이나 사용자 정의 CUDA 구현으로 이점을 얻을 수 있다.

 

Capture Token Context by Calculating Attention

언어 처리 작업에서는 맥락이 중요하다. 맥락 없이 "나는 개가 다람쥐를 쫓는 것을 보았고, 그것은 나무 위로 올라갔다"라는 문장이 주어지면, '그것'이 개인지 다람쥐인지 불분명하다.

 

2017년 "Attention Is All You Need" 출간 이후 셀프 어텐션은 트랜스포머의 주요 어텐션 메커니즘이 됐다. 시퀀스의 각 토큰이 다른 토큰을 "살펴보고(look at)" 자체 표현을 계산할 때 관련성을 평가할 수 있게 해준다. 예를 들어 "Write a short introduction about the US capital city."라는 프롬프트를 처리할 때, 셀프 어텐션은 모델이 다음을 할 수 있게 한다. 여기서 말하는 capital이 금융 자본이 아닌 국가의 수도임을 이해하기 위해 앞의 US를 다시 살펴보고, 글을 간결하고 포괄적으로 작성하라는 지시로 introduction을 고려하며, 전체 과제가 교육적임을 알기 위해 Write를 사용한다.

 

어텐션 계산

각 토큰에 대해 LLM은 쿼리(Q), 키(K), 값(V)의 세 가지 벡터를 계산한다. 토큰의 어텐션 점수는 해당 토큰의 쿼리와 입력 시퀀스 모든 토큰의 키 벡터의 내적을 구하고, 결과를 스케일링한 후, 소프트맥스를 적용해 가중치를 얻고, 이 가중치로 값 벡터들의 가중합(weighted sum)을 계산해 얻어진다. 이 결과가 전체 시퀀스의 컨텍스트로 보강된 토큰의 업데이트된 표현이 된다.

 

멀티 헤드 어텐션

모델의 이해도를 높이기 위해, 토큰당 하나의 어텐션 계산을 수행하는 대신 동일한 토큰에 대해 여러 개의 어텐션 계산("헤드"라고 함)을 수행한다. 각 헤드는 고유한 Q/K/V 프로젝션을 갖는다. 이를 통해 모델은 구문적, 위치적, 의미적 관계 같은 다양한 유형의 관계를 병렬로 포착할 수 있다. 모든 헤드의 출력은 연결된 후 선형 레이어를 거쳐 최종 어텐션 출력을 만든다.

 

다음 코드에서는 입력 시퀀스에 대해 BertViz 라이브러리의 head_view를 사용해 Qwen 모델 각 디코더 레이어의 어텐션을 시각화한다. 모델과 토크나이저를 로드할 때 output_attentions=True를 지정하는 것이 핵심이며, .eval().cuda()로 평가 모드와 GPU 배치를 지정한다. torch.no_grad() 아래에서 순방향 실행만 수행해 outputs.attentions로 어텐션 가중치를 얻고, convert_ids_to_tokens로 토큰 문자열을 만들어 가중치와 함께 head_view에 넘긴다.

# prompt: use bertviz library to visualize the attention result of the input prompt "write a short introduction about US capital city"
from transformers import AutoTokenizer
from bertviz import head_view

# Your input text
text = "The tiny animal was overwhelmed by the confetti and it attempted to bat away the glitter with its little paws."
model_name = "Qwen/Qwen2.5-0.5B"
tokenizer = AutoTokenizer.from_pretrained(model_name, output_attentions=True)
model = AutoModelForCausalLM.from_pretrained(model_name, output_attentions=True).eval().cuda()

# Tokenize input and get token strings
inputs = tokenizer(text, return_tensors="pt").to(model.device)
tokens = tokenizer.convert_ids_to_tokens(inputs['input_ids'][0])

# Generate outputs with attention
with torch.no_grad():
    outputs = model(**inputs, output_attentions=True)

# Get attention weights
attention = outputs.attentions

# Use bertviz to visualize
head_view(attention, tokens)

출력된 그림의 각 선은 두 토큰 간의 어텐션 연결을 나타낸다. 선의 굵기는 어텐션 가중치의 강도를, 색상은 서로 다른 어텐션 헤드를 구분한다. 이 예시에서 capital 토큰은 US와 write에 강한 주의(어텐션)를 기울이는데, 이는 이 토큰들이 10번째 레이어에서 해당 토큰의 맥락적 표현을 형성하는 데 중요한 역할을 한다는 것을 시사한다.

 

여기서 설명한 어텐션 연산은 토큰 및 임베딩 차원, 각 어텐션 헤드 내의 행렬 형태, 출력 집계의 정확한 메커니즘 같은 구현 세부 사항은 추상화한 개념적 설명이나 모델 서빙 & 최적화에 집중하는 MLE에게는 이 수준의 이해가 일반적으로 충분하고 실용적이다. 셀프 어텐션에 대한 개념 중 서빙 관점에서 중요한 것은 어텐션은 프리필 단계에서 연산 집약적이며 그리고 메모리 및 지연 시간 비용이 입력 시퀀스 길이에 비례해 증가한다는 점이다. 

 

Executing LLM Generation: A Step-by-Step Walkthrough

KV 캐시, 프리필, 디코딩 같은 LLM의 핵심 내부 서빙 개념을 단계별로 보여주며 토큰 생성 과정을 자세히 설명한다. 참고로 데모 코드는 설명 목적의 단일 요청, 단일 시퀀스 예제이므로 실제 운영 환경에는 권장되지 않는다. 코드에서는 각 줄이 무엇을 하는지뿐 아니라 실행이 시간에 따라 어떻게 전개되는지에 주목해야 한다. 토큰 수준 실행, 프리필과 디코드, 캐시된 상태 같은 개념은 배치 처리, 스트리밍, KV 캐시 최적화, vLLM과 SGLang 같은 서빙 프레임워크를 다룰 때 책 전체에 걸쳐 다시 등장한다.

Run the Qwen Model

쉽게 시작하기 위해 Hugging Face 파이프라인 라이브러리로 Qwen 2.5 모델을 바로 실행해본다. LLM을 사용하는 가장 간단한 방법으로, pipeline 객체가 기본적인 복잡성을 추상화하고 텍스트 생성을 위한 직관적인 generator() API를 제공한다. max_length=50으로 생성 길이를, num_return_sequences=1로 반환할 시퀀스 개수를 지정하며, 결과는 딕셔너리 리스트로 반환되므로 [0]['generated_text']로 꺼낸다.

# Initialize the text generation pipeline
generator = pipeline('text-generation', model='Qwen/Qwen2.5-0.5B')
# Define your prompt
prompt = "Write a short introduction about the US capital city."
# Generate text
generated_text = generator(prompt, max_length=50, num_return_sequences=1)
# Print the generated text
print(generated_text[0]['generated_text'])

출력은 다음과 같다.

Write a short introduction about the US capital city. 
The United States of America is the largest country in 
the world by area, .. .. ..The city is also home to many 
other important institutions, including the National Mall, 
the White House Museum, and the National Archives.

 

Model Prediction, Line by Line

그럼 generator() 함수 내부에서 어떤 일이 발생하는지 살펴본다. 그림 2-7은 LLM 생성의 두 가지 핵심 측면을 보여준다. 첫째, LLM은 한 번에 하나의 토큰을 생성한다. 둘째, 새로 생성된 각 토큰은 이전 입력 시퀀스에 추가되어 다음 예측 단계의 새로운 입력을 형성한다.

예제 2-1에서는 파이프라인 대신 AutoModelForCausalLM을 사용해 Qwen 2.5 모델을 로드한다. 생성 구성, 입력, 디코딩 전략을 완벽하게 제어할 수 있으므로 입력을 수동으로 준비하고 생성 프로세스를 단계별로 처리하려는 경우에 이상적이다.

예제 2-1. Qwen 토큰 생성 구현 (토큰 단위)

(1) 먼저 토크나이저와 모델을 로드한다. .to("cuda" if torch.cuda.is_available() else "cpu")로 GPU가 있으면 GPU에, 없으면 CPU에 모델을 올린다.

# (1) Load tokenizer and model
model_name = "Qwen/Qwen2.5-0.5B"
tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
model = (
    AutoModelForCausalLM.from_pretrained(
        model_name, trust_remote_code=True,
    ).to("cuda" if torch.cuda.is_available() else "cpu")
)

(2) 다음으로 입력 프롬프트를 정의한다. 인간 커뮤니케이션의 역사를 다룬 긴 지문으로, 다음 물결의 커뮤니케이션 도구가 관계·사회·정체성에 어떤 영향을 줄지 묻는 내용이다.

(3) 이제 프롬프트를 토큰화한다. 즉 모델이 이해할 수 있는 입력 형식으로 변환하는 것이다. tokenizer(prompt, ...).input_ids가 프롬프트를 초기 모델 입력 idx로 만들고, .to(model.device)로 모델과 같은 장치에 올린다. max_new_tokens = 100은 생성할 토큰 수이고, times 리스트는 토큰별 지연 시간을 기록하기 위한 것이다.

# (3) Convert (tokenize) prompt to the input format that model understands
max_new_tokens = 100
# tokenize the input prompt for the first output token
# PS: prompt is the initial input sequence for LLM generation
idx = tokenizer(prompt, return_tensors="pt").input_ids.to(model.device)
start_time = total_time = time.time()
times = []

(4) 그런 다음 메인 생성 루프를 시작해 토큰을 하나씩 생성한다. 4단계(B)의 outputs = model(idx_cond)는 입력 idx_cond가 주어졌을 때 Qwen 모델을 실행해 새 토큰의 후보들을 만들어내고, logits = outputs.logits는 각 토큰 예측에 대한 로짓(원시 예측 점수)을 가져온다. torch.no_grad()는 추론에 불필요한 그래디언트 계산을 끈다.

# (4) Main generation loop - generate tokens one by one
for _ in range(max_new_tokens):
   # (A) Set the current context for generation
   idx_cond = idx
   with torch.no_grad():
       # (B) Generate predictions (token candidates) for next token
       outputs = model(idx_cond)
       # Get the logits (raw prediction scores) for each token prediction
       logits = outputs.logits

(C) 다음으로 모델은 방금 생성한 예측값 중에서 다음 토큰을 선택한다. logits[:, -1, :]로 마지막 토큰의 로짓만 고르고, torch.softmax로 확률로 변환한 뒤, torch.multinomial로 그 확률 분포에서 다음 토큰을 샘플링한다. 이 시점에 경과 시간을 times에 기록한다.

# (C) Select next token from the predictions generated in step (B)
   logits = logits[:, -1, :] #  Select only the logits for the last token
   # Convert logits to probabilities using softmax
   probas = torch.softmax(logits, dim=-1)    
   # Sample the next token from the probability distribution of the 
   predicted tokens from step (B)
   idx_next = torch.multinomial(probas, num_samples=1)
   print("Next Token is:", tokenizer.decode(idx_next[0])
   time_cost = time.time() - start_time
   times.append(time_cost)

(D) idx = torch.cat((idx, idx_next), dim=1)을 사용해 새 토큰을 입력 시퀀스에 추가한다. (E) 마지막으로 시퀀스 종료(end-of-sequence) 토큰이 생성되었는지 확인한다. idx에는 최종 출력 텍스트도 담겨 있다.

# (D) Append the new token to the input sequence
 idx = torch.cat((idx, idx_next), dim=1)

 # (E) Check if end-of-sequence token was generated
 if idx_next.item() == tokenizer.eos_token_id:
     print("\n[Generation completed - EOS token reached]")
     break

(5) 마지막으로 생성된 전체 시퀀스를 디코딩한다. skip_special_tokens=True로 EOS 같은 특수 토큰은 출력에서 제외한다.

# (5) Decode the entire generated sequence
generated_text = tokenizer.decode(idx[0], skip_special_tokens=True)

이 코드는 ch2_Workthrough_LLM_execution.ipynb 노트북에서 직접 실행해볼 수 있다. 코드 시연 외에도 토큰 생성 과정에서 각 토큰의 지연 시간을 추적했는데, 데모에서는 100개 토큰 생성에 9.1205초가 걸렸고 토큰당 평균 약 0.09초가 소요됐다.

그림 2-8을 보면 첫 번째 토큰을 제외하고 토큰 생성 시간이 점차 증가하는 패턴이 나타난다. 새로 생성된 각 토큰이 입력 시퀀스에 추가되므로 모델이 매 단계마다 더 긴 컨텍스트를 처리해야 하기 때문에 예상되는 결과다. 결과적으로 계산 시간과 리소스 사용량 모두 토큰이 추가될 때마다 증가한다.

MLE 관점에서 그림 2-9에 나타난 비효율성은 쉽게 알아차릴 수 있다. 이 모델은 새로운 토큰이 생성될 때마다 이전의 모든 토큰에 대한 어텐션을 다시 계산한다. 이미 알려진 토큰에 대한 반복 계산은 비용이 많이 들고 불필요하다. 이전 토큰에 대한 동일한 어텐션을 다시 계산하는 대신 새로 생성된 토큰에 대한 계산에만 집중할 수는 없을까?

Enable the KV Cache to Boost Performance

이전의 모든 토큰에서 얻은 중간 결과, 특히 어텐션 메커니즘이 계산한 키 및 값 벡터를 캐싱한다고 상상해보자. 그러면 새로운 토큰을 생성하는 각 단계에서 전체 시퀀스에 대한 어텐션을 다시 계산하는 대신 새로 추가된 토큰만 처리하면 된다. 이것이 KV 캐시를 활성화하는 이유다. 이 최적화는 이전에 생성된 토큰에 대해 각 레이어에서 계산된 어텐션 키와 값을 저장해 디코딩 중 중복 계산을 건너뛸 수 있게 한다.

그림 2-10이 보여주듯 KV 캐시는 LLM 계산을 전체 시퀀스 재계산에서 캐시 증강 워크플로로 전환하여, 메모리 사용량 증가를 감수하는 대신 상당한 연산 시간 절감을 가능하게 한다.

예제 2-2. KV 캐싱을 활성화한 상태에서 토큰 단위로 Qwen 텍스트 생성 구현

# (1) Define key/value cache for faster generation
past_key_values = None

for _ in range(num_interations):
   print("input_ids size: " + str(input_ids.size()))
   with torch.no_grad():
       outputs = model(input_ids=input_ids,
       # (2) Use KV-cache from previous iteration
       past_key_values=past_key_values,
       # (2) Enable KV caching
       use_cache=True,
       max_new_tokens = 100,
       min_new_tokens= 100)

       logits = outputs.logits
       # (3) Update KV Cache
       past_key_values = outputs.past_key_values
       torch.cuda.synchronize()

   logits = logits[:, -1, :]
   probas = torch.softmax(logits, dim=-1)
   generated_token_id = torch.multinomial(probas, num_samples=1)

   # (4) Update input_ids with only the new token (using KV-cache)
   # Note: Not concatenating with previous tokens due to KV-cache
   input_ids = generated_token_id
   idx = torch.cat((idx, generated_token_id), dim=1)

   if generated_token_id.item() == tokenizer.eos_token_id:
       print("\n[Generation completed - EOS token reached]")
       break

예제 2-1과 2-2의 가장 큰 차이점은 세 가지다.

  • 예제 2-2는 새로 생성된 토큰 generated_token_id만을 다음 토큰 생성을 위한 모델 입력으로 사용한다. 즉 input_ids = generated_token_id와 outputs = model(input_ids=input_ids, ..) 형태다. 루프 안의 print("input_ids size: ...")가 이 크기 변화를 확인하는 용도다
  • 토큰을 전달하는 것 외에도 KV 캐시를 모델 생성 입력으로 지정한다. outputs = model(input_ids=input_ids, past_key_values=past_key_values, ..)의 2단계 부분이며, use_cache=True로 캐싱을 켠다
  • 3단계에서 past_key_values = outputs.past_key_values로 각 토큰 생성 후 KV 캐시를 업데이트한다. torch.cuda.synchronize()는 GPU 연산이 끝날 때까지 기다려 시간 측정을 정확하게 만든다

예제 2-2의 실행 시간은 3.1416초였다. 그림 2-11은 예제 2-1(왼쪽, 캐시 없음)과 예제 2-2(캐시 있음)의 100개 토큰에 대한 실행 시간을 비교한다. 캐싱을 사용하지 않으면 모델이 각 단계에서 증가하는 입력 시퀀스를 다시 처리하므로 생성 시간이 꾸준히 증가한다. 반면 KV 캐시를 활성화하면 이전에 계산된 키-값 쌍을 재사용해 계산 시간을 크게 줄이므로, 첫 번째 토큰 이후 더 안정적이고 효율적인 생성 속도를 얻을 수 있다.

실제로 KV 캐시를 더 효율적으로 설계·관리·최적화하기 위한 여러 고급 기법들이 제안되어 왔다. 여기서 얻은 기초적인 직관을 바탕으로 그러한 기법들을 탐색하고 자신의 업무에 적용하는 일이 한결 수월해질 것이다. KV 캐시 최적화 전략은 성능 튜닝에 초점을 맞춘 6장과 7장에서 더 자세히 다룬다.

The Prefill and Decode Phases

토큰이 어떻게 생성되는지 살펴보았으니, 이제 다음 두 단계인 프리필(prefill)과 디코드(decode)를 알아볼 차례다. 이 용어들은 LLM 서빙 분야에서, 특히 성능 최적화와 확장성을 논할 때 널리 쓰인다.

프리필 단계 — 프롬프트 처리라고도 하며, 모델이 전체 입력 프롬프트를 한 번에 처리한다. 프롬프트의 모든 토큰에 걸쳐 어텐션을 계산하기 때문에 연산 집약적이다(시퀀스 길이에 대해 제곱 복잡도).

디코딩 단계 — 토큰 단위 생성이라고도 하며, 한 번에 하나의 토큰을 생성하고 새로운 토큰이 생성될 때마다 이 과정을 반복한다. 시퀀스는 증가하지만 어텐션 연산은 일반적으로 가장 최근에 생성된 토큰에만 집중한다.

그림 2-12에서 보듯 프리필 단계는 사용자가 "미국 수도에 대한 간략한 소개를 작성하세요"라는 프롬프트를 제출할 때 발생한다. 이 단계에서 모델은 프롬프트의 모든 토큰을 동시에 처리하며, 병렬 처리를 활용해 비교적 효율적으로 계산한다. 프리필이 완료되면 디코딩 단계가 시작되어 Washington, D.C., is, the 같은 토큰을 하나씩 생성해 응답을 구성한다.

그림 2-13은 KV 캐싱이 활성화된 상태에서 프리필 단계와 디코딩 단계의 처리 시간 차이를 비교한다. 왼쪽 첫 번째 막대는 첫 번째 토큰을 생성하는 데 걸린 시간으로, 프리필 단계에 해당하며 모델이 전체 프롬프트를 한 번에 처리해야 하므로 시간이 훨씬 더 많이 걸린다. 반면 이어지는 막대들(디코딩 단계)은 토큰 생성 시간이 훨씬 짧고 일정한데, 모델이 앞 단계에서 캐시해둔 정보를 사용해 토큰을 하나씩 생성하기 때문이다.

💡 프리필 및 디코딩 단계를 배우는 것이 왜 중요할까 — 두 단계의 차이와 각각의 컴퓨팅 패턴을 이해하는 것은 LLM 서빙에서 정보에 입각한 최적화 및 리소스 계획 결정을 내리는 데 매우 중요하다. 프리필 단계는 여러 프롬프트 토큰을 병렬 처리하기 때문에 컴퓨팅 집약적이고, 디코딩 단계는 주로 모델 가중치의 빈번한 로드와 KV 캐시 크기 증가로 인해 메모리 집약적이다. 예를 들어 500페이지가 넘는 PDF를 처리하는 것처럼 긴 프롬프트가 필요한 시나리오에서는 프리필이 비싼 작업이 되고, 챗봇 답변이나 스토리 생성처럼 짧은 프롬프트와 긴 생성 작업에서는 디코딩이 병목이 된다. 단계별 최적화 기법은 6장과 7장에서 자세히 다룬다.

이 섹션에서는 모델을 수동으로 로드하고 토큰을 단계별로 생성하는 등 추론을 처음부터 구현하는 방법을 살펴봤다. 이러한 접근은 LLM이 내부적으로 어떻게 작동하는지 이해하는 데 유용한 인사이트를 주지만, 실제 애플리케이션에서는 일반적으로 사용자 정의 추론 파이프라인보다 서빙 프레임워크를 사용한다.

Run the LLM with a Serving Framework

vLLM 및 SGLang 같은 모델 서빙 프레임워크는 특정 목적을 위해 설계된 시스템이다. 사전 학습된 언어 모델을 로드하고 API(일반적으로 REST 또는 gRPC)로 노출해 실시간 또는 배치 추론을 수행한다. PyTorch와 TensorFlow 같은 학습 프레임워크가 모델 개발과 경사 기반(gradient-based) 학습에 중점을 두는 반면, 서빙 프레임워크는 효율적이고 확장 가능하며 지연 시간이 짧은 추론에 최적화되어 있다.

하지만 서빙 프레임워크는 추론 실행 그 이상을 제공한다. 필수적인 기능은 다음과 같다.

  • KV 캐시 재사용을 통한 효율적인 디코딩
  • 스케줄링 요청 (예: 일괄 처리 또는 마이크로 배치 처리)
  • 다중 사용자 동시 접속 지원
  • 토큰 스트리밍, 취소 및 중단 처리

잘 설계된 서빙 프레임워크(vLLM 같은)는 페이지드 어텐션(paged attention)이나 투기적 디코딩(speculative decoding)처럼 최신 연구에서 나온 최적화 기법들을 지속적으로 통합하는 한편, 저수준 인프라 문제는 추상화해 감춰준다. 덕분에 개발자는 모델 아키텍처마다 추론 로직을 다시 구현하거나 개선 사항을 다룬 최신 논문을 쫓아다니는 대신, 애플리케이션을 만드는 데 집중할 수 있다.

Serve the LLM (Qwen) with vLLM

vLLM으로 LLM을 제공하는 것은 상당히 간단하고 효율적이다. 단 몇 줄의 코드로 Qwen 2.5 같은 모델을 불러와 추론을 실행할 수 있다. LLM(model=..., dtype="float16")으로 모델을 로드하고, SamplingParams로 추론 파라미터를 정의한 뒤, llm.generate([prompt], inference_params)로 생성한다. 프롬프트는 리스트로 넘긴다는 점에 유의한다.

import time
from vllm import LLM, SamplingParams

model_name = "Qwen/Qwen2.5-0.5B"

# Load model with vLLM
llm = LLM(model=model_name, dtype="float16")

# Define the prompt.
prompt = """You are an expert AI historian writing a 
detailed chapter for a book titled "The Evolution of 
Human-AI Collaboration." … … Write in a formal tone, 
with rich detail and examples in each era."""

# Create inference parameters.
inference_params = SamplingParams(temperature=0.8, top_p=0.95, max_tokens=128)

# Run token (text) generation with prompt and inference parameters.
outputs = llm.generate([prompt], inference_params)

# Print the results.
for output in outputs:
    print(f"Generated text: {output}")

이와 같이 LLM()과 generate() 함수는 복잡성을 상당 부분 추상화해 LLM을 빠르게 실험할 수 있게 해준다. 단순함에도 불구하고 vLLM은 성능과 동작을 세밀하게 조정할 수 있는 다양한 구성 옵션을 제공한다. 다음은 고급 구성 예시로, 메모리 관리(swap_space, max_model_len), PagedAttention 설정(block_size, enable_prefix_caching), KV 캐시 관리(max_num_sequences, max_sequence_length), 성능 최적화(enable_chunked_prefill, enable_cuda_graph), 시스템 설정(worker_use_ray, disable_custom_all_reduce)으로 나뉜다.

# Configure Model Loading
model = LLM(
   model="Qwen/Qwen-7B",
   # Memory management
   swap_space=16,  # CPU swap space size in GB
   max_model_len=4096,  # Maximum model length
   # PagedAttention settings
   block_size=16,  # Block size for PagedAttention
   enable_prefix_caching=True,  # Enable prefix caching
   # KV cache management
   max_num_sequences=256,  # Maximum number of sequences
   max_sequence_length=4096,  # Maximum sequence length
   # Performance optimizations
   enable_chunked_prefill=True,  # Enable chunked prefill
   enable_cuda_graph=True,  # Enable CUDA graph
   # System settings
   worker_use_ray=False,  # Use Ray for distributed serving
   disable_custom_all_reduce=False,  # Disable custom all-reduce
)

# Configure Model Inference Parameters
sampling_params = SamplingParams(
   temperature=0.7,  # Controls randomness (0.0 = deterministic)
   top_p=0.9,  # Nucleus sampling parameter
   top_k=50,  # Top-k sampling parameter
   max_tokens=100,  # Maximum tokens to generate
   stop=["\n", "###"],  # Stop sequences
   frequency_penalty=0.1,  # Frequency penalty
   presence_penalty=0.1,  # Presence penalty
   repetition_penalty=1.1,  # Repetition penalty
   skip_special_tokens=True,  # Skip special tokens in output
)

vLLM은 OpenAI 텍스트 완성 API의 매개변수 규칙을 따르므로 친숙하고 쉽게 도입할 수 있다. 모델 로딩 구성 옵션의 전체 목록은 vLLM GitHub 저장소에서 확인할 수 있다.

Performance Comparison: vLLM Versus Hugging Face Transformers

편의성 외에도 vLLM은 상당한 성능 향상을 제공한다. 여러 벤치마크에서 vLLM은 Hugging Face의 .generate() API보다 10~20배 높은 처리량을 달성한다. 동일한 프롬프트와 모델로 비교하기 위한 Hugging Face 코드는 다음과 같다. pipeline을 만들어 앞선 vLLM 예제와 같은 프롬프트·max_length=128·temperature=0.8·top_p=0.95 조건으로 실행하고, 앞뒤로 time.time()을 찍어 소요 시간을 잰다.

# Run model inference with Hugging Face library
# Load model and tokenizer in Hugging Face library   
tokenizer = AutoTokenizer.from_pretrained(
   "Qwen/Qwen2.5-0.5B",
   trust_remote_code=True,
)
model = AutoModelForCausalLM.from_pretrained(
   "Qwen/Qwen2.5-0.5B",
   device_map="auto",
   trust_remote_code=True,
)

start_time_basic = time.time()
# Create the model prediction pipeline
generator = pipeline('text-generation', model=model, tokenizer=tokenizer)
# Generate prediction with prompt
outputs_basic = generator(prompt, max_length=128, temperature=0.8, top_p=0.95)
end_time_basic = time.time()

vLLM은 1.12초가 걸리는 반면 Hugging Face 라이브러리는 19.58초가 걸린다. 단일 프롬프트에서 17배의 속도 향상을 보여주며, 동시 또는 배치 추론으로 확장할수록 성능 격차는 더욱 커진다.

💡 모범 사례: 간단하게 시작한 후 최적화 — 실제 개발 환경에서 팀은 Hugging Face Transformers로 사용 편의성을 높인 프로토타입을 제작한 다음, vLLM 같은 프레임워크로 마이그레이션해 프로덕션에 사용하고, 서빙 구성을 미세 조정해 지연 시간·처리량·동시성을 개선한다. vLLM 같은 프레임워크는 특정 모델과 사용 사례에 맞게 서빙 매개변수를 조정할 수 있어 최소한의 엔지니어링 오버헤드로 엔터프라이즈급 성능을 제공한다.

 

LLM Streaming Serving Basics

앞선 vLLM 코드 예제에서 LLM이 내부적으로는 토큰을 하나씩 생성하는데도 outputs = llm.generate([prompt], inference_params) 호출은 전체 출력이 다 생성될 때까지 기다린 뒤에야 결과를 반환한다는 점을 눈치챘을 수도 있다. 웹 서비스 환경에서는(예를 들어 챗봇이라면) 사용자가 응답을 하나도 받지 못한 채 긴 지연을 겪는다는 뜻이며, 출력 길이에 따라 몇 초에서 심지어 몇 분까지 걸리기도 한다.

챗봇 같은 대화형 인터페이스나 실시간 자막·요약처럼 실시간 피드백이 필요한 애플리케이션에서 이러한 응답 지연은 사용자 참여도에 상당한 영향을 미친다. 느린 응답은 사용자를 짜증나게 하고, 유지율을 떨어뜨리며, 궁극적으로 비즈니스 목표 달성 실패로 이어질 수 있다.

토큰은 디코딩 단계에서 순차적으로 생성되므로, 응답성을 높이려면 각 토큰이 생성되는 즉시 반환하는 기법을 쓸 수 있다. 이를 LLM 스트리밍이라 한다. 전체 출력이 완료될 때까지 기다리는 대신 생성 과정에서 출력 토큰을 토큰 단위로(또는 작은 청크 단위로) 점진적으로 반환하는 프로세스다.

# Initialize the vLLM async streaming engine arguments
engine_args = AsyncEngineArgs(
    model="Qwen/Qwen2.5-0.5B",
    dtype="float16",
    .. .. ..
)

# Create the vLLM async streaming engine
engine = AsyncLLMEngine.from_engine_args(engine_args)

# Define the async function to generate text in streaming mode
async def generate_text(prompt: str, max_tokens: int = 100):
  try:
    # Define sampling parameters
    sampling_params = SamplingParams(
        temperature=0.0,
        max_tokens=max_tokens,
        stop=["\n"],  # Stop at newline
    )

    # Generate text
    request_id = "test-request"  # Unique ID for this request
    # Generate tokens in streaming mode
    results_generator = engine.generate(
        prompt=prompt,
        sampling_params=sampling_params,
        request_id=request_id
    )

    # Process the results
    final_output = None
    async for request_output in results_generator:
        final_output = request_output
        # Print each token as it's generated
        for chunk in request_output.outputs:
            print(chunk.text, end="", flush=True)
        # We could yield return the token chunk here
        # if it's a web service
    return final_output

vLLM 코드의 스트리밍 버전에서 핵심적인 차이는 표준 LLM 클래스 대신 AsyncLLMEngine 클래스로 모델을 초기화한다는 점이다. AsyncLLMEngine을 쓰면 generate() 함수가 비동기 스트림 객체(AsyncStream)를 반환하므로, async for 루프를 사용해 새로 생성된 토큰을 하나씩 받아올 수 있다. 예를 들어 async for request_output in results_generator와 같은 형태다. 코드에서 request_id는 이 요청을 식별하는 고유 ID이며, 루프 안의 print(chunk.text, end="", flush=True)가 토큰이 나올 때마다 즉시 출력하는 부분이다. 웹 서비스라면 이 지점에서 토큰 청크를 yield로 반환하면 된다.

스트리밍의 또 다른 큰 이점은 출력이 원치 않는 방향으로 흘러갈 때 사용자가 생성을 중간에 취소할 수 있다는 것이다. vLLM에서는 engine.abort(request_id)를 호출해 이를 수행할 수 있으며, 여기서 request_id는 해당 생성 요청에 부여된 고유 식별자다. 이 기능은 무관하거나 잘못된 완성 결과를 막아 사용자 경험을 개선할 뿐 아니라 컴퓨팅 자원을 아끼는 데도 도움이 된다. 효율성과 비용 관리가 중요한 프로덕션 환경에서는 특히 중요한 부분이다.

LLM 스트리밍에 관심이 있다면 streaming.py 파일에 포함된 실습 예제를 참조하면 된다.

 

LLM Batch Serving Basics

이 장에서 살펴본 예시에서는 지금까지 LLM에 한 번에 하나의 프롬프트만 전송했다. 프로토타이핑에는 간단하고 효과적이지만, 10만 개의 문서를 요약하거나 5천 개의 PDF를 색인화하거나 2만 명의 챗봇 사용자를 동시에 처리하는 대량 시나리오에서는 병목이 발생한다. 한 번에 하나의 입력만 처리하는 방식으로는 확장이 불가능하다.

이러한 성능 제한을 해결하고 GPU 컴퓨팅 리소스를 최대한 활용하려면 배치 처리가 필요하다. 배치 처리란 여러 입력 요청을 그룹화해 모델을 한 번에 순방향으로 처리하는 것을 말한다. 한 번에 하나의 프롬프트를 처리하는 대신 여러 프롬프트에 대한 출력을 병렬로 생성하므로 처리량이 크게 향상된다. 트랜스포머 기반 LLM은 행렬 곱셈(matrix multiplications)과 어텐션 같은 연산을 병렬화할 수 있고, 공유 모델 가중치(shared model weights)와 GPU의 병렬 컴퓨팅 기능 덕분에 최소한의 오버헤드로 여러 요청을 동시에 처리할 수 있다.

다음 코드는 배치 생성이 실제로 어떻게 작동하는지 살펴보고 단일 프롬프트 생성과 지연 시간을 비교하는 실험이다. 앞부분은 4개 프롬프트를 리스트로 만들어 llm.generate(prompts, sampling_params)에 한 번에 넘기고, 뒷부분은 같은 프롬프트들을 for 루프로 하나씩 llm.generate([prompt], ...)에 넘긴다. 두 구간 모두 앞뒤로 시간을 재서 비교한다.

# Prompts for batch generation, 4 input sequences
prompts = [
    "What is the meaning of life?",
    "Write a short story about a robot learning to love.",
    "Explain quantum physics in simple terms.",
    "Translate 'Hello, world!' into Spanish."
]

sampling_params = SamplingParams(
    temperature=0.8,
    top_p=0.95,
    max_tokens=100
)

start_time = time.time()
# process four input sequences together in one batch
vllm_outputs = llm.generate(prompts, sampling_params)
end_time = time.time()
vllm_time = end_time - start_time

print(
    f"\nvLLM generation time for 4 prompts in a batch: "
    f"{vllm_time:.4f} seconds"
)

# process prompt one by one
start_time = time.time()
for prompt in prompts:
    vllm_outputs = llm.generate([prompt], sampling_params)
end_time = time.time()
vllm_time = end_time - start_time

print(
    f"\nvLLM generation time for 4 prompts one by one: "
    f"{vllm_time:.4f} seconds"
)

이 실험을 실행했을 때 vLLM은 4개 프롬프트를 일괄 처리하는 데 1.0626초가 걸린 반면, 하나씩 처리하는 데는 총 2.3865초가 걸렸다. 일괄 처리를 사용했을 때 처리량이 2.2배 향상된 것이다.

 

추가 최적화 기법을 활용하면 처리량을 더 높이면서 지연 시간을 줄일 수 있다. 그중 하나가 연속 배치 처리(continuous batching, 6장에서 자세히 설명)로, 다른 요청이 완료되는 대로 새로운 요청을 동적으로 추가하는 방식이다. Anyscale의 2023년 연구에 따르면 연속 배치 처리는 LLM 추론 처리량을 최대 23배까지 향상시키고 p50 지연 시간을 크게 줄일 수 있다.

저작자표시 비영리 동일조건 (새창열림)

'ETC' 카테고리의 다른 글

Hands-On LLM Serving and Optimization 책 스터디 2주차  (0) 2026.08.16
[AI 시대에 개발자가 알아야 할 인프라 구성 배포 with 클로드코드]스터디 4주차  (0) 2026.07.26
[AI 시대에 개발자가 알아야 할 인프라 구성 배포 with 클로드코드]스터디 3주차  (0) 2026.07.20
AI 시대에 개발자가 알아야 할 인프라 구성 배포 with 클로드코드 스터디: CH03-04  (2) 2026.07.12
[AI 시대에 개발자가 알아야 할 인프라 구성 배포 with 클로드코드]스터디 1주차  (0) 2026.07.05
'ETC' 카테고리의 다른 글
  • Hands-On LLM Serving and Optimization 책 스터디 2주차
  • [AI 시대에 개발자가 알아야 할 인프라 구성 배포 with 클로드코드]스터디 4주차
  • [AI 시대에 개발자가 알아야 할 인프라 구성 배포 with 클로드코드]스터디 3주차
  • AI 시대에 개발자가 알아야 할 인프라 구성 배포 with 클로드코드 스터디: CH03-04
Canary_카나리아
Canary_카나리아
어쩌다 데브옵스 엔지니어가 된 문과생의 기록용 블로그
    반응형
  • Canary_카나리아
    release: canary
    Canary_카나리아
  • 전체
    오늘
    어제
    • 분류 전체보기 (126)
      • DevOps (67)
      • Programming (28)
      • Certifications (3)
      • ETC (26)
  • 블로그 메뉴

    • 홈
    • 태그
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    개발자
    제로베이스
    클로드코드
    그라파나예제
    클라우드엔지니어자격증
    백엔드스쿨
    java개념
    AI 시대에 개발자가 알아야 할 인프라 구성 배포
    클라우드자격증
    네이버클라우드서비스
    argocd
    백엔드
    terraform
    GitAIOps
    프로메테우스예제
    백엔드개발
    kops사용법
    AIOps
    Grafana
    spring
    바이브코딩
    프로메테우스
    자바개념
    AI
    ncp서비스
    백엔드공부
    Java
    Prometheus
    한빛출판네트워크
    kops란
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.2
Canary_카나리아
Hands-On LLM Serving and Optimization 책 스터디 1주차
상단으로

티스토리툴바