Skip to content

AI 인프라

LLM 추론 해설: 모델을 서빙할 때 실제로 벌어지는 일

HTTP 요청과 생성된 토큰 사이에서 무슨 일이 일어나는지에 대한 실무자 가이드. 프로덕션에서 모델을 운영하기 전에 모든 플랫폼 팀에게 필요한 개념을 다룹니다.

Ivan Porta

창립자 겸 프린시펄 엔지니어

16 분 소요
#llm#inference#vllm#platform-engineering
LLM 추론 해설: 모델을 서빙할 때 실제로 벌어지는 일

ChatGPT, Claude, Gemini 같은 프런티어 AI 서비스에 대한 지출이 늘어나고, 사용량 한도가 개발자들의 발목을 잡고, 오픈소스 모델의 인기가 높아지면서, 대부분의 플랫폼 팀은 결국 자체 인프라에서 대규모 언어 모델(LLM)을 서빙해야 하는 시점을 맞이합니다. 언뜻 보면 로드 밸런서 뒤에서 HTTP로 동작하는 여느 워크로드와 다를 바 없어 보이지만, 닮은 점은 거기까지입니다. 모델 자체는 부동소수점 숫자로 가득한 폴더일 뿐이고, 그 안에 실행할 수 있는 것은 아무것도 없습니다. API는 무상태(stateless)여서 대화를 이어 가려면 클라이언트가 매번 전체 대화 이력을 다시 보내야 하고, 보통 밀리초면 끝나던 API 호출이 이제는 몇 초, 길게는 몇 분씩 걸립니다.

이 모든 차이는 모델이 프롬프트를 토큰으로 바꾸는 방식에 관한 몇 가지 기본적인 사실에서 비롯되며, GPU 구매부터 게이트웨이 구성과 오토스케일링까지 모든 플랫폼 의사 결정을 좌우합니다. 이 글에서는 그 전체 과정을 차례로 살펴봅니다. 배포하는 파일, 그 파일을 서빙하는 엔진, 그리고 요청 하나가 원문 텍스트에서 스트리밍되는 토큰이 되기까지 겪는 일을 다룹니다.

모델: 숫자로 가득한 폴더

모델가중치라고 부르는 수십억 개의 숫자 값에 토크나이저와 약간의 설정을 더해 담은 파일(더 정확히는, 대개 여러 파일의 묶음)입니다. 모델은 프로그램이 아니며, 실행할 것이 없습니다. 모델은 그 자체로는 정말 아무 일도 하지 않습니다. 예를 들어 다음은 Qwen3-4B의 파일 목록입니다.

3.7G model-00002-of-00003.safetensors
3.7G model-00001-of-00003.safetensors
 95M model-00003-of-00003.safetensors
 11M tokenizer.json
2.6M vocab.json
1.6M merges.txt
 32K model.safetensors.index.json
 16K README.md
 11K LICENSE
9.5K tokenizer_config.json
726B config.json
239B generation_config.json

