LLM 엔지니어링17장 · Project 02. LLM 추론 API 서빙12 / 14← 이전목차다음 →MetaContents

17장. Project 02. LLM 추론 API 서빙

프로젝트 3 — 두 서비스를 한 묶음으로 배포하기

요약 API 와 분류 API 를 Ollama 와 함께 한 묶음으로 배포할 준비를 하고, 다른 팀이 부르는 것처럼 부하 시험을 해 보세요. 이 PC 에는 Docker 가 없으므로 compose 는 파일로 만들어 서로 맞는지 코드로 검사하고, 서비스는 배포 폴더에서 하위 프로세스로 띄워 확인합니다(14장와 같은 방식). 프로젝트 1과 2를 먼저 실행해야 합니다.

만들 것내용
out_llm_serving/summary/app.py(프로젝트 1의 API), requirements.txt, Dockerfile
out_llm_serving/inquiry/app.py(프로젝트 2의 API), out_inquiry_clf.joblib, requirements.txt, Dockerfile
out_llm_serving/compose.yamlollama, ollama-pull, summary, inquiry 의 네 서비스

요구사항은 다섯 가지입니다.

  1. 두 서비스의 폴더에 코드, 판을 고정한 패키지 목록, Dockerfile 을 둡니다. 분류 API 의 이미지는 임베딩 모델을 만들 때 미리 받아 둡니다.
  2. compose 에 Ollama(GPU, 모델 볼륨, 상태 검사), 모델을 한 번 받는 서비스, 두 API 를 적고 뜨는 차례를 정합니다.
  3. 앱이 읽는 환경 변수, 포트, 패키지 목록이 compose 와 Dockerfile 에 맞는지 코드로 검사합니다.
  4. 두 서비스를 이 PC 에서 띄워, 다른 팀이 부르는 것처럼 표준 라이브러리로만 부릅니다.
  5. 분류 API 에 동시 요청 1, 4, 8 개로 부하 시험을 하고, 요약 API 는 503 을 받으면 기다렸다 다시 보내는 부르는 쪽 코드로 시험합니다.

