LLM 엔지니어링16장 · vLLM 서빙 벤치마킹과 RAG 최적화14 / 22← 이전목차다음 →MetaContents

16장. vLLM 서빙 벤치마킹과 RAG 최적화

실습 1 — 요청 하나를 쪼개 재기

Ollama 의 qwen2.5:3b 에 스트리밍으로 요청을 보내 첫 토큰까지의 시간(TTFT), 토큰 사이 시간(TPOT), 전체 지연을 잽니다. 첫 요청과 몸풀기 뒤의 요청, 입력이 짧을 때와 길 때, 출력이 짧을 때와 길 때를 견줍니다. Ollama 가 켜져 있고 qwen2.5:3b 를 받아 두어야 합니다. 규정은 7장의 가상 회사 규정집입니다.

1단계. 스트리밍으로 재는 함수

Python
import json, re, time, urllib.request, urllib.error

HOST = "http://127.0.0.1:11434"          # localhost 대신 127.0.0.1 (13차시: Windows 에서 더 빠르다)
MODEL = "qwen2.5:3b"


def post(path, body):
    """Ollama 에 JSON 을 보내고 JSON 답을 받는다(스트리밍이 아닌 요청)"""
    req = urllib.request.Request(HOST + path, data=json.dumps(body).encode(), headers={"Content-Type": "application/json"})
    return json.loads(urllib.request.urlopen(req, timeout=300).read())


def unload_all():
    """올라가 있는 모델을 모두 내린다(keep_alive 0). 재기 전에 GPU 를 비운다"""
    for m in json.loads(urllib.request.urlopen(HOST + "/api/ps").read())["models"]:
        post("/api/generate", {"model": m["name"], "keep_alive": 0})


BROKEN = re.compile(r"(.)\1{9,}")        # 같은 글자가 10번 넘게 이어지면 깨진 답(15차시)


def stream_chat(messages, num_predict=80, model=MODEL):
    """스트리밍으로 보내 조각이 올 때마다 시각을 잰다.
    돌려주는 것: 답, TTFT, 전체 지연, 출력 토큰 수, TPOT, 입력 토큰 수, 캐시에서 읽은 입력 토큰 수, 모델 올린 시간"""
    body = {"model": model, "messages": messages, "stream": True,
            "options": {"temperature": 0, "seed": 42, "num_predict": num_predict}}
    for _ in range(3):                                   # 서버 오류나 깨진 답이면 모델을 내렸다 두 번까지 다시 보낸다
        req = urllib.request.Request(HOST + "/api/chat", data=json.dumps(body).encode(),
                                     headers={"Content-Type": "application/json"})
        start, first, text, last = time.perf_counter(), None, "", {}
        try:
            with urllib.request.urlopen(req, timeout=300) as r:
                for line in r:                           # 스트리밍 답은 한 줄에 JSON 하나씩 온다
                    d = json.loads(line)
                    piece = d.get("message", {}).get("content", "")
                    if piece and first is None:
                        first = time.perf_counter() - start   # 첫 조각이 온 시각 = TTFT
                    text += piece
                    if d.get("done"):
                        last = d                             # 마지막 줄에 Ollama 가 잰 숫자들이 온다
        except urllib.error.HTTPError:
            last = {}
        if last and not BROKEN.search(text):
            break
        post("/api/generate", {"model": model, "keep_alive": 0})
    total = time.perf_counter() - start
    n = last["eval_count"]
    return {"답": text.strip(), "TTFT": first, "지연": total, "출력토큰": n,
            "TPOT": (total - first) / max(n - 1, 1), "입력토큰": last["prompt_eval_count"],
            "캐시토큰": last.get("prompt_eval_cached_count", 0), "올리기": last.get("load_duration", 0) / 1e9}


articles = [l.strip() for l in open("company_rules.txt", encoding="utf-8") if re.match(r"^제\d+조\(", l)]
SYSTEM = "당신은 솔내기술(가상 회사)의 규정 도우미입니다. [조항]에 있는 내용만으로 답하세요."
print(len(articles), "조항")
출력
30 조항

2단계. 첫 요청과 몸풀기 뒤의 요청

Python
# 모델을 모두 내려, 첫 요청에 모델을 올리는 시간이 들어가게 한다
unload_all()
msgs = [{"role": "system", "content": SYSTEM},
        {"role": "user", "content": f"[조항]\n{articles[2]}\n\n[질문]\n재택 근무는 일주일에 며칠까지 되나요? 한 문장으로 답하세요."}]