이것이 한자리에 모인 전체 구조입니다. 순수한 숫자로 이루어진 세 개의 샤드가 데이터의 99% 이상을 차지하고, 그 사용법을 설명하는 작은 JSON·텍스트 파일 몇 개가 곁들여져 있습니다. 다음은 표준 Hugging Face 레이아웃을 따르는 주요 파일들입니다.

  • config.json은 설계도입니다. 엔진이 가중치를 배치하고 메모리 크기를 산정하는 데 필요한 아키텍처 정보를 담고 있습니다.
{
  "architectures": [
    "Qwen3ForCausalLM"
  ],
  ...
  "max_window_layers": 36,
  "model_type": "qwen3",
  "num_attention_heads": 32,
  "num_hidden_layers": 36,
  "num_key_value_heads": 8,
  "torch_dtype": "bfloat16",
  "transformers_version": "4.51.0",
  "use_cache": true,
  "use_sliding_window": false,
  "vocab_size": 151936
}
  • tokenizer.json은 모델의 사전 역할을 합니다. 모델은 텍스트를 직접 읽지 않습니다. 이 파일에는 사람의 텍스트를 모델이 사용하는 정수 ID로 바꾸는 데 필요한 모든 것이 들어 있습니다.
{
  "version": "1.0",
  "truncation": null,
  "padding": null,
  "added_tokens": [
    {
      "id": 151643,
      "content": "<|endoftext|>",
      "single_word": false,
      "lstrip": false,
      "rstrip": false,
      "normalized": false,
      "special": true
    },
    ...
  ],
  "normalizer": {...},
  "decoder": {...},
  "model": {
    "type": "BPE",
    "vocab": {
      "!": 0,
      "\"": 1,
      "#": 2,
      "$": 3,
      ...
    }
  },
  "merges": [...]
}
  • tokenizer_config.json에는 여러 설정과 함께 챗 템플릿이 들어 있습니다. 이 템플릿은 구조화된 대화(system, user, assistant 턴과 도구 호출)를 모델이 이해하도록 학습된 하나의 토큰 스트림으로 바꾸는 조리법입니다.
{
  "chat_template": "{%- if tools %}\n    {{- '<|im_start|>system\\n' }}\n    {%- if messages[0].role == 'system' %}\n        {{- messages[0].content + '\\n\\n' }}\n    {%- endif %}\n    {{- \"# Tools\\n\\nYou may call one or more functions to assist with the user query.\\n\\nYou are provided with function signatures within <tools></tools> XML tags:\\n<tools>\" }}\n    {%- for tool in tools %}\n        {{- \"\\n\" }}\n        {{- tool | tojson }}\n    {%- endfor %}\n    {{- \"\\n</tools>\\n\\nFor each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:\\n<tool_call>\\n{\\\"name\\\": <function-name>, \\\"arguments\\\": <args-json-object>}\\n</tool_call><|im_end|>\\n\" }}\n{%- else %}\n    {%- if messages[0].role == 'system' %}\n        {{- '<|im_start|>system\\n' + messages[0].content + '<|im_end|>\\n' }}\n    {%- endif %}\n{%- endif %}\n{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}\n{%- for message in messages[::-1] %}\n    {%- set index = (messages|length - 1) - loop.index0 %}\n    {%- if ns.multi_step_tool and message.role == \"user\" and message.content is string and not(message.content.startswith('<tool_response>') and message.content.endswith('</tool_response>')) %}\n        {%- set ns.multi_step_tool = false %}\n        {%- set ns.last_query_index = index %}\n    {%- endif %}\n{%- endfor %}\n{%- for message in messages %}\n    {%- if message.content is string %}\n        {%- set content = message.content %}\n    {%- else %}\n        {%- set content = '' %}\n    {%- endif %}\n    {%- if (message.role == \"user\") or (message.role == \"system\" and not loop.first) %}\n        {{- '<|im_start|>' + message.role + '\\n' + content + '<|im_end|>' + '\\n' }}\n    {%- elif message.role == \"assistant\" %}\n        {%- set reasoning_content = '' %}\n        {%- if message.reasoning_content is string %}\n            {%- set reasoning_content = message.reasoning_content %}\n        {%- else %}\n            {%- if '</think>' in content %}\n                {%- set reasoning_content = content.split('</think>')[0].rstrip('\\n').split('<think>')[-1].lstrip('\\n') %}\n                {%- set content = content.split('</think>')[-1].lstrip('\\n') %}\n            {%- endif %}\n        {%- endif %}\n        {%- if loop.index0 > ns.last_query_index %}\n            {%- if loop.last or (not loop.last and reasoning_content) %}\n                {{- '<|im_start|>' + message.role + '\\n<think>\\n' + reasoning_content.strip('\\n') + '\\n</think>\\n\\n' + content.lstrip('\\n') }}\n            {%- else %}\n                {{- '<|im_start|>' + message.role + '\\n' + content }}\n            {%- endif %}\n        {%- else %}\n            {{- '<|im_start|>' + message.role + '\\n' + content }}\n        {%- endif %}\n        {%- if message.tool_calls %}\n            {%- for tool_call in message.tool_calls %}\n                {%- if (loop.first and content) or (not loop.first) %}\n                    {{- '\\n' }}\n                {%- endif %}\n                {%- if tool_call.function %}\n                    {%- set tool_call = tool_call.function %}\n                {%- endif %}\n                {{- '<tool_call>\\n{\"name\": \"' }}\n                {{- tool_call.name }}\n                {{- '\", \"arguments\": ' }}\n                {%- if tool_call.arguments is string %}\n                    {{- tool_call.arguments }}\n                {%- else %}\n                    {{- tool_call.arguments | tojson }}\n                {%- endif %}\n                {{- '}\\n</tool_call>' }}\n            {%- endfor %}\n        {%- endif %}\n        {{- '<|im_end|>\\n' }}\n    {%- elif message.role == \"tool\" %}\n        {%- if loop.first or (messages[loop.index0 - 1].role != \"tool\") %}\n            {{- '<|im_start|>user' }}\n        {%- endif %}\n        {{- '\\n<tool_response>\\n' }}\n        {{- content }}\n        {{- '\\n</tool_response>' }}\n        {%- if loop.last or (messages[loop.index0 + 1].role != \"tool\") %}\n            {{- '<|im_end|>\\n' }}\n        {%- endif %}\n    {%- endif %}\n{%- endfor %}\n{%- if add_generation_prompt %}\n    {{- '<|im_start|>assistant\\n' }}\n    {%- if enable_thinking is defined and enable_thinking is false %}\n        {{- '<think>\\n\\n</think>\\n\\n' }}\n    {%- endif %}\n{%- endif %}",
  ...
}
  • generation_config.json은 모델 제작자가 권장하는 실행 설정을 제공합니다. 요청에서 다른 값을 지정하지 않는 한, 엔진은 이를 기본 설정으로 사용합니다.
{
    "bos_token_id": 151643,
    "do_sample": true,
    "eos_token_id": [
        151645,
        151643
    ],
    "pad_token_id": 151643,
    "temperature": 0.6,
    "top_k": 20,
    "top_p": 0.95,
    "transformers_version": "4.51.0"
}
  • 마지막으로 여러 개의 model-*.safetensors 샤드와, 그 목차 역할을 하는 model.safetensors.index.json이 있습니다. 이 JSON 파일은 각 텐서 이름을 그 텐서가 들어 있는 샤드에 매핑합니다. 샤드의 구조는 단순합니다. 작은 JSON 헤더가 텐서 이름, 데이터 타입, 형태, 바이트 오프셋을 나열하고, 그 뒤로 순수 텐서 바이트가 이어집니다. Qwen3-4B의 경우 bfloat16 값 약 40억 개가 각 2바이트씩, 총 8 GB 안팎을 차지한다는 뜻입니다. 샤드를 열어 보면 레이어별로 이름이 붙은 숫자 배열이 보입니다.