단계별 힌트입니다.

  1. 패키지 판은 importlib.metadata.version() 으로 지금 PC 의 것을 적습니다.
  2. 앱이 읽는 환경 변수는 정규식 os.environ.get\("(\w+)" 로 찾습니다.
  3. import 한 패키지 이름과 설치 이름이 다른 것(sklearn 은 scikit-learn)에 주의합니다.
  4. 부하 시험은 ThreadPoolExecutor(max_workers=동시 수) 로 같은 문의 40개를 보내고, 전체 시간으로 초당 처리 수를 냅니다.
  5. 끝나면 띄운 서버를 반드시 끕니다.

확인할 것: 다 풀었으면 항목마다 스스로 확인해 체크해 보세요.

실제 데이터로 해 보려면

이 프로젝트의 "실제"는 데이터보다 실제 서버입니다. 사내 GPU 서버에 올릴 때 확인할 것입니다.

확인할 것어디서내용
GPU 를 컨테이너에 주기NVIDIA Container Toolkit 공식 문서서버에 드라이버와 툴킷을 설치하고 docker run --gpus all 로 확인한다
Ollama 이미지Docker Hub 의 ollama/ollama판을 고정하고, 모델 볼륨 위치를 확인한다
폐쇄망에서 모델 옮기기11장인터넷이 되는 PC 에서 모델을 받아 볼륨째 옮긴다
실제 사용량13장의 요청 기록하루 요청 수와 몰리는 시각을 보고 동시 처리 수와 시간 제한을 정한다

코드의 흐름

예시 풀이의 코드가 어떤 차례로 흘러가는지 한 장으로 그렸습니다. 풀기 전에 큰 줄기를 먼저 보고, 풀다가 막히면 지금 어느 상자에 있는지 확인해 보세요.

서비스 폴더, compose, 맞는지 검사, 두 서비스, 부하 시험의 다섯 상자를 화살표로 이은 배포 프로젝트의 코드 흐름도

주황 화살표가 실행 차례이고, 상자의 번호는 예시 풀이의 단계 번호와 같습니다. 1단계(청록)는 out_llm_serving 아래에 서비스마다 app.py, requirements.txt, Dockerfile 을 담은 서비스 폴더를 만들고, 2단계(마젠타)는 ollama, ollama-pull, summary, inquiry 의 네 서비스를 compose.yaml 에 적습니다. 3단계(다홍)는 환경 변수, 포트, 패키지 목록이 compose 와 Dockerfile 에 맞는지 검사합니다. 4단계(빨강)는 배포 폴더에서 두 서비스를 uvicorn 으로 띄워 /health 로 확인하고, 5단계(짙은 청록)는 ThreadPoolExecutor 의 max_workers 를 바꿔 가며 부하 시험을 해 초당 처리 수를 냅니다.

결과 보기 (예시 풀이)

1단계. 서비스마다 폴더 만들기

Python
import importlib.metadata as md
import shutil, sys
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd

plt.rcParams["font.family"] = "Malgun Gothic"        # 한글 글꼴(5단계의 그래프)
plt.rcParams["axes.unicode_minus"] = False
test = pd.read_csv("inquiries.csv")                  # 5단계의 부하 시험에 쓸 문의 40개
summary_bodies = pd.read_json("summary_docs.jsonl", lines=True)["본문"].tolist()

DEPLOY = Path("out_llm_serving")
for name, src, extra in [("summary", "out_summary_api.py", []), ("inquiry", "out_inquiry_api.py", ["out_inquiry_clf.joblib"])]:
    folder = DEPLOY / name
    folder.mkdir(parents=True, exist_ok=True)
    shutil.copy(src, folder / "app.py")                # 프로젝트 1, 2 의 API 를 app.py 로
    for f in extra:
        shutil.copy(f, folder / f)

# 판을 고정한 패키지 목록. 지금 이 PC 에 깔린 판을 적는다
PACKAGES = {"summary": ["fastapi", "uvicorn", "pydantic"],
            "inquiry": ["fastapi", "uvicorn", "pydantic", "joblib", "scikit-learn", "sentence-transformers"]}
for name, pkgs in PACKAGES.items():
    (DEPLOY / name / "requirements.txt").write_text("".join(f"{p}=={md.version(p)}\n" for p in pkgs), encoding="utf-8")

(DEPLOY / "summary" / "Dockerfile").write_text("""FROM python:3.14-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 8000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
""", encoding="utf-8")
(DEPLOY / "inquiry" / "Dockerfile").write_text(f"""FROM python:3.14-slim
WORKDIR /app
# 임베딩은 CPU 에서 돌리므로 CPU 판 PyTorch 를 깐다(GPU 판보다 훨씬 작다)
RUN pip install --no-cache-dir torch=={md.version("torch").split("+")[0]} --index-url https://download.pytorch.org/whl/cpu
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 임베딩 모델을 이미지를 만들 때 받아 둔다. 컨테이너가 뜰 때 인터넷이 없어도 된다
RUN python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('intfloat/multilingual-e5-small')"
COPY app.py out_inquiry_clf.joblib ./
EXPOSE 8000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
""", encoding="utf-8")
for p in sorted(DEPLOY.rglob("*")):
    if p.is_file():
        print(p.as_posix(), f"{p.stat().st_size:,}바이트")
print((DEPLOY / "inquiry" / "requirements.txt").read_text(encoding="utf-8"))
출력
out_llm_serving/compose.yaml 1,494바이트
out_llm_serving/inquiry/app.py 4,809바이트
out_llm_serving/inquiry/Dockerfile 666바이트
out_llm_serving/inquiry/out_inquiry_clf.joblib 8,814바이트
out_llm_serving/inquiry/requirements.txt 119바이트
out_llm_serving/summary/app.py 5,201바이트
out_llm_serving/summary/Dockerfile 209바이트
out_llm_serving/summary/requirements.txt 53바이트
fastapi==0.162.0
uvicorn==0.54.0
pydantic==2.14.1
joblib==1.6.0
scikit-learn==1.9.1
sentence-transformers==6.2.0

2단계. compose.yaml 쓰기

Python
import yaml

compose_text = """services:
  ollama:
    image: ollama/ollama:0.40.12
    volumes:
      - ollama-models:/root/.ollama
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    healthcheck:
      test: ["CMD", "ollama", "list"]
      interval: 10s
      retries: 30
  ollama-pull:
    image: ollama/ollama:0.40.12
    environment:
      OLLAMA_HOST: ollama:11434
    entrypoint: ["ollama", "pull", "qwen2.5:3b"]
    depends_on:
      ollama:
        condition: service_healthy
  summary:
    build: ./summary
    ports: ["8171:8000"]
    environment:
      OLLAMA_URL: http://ollama:11434
      SUMMARY_MODEL: qwen2.5:3b
      SUMMARY_MAX_BUSY: "2"
      SUMMARY_TIMEOUT: "60"
    depends_on:
      ollama-pull:
        condition: service_completed_successfully
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')"]
      interval: 15s
  inquiry:
    build: ./inquiry
    ports: ["8172:8000"]
    environment:
      OLLAMA_URL: http://ollama:11434
      INQUIRY_MODEL: qwen2.5:3b
      INQUIRY_THRESHOLD: "0.6"
    depends_on:
      ollama-pull:
        condition: service_completed_successfully
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')"]
      interval: 15s
volumes:
  ollama-models:
"""
(DEPLOY / "compose.yaml").write_text(compose_text, encoding="utf-8")
compose = yaml.safe_load(compose_text)
for name, svc in compose["services"].items():
    print(f"{name:12s} 포트 {svc.get('ports', '-')} · 기다림 {list(svc.get('depends_on', {}))} · GPU {'deploy' in svc}")
출력
ollama       포트 - · 기다림 [] · GPU True
ollama-pull  포트 - · 기다림 ['ollama'] · GPU False
summary      포트 ['8171:8000'] · 기다림 ['ollama-pull'] · GPU False
inquiry      포트 ['8172:8000'] · 기다림 ['ollama-pull'] · GPU False

3단계. 파일끼리 맞는지 검사하기

Python
import ast, re
from urllib.parse import urlsplit

IMPORT_NAME = {"sklearn": "scikit-learn", "sentence_transformers": "sentence-transformers"}   # import 이름과 설치 이름이 다른 것
problems = []
services = compose["services"]
for name in ["summary", "inquiry"]:
    folder, svc = DEPLOY / name, services[name]
    code = (folder / "app.py").read_text(encoding="utf-8")
    # 1) 앱이 읽는 환경 변수 가운데 compose 가 주는 것과 기본값을 쓰는 것
    used = re.findall(r'os\.environ\.get\("(\w+)"', code)
    given = svc.get("environment", {})
    print(f"{name}: compose 가 주는 값 {[v for v in used if v in given]} · 기본값을 쓰는 값 {[v for v in used if v not in given]}")
    problems += [f"{name}: 앱이 읽지 않는 {k}" for k in given if k not in used]
    # 2) 주소의 호스트가 compose 의 서비스 이름인가
    problems += [f"{name}.{k}: {urlsplit(v).hostname} 서비스가 없다" for k, v in given.items()
                 if str(v).startswith("http") and urlsplit(v).hostname not in services]
    # 3) Dockerfile 의 포트와 compose 의 컨테이너 쪽 포트
    if '"--port", "8000"' not in (folder / "Dockerfile").read_text(encoding="utf-8") or not svc["ports"][0].endswith(":8000"):
        problems.append(f"{name}: 포트가 맞지 않는다")
    # 4) import 한 바깥 패키지가 requirements.txt 에 있나
    tree = ast.parse(code)
    mods = {a.name.split(".")[0] for n in ast.walk(tree) if isinstance(n, ast.Import) for a in n.names}
    mods |= {n.module.split(".")[0] for n in ast.walk(tree) if isinstance(n, ast.ImportFrom) and n.module}
    third = {IMPORT_NAME.get(m, m) for m in mods if m not in sys.stdlib_module_names}
    reqs = {line.split("==")[0] for line in (folder / "requirements.txt").read_text(encoding="utf-8").split()}
    problems += [f"{name}: requirements.txt 에 {p} 가 없다" for p in third - reqs]
gpu = [n for n, s in services.items() if "deploy" in s]
print("GPU 를 받는 서비스:", gpu, "· 모델 볼륨:", services["ollama"]["volumes"])
print("문제:", problems or "없음")
출력
summary: compose 가 주는 값 ['OLLAMA_URL', 'SUMMARY_MODEL', 'SUMMARY_MAX_BUSY', 'SUMMARY_TIMEOUT'] · 기본값을 쓰는 값 []
inquiry: compose 가 주는 값 ['OLLAMA_URL', 'INQUIRY_MODEL', 'INQUIRY_THRESHOLD'] · 기본값을 쓰는 값 ['INQUIRY_CLF']
GPU 를 받는 서비스: ['ollama'] · 모델 볼륨: ['ollama-models:/root/.ollama']
문제: 없음

4단계. 이 PC 에서 두 서비스 띄우기

compose 로 띄우는 명령은 아래와 같습니다. 이 PC 에서 실행하지 않은 안내 코드입니다.

출력
cd out_llm_serving
docker compose up -d --build
docker compose ps
curl http://127.0.0.1:8172/health

예상 출력(실행하지 않고 유추한 예시)입니다. 처음에는 이미지를 만들고 모델(약 2GB)을 받느라 몇 분 걸리고, 두 API 는 모델 받기가 끝난 뒤에 뜹니다.

출력
[+] Running 6/6
 ✔ Volume "out_llm_serving_ollama-models"    Created
 ✔ Container out_llm_serving-ollama-1        Healthy
 ✔ Container out_llm_serving-ollama-pull-1   Exited
 ✔ Container out_llm_serving-summary-1       Started
 ✔ Container out_llm_serving-inquiry-1       Started
NAME                          SERVICE    STATUS                    PORTS
out_llm_serving-inquiry-1     inquiry    Up 1 minute (healthy)     0.0.0.0:8172->8000/tcp
out_llm_serving-ollama-1      ollama     Up 3 minutes (healthy)    11434/tcp
out_llm_serving-summary-1     summary    Up 1 minute (healthy)     0.0.0.0:8171->8000/tcp
{"준비":true,"판":"inquiry-lr-1","기준값":0.6,"모델":"qwen2.5:3b","데우기실패":false,"LLM호출":0,"LLM실패":0}

이 PC 에서는 compose 가 하는 일을 흉내 냅니다. 두 서비스를 각자의 배포 폴더에서 띄우고, 서비스 이름 ollama 대신 이 PC 의 Ollama 주소를 줍니다.

Python
import json, os, subprocess, time, urllib.error, urllib.request

servers = {}
for name, port in [("summary", 8171), ("inquiry", 8172)]:
    env = {k: str(v) for k, v in services[name]["environment"].items()}
    env["OLLAMA_URL"] = "http://127.0.0.1:11434"                   # compose 라면 http://ollama:11434
    servers[name] = subprocess.Popen([sys.executable, "-m", "uvicorn", "app:app", "--port", str(port), "--log-level", "warning"],
                                     cwd=DEPLOY / name, env=dict(os.environ, **env))
for name, port in [("summary", 8171), ("inquiry", 8172)]:
    for _ in range(240):                                          # 데우기까지 끝나야 /health 가 답한다
        if servers[name].poll() is not None:                      # 켜다가 멈췄으면 더 기다리지 않는다
            print(name, "켜지 못함")
            break
        try:
            print(name, urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=2).read().decode())
            break
        except OSError:
            time.sleep(0.5)