for i in range(3):
    r = stream_chat(msgs)
    # 결과: 회차마다 모델 올린 시간, TTFT, TPOT, 지연
    print(f"{i + 1}번째: 올리기 {r['올리기']:.2f}초 · TTFT {r['TTFT']:.2f}초 · TPOT {r['TPOT'] * 1000:.0f}ms · 지연 {r['지연']:.2f}초 · 출력 {r['출력토큰']}토큰")
print("답:", r["답"])
출력
1번째: 올리기 3.13초 · TTFT 3.83초 · TPOT 22ms · 지연 4.20초 · 출력 18토큰
2번째: 올리기 0.01초 · TTFT 0.04초 · TPOT 20ms · 지연 0.39초 · 출력 18토큰
3번째: 올리기 0.01초 · TTFT 0.03초 · TPOT 20ms · 지연 0.38초 · 출력 18토큰
답: 일주일에 이틀까지 재택 근무를 할 수 있습니다.

3단계. 입력 길이에 따라 달라지는 TTFT

Python
rows = []
for k in [1, 10, 30]:
    # 조항 k개를 붙인 프롬프트. 질문 끝에 k 를 적어 앞 요청과 앞부분이 달라지게 한다(프리픽스 캐시를 피한다)
    ctx = "\n".join(articles[:k])
    m = [{"role": "system", "content": f"{SYSTEM} (조항 {k}개)"},
         {"role": "user", "content": f"[조항]\n{ctx}\n\n[질문]\n근무 시간은 언제부터 언제까지인가요? 한 문장으로 답하세요."}]
    r = stream_chat(m)
    rows.append({"조항 수": k, "입력토큰": r["입력토큰"], "캐시토큰": r["캐시토큰"], "TTFT(초)": round(r["TTFT"], 2),
                 "TPOT(ms)": round(r["TPOT"] * 1000), "지연(초)": round(r["지연"], 2)})
import pandas as pd
# 결과: 입력이 길수록 TTFT(프리필)가 늘고, TPOT 은 거의 그대로다
print(pd.DataFrame(rows).to_string(index=False))
출력
 조항 수  입력토큰  캐시토큰  TTFT(초)  TPOT(ms)  지연(초)
    1   125    35     0.40        21   0.77
   10   691    40     2.72        21   3.09
   30  1675    39     6.99        21   7.37

4단계. 출력 길이에 따라 달라지는 지연

Python
rows = []
m = [{"role": "system", "content": SYSTEM},
     {"role": "user", "content": f"[조항]\n{articles[4]}\n\n[질문]\n연차가 생기는 규칙을 아주 자세히 설명해 주세요."}]
for n in [20, 80, 240]:
    # num_predict 로 출력 토큰의 최대 수를 정한다. 자세히 쓰라고 했으므로 대개 끝까지 쓴다
    r = stream_chat(m, num_predict=n)
    rows.append({"num_predict": n, "출력토큰": r["출력토큰"], "TTFT(초)": round(r["TTFT"], 2),
                 "TPOT(ms)": round(r["TPOT"] * 1000), "지연(초)": round(r["지연"], 2)})
# 결과: 출력 토큰이 늘면 지연이 거의 비례해 늘고, TTFT 는 그대로다
print(pd.DataFrame(rows).to_string(index=False))
출력
 num_predict  출력토큰  TTFT(초)  TPOT(ms)  지연(초)
          20    20     0.44        21   0.83
          80    80     0.03        20   1.64
         240   240     0.03        20   4.92

이 실습이 뜻하는 것

이 실습은 식당에서 주문 하나가 나오기까지의 시간을 초시계로 쪼개 재는 일과 같습니다. 먼저 손님이 주문한 뒤 첫 접시가 나온 시각과 접시 사이의 간격을 적는 초시계를 만듭니다(1단계). 아침 첫 손님은 주방에 불을 켜는 시간까지 기다리고, 둘째 손님부터는 바로 받는다는 것을 봅니다(2단계). 주문서가 길수록 첫 접시가 늦게 나오는지 보고(3단계), 접시 수가 많을수록 다 받는 시간이 길어지는지 봅니다(4단계). 여기서 주방에 불 켜기가 모델을 GPU(Graphics Processing Unit, 그래픽 처리 장치)에 올리는 일, 주문서 읽기가 프리필, 접시를 하나씩 내는 일이 디코드, 첫 접시까지의 시간이 TTFT(Time To First Token, 첫 토큰까지의 시간), 접시 사이 간격이 TPOT(Time Per Output Token, 토큰 하나당 시간)입니다. 식당과 다른 점은, 모델은 주문서를 한꺼번에 읽지만 그 읽는 시간이 주문서 길이에 거의 비례해 늘어난다는 것입니다.