model.embed_tokens.weight
tensor([[-0.0287,  0.0117,  0.0104,  ..., -0.0055, -0.0270, -0.0096],
        [-0.0291, -0.0212,  0.0109,  ..., -0.0033, -0.0062, -0.0004],
        [ 0.0035,  0.0177,  0.0012,  ..., -0.0058,  0.0022,  0.0130],
        ...,
        [ 0.0060,  0.0131,  0.0190,  ...,  0.0068, -0.0049, -0.0040],
        [ 0.0060,  0.0131,  0.0190,  ...,  0.0068, -0.0049, -0.0040],
        [ 0.0060,  0.0131,  0.0190,  ...,  0.0068, -0.0049, -0.0040]],
       dtype=torch.bfloat16)
model.layers.0.input_layernorm.weight
tensor([0.0598, 0.0288, 0.0576,  ..., 0.0184, 0.0222, 0.0223],
       dtype=torch.bfloat16)
model.layers.0.mlp.down_proj.weight
tensor([[ 0.1396,  0.0056,  0.0054,  ..., -0.0084,  0.0781, -0.0693],
        [-0.0859, -0.0074, -0.0142,  ..., -0.0161, -0.0022, -0.0403],
        [ 0.0649, -0.0021,  0.0081,  ...,  0.0110,  0.0223,  0.0283],
        ...,
        [ 0.0106,  0.0208,  0.0176,  ...,  0.0060, -0.0349, -0.0060],
        [-0.0079, -0.0007, -0.0603,  ..., -0.0059, -0.0271,  0.0085],
        [-0.0221, -0.0306,  0.0121,  ..., -0.0089, -0.0233,  0.0089]],
       dtype=torch.bfloat16)
model.layers.0.mlp.gate_proj.weight
tensor([[ 0.0014,  0.0057, -0.0032,  ..., -0.0415, -0.0276, -0.0154],
        [-0.0178,  0.0078,  0.0043,  ..., -0.0186, -0.0028, -0.0018],
        [-0.0042, -0.0018, -0.0016,  ...,  0.0330, -0.0026,  0.0244],
        ...,
        [-0.0081, -0.0007, -0.0052,  ..., -0.0076, -0.0114,  0.0062],
        [-0.0061,  0.0118, -0.0116,  ..., -0.0164, -0.0015,  0.0237],
        [ 0.0130,  0.0046, -0.0036,  ..., -0.0105,  0.0297, -0.0347]],
       dtype=torch.bfloat16)
model.layers.0.mlp.up_proj.weight
tensor([[ 0.0137,  0.0089,  0.0046,  ..., -0.0339,  0.0061, -0.0117],
        [ 0.0077,  0.0078,  0.0036,  ...,  0.0134,  0.0216, -0.0040],
        [-0.0105,  0.0057,  0.0053,  ...,  0.0251,  0.0044,  0.0092],
        ...,
        [ 0.0067, -0.0003, -0.0082,  ..., -0.0049,  0.0247, -0.0181],
        [-0.0021,  0.0044, -0.0056,  ..., -0.0111,  0.0133,  0.0135],
        [ 0.0046, -0.0028,  0.0004,  ...,  0.0167,  0.0505,  0.0347]],
       dtype=torch.bfloat16)
