4-1편. LLM Serving Service(Single Model Serving)

Gyullbb·2026년 8월 15일

LLM

목록 보기
5/7

3-1편에서는 LLM Serving의 전체 구조를 살펴봤고, 3-2편에서는 Prefill, Decode, KV Cache를 통해 LLM이 실제로 어디에서 병목이 발생하는지 살펴봤다.

이번 편에서는 이론에서 벗어나 직접 작은 LLM Serving Service를 만들어본다.

가장 단순한 구조에서 시작해서 기능을 하나씩 추가해보며 확인해보자.

코드는 https://github.com/orca3/llm-model-inference의 코드를 기반으로 확인한다.

Single Request
      ↓
Batch Request
      ↓
Streaming Batch
      ↓
vLLM

1. Single Request

가장 단순한 Serving System 구조이다.

┌──────────┐
│  Client  │
└────┬─────┘
     │ HTTP Request
     ↓
┌──────────────┐
│ Serving API  │
└──────┬───────┘
       ↓
   Tokenizer
       ↓
     Model
       ↓
     GPU
       ↓
  Detokenizer
       ↓
   Response

사용자가 Prompt를 보내면 서버가 모델을 실행하고 생성된 결과를 반환한다.

요청 하나가 들어와서 응답이 나가기까지, 코드는 정확히 이 순서로 실행된다.

(1) API가 prompt를 받는다

async def basic_generate(request, llm = Depends(get_llm)):
    generated_text = llm.basic_generate(request.prompt)

prompt를 그대로 llm.basic_generate에 넘긴다.

(2) prompt를 Sequence로 감싸 model_executor로 보낸다

sequence = Sequence(str(uuid.uuid4()), prompt, None, None)
results = self.model_executor.execute_batch([sequence])

prompt에 고유 id를 붙여 Sequence로 감싼 다음, 리스트에 담아 execute_batch에 넘긴다. 지금은 요청이 하나이기 때문에 길이 1짜리 배치를 보내게 된다.

(3) model_executor는 큐에 작업을 던지고 결과를 기다린다 (API / GPU 연산 분리)

self.task_queue.put((prompts, False))
results = self.result_queue.get()   # 결과가 올 때까지 블로킹

여기서부터 API 프로세스와 실제 GPU 연산을 하는 프로세스가 분리된다. execute_batch는 작업을 task_queue에 던져놓고, result_queue.get()에서 결과가 돌아올 때까지 기다린다.
모델 실행을 별도 프로세스로 떼어낸 이유는, 무거운 GPU 연산이 API 서버의 이벤트 루프를 막지 않게 하기 위해서다.

(4) worker는 큐에서 한번에 하나씩 작업을 꺼내 처리한다

while True:
    batch_data = task_queue.get()
    ...
    result_queue.put(('complete', worker.generate(batch)))

전체 구조에서 실제로 모델을 돌리는 곳은 이 while 루프뿐이다.
worker 프로세스도 하나, 루프도 하나이기 때문에 task_queue.get()으로 작업을 하나 꺼내면, worker.generate(batch)가 끝나기 전까지는 절대 다음 작업을 꺼내지 않는다.

즉 사용자 A, B, C가 동시에 요청을 보내도 세 요청은 모두 이 하나의 while 루프 앞에 줄을 서게 된다. A의 생성이 끝나야 B가 큐에서 꺼내지고, B가 끝나야 C 차례가 온다
GPU 안에는 병렬로 계산할 여유가 있어도, 루프가 한 번에 batch 하나만 꺼내기 때문에 대기를 하게된다.

(5) worker 내부에서 추론 작업을 진행한다

inputs = self.tokenizer(prompt_texts, padding=True, truncation=True, max_length=512).to(self.device)
outputs = self.model.generate(inputs.input_ids, attention_mask=inputs.attention_mask, max_new_tokens=50)
generated_texts = self.tokenizer.batch_decode(outputs, skip_special_tokens=True)

prompt를 토크나이징하고, model.generate로 토큰을 생성하고, 다시 텍스트로 디코딩하는 세 단계다.

결과