단계출력뜻
1. 조항 읽기30 조항규정집에서 "제N조(" 로 시작하는 줄 30개를 조항으로 읽었습니다. 3단계에서 이 조항을 1개, 10개, 30개씩 붙입니다
2. 첫 요청올리기 3.13초 · TTFT 3.83초 · 지연 4.20초첫 글자를 받기까지 4초 가까이 걸렸고, 그 대부분이 모델을 GPU 에 올리는 시간입니다
2. 둘째·셋째 요청TTFT 0.04초, 0.03초 · 지연 0.39초, 0.38초모델이 올라간 뒤에는 같은 답(18토큰)이 0.4초 안에 나옵니다. 그래서 속도를 잴 때는 몸풀기 요청을 먼저 보내고 뺍니다
2. TPOT22ms, 20ms, 20ms토큰 하나를 쓰는 데 0.02초(ms 는 millisecond, 1,000분의 1초)쯤 듭니다. 1초에 약 50토큰을 쓰는 빠르기입니다
2. 답일주일에 이틀까지 재택 근무를 할 수 있습니다.조항 하나를 주고 물은 질문에 맞게 답했습니다. 시간만 잰 것이 아니라 답도 제대로 나왔다는 확인입니다
3. 입력 길이입력 125, 691, 1675토큰 → TTFT 0.40, 2.72, 6.99초붙인 조항이 많아 입력이 길수록 첫 글자가 늦게 나옵니다. 긴 주문서를 읽는 시간, 곧 프리필이 늘어난 것입니다
3. TPOT21ms 그대로입력이 열 배 넘게 길어져도 글을 쓰는 빠르기는 같습니다
4. 출력 길이출력 20, 80, 240토큰 → 지연 0.83, 1.64, 4.92초쓸 양이 늘수록 전체 시간이 거의 비례해 늘어납니다. TTFT 는 0.44초, 0.03초, 0.03초로 출력 길이와 관계가 없습니다

한 줄로 줄이면, 요청 하나의 시간은 "모델 올리기, 입력 읽기(프리필), 토큰 하나씩 쓰기(디코드)"로 나뉘고, 입력이 길면 첫 글자가, 출력이 길면 전체 시간이 늘어난다는 것을 잰 실습입니다. 실습 4는 이 숫자로 느린 RAG 의 생성 시간을 어림합니다. 이 숫자는 이 PC(GTX 1650)에서 한 사람이 물었을 때의 것이라 다른 컴퓨터에서는 다르게 나옵니다. 여러 사람이 함께 물을 때는 실습 2에서 잽니다.

코드 설명

  • "stream": True 로 보내면 Ollama 는 답을 몇 글자씩 한 줄에 JSON 하나로 나눠 보냅니다. 첫 조각이 온 시각이 TTFT 이고, 마지막 줄(done)에 Ollama 가 직접 잰 입력 토큰 수(prompt_eval_count), 출력 토큰 수(eval_count), 앞 요청에서 다시 쓴 입력 토큰 수(prompt_eval_cached_count)가 옵니다. TPOT 은 (지연 − TTFT) ÷ (출력 토큰 − 1) 로 구했습니다.
  • 2단계의 첫 요청은 모델을 GPU 에 올리는 데 3.13초가 들어 TTFT 가 3.83초였고, 둘째부터는 TTFT 0.03~0.04초, 지연 0.38~0.39초로 열 배 넘게 빨랐습니다. 같은 답(18토큰)인데 이만큼 차이가 나므로, 벤치마크는 몸풀기 요청을 뺀 뒤에 잽니다. TPOT 은 세 번 모두 20~22ms 로, 3B 모델이 이 PC 에서 1초에 45~50토큰쯤 씁니다.
  • 3단계에서 조항을 1개, 10개, 30개 붙이자 입력이 125, 691, 1,675토큰으로 늘고 TTFT 가 0.40, 2.72, 6.99초로 거의 비례해 늘었습니다. 프리필이 1초에 240토큰쯤 처리한 셈입니다. TPOT 은 21ms 로 그대로였습니다. 캐시토큰 35~40개는 앞 요청과 같은 시스템 프롬프트의 앞부분을 다시 쓴 몫입니다.
  • 4단계에서 출력을 20, 80, 240토큰으로 늘리자 지연이 0.83, 1.64, 4.92초로 늘었고 TPOT 은 20~21ms 로 같았습니다. 같은 프롬프트를 되풀이했으므로 둘째부터는 입력을 캐시에서 읽어 TTFT 가 0.03초였습니다. 이 PC 의 3B 에서는 입력 토큰 하나보다 출력 토큰 하나가 다섯 배쯤 비쌉니다(프리필 약 4ms, 디코드 약 20ms).
← 이전 절16장 · 실습 지도다음 절 →16장 · 실습 2 — 동시 요청을 늘려 가며 재기