model.layers.0.post_attention_layernorm.weight
tensor([-2.0862e-05,  2.7466e-04, -5.5313e-05,  ...,  2.1582e-01,
         2.3145e-01,  2.1484e-01], dtype=torch.bfloat16)
model.layers.0.self_attn.k_norm.weight
tensor([ 1.9453e+00,  1.1172e+00,  1.7031e+00,  1.7969e+00,  1.4688e+00,
         1.9141e+00,  1.9844e+00,  1.8281e+00,  1.8203e+00,  1.6406e+00,
         ...,
         6.4844e-01,  1.8203e+00,  2.3750e+00,  2.9062e+00,  4.0938e+00,
         2.0625e+00,  2.1094e+00,  3.0781e+00,  1.5234e+00,  2.3125e+00,
         1.9141e+00,  2.0469e+00,  2.0156e+00], dtype=torch.bfloat16)

밀집 대 전문가 혼합 트랜스포머 아키텍처

다음으로 넘어가기 전에 짚어 둘 중요한 차이가 하나 있습니다. 업계에서 가장 큰 공개 모델들이 최근 달라졌기 때문입니다. 오랫동안, 내려받을 수 있는 거의 모든 모델은 단일한 설계를 공유했고, 이 설계는 지금 밀집(dense) 방식이라고 불립니다. 반면 최신 플래그십 모델들은 다른 설계인 전문가 혼합(Mixture-of-Experts, MoE)을 사용합니다. 둘의 핵심 차이는 각 토큰이 그 텐서들 가운데 몇 개를 통과하느냐입니다.

  • 밀집 모델: Llama 계열을 비롯해 여러분이 지금까지 서빙해 온 대부분의 모델이 여기에 해당하며, 모든 토큰을 샤드 안의 모든 텐서와 곱합니다. 앞의 Qwen3-4B 목록을 다시 보면, 각 레이어에는 mlp.gate_proj, mlp.up_proj, mlp.down_proj가 하나씩 있고 모든 토큰의 벡터가 이들 전부를 통과합니다. 저장된 숫자를 건너뛸 방법이 없으므로 토큰당 연산량은 모델 크기에 비례해 커집니다. 70B 밀집 모델은 8B 모델보다 토큰당 대략 9배의 연산을 수행합니다.

  • 전문가 혼합(MoE) 모델은 같은 파일 구조를 유지하되, 각 레이어에 그 피드포워드 3종 세트의 작은 복사본을 여러 개 저장하고(mlp.experts.0.gate_proj, mlp.experts.1.gate_proj 같은 이름의 텐서들), 아주 작은 텐서 하나를 추가로 둡니다. 바로 학습된 라우터입니다. 라우터는 토큰마다 모든 전문가에 점수를 매기고, 점수가 높은 소수의 전문가(여기에 여러 최신 설계에서는 항상 켜져 있는 공유 전문가 하나까지)만 실제로 곱셈에 참여하며, 나머지 전문가의 텐서는 그 토큰에 대해서는 손대지 않은 채 남아 있습니다. Mixtral-8x7B는 47B 파라미터를 싣고 있지만 각 토큰은 그중 약 13B와만 곱해지고, DeepSeek-R1은 671B를 싣고 약 37B만 사용합니다. 토큰당 연산 비용은 작은 모델 수준이면서 큰 모델의 품질을 얻는 셈입니다. 압축 KV 캐싱(MLA, 메모리 절에서 다시 다룹니다) 같은 추론 계층의 혁신과 더불어, 이것이 DeepSeek가 프런티어급 품질을 그토록 저렴하게 서빙한 비결의 큰 부분입니다.

함정은 메모리 요구량이 어떤 텐서가 사용되느냐에 좌우되지 않는다는 점입니다. 설계에 따라 각 토큰이 전체의 대략 3–30%만 사용하는데도(Mixtral‑8x7B는 약 28%, DeepSeek‑R1은 약 5%) 모든 파라미터는 로드되어 접근 가능한 상태여야 합니다. MoE 모델은 실행 비용은 낮지만 저장 비용은 비쌉니다. 이런 모델을 효율적으로 서빙하려면 전문가들을 여러 GPU와 호스트에 분산하고 그 사이에서 토큰을 빠르게 라우팅해야 하는 경우가 많고, 여기에는 네트워크와 토폴로지를 인지하는 스케줄링이 필요합니다.

서빙 엔진은 새로운 웹 서버입니다