print("문서 화면:", urllib.request.urlopen("http://127.0.0.1:8172/docs").status)
출력
summary {"준비":true,"데우기초":0.39,"데우기실패":false,"다시물음":0,"캐시적중":0,"모델":"qwen2.5:3b","판":"summary-1.0","동시처리":2,"시간제한":60.0}
inquiry {"준비":true,"판":"inquiry-lr-1","기준값":0.6,"모델":"qwen2.5:3b","데우기실패":false,"LLM호출":0,"LLM실패":0}
문서 화면: 200

5단계. 다른 팀처럼 부르고 부하 시험하기

Python
from concurrent.futures import ThreadPoolExecutor


def call(url, body, tries=4):
    """다른 팀이 쓸 부르는 쪽 코드. 503 이면 Retry-After 만큼 기다렸다 다시 보낸다. (상태, 본문, 보낸 횟수)"""
    data = json.dumps(body, ensure_ascii=False).encode()
    for i in range(tries):
        req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
        start = time.perf_counter()
        try:
            with urllib.request.urlopen(req, timeout=120) as r:
                return r.status, json.loads(r.read()), i + 1, time.perf_counter() - start
        except urllib.error.HTTPError as e:
            if e.code != 503:
                return e.code, json.loads(e.read()), i + 1, time.perf_counter() - start
            time.sleep(float(e.headers.get("Retry-After", 5)))
    return 503, None, tries, None