curl -s -X POST http://localhost:8000/basic_generate \
  -H "Content-Type: application/json" \
  -d '{"prompt": "The weather is"}' | jq
{
  "generated_text": "The weather is going to be a bit of a problem for the next few days.\nThe weather is going to be a bit of a problem for the next few days.\nThe weather is going to be a bit of a problem for the next few days."
}

이 서버에 사용자가 한 명만 접속한다면 문제가 없지만, 동시에 요청이 들어오면 어떻게 될까?

User A → Request
User B → Request
User C → Request

방금 살펴본 구조라면 아래처럼 하나의 요청이 끝나야지만 다음 요청 처리가 가능하다.

A 처리
 ↓
B 처리
 ↓
C 처리

앞서 1편에서 살펴봤듯 GPU는 많은 연산을 병렬적으로 처리할 때 강하기. 때문에 이렇게 되면 GPU를 제대로 활용하지 못하게 된다.

그렇기 때문에 여러 요청을 동시에 GPU에 넣을 수 있도록 구조 변경이 필요하다.

여기서 Batching이 등장한다.


2. Batch Request

1번의 문제는 결국 하나였다. worker가 큐에서 작업을 하나씩만 꺼내 가기 때문에, 여러 요청이 GPU 앞에 줄을 서야 했다. 그렇다면 큐에서 하나가 아니라 여러 개를 한꺼번에 묶어서 꺼내면 어떨까?

┌──────────┐
│  Client  │  prompts: [p1, p2, p3, p4, p5 ...]
└────┬─────┘
     │ HTTP Request
     ↓
┌──────────────┐
│ Serving API  │
└──────┬───────┘
       ↓
 WorkloadManager   ← 대기 중인 요청을 batch_size(4)개까지 모은다
       ↓
 model.generate(batch)  ← 한 번의 GPU 호출로 여러 prompt를 동시에 처리
       ↓
    Response (여러 개)

(1) API는 prompt 하나가 아니라 리스트를 받는다

@app.post("/generate", response_model=BatchGenerateResponse)
async def generate(request: BatchGenerateRequest, llm = Depends(get_llm)):
    generated_texts = llm.generate(request.prompts)

GenerateRequest.prompt: str 대신 BatchGenerateRequest.prompts: List[str]를 받는다.

(2) 모든 prompt를 큐에 등록하고, 다 끝날 때까지 배치를 반복 실행한다

request_ids = [self.workload_manager.add_request(p) for p in prompts]

while not self._is_batch_finished(request_ids):
    sequences = self.workload_manager.get_next_batch()
    results = self.model_executor.execute_batch(sequences)
    for result in results[1]:
        self.workload_manager.update_sequence_output(
            result['request_id'], result['generated_text'], is_finished=True
        )

호출하는 execute_batch는 1번과 똑같은 함수다. 달라지는 건 넘기는 sequences가 하나가 아니라 여러 개라는 점이다.

(3) WorkloadManager가 실제로 요청을 "묶는" 지점 — 1번의 한계가 풀리는 곳

def get_next_batch(self, is_streaming=False):
    while len(self.active_sequences) < self.batch_size and not self.incoming_queue.empty():
        self.active_sequences.append(self.incoming_queue.get())
    return self.active_sequences

batch_size는 4로 고정돼 있다. 대기 큐에 쌓인 요청 중 최대 4개를 한 번에 꺼내 하나의 배치로 묶는다.
이 큐는 한 사용자의 prompt들만 모으는 게 아니라 /generate를 호출한 서로 다른 사용자의 요청도 순서대로 같이 쌓인다.
즉 A와 B가 동시에 요청을 보내면, 둘이 같은 배치에 섞여 같은 GPU 호출 한 번으로 함께 처리될 수 있다.

worker 쪽 generate() 코드 자체는 1번과 완전히 동일하다. 다만 이제 prompts 인자에 여러 개의 Sequence가 들어있을 뿐이다. padding=True로 길이가 다른 prompt들을 맞춰주던 것도, 사실 배치 크기가 1보다 클 때를 위한 장치였다는 게 여기서 드러난다.

결과 (prompt 5개를 보내면 batch_size=4라 4개, 1개 총 두 번의 배치로 나뉘어 처리된다)