모델은 웹사이트의 HTML 파일만큼이나 수동적입니다. 웹 서버가 로드해서 요청을 처리해 주기 전까지는 아무 일도 하지 않습니다. 서빙 엔진은 모델의 웹 서버라고 생각할 수 있습니다. 서빙 엔진은 가중치를 가속기에 로드하고, HTTP API를 제공하며, 클라이언트의 JSON과 GPU 행렬 연산 사이를 오가며 변환합니다. 요청을 큐에 넣는 것부터 하드웨어에 맞게 배칭하는 것, 준비되는 즉시 토큰을 하나씩 스트리밍하는 것까지, 그 밖의 모든 동작은 하나의 큰 목표를 위한 것입니다. 바로 어떤 요청 하나도 지나치게 오래 기다리게 하지 않으면서, 값비싼 가속기를 계속 바쁘게 유지하는 것입니다.

이 글을 쓰는 시점의 엔진 지형은 다음과 같습니다.

vLLM오픈소스자체 호스팅 서빙의 사실상 기본값. PagedAttention을 개척했고, 연속 배칭이 기본입니다.
SGLang오픈소스vLLM의 가장 강력한 오픈소스 경쟁자. RadixAttention 프리픽스 캐싱을 제공합니다.
TGIHugging Face성숙한 엔진으로, Hub와 긴밀하게 통합되어 있습니다.
NIMNVIDIA상용. 특정 모델과 함께 엔진을 컨테이너로 미리 패키징해 제공합니다. NVIDIA가 배포하는 것을 그대로 실행합니다.
Ray ServeAnyscale / RayML 라이프사이클 프레임워크. LLM의 경우 보통 내부에서 vLLM을 감쌉니다.
Ollama오픈소스노트북과 엣지에 친화적이며, 이 글의 실험에는 안성맞춤입니다. 멀티 GPU 프로덕션 서빙용으로 만들어지지는 않았습니다.

엔진 이름 자체보다 더 중요한 것이 두 가지 있습니다. 엔진은 모델에 종속적입니다. 새로운 모델 아키텍처는 엔진이 지원을 추가해야 비로소 동작합니다. 모든 엔진이 지원 모델 목록을 공개하는 이유이자, 비즈니스에 무언가를 약속하기 전에 그 모델을 서빙할 수 있는지 항상 먼저 확인해야 하는 이유입니다. 그리고 거의 모든 엔진이 OpenAI API 사양(/v1/chat/completions, /v1/models)을 사용하므로, 클라이언트와 게이트웨이, 벤치마크가 서로 다른 엔진을 넘나들며 동작할 수 있습니다.

토큰과 컨텍스트 윈도우

모델은 단어를 보지 않습니다. 모델이 보는 것은 토큰, 즉 앞서 tokenizer.json에서 확인한 토크나이저가 만들어 내는 정수 ID입니다. 이후의 모든 것은 토큰 단위로 과금되고, 벤치마크되고, 메모리 예산이 책정됩니다. 다음은 짧은 문장 세 개에 대한 Qwen3-4B 토크나이저의 결과입니다.

from urllib.request import urlopen
from tokenizers import Tokenizer
TOK_URL = "https://huggingface.co/Qwen/Qwen3-4B/resolve/main/tokenizer.json"
tok = Tokenizer.from_buffer(urlopen(TOK_URL).read())
for text in ["Why is the sky blue?", "我喜欢吃火锅!", "김치찌개를 좋아해요!"]:
    enc = tok.encode(text)
    pieces = [tok.decode([i]) for i in enc.ids]
    print(f"{text!r}")
    print(f"  {len(enc.ids):>2} tokens -> {pieces}")
 
'Why is the sky blue?'
   6 tokens -> ['Why', ' is', ' the', ' sky', ' blue', '?']
 
'我喜欢吃火锅!'
   4 tokens -> ['我喜欢', '吃', '火锅', '!']
 
'김치찌개를 좋아해요!'
   9 tokens -> ['김', '치', '찌', '개', '를', ' 좋아', '해', '요', '!']

보다시피 문자 수와 토큰 수 사이에 고정된 비율은 없습니다. 영어 단어 다섯 개가 토큰 여섯 개가 되기도 하고, 한국어 단어 하나가 아홉 개가 되기도 합니다. 이것이 중요한 이유는 모델의 핵심 한계가 토큰 단위로 측정되기 때문입니다. 컨텍스트 윈도우는 모델이 요청 하나에서 다룰 수 있는 최대 토큰 수(프롬프트와 응답을 모두 포함)이며, 호스팅 모델이 "컨텍스트 100만 토큰"을 내세울 때 보이는 바로 그 숫자입니다.

토큰화 전에 한 단계가 더 있습니다. 애플리케이션은 구조화된 메시지 배열을 보내지만, 모델은 누가 말하고 있는지를 특수 토큰으로 표시한 하나의 평면적인 스트림으로 학습되었습니다. tokenizer_config.json의 챗 템플릿이 배열을 스트림으로 평탄화해 이 간극을 메우고, 그 스트림이 토큰화됩니다.