questions = test["문의"].tolist()
load = []
for workers in [1, 4, 8]:
    t = time.perf_counter()
    with ThreadPoolExecutor(max_workers=workers) as pool:          # 다른 팀 여럿이 한꺼번에 부르는 흉내
        res = list(pool.map(lambda q: call("http://127.0.0.1:8172/classify", {"문의": q}), questions))
    total = time.perf_counter() - t
    lat = [r[3] for r in res]
    ok = np.mean([r[1]["분류"] == y for r, y in zip(res, test["분류"])])
    load.append({"동시": workers, "초당 처리": round(40 / total, 2), "p50": round(float(np.percentile(lat, 50)), 2),
                 "p95": round(float(np.percentile(lat, 95)), 2), "전체 초": round(total, 1), "정확도": round(float(ok), 3),
                 "LLM": sum(r[1]["판단"] == "LLM" for r in res)})
print(pd.DataFrame(load).to_string(index=False))

# 요약 API: 넷이 한꺼번에 보내도, 부르는 쪽이 Retry-After 를 지키면 모두 답을 받는다
with ThreadPoolExecutor(max_workers=4) as pool:
    res = list(pool.map(lambda d: call("http://127.0.0.1:8171/summarize", {"본문": d, "문장수": 1}), summary_bodies[:4]))