curl -X POST http://localhost:8000/generate \
  -H "Content-Type: application/json" \
  -d '{"prompts": ["Hello, I am", "The weather is", "I want to", "The best way to", "The most efficient way to"]}' | jq
{
  "generated_texts": [
    "Hello, I am a student at the University of California, Berkeley. I am a graduate student in the Department of Psychology. I am a graduate student in the Department of Psychology. I am a graduate student in the Department of Psychology. I am a graduate student in the",
    "The weather is, of course, a factor in the weather.\n\nThe weather is a factor in the weather.\n\nThe weather is a factor in the weather.\n\nThe weather is a factor in the weather.\n\nThe weather is a factor",
    "I want toand I want to be a part of this.\nI want to be a part of this.\nI want to be a part of this.\nI want to be a part of this.\nI want to be a part of this.",
    "The best way to get a job is to get a job.                                         ",
    "The most efficient way to get a job is to get a job.                                         "
  ]
}

서버 로그 참조

4개의 prompt 먼저 처리
2026-08-15 22:51:04,344 - llm.model_executor - DEBUG - Sending batch to worker: [<llm.workload_manager.Sequence object at 0x30b028b50>, <llm.workload_manager.Sequence object at 0x41b7af550>, <llm.workload_manager.Sequence object at 0x41b7aed10>, <llm.workload_manager.Sequence object at 0x41b7af3d0>]
DEBUG:llm.model_executor:Sending batch to worker: [<llm.workload_manager.Sequence object at 0x30b028b50>, <llm.workload_manager.Sequence object at 0x41b7af550>, <llm.workload_manager.Sequence object at 0x41b7aed10>, <llm.workload_manager.Sequence object at 0x41b7af3d0>]
2026-08-15 22:51:04,344 - llm.model_executor - DEBUG - Waiting for results from worker
...
2026-08-15 22:51:04,345 - llm.model_worker - DEBUG - Batch input shape: torch.Size([4, 5])


나머지 1개의 프롬프트 처리
...
2026-08-15 22:51:06,981 - llm.model_worker - DEBUG - Received prompts: [<llm.workload_manager.Sequence object at 0x16f844f50>]
2026-08-15 22:51:06,982 - llm.model_worker - DEBUG - Batch input shape: torch.Size([1, 6])
...
INFO:     127.0.0.1:60584 - "POST /generate HTTP/1.1" 200 OK


/generate는 배치 안 prompt들이 지정한 개수의 토큰이 다 생성될 때까지(코드 예제에서는 최대 50개의 토큰) 기다렸다가 결과를 한꺼번에 돌려준다.
배치에 짧게 끝나는 prompt와 길게 끝나는 prompt가 섞이면, 짧은 쪽은 이미 다 끝났는데도 긴 쪽이 끝날 때까지 배치 안의 자리를 계속 붙잡고 있어야 한다.
이 문제를 풀기 위해 등장하는 게 Streaming Batch다.


3. Streaming Batch

Streaming Batch의 핵심 아이디어는 한 번에 "prompt 전체를 끝까지" 생성하지 말고, 토큰을 한 개씩만 생성하고,
끝난 요청은 즉시 배치에서 빼고, 그 빈자리에 대기 중이던 다음 요청을 채워 넣자는 것이다.

┌──────────┐
│  Client  │
└────┬─────┘
     │ HTTP Request (SSE)
     ↓
┌──────────────┐
│ Serving API  │──yield──▶ token, token, token, ... (완료될 때까지)
└──────┬───────┘
       ↑ asyncio.Queue (요청별 1개)
       │
 requests_processing_loop (백그라운드 스레드)
       │  매 반복마다 활성 시퀀스들에게 "딱 한 토큰씩"만 생성
       ↓
   model(...)  ← model.generate()가 아니라 forward 1스텝

(1) API는 결과 전체가 아니라 토큰 스트림을 Server-Sent Events로 돌려준다

@app.post("/generate_stream")
async def generate_stream(request: GenerateRequest, llm = Depends(get_llm)):
    async def event_generator():
        async for token in llm.event_generator(loop, request.prompt):
            yield token
    return StreamingResponse(event_generator(), media_type="text/event-stream")