프리필과 디코드: 모든 응답 뒤에 있는 두 단계

추론은 그 토큰들을 새 토큰으로 바꾸는 계산입니다. 프롬프트가 들어가면 생성 결과(completion)가 나옵니다. 엔진 안에서 추론은 두 단계로 실행됩니다.

  • 프리필은 프롬프트 전체를 한 번의 큰 병렬 패스로 처리합니다. 모든 프롬프트 토큰에 대해 키와 값 벡터를 계산해 KV 캐시에 저장하는데, KV 캐시는 그 시퀀스의 어텐션 상태를 담아 두는 GPU 메모리입니다. 최종 출력은 첫 번째 응답 토큰에 대한 예측이고, 이 순간 사용자의 첫 토큰까지의 시간(time-to-first-token, TTFT)이 멈춥니다.

  • 디코드는 응답의 나머지를 토큰 하나씩 생성합니다. 토큰마다 키와 값을 계산해 캐시에 추가하고, 앞선 토큰 전부에 대해서는 다시 계산하는 대신 저장된 항목을 읽습니다. KV 캐시가 없다면 500번째 토큰을 생성할 때 앞의 499개 토큰을 다시 처리해야 할 것입니다. 캐시가 있으면 각 토큰의 어텐션 계산은 그 토큰이 시퀀스에 처음 들어올 때 딱 한 번만 수행됩니다.

무상태성과 대화의 비용

KV 캐시는 서버에 남지만, API는 무상태입니다. 모델이 사용할 수 있는 세션 객체나 대화 ID는 없습니다. 사용자가 "벨기에의 수도는 어디인가요?"라고 묻고 이어서 "그 도시에는 몇 명이 살고 있나요?"라고 물으면, 애플리케이션이 요청마다 전체 대화를 다시 보내야만 모델이 두 질문을 연결할 수 있습니다.

POST /v1/chat/completions HTTP/1.1
Host: localhost:11434
Content-Type: application/json
{
  "messages": [
    { "role": "user", "content": "What is the capital of Belgium?" },
    { "role": "assistant", "content": "The capital of Belgium is **Brussels**..." },
    { "role": "user", "content": "How many people live in that city?" }
  ],
  "model": "qwen3:8b"
}
 
HTTP/1.1 200 OK
Content-Type: application/json
{
  ...
  "choices": [
    {
      "message": {
        "role": "assistant",
        "content": "As of 2023, the **Brussels-Capital Region** has a population
                    of approximately **1.2 million people**..."
      },
      ...
    }
  ],
  "usage": { "prompt_tokens": 134, "completion_tokens": 843, "total_tokens": 977 }
}

그렇다면 대화가 길어질수록 연산 비용도 점점 더 커지는가는 좀 더 미묘한 질문이고, 여기서 KV 캐시가 다시 등장합니다. 무상태성은 이력을 다시 보내는 일이 애플리케이션의 몫이라는 뜻이지, 서버가 항상 그것을 다시 계산한다는 뜻은 아닙니다. 엔진이 그 대화의 앞선 턴에서 만든 KV 캐시를 아직 들고 있고 다시 보낸 프리픽스가 토큰 단위로 정확히 일치하면, 해당 토큰들의 프리필을 건너뛰고 거의 곧바로 디코드로 넘어갑니다. 자기 대화의 캐시를 이미 들고 있는 레플리카에 도착한 요청은, 캐시가 전혀 없는 곳에 도착한 요청보다 서빙 비용이 열 배 규모로 저렴할 수 있습니다.

배칭: 엔진이 처리량을 벌어들이는 방법

지금까지는 요청 하나만 따라왔습니다. 하지만 프로덕션 환경은 금세 하루 수십만 건의 요청에 도달할 수 있습니다. 높은 사용률을 유지하고 낭비를 줄이기 위해 엔진들은 두 가지 전략을 채택하고 있습니다.

  • 정적 배칭은 배치가 가득 찰 때까지 요청을 모았다가 한꺼번에 처리합니다. 처리량은 훌륭하지만, 배치의 첫 번째 요청이 마지막 요청이 도착할 때까지 기다려야 해서 지연이 늘어납니다. 이 트레이드오프는 문서 요약, 야간 임베딩 작업, 배치 분류처럼 아무도 즉각적인 응답을 기다리지 않는 오프라인 작업에 잘 맞습니다.

  • 연속 배칭은 다른 요청이 끝나는 대로 새 요청이 실행 중인 배치에 합류하도록 하고, 토큰 단위로 처리합니다. 이 방식은 요청당 지연을 크게 줄여 주며, 챗봇 같은 대화형 워크로드에 중요합니다.