print("요약 넷: 상태", [r[0] for r in res], "· 보낸 횟수", [r[2] for r in res])

fig, ax = plt.subplots(1, 2, figsize=(10, 4))
d = pd.DataFrame(load)
ax[0].bar(d["동시"].astype(str), d["초당 처리"])
ax[0].set_title("분류 API 의 초당 처리 수")
ax[0].set_xlabel("동시 요청 수")
ax[1].plot(d["동시"], d["p50"], marker="o", label="p50")
ax[1].plot(d["동시"], d["p95"], marker="o", label="p95")
ax[1].set_title("요청 하나의 응답 시간(초)")
ax[1].set_xlabel("동시 요청 수")
ax[1].legend()
plt.tight_layout()
plt.show()

for name, proc in servers.items():                               # 띄운 서버를 끈다
    proc.terminate()
    proc.wait(timeout=10)
print("서버를 껐습니다:", list(servers))
출력
 동시  초당 처리  p50  p95  전체 초  정확도  LLM
  1   3.22 0.40 0.51  12.4  0.9   28
  4   3.58 1.53 1.65  11.2  0.9   28
  8   3.54 3.01 3.22  11.3  0.9   28
요약 넷: 상태 [200, 200, 200, 200] · 보낸 횟수 [1, 1, 3, 2]
서버를 껐습니다: ['summary', 'inquiry']
분류 API 에 동시 요청 수를 1, 4, 8 로 바꿔 초당 처리 수와 p50, p95 응답 시간을 그린 그래프

배포 폴더와 compose 의 검사에서는 문제가 나오지 않았습니다. 앱이 읽는 환경 변수 가운데 INQUIRY_CLF 만 기본값(폴더 안의 out_inquiry_clf.joblib)을 쓰고, GPU 는 ollama 서비스만 받습니다. 배포 폴더에서 띄운 두 서비스는 데우기를 마친 뒤 /health 에 "준비"라고 답했습니다. 분류 API 의 부하 시험에서 초당 처리 수는 동시 요청 1, 4, 8 에서 3.22, 3.58, 3.54 로 거의 그대로였습니다. 요청 하나의 p50 은 0.40초, 1.53초, 3.01초로 동시 수에 비례해 늘었습니다. 40개 가운데 28개가 LLM 을 거치는데, 이 PC 의 Ollama 는 요청을 하나씩 처리하기 때문입니다(16장). 병목은 API 가 아니라 LLM 이고, 동시 수를 늘리면 줄만 길어집니다. 처리량을 늘리려면 LLM 에 넘기는 수를 줄이거나(분류기를 더 학습), 여러 요청을 함께 처리하는 서빙 엔진(vLLM)을 써야 합니다. 요약 API 에는 넷을 한꺼번에 보냈습니다. 부르는 쪽이 Retry-After 를 지키자 넷 모두 200 을 받았고, 보낸 횟수는 1, 1, 3, 2 번이었습니다. 붐빌 때 503 으로 돌려보내도 부르는 쪽 코드가 기다렸다 다시 보내면 결국 모두 답을 받습니다. docker compose up 은 이 PC 에 Docker 가 없어 실행하지 않았고, 위의 예상 출력은 유추한 것입니다.

이 프로젝트가 뜻하는 것