StreamingResponse는 결과가 다 모일 때까지 기다리지 않고, yield되는 즉시 클라이언트로 흘려보낸다.

(2) 요청마다 자기 전용 큐를 만들고, 거기서 토큰이 나올 때까지 기다린다

queue = asyncio.Queue()
seq_id = self.workload_manager.add_streaming_request(prompt, queue, loop)

while True:
    data = await queue.get()
    if data is None:      # 종료 신호
        break
    yield f"data: {data}\n\n"

이 큐를 채우는 주체는 이 API 핸들러 자신이 아니다. LLMEngine이 서버가 뜰 때부터 별도 스레드에서 계속 돌리고 있는 처리 루프가 채운다.

(3) 백그라운드 스레드가 활성 시퀀스들을 모아 "한 스텝씩"만 돌린다

while True:
    active_sequences = self.workload_manager.get_next_batch(is_streaming=True)
    prompts = [{'prompt': seq.prompt, 'request_id': seq.id} for seq in active_sequences]
    results = self.model_executor.execute_forward_batch(prompts)

    for result in results:
        seq = self.workload_manager.get_sequence(result['request_id'])
        if result['is_finished'] or seq.token_count > self.max_tokens:
            asyncio.run_coroutine_threadsafe(seq.client_stream.put(None), seq.loop)
            self.workload_manager.remove_finished_sequence(result['request_id'])
        else:
            asyncio.run_coroutine_threadsafe(
                seq.client_stream.put(json.dumps({"token": result['token'], "sequence_id": result['request_id']})),
                seq.loop
            )

2번과 똑같은 get_next_batch를 쓰지만 is_streaming=True를 넘겨서, 스트리밍 전용 큐를 대상으로 최대 4개까지 모은다.
다른 점은 이 4개를 완료될 때까지 붙잡지 않는다는 것이다.
매 반복은 "딱 한 토큰"만 생성하고 곧바로 다음 반복으로 넘어가기 때문에, 어떤 시퀀스가 먼저 끝나면(is_finished) 그 즉시 remove_finished_sequence로 자리를 비우고, 다음 반복에서 대기 중이던 새 요청이 그 자리를 채운다.
짧은 요청이 긴 요청 때문에 자리를 계속 붙잡고 있어야 했던 한계가 여기서 해소된다.

(4) worker는 model.generate() 대신 딱 한 스텝의 forward pass만 실행한다

outputs = self.model(input_ids=..., attention_mask=..., use_cache=False)
next_token_logits = outputs.logits[:, -1, :]
next_token = torch.multinomial(torch.softmax(next_token_logits / 0.7, dim=-1), num_samples=1)

1·2번의 worker.generate()는 내부에서 최대 50번 토큰을 반복 생성하는 model.generate()를 한 번에 통째로 호출했다.
여기서는 self.model(...)을 딱 한 번만 호출해서 다음 토큰의 logits만 얻고 샘플링까지만 한다. "한 스텝 = 한 토큰"이라는 단위가 이 함수 안에서 결정된다.

결과 (토큰이 하나씩 도착한다)

curl -N -H "Accept: text/event-stream" \
     -H "Content-Type: application/json" \
     -d '{"prompt": "The weather is"}' \
     http://localhost:8000/generate_stream

data: {"token": ".", "sequence_id": "614f4775-c6b0-4472-9279-8e65eee349de"}

data: {"token": " ", "sequence_id": "614f4775-c6b0-4472-9279-8e65eee349de"}

data: {"token": " ", "sequence_id": "614f4775-c6b0-4472-9279-8e65eee349de"}

data: {"token": " Also", "sequence_id": "614f4775-c6b0-4472-9279-8e65eee349de"}

data: {"token": ",", "sequence_id": "614f4775-c6b0-4472-9279-8e65eee349de"}

data: {"token": " I", "sequence_id": "614f4775-c6b0-4472-9279-8e65eee349de"}

data: {"token": "'m", "sequence_id": "614f4775-c6b0-4472-9279-8e65eee349de"}
...