관측 가능성과 중요한 네 가지 숫자

관측 가능성 관점에서 보면, 단계·캐시·배치 등 위의 모든 것이 네 가지 지표로 드러납니다.

  • TTFT(첫 토큰까지의 시간)는 무언가 화면에 나타나기 전까지 사용자가 기다리는 시간이며, 큐 대기와 프리필이 좌우합니다.

  • TPOT(출력 토큰당 시간)는 생성이 시작된 뒤의 생성 속도이며, 디코드가 좌우합니다.

  • 초당 토큰 수는 처리량입니다. 요청별로 볼 수도 있고 플릿 전체 합계로 볼 수도 있습니다.

  • 초당 요청 수는 일반적인 토큰 수를 파악하고 나면 주로 용량 계획에서 중요해집니다.

여러분의 인프라를 상대로 모델을 빠르게 벤치마크하는 방법 하나는 공식 vLLM 벤치마크 클라이언트인 vllm bench serve를 사용하는 것입니다. 이 도구는 OpenAI 호환 엔드포인트라면 어디에든 통제된 부하를 가합니다.

vllm bench serve \
  --model Qwen/Qwen3-4B-AWQ \
  --base-url http://localhost:8000 \
  --dataset-name random \
  --random-input-len 512 --random-output-len 128 \
  --num-prompts 64 --max-concurrency 8

이 도구의 리포트는 정확히 위의 네 가지 숫자를 중심으로 구성됩니다(ITL은 토큰 사이 간격을 하나하나 재는 방식의 TPOT입니다).

================= Serving Benchmark Result =================
Successful requests:                     64        
Failed requests:                         0         
Maximum request concurrency:             8         
Benchmark duration (s):                  38.83     
Total input tokens:                      32707     
Total generated tokens:                  8192      
Request throughput (req/s):              1.65      
Output token throughput (tok/s):         210.95    
Peak output token throughput (tok/s):    392.00    
Peak concurrent requests:                16.00     
Total token throughput (tok/s):          1053.20   
--------------------Time to First Token---------------------
Mean TTFT (ms):                          1632.54   
Median TTFT (ms):                        1355.88   
P99 TTFT (ms):                           5376.73   
P90 TTFT (ms):                           4301.80   
----------Time per Output Token (excl. 1st token)-----------
Mean TPOT (ms):                          25.34     
Median TPOT (ms):                        24.11     
P99 TPOT (ms):                           40.39     
P90 TPOT (ms):                           32.41     
--------------------Inter-token Latency---------------------
Mean ITL (ms):                           25.36     
Median ITL (ms):                         20.61     
P99 ITL (ms):                            29.10     
P90 ITL (ms):                            21.39     
---------------------End-to-end Latency---------------------
Mean E2EL (ms):                          4851.09   
Median E2EL (ms):                        4414.25   
P99 E2EL (ms):                           7989.19   
P90 E2EL (ms):                           7964.52   
============================================================

동시 요청 수를 늘려 가며 실행해 보면 연속 배칭이 일하는 모습을 볼 수 있습니다. 전체 합계 초당 토큰 수는 몇 배로 오르는 반면, 개별 요청의 TTFT와 TPOT는 완만하게만 나빠집니다.

배치가 하드웨어 용량을 넘어서면 처리량은 정체되고 TTFT가 첫 번째 희생자가 됩니다(요청이 큐에 쌓입니다). LLM 백엔드에서 중요한 라우팅 신호가 CPU가 아니라 큐 깊이인 이유가 바로 여기에 있습니다. 그리고 이 지표들이 무엇을 무시하는지도 눈여겨봐야 합니다. 바로 기존 오토스케일링이 지켜볼 줄 아는 두 숫자, CPU와 메모리 사용률입니다. 이 불일치가 망가뜨리는 것은 대시보드만이 아닙니다. LLM 트래픽이 표준 Kubernetes 로드 밸런싱과 오토스케일링을 무력화하는 이유는 별도의 글에서 다룹니다.

가중치, 양자화, 그리고 메모리 청구서

이제 예산 질문입니다. 이 모든 것을 담아 두려면 무엇이 필요할까요? 가중치는 파라미터 수에 바이트 수를 곱하기만 하면 점유량이 나옵니다. 그러나 오늘날의 대규모 언어 모델에서는 그 비용이 상당합니다. 16비트 정밀도에서 70B 파라미터 모델은 토큰 하나를 처리하기도 전에 대략 141 GB를 차지하는데, 이는 H100 GPU가 제공하는 80 GB를 한참 넘어섭니다. 따라서 과제는 수요에 맞춰 확장하는 것이 아니라, 일단 모델을 메모리에 넣는 것 그 자체입니다. 여기서 양자화가 등장합니다. 양자화는 가중치를 더 낮은 정밀도로 저장해 바이트 수와 그에 따른 메모리를 줄이며, 그 대가인 정확도 손실은 현대적인 4비트 기법들이 놀라울 만큼 작게 유지합니다.