이 프로젝트는 가게 두 곳(요약, 분류)과 두 가게가 함께 쓰는 공용 주방을 새 건물로 옮길 이사 준비를 하는 일과 같습니다. 가게마다 상자에 짐과 물품 목록, 설치 설명서를 넣습니다(1단계). 건물 배치도를 그려 공용 주방이 먼저 문을 열고, 재료를 받은 뒤에 가게가 문을 열도록 차례를 정합니다(2단계). 배치도와 상자 속 내용이 서로 맞는지 점검합니다(3단계). 지금 건물(이 PC)에서 두 가게를 임시로 열어 보고(4단계), 다른 팀 손님처럼 한꺼번에 몰려와 얼마나 버티는지 시험합니다(5단계). 여기서 상자가 서비스 폴더, 물품 목록이 requirements.txt, 설치 설명서가 Dockerfile, 건물 배치도가 compose.yaml, 공용 주방이 ollama 서비스, 재료 받기가 ollama-pull 서비스입니다. 비유와 다른 점은, 이 PC 에 Docker 가 없어 새 건물로 실제 이사(docker compose up)는 하지 않았다는 것입니다.

단계출력뜻
1. 서비스 폴더summary/, inquiry/ 아래 app.py, Dockerfile, requirements.txt 등두 서비스의 상자가 만들어졌습니다. 분류 서비스에는 학습한 분류기 파일(out_inquiry_clf.joblib)도 들어 있습니다. 목록의 compose.yaml 은 2단계에서 쓰는 파일이라, 앞서 실행해 둔 것이 남아 있는 것으로 보입니다
1. 패키지 목록fastapi==0.162.0 … sentence-transformers==6.2.0이 PC 에 깔린 판을 그대로 적어, 새 건물에서도 같은 판이 깔리게 했습니다
2. composeollama 는 GPU True · ollama-pull 은 ollama 를 기다림 · summary 8171, inquiry 8172 는 ollama-pull 을 기다림GPU(Graphics Processing Unit, 그래픽 처리 장치)는 주방(ollama)만 받고, 두 가게는 모델 받기가 끝난 뒤에 뜨도록 차례가 정해졌습니다
3. 환경 변수summary 는 넷 모두 compose 가 줌 · inquiry 는 셋을 받고 INQUIRY_CLF 만 기본값앱이 읽는 설정이 배치도에 빠짐없이 적혀 있습니다. INQUIRY_CLF 는 폴더 안의 분류기 파일을 기본값으로 씁니다
3. 점검 결과GPU 를 받는 서비스 ['ollama'] · 문제: 없음환경 변수, 주소, 포트, 패키지 목록이 서로 어긋난 곳이 없습니다
4. compose 의 예상 출력컨테이너 네 개가 뜨고 상태가 healthy실행하지 않고 유추한 예시입니다. 실제 결과가 아닙니다
4. 이 PC 에서 띄우기summary 와 inquiry 모두 "준비":true · 문서 화면: 200배포 폴더에서 두 서비스를 띄우자 데우기를 마치고 준비되었다고 답했습니다
5. 초당 처리동시 1, 4, 8 → 3.22, 3.58, 3.54한꺼번에 부르는 수를 여덟 배로 늘려도 1초에 처리하는 문의 수는 거의 그대로입니다
5. 응답 시간 p500.40, 1.53, 3.01초한꺼번에 많이 부를수록 문의 하나가 끝나는 가운데 값이 거의 비례해 늘었습니다. 줄을 서기 때문입니다
5. 정확도와 LLM정확도 0.9 · LLM 28부하가 늘어도 답은 그대로이고, 40개 가운데 28개가 LLM 을 거쳤습니다
5. 요약 넷상태 [200, 200, 200, 200] · 보낸 횟수 [1, 1, 3, 2]503 을 받은 둘도 부르는 쪽이 Retry-After 만큼 기다렸다 다시 보내 결국 모두 답을 받았습니다
5. 그래프초당 처리 막대와 p50, p95 꺾은선막대는 높이가 거의 같고, 두 선은 동시 요청 수를 따라 오릅니다

한 줄로 줄이면, 두 API 와 모델 서버를 한 묶음으로 내보낼 파일을 서로 맞게 짜 두었고, 부하를 걸어 보니 병목은 API 가 아니라 요청을 하나씩 처리하는 LLM 이라는 것을 확인한 프로젝트입니다. 16장 실습 2에서 본 "처리량은 그대로, 기다림만 늘어남"과 같은 모습입니다. 처리량을 늘리려면 LLM 에 넘기는 수를 줄이거나 vLLM 처럼 묶어서 처리하는 엔진을 써야 합니다. Docker 로 실제로 띄우지 않았으므로 compose 의 예상 출력은 유추한 것이고, 부하 시험의 숫자는 이 PC 의 Ollama 에서 잰 것이라 GPU 서버에서는 다르게 나옵니다.

← 이전 절17장 · 프로젝트 2 — 문의 분류 API 서비스다음 절 →17장 · 정리