이제 여러 사용자가 동시에 /generate_stream을 호출해도, 같은 처리 루프 안에서 토큰 단위로 번갈아 생성되기 때문에 한 사용자의 생성이 끝날 때까지 다른 사용자가 통째로 기다릴 필요가 없다.
다만 이 구조는 우리가 큐, 스레드, while True 루프를 직접 짜서 만든 것이다.
실제 서비스에서는 이런 배칭과 스케줄링을 훨씬 정교하게 대신해주는 라이브러리가 있다. 바로 vLLM이다.


4. vLLM

(1) 서버가 뜰 때 vLLM 모델도 함께 로드한다

from vllm import LLM as VLLM, SamplingParams

self.vllm_model = VLLM(model="facebook/opt-125m")

지금까지 직접 만든 WorkloadManager + ModelExecutor + ModelWorker 조합을, vLLM 하나가 통째로 대신한다.

(2) prompt 리스트를 그대로 넘기기만 하면 된다

sampling_params = SamplingParams(temperature=0.7, top_p=0.95, max_tokens=self.max_tokens)
outputs = self.vllm_model.generate(prompts, sampling_params)
generated_texts = [output.outputs[0].text for output in outputs]

큐도, worker 프로세스도, 고정된 batch_size도, "한 토큰씩 생성해서 끝난 자리를 비워주는" 로직도 코드에 보이지 않는다. vllm_model.generate(prompts, ...) 한 줄이 내부적으로 다 처리한다.

@app.post("/generate_vllm", response_model=BatchGenerateResponse)
async def generate_vllm(request: BatchGenerateRequest, llm = Depends(get_llm)):
    generated_texts = llm.generate_vllm(request.prompts)
    return BatchGenerateResponse(generated_texts=generated_texts)

결과

curl -X POST http://localhost:8000/generate_vllm \
  -H "Content-Type: application/json" \
  -d '{"prompts": ["Hello, I am", "The weather is", "Once upon a time"]}' | jq
{
  "generated_texts": [
    " interested in a female HA Klefki if you are interested in a HA Raichu if you",
    " definitely a factor, too. You can't go out and run around in the rain because it's",
    ", I had a very good relationship with a woman. She was into music, and she was into"
  ]
}

앞서 큐로 요청을 모으고, 고정된 batch_size만큼 배치를 구성하고, 한 토큰씩 생성해서 끝난 시퀀스의 자리를 비워주도록 짠 코드를 vLLM은 Continuous Batching으로 내부에서 훨씬 정교하게 자동 처리한다.
매 스텝마다 배치를 다시 구성하되 요청을 더 세밀하게 추가·제거하고, KV Cache도 PagedAttention으로 메모리 낭비 없이 관리한다.

PagedAttention을 동작 로그로 확인하기

vLLM은 초기화 시점과 요청 처리 중에 KV Cache Block 상태를 로그로 그대로 찍어준다. requirements.txt에 고정된 vllm==0.9.0.1 기준으로, vllm/v1/core/kv_cache_utils.pyvllm/v1/metrics/loggers.py에 이 로그를 찍는 코드가 그대로 들어 있다.


import os
import sys
import nest_asyncio

# Colab 환경에서 vLLM V1 엔진의 fileno() 오류 회피
sys.stdout = sys.__stdout__
sys.stderr = sys.__stderr__
nest_asyncio.apply()

# VLLM_USE_V1 환경 변수는 최신 vLLM에서 무시될 수 있지만, 호환성을 위해 유지
os.environ["VLLM_USE_V1"] = "0" 

from vllm import LLM, SamplingParams

llm = LLM(model="facebook/opt-125m")

# vllm==0.9.0.1 에서는 llm.llm_engine.cache_config였지만,
# 이후 버전에서는 llm.llm_engine.vllm_config.cache_config로 위치가 바뀌었다.
try:
    cache_config = llm.llm_engine.cache_config
except AttributeError:
    cache_config = llm.llm_engine.vllm_config.cache_config

print(cache_config)
print(cache_config.block_size)   
print(cache_config.num_gpu_blocks)

위 코드를 실행하면 초기화 로그에 다음 세 줄이 찍힌다.

INFO 08-15 15:35:45 [kv_cache_utils.py:2235] GPU KV cache size: 376,752 tokens
INFO 08-15 15:35:45 [kv_cache_utils.py:2236] Maximum concurrency for 2,048 tokens per request: 183.96x