KV 캐시는 가중치에 더해 GPU 메모리를 소비하며, 동시에 진행 중인 모든 대화의 토큰 하나하나마다 늘어납니다. 엔진은 이를 위해 VRAM의 큰 몫을 예약해 둡니다. 예를 들어 vLLM은 전체 GPU 메모리의 설정 가능한 비율(v0.20부터는 기본 92%, 그 전에는 90%)로 정의된 예산 안에서 동작합니다. 먼저 가중치를 로드하고, 활성화 오버헤드를 측정하는 프로파일링 순전파를 한 번 실행한 다음, 예산에서 남는 것 전부를 KV 캐시 공간으로 미리 할당합니다. KV 여유 공간이 많으면 배치가 커지고 처리량이 좋아지며, 적으면 큐잉이 더 일찍 시작됩니다. 그 여유 공간이 얼마나 되는지를 이 숫자 하나가 결정합니다.

아래 표는 대표적인 모델 네 개에 대해 이 두 계산을 모두 수행한 것으로, 각 모델의 레이어 수, KV 헤드 수, 헤드 차원은 해당 모델의 config.json에서 가져왔습니다(토큰당 KV 캐시 = 2 × 레이어 수 × KV 헤드 수 × 헤드 차원 × 바이트 수).

Llama-3.1-8B (밀집)16 GB8 GB4 GB128 KB16 GB
Llama-3.3-70B (밀집)141 GB71 GB35 GB320 KB40 GB
Qwen3-235B-A22B (MoE)470 GB235 GB118 GB188 KB23.5 GB
Kimi K2.5 (MoE)2,080 GB*1,040 GB520 GB~69 KB (MLA)†~8.6 GB†

† Kimi K2.5는 Multi-head Latent Attention(멀티헤드 잠재 어텐션)을 사용합니다(DeepSeek-V3 계보에서 물려받은 것입니다).

실용적인 제안

토큰 비용이나 사용량 제한, 컴플라이언스 요구 때문에 자체 인프라에서 모델을 운영할 생각이라면, GPU를 사기 전에 노트북에서 먼저 시험해 보시기 바랍니다. qwen3:8b나 llama3.2:1b 같은 작은 모델을 Ollama로 내려받으십시오. 그런 다음 Ollama로 서빙하고, vllm bench serve를 그쪽으로 향하게 한 뒤, --max-concurrency를 1에서 32까지 서서히 올리며 하드웨어가 부하를 어떻게 감당하는지 지켜보십시오. 이렇게 하면 TTFT, TPOT, KV 캐시를 비롯한 중요한 추론 지표들에 익숙해질 수 있습니다. 이 실험은 비용이 들지 않고, 과정이 어떻게 돌아가는지 배우면서 흔한 실수를 피하는 데 도움이 됩니다.

FAQ

LLM 서빙에 대해 가장 자주 받는 질문 네 가지.

모델을 서빙하는 데 ML 엔지니어가 필요한가요?

모델을 서빙하는 것만으로는 ML 엔지니어가 필요하지 않습니다. 추론은 주로 메모리, 큐, 배칭, 라우팅 같은 것이 관련된 인프라 문제입니다. ML 전문성이 필요한 곳은 모델을 학습하고 평가하는 일이지, 모델을 운영하는 일이 아닙니다.

더 큰 GPU를 쓰면 추론이 빨라지나요?

연산이 병목(compute-bound)일 때만 그렇습니다. 보통은 프리필 비중이 큰 워크로드가 여기에 해당합니다. 디코드는 메모리 대역폭이 병목이라 FLOPS를 늘려도 아무것도 달라지지 않는 경우가 많으니, 구매하기 전에 TTFT와 TPOT부터 측정하시기 바랍니다.

Ollama를 프로덕션에서 운영해도 되나요?

Ollama는 노트북과 엣지 디바이스, 그리고 이 글에서 다룬 모든 실험에 아주 잘 맞습니다. 다만 멀티 GPU나 높은 동시성 서빙을 위해 설계된 것은 아닙니다. 프로덕션 용도라면 대신 vLLM이나 SGLang을 검토하시기 바랍니다.

모델에는 GPU 메모리가 얼마나 필요한가요?

GPU 메모리 필요량을 추정하려면, 가중치에 대해서는 파라미터 수에 파라미터당 바이트 수를 곱하고, 여기에 동시 컨텍스트에 따라 늘어나는 KV 캐시를 더하면 됩니다.

이 사이트는 분석을 위해 쿠키를 사용합니다.