num_gpu_blocks가 바로 Block Pool의 실제 크기다.
GPU KV cache sizenum_gpu_blocks × block_size로 계산된 전체 토큰 수이고, Maximum concurrency는 "이 Block Pool로 max_model_len짜리 요청을 최대 몇 개 동시에 감당할 수 있는가"다. Python에서 직접 값을 꺼내볼 수도 있다.

print(llm.llm_engine.cache_config.block_size)       # 예: 16 (Block 하나가 담는 Token 수)
print(llm.llm_engine.cache_config.num_gpu_blocks)    # 예: 8192 (물리 Block 총 개수)

이 값이 모델이나 Prompt 길이가 아니라 GPU 여유 메모리(gpu_memory_utilization)로 정해진다는 사실 자체가, 요청마다 크기가 다른 KV Cache를 매번 새로 통짜로 할당하는 게 아니라 미리 잘라둔 고정 크기 Block Pool에서 빌려 쓴다는 증거다.

더 확실하게 보려면, 짧은 요청과 긴 요청을 섞어 동시에 여러 개 보내고 실행 중 로그를 지켜보면 된다.

prompts = ["Hi"] * 20 + ["Explain how transformers work in detail. " * 20] * 5
outputs = llm.generate(prompts, SamplingParams(max_tokens=100))

요청이 몰리는 동안 다음과 같은 로그가 주기적으로 찍힌다.

(TBD)

GPU KV cache usage가 요청이 늘어날수록 올라가고, 요청이 끝나 Block이 반납되면 다시 내려가는 걸 볼 수 있다.
ModelManager의 LRU 캐시가 "모델 전체" 단위로 하던 일을, vLLM은 "KV Cache Block" 단위로 훨씬 잘게 쪼개서 하고 있다는 뜻이다.
직접 Block Pool을 구현하지 않아도, 로그에 찍히는 num_gpu_blocksGPU KV cache usage 두 값만으로 PagedAttention이 실제로 동작 중이라는 것을 충분히 확인할 수 있다. (코드 오류로 인해 추후 수정예정)


마무리

지금까지 만든 네 가지 버전을 한 줄씩 정리하면 다음과 같다.

  • Single Request : 요청 하나를 큐에 넣고 worker가 하나씩 처리한다. 동시 요청이 오면 GPU가 비어 있어도 순서대로 줄을 서야 했다.
  • Batch Request : 여러 요청을 batch_size만큼 묶어 한 번의 GPU 호출로 처리한다. 다만 배치 안에서 가장 긴 요청이 끝날 때까지 짧은 요청도 자리를 붙잡고 기다려야 했다.
  • Streaming Batch : 한 번에 한 토큰씩만 생성하고, 끝난 요청은 즉시 배치에서 빼고 새 요청으로 채운다. 이것이 3-2편에서 살펴본 Continuous Batching을 손으로 구현한 버전이다.
  • vLLM : 지금까지 직접 짠 큐·스레드·배치 관리 로직을 Continuous Batching과 PagedAttention으로 대체한다.

단계별로 버전을 따라가면서 왜 vLLM 같은 프레임워크가 필요한지를 알 수 있다.
Single Request의 한계는 Batching이, Batching의 한계는 Streaming Batch가, 그리고 Streaming Batch를 직접 운영하는 부담은 vLLM이 풀어준다.

지금까지는 하나의 GPU에서 하나의 모델을 서비스하는 문제를 다뤘다.

"만약 하나의 GPU에서 여러 개의 LLM을 서비스해야 한다면?"

여기서부터는 Multi-Model Serving이 새로운 문제로 등장한다.

  • 여러 모델을 GPU Memory에 어떻게 함께 올릴 것인가
  • 요청이 들어올 때마다 모델을 새로 로드해야 한다면 그 비용은 어떻게 감당할 것인가
  • 어떤 요청을 어떤 모델로 보낼 것인가 (Routing)
  • 모델마다 다른 Latency와 Cost를 어떻게 균형 있게 관리할 것인가

다음 편에서는 Mult-Model Serving에 대해 알아본다.

0개의 댓글