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

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

프로젝트 1 — 요약 API 서비스

솔빛생활의 직원들은 회의록, 공지, 고객 리뷰 묶음, 장애 보고를 매일 읽습니다. 글을 보내면 정한 문장 수로 요약해 주는 사내 API 를 만들어 보세요. 지금까지는 노트북에서 "요약해 주세요"라고 물어 보고 끝났다면, 이번에는 여러 직원과 다른 프로그램이 함께 부르는 서비스로 만듭니다. 문서는 모두 지어낸 연습용 데이터입니다.

파일생김새
summary_docs.jsonl문서 12건. 줄마다 번호, 종류(회의록, 공지, 리뷰 묶음, 장애 보고), 제목, 본문, 핵심
핵심요약에 들어가야 할 사실 셋. 사실마다 그 가운데 하나만 있으면 되는 낱말을 세로줄 | 로 이어 적었다

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

  1. 노트북 방식(그냥 "3문장으로 요약해 주세요")으로 기준선을 재어, 핵심 사실 수, 3문장을 지킨 수, 평균 시간을 적습니다.
  2. POST /summarize 는 본문(50~3,000자)과 문장수(1~5, 기본 3)를 받아 요약, 문장수, 모델이 쓴 문장 수, 모델, 판, 캐시 여부, 걸린 초를 돌려줍니다.
  3. 모델 이름, Ollama 주소, 동시 처리 수, 시간 제한은 환경 변수로 받습니다.
  4. 서버가 켜질 때 모델을 데우고, /health 는 준비 상태와 설정을 알려 줍니다.
  5. 서버가 문장 수를 지키고, 같은 글은 캐시로 답하고, 붐비면 503, 늦으면 504 로 답합니다.
  6. 기준선과 API 를 같은 문서 12건으로 견줍니다.

단계별 힌트입니다.

  1. 채점은 핵심마다 any(w in summary for w in key.split("|")) 로 봅니다.
  2. API 는 파일(out_summary_api.py)로 쓰고, TestClient(app) 를 with 로 열어 lifespan 까지 시험합니다.
  3. 동시 처리 제한은 threading.BoundedSemaphore 의 acquire(blocking=False) 로, 503 의 Retry-After 는 HTTPException(..., headers={...}) 로 붙입니다.
  4. 503 과 504 는 TestClient 로는 만들기 어렵습니다. 서버를 따로 띄워 한꺼번에 보내고(13장 실습 4), 시간 제한을 1초로 줄인 서버를 하나 더 띄워 봅니다.
  5. Ollama 가 가끔 500 오류나 깨진 답을 냅니다(16장). 모델을 내렸다 다시 묻고, 다시 물은 수를 /health 에 적습니다.

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

실제 데이터로 해 보려면

요약 시험에는 원문과 사람이 쓴 요약이 짝지어진 말뭉치가 좋습니다. 자료마다 이용 약관과 신청 절차를 확인하세요.

자료구하는 곳내용
문서요약 텍스트AI Hub(aihub.or.kr)신문 기사, 사설, 법률 문서와 사람이 쓴 요약
요약 말뭉치국립국어원 모두의 말뭉치(kli.korean.go.kr)신문 기사의 요약. 이용 신청 뒤 내려받는다
우리 회사의 회의록과 공지사내 게시판사람 이름, 연락처를 지우고 쓴다. 외부로 보내지 않는 모델(이 장처럼 Ollama)로만 요약한다

코드의 흐름

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

기준선, API 파일, 약속 시험, 12건 견주기, 붐빔 시험의 다섯 상자를 화살표로 이은 요약 API 프로젝트의 코드 흐름도

주황 화살표가 실행 차례이고, 상자의 번호는 예시 풀이의 단계 번호와 같습니다. 1단계(청록)는 summary_docs.jsonl 의 문서를 노트북 방식으로 요약하고 score() 로 채점해 기준선을 잡습니다. 2단계(마젠타)는 lifespan 데우기와 /summarize 를 담은 API 파일 out_summary_api.py 를 쓰고, 3단계(다홍)는 TestClient 를 with 로 열어 응답 약속을 시험합니다. 4단계(빨강)는 문서 12건을 /summarize 로 요약해 기준선과 견주고, 5단계(짙은 청록)는 uvicorn 으로 서버를 띄워 붐빌 때의 503 과 시간 제한의 504 를 확인합니다.

결과 보기 (예시 풀이)

1단계. 문서 읽기와 노트북 방식의 기준선

Python
import json, re, time, urllib.error, urllib.request
from pathlib import Path
import numpy as np

docs = [json.loads(line) for line in Path("summary_docs.jsonl").read_text(encoding="utf-8").splitlines()]
print(len(docs), "건 ·", {k: sum(d["종류"] == k for d in docs) for k in ["회의록", "공지", "리뷰 묶음", "장애 보고"]})
print("본문 길이:", min(len(d["본문"]) for d in docs), "~", max(len(d["본문"]) for d in docs), "자 · 1번 핵심:", docs[0]["핵심"])

OLLAMA = "http://127.0.0.1:11434"
MODEL = "qwen2.5:3b"
BROKEN = re.compile(r"(.)\1{9,}")            # 같은 글자가 10번 넘게 이어지면 깨진 답(16차시)
retried = {"수": 0}


def post(path, body, timeout=120):
    req = urllib.request.Request(OLLAMA + path, data=json.dumps(body).encode(), headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return json.loads(r.read())


def ask(messages, num_predict=300):
    """Ollama 에 한 번 묻는다. 500 오류나 깨진 답이면 모델을 내렸다 다시 묻는다(16차시)"""
    body = {"model": MODEL, "messages": messages, "stream": False,
            "options": {"temperature": 0, "seed": 42, "num_predict": num_predict}}
    for _ in range(3):
        try:
            out = post("/api/chat", body)["message"]["content"].strip()
            if not BROKEN.search(out):
                return out
        except urllib.error.HTTPError:
            pass
        retried["수"] += 1
        post("/api/generate", {"model": MODEL, "keep_alive": 0})     # 모델을 내린다
        time.sleep(2)
    return out


def split_sentences(text):
    text = re.sub(r"\s+", " ", text).strip()
    return [s for s in re.split(r"(?<=[.!?])\s+", text) if s]


def score(doc, summary):
    """핵심 사실 셋 가운데 요약에 든 수"""
    return sum(any(w in summary for w in key.split("|")) for key in doc["핵심"])


ask([{"role": "user", "content": "안녕하세요"}], num_predict=5)      # 몸풀기: 모델을 GPU 에 올린다
base = []
for d in docs:                                                       # 노트북에서 하던 그대로 묻는다
    t = time.perf_counter()
    s = ask([{"role": "user", "content": "다음 글을 3문장으로 요약해 주세요.\n\n" + d["본문"]}])
    base.append({"번호": d["번호"], "요약": s, "핵심": score(d, s), "문장": len(split_sentences(s)), "초": time.perf_counter() - t})
print(f"기준선: 핵심 {sum(b['핵심'] for b in base)}/36 · 3문장을 지킴 {sum(b['문장'] == 3 for b in base)}/12 · "
      f"평균 {np.mean([b['초'] for b in base]):.2f}초 · 문장 수 {sorted(b['문장'] for b in base)} · 다시 물음 {retried['수']}")
print("1번 요약:", base[0]["요약"].replace("\n", " ")[:160])
출력
12 건 · {'회의록': 4, '공지': 3, '리뷰 묶음': 3, '장애 보고': 2}
본문 길이: 166 ~ 247 자 · 1번 핵심: ['빠른별택배', '쿠폰|3,000원|3000원', '1,240|38%|늘었|늘어']
기준선: 핵심 31/36 · 3문장을 지킴 5/12 · 평균 3.88초 · 문장 수 [1, 2, 3, 3, 3, 3, 3, 4, 4, 4, 5, 6] · 다시 물음 1
1번 요약: 11월 4일, 고객 Kosong dan Tugas Pengiriman (KTP) dan Tugas Pengiriman (TP) berdiskusi tentang strategi untuk mengatasi penundaan pengiriman. Selama bulan Oktober, K

2단계. API 파일 쓰기

Python
SUMMARY_API = r'''
"""솔빛생활 사내 문서 요약 API (17차시 프로젝트 1)"""
import json, os, re, threading, time, urllib.error, urllib.request
from contextlib import asynccontextmanager

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field

# 설정은 환경 변수에서 읽는다. 없으면 오른쪽의 기본값
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434")
MODEL = os.environ.get("SUMMARY_MODEL", "qwen2.5:3b")
MAX_BUSY = int(os.environ.get("SUMMARY_MAX_BUSY", "2"))       # 한꺼번에 처리할 요청 수
TIMEOUT = float(os.environ.get("SUMMARY_TIMEOUT", "60"))      # 모델을 기다릴 최대 초
VERSION = "summary-1.0"
SYSTEM = ("당신은 사내 문서를 요약하는 도우미입니다. 글에 있는 사실만 써서 정확히 {n}문장으로 요약하세요. "
          "날짜와 숫자는 글에 있는 그대로 쓰세요. 머리말이나 목록 없이 문장만 쓰세요.")
BROKEN = re.compile(r"(.)\1{9,}")

state = {"준비": False, "데우기초": None, "데우기실패": False, "다시물음": 0, "캐시적중": 0}
slots = threading.BoundedSemaphore(MAX_BUSY)
cache = {}


def post(path, body, timeout):
    req = urllib.request.Request(OLLAMA_URL + path, data=json.dumps(body).encode(), headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return json.loads(r.read())


def generate(text, n, timeout):
    """모델에 요약을 시킨다. 500 오류나 깨진 답이면 모델을 내렸다 두 번까지 다시 묻는다"""
    body = {"model": MODEL, "stream": False, "keep_alive": "30m",
            "messages": [{"role": "system", "content": SYSTEM.format(n=n)}, {"role": "user", "content": text}],
            "options": {"temperature": 0, "seed": 42, "num_predict": 100 * n}}
    for _ in range(3):
        try:
            out = post("/api/chat", body, timeout)["message"]["content"].strip()
            if not BROKEN.search(out):
                return out
        except urllib.error.HTTPError:
            pass
        state["다시물음"] += 1
        post("/api/generate", {"model": MODEL, "keep_alive": 0}, 30)
        time.sleep(2)
    raise RuntimeError("모델이 세 번 모두 답하지 못했습니다")


def split_sentences(text):
    text = re.sub(r"\s+", " ", text).strip()
    return [s for s in re.split(r"(?<=[.!?])\s+", text) if s]


@asynccontextmanager
async def lifespan(app):
    t = time.perf_counter()
    try:                                                    # 데우기: 모델을 GPU 에 올린다
        generate("서버를 켭니다. 모델을 올려 두려고 보내는 글입니다.", 1, 300)
    except (OSError, RuntimeError):                         # 데우기에 실패해도 서버는 뜬다. 첫 요청이 대신 늦을 뿐이다
        state["데우기실패"] = True
    state["데우기초"] = round(time.perf_counter() - t, 2)
    state["준비"] = True
    yield
    state["준비"] = False


app = FastAPI(title="사내 문서 요약 API", version=VERSION, lifespan=lifespan)


class SummaryIn(BaseModel):
    본문: str = Field(min_length=50, max_length=3000, description="요약할 글")
    문장수: int = Field(default=3, ge=1, le=5, description="요약의 문장 수")


class SummaryOut(BaseModel):
    요약: str
    문장수: int
    모델문장수: int
    모델: str
    판: str
    캐시: bool
    걸린초: float


@app.get("/health")
def health():
    return {**state, "모델": MODEL, "판": VERSION, "동시처리": MAX_BUSY, "시간제한": TIMEOUT}


@app.post("/summarize", response_model=SummaryOut)
def summarize(req: SummaryIn):
    t = time.perf_counter()
    key = (" ".join(req.본문.split()), req.문장수)          # 빈칸만 다른 글은 같은 글로 본다
    if key in cache:
        state["캐시적중"] += 1
        return SummaryOut(**cache[key], 캐시=True, 걸린초=round(time.perf_counter() - t, 3))
    if not slots.acquire(blocking=False):                   # 화구가 모두 차 있으면 바로 돌려보낸다
        raise HTTPException(503, "요청이 많아 지금은 받을 수 없습니다. 잠시 뒤 다시 보내 주세요.",
                            headers={"Retry-After": "5"})
    try:
        raw = generate(req.본문, req.문장수, TIMEOUT)
    except TimeoutError:
        raise HTTPException(504, f"모델이 {TIMEOUT:g}초 안에 답하지 못했습니다.")
    except (urllib.error.URLError, RuntimeError) as e:
        raise HTTPException(502, f"모델 서버 오류: {e}")
    finally:
        slots.release()
    sentences = split_sentences(raw)
    result = {"요약": " ".join(sentences[:req.문장수]), "문장수": min(len(sentences), req.문장수),
              "모델문장수": len(sentences), "모델": MODEL, "판": VERSION}
    if len(cache) >= 1000:                                  # 캐시가 차면 가장 오래된 것부터 지운다
        cache.pop(next(iter(cache)))
    cache[key] = result
    return SummaryOut(**result, 캐시=False, 걸린초=round(time.perf_counter() - t, 3))
'''
Path("out_summary_api.py").write_text(SUMMARY_API.lstrip(), encoding="utf-8")
print("out_summary_api.py ·", len(SUMMARY_API.splitlines()), "줄")
출력
out_summary_api.py · 113 줄

3단계. TestClient 로 약속 시험하기

Python
import sys
sys.path.insert(0, ".")                    # 작업 폴더의 out_summary_api.py 를 불러오려고
from fastapi.testclient import TestClient
import out_summary_api as api

with TestClient(api.app) as client:        # with 로 열면 lifespan 의 데우기가 먼저 돈다
    print("health:", client.get("/health").json())
    doc = docs[0]["본문"]
    first = client.post("/summarize", json={"본문": doc, "문장수": 2}).json()
    again = client.post("/summarize", json={"본문": doc.replace(" ", "  "), "문장수": 2}).json()   # 빈칸만 다른 글
    print("첫 요청:", {k: first[k] for k in ["문장수", "모델문장수", "캐시", "걸린초"]})
    print("같은 글:", {k: again[k] for k in ["문장수", "모델문장수", "캐시", "걸린초"]}, "· 요약 같음:", first["요약"] == again["요약"])
    print("짧은 글:", client.post("/summarize", json={"본문": "너무 짧아요"}).status_code,
          "· 문장수 9:", client.post("/summarize", json={"본문": doc, "문장수": 9}).status_code,
          "· 본문 없음:", client.post("/summarize", json={"문장수": 2}).status_code)
    print("요약:", first["요약"])
출력
health: {'준비': True, '데우기초': 0.78, '데우기실패': False, '다시물음': 0, '캐시적중': 0, '모델': 'qwen2.5:3b', '판': 'summary-1.0', '동시처리': 2, '시간제한': 60.0}
첫 요청: {'문장수': 2, '모델문장수': 6, '캐시': False, '걸린초': 13.446}
같은 글: {'문장수': 2, '모델문장수': 6, '캐시': True, '걸린초': 0.0} · 요약 같음: True
짧은 글: 422 · 문장수 9: 422 · 본문 없음: 422
요약: 11월 4일 고객지원팀과 물류팀은 배송 지연 대책을 논의했습니다. 10월 배송 지연 문의 건수는 1,240건으로, 9월보다 38% 증가했습니다.

4단계. 문서 12건을 API 로 요약해 기준선과 견주기

Python
with TestClient(api.app) as client:
    rows = []
    for d in docs:
        r = client.post("/summarize", json={"본문": d["본문"], "문장수": 3}).json()
        rows.append({"번호": d["번호"], "요약": r["요약"], "핵심": score(d, r["요약"]), "문장": r["문장수"],
                     "모델문장": r["모델문장수"], "초": r["걸린초"]})
    health = client.get("/health").json()

print(f"기준선: 핵심 {sum(b['핵심'] for b in base)}/36 · 3문장 {sum(b['문장'] == 3 for b in base)}/12 · 평균 {np.mean([b['초'] for b in base]):.2f}초")
print(f"API   : 핵심 {sum(r['핵심'] for r in rows)}/36 · 3문장 {sum(r['문장'] == 3 for r in rows)}/12 · 평균 {np.mean([r['초'] for r in rows]):.2f}초")
print("모델이 쓴 문장 수:", [r["모델문장"] for r in rows], "· 다시 물음:", health["다시물음"])
for r in rows:
    if r["핵심"] < 3:                                     # 핵심을 놓친 요약을 읽어 본다
        print(f"  {r['번호']}번 핵심 {r['핵심']}/3:", r["요약"][:170])
출력
기준선: 핵심 31/36 · 3문장 5/12 · 평균 3.88초
API   : 핵심 30/36 · 3문장 11/12 · 평균 3.21초
모델이 쓴 문장 수: [6, 4, 5, 4, 5, 5, 4, 4, 4, 2, 5, 3] · 다시 물음: 1
  1번 핵심 1/3: 11월 4일 고객지원팀과 물류팀이 배송 지연 대책을 논의했습니다. 10월 배송 지연 문의는 1,240건으로 9월보다 38% 증가했습니다. 지연은 주로 한결택배의 수도권 물류 센터 이전 때문이었습니다.
  3번 핵심 1/3: 11월 10일 고객지원팀과 개발팀은 상담 챗봇 도입을 검토했습니다. 현재 하루 평균 900건의 문의 중 60%가 반복적인 질문입니다. 개발팀은 사내 서버에 공개 모델을 올려 자주 묻는 질문에 먼저 답변하도록 제안했습니다.
  4번 핵심 2/3: 11월 12일 물류팀과 고객지원팀은 반품 절차를 고치기로 회의를 가졌습니다. 현재 고객이 반품을 신청하면 상담원이 직접 택배 회수를 접수하고, 이 과정이 평균 4일 걸립니다. 앞으로는 반품 신청 화면에서 자동으로 회수가 접수되게 바꾸어, 회수 기간을 2일로 줄일 계획입니다.
  11번 핵심 2/3: 11월 8일 오후 8시 10분부터 55분까지 결제 실패로 312건의 주문 중 287건이 실패했습니다. 원인은 결제 대행사의 인증서 변경에 따른 서버 설정 미변경 때문이었습니다. 개발팀이 설정을 수정하고 8시 55분에 복구했습니다.

5단계. 서버를 띄워 붐빔과 시간 제한 시험하기

Python
import os, subprocess
from concurrent.futures import ThreadPoolExecutor


def start(port, **env):
    """out_summary_api 를 uvicorn 으로 띄우고 /health 가 답할 때까지 기다린다(데우기 포함)"""
    proc = subprocess.Popen([sys.executable, "-m", "uvicorn", "out_summary_api:app", "--port", str(port), "--log-level", "warning"],
                            env=dict(os.environ, **env))
    for _ in range(240):
        if proc.poll() is not None:                       # 켜다가 멈췄으면 더 기다리지 않는다
            raise RuntimeError("서버를 켜지 못했습니다")
        try:
            urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=2)
            return proc
        except OSError:
            time.sleep(0.5)


def call(port, body):
    """POST /summarize 를 보내고 (상태 코드, 본문, Retry-After) 를 돌려준다"""
    req = urllib.request.Request(f"http://127.0.0.1:{port}/summarize", data=json.dumps(body, ensure_ascii=False).encode(),
                                 headers={"Content-Type": "application/json"})
    try:
        with urllib.request.urlopen(req, timeout=120) as r:
            return r.status, json.loads(r.read()), None
    except urllib.error.HTTPError as e:
        return e.code, json.loads(e.read()), e.headers.get("Retry-After")


server = start(8171, SUMMARY_MAX_BUSY="2")
bodies = [{"본문": d["본문"], "문장수": 2} for d in docs[4:8]]          # 서로 다른 글 넷(캐시에 없는 글)
with ThreadPoolExecutor(max_workers=4) as pool:                       # 넷을 한꺼번에 보낸다
    results = list(pool.map(lambda b: call(8171, b), bodies))
print("넷을 한꺼번에:", sorted(r[0] for r in results))
busy = [r for r in results if r[0] == 503][0]
print("503 의 본문과 Retry-After:", busy[1]["detail"], "·", busy[2])
time.sleep(int(busy[2]))
print("5초 뒤 다시:", call(8171, bodies[results.index(busy)])[0])
server.terminate(); server.wait(timeout=10)

slow = start(8172, SUMMARY_TIMEOUT="1")                               # 시간 제한을 1초로 줄인 서버
status, body, _ = call(8172, {"본문": docs[10]["본문"], "문장수": 3})
print("시간 제한 1초:", status, body["detail"])
slow.terminate(); slow.wait(timeout=10)
출력
넷을 한꺼번에: [200, 200, 503, 503]
503 의 본문과 Retry-After: 요청이 많아 지금은 받을 수 없습니다. 잠시 뒤 다시 보내 주세요. · 5
5초 뒤 다시: 200
시간 제한 1초: 504 모델이 1초 안에 답하지 못했습니다.

노트북 방식으로 "3문장으로 요약해 주세요"라고만 물었을 때 3문장을 지킨 요약은 12건 가운데 5건이었습니다. 1문장부터 6문장까지 제각각이었고, 1번 요약은 첫 마디 뒤로 인도네시아어가 섞여 나왔습니다. 작은 모델에 지시를 짧게 주면 이런 일이 생깁니다. API 는 시스템 프롬프트로 지시를 분명히 하고 서버가 문장 수를 잘랐습니다. 그래서 3문장을 지킨 요약이 11건이 되었고, 나머지 1건(10번)은 모델이 2문장만 써서 더 채울 수 없었습니다. 그래도 모델이 쓴 문장 수는 2~6개로, 지시만으로는 약속이 지켜지지 않았습니다. 대가도 있습니다. 핵심 사실은 기준선 31개에서 30개로 하나 줄었습니다. 1번은 빠른별택배로 옮긴다는 결정과 쿠폰이, 3번은 시험판과 90% 기준이 넷째 문장 뒤에 있다가 잘려 나갔습니다. 앞의 날짜와 배경을 먼저 쓰는 모델의 습관 때문입니다. 결정을 앞에 쓰라고 프롬프트를 고치거나 문장 수를 늘리는 것이 다음에 해 볼 일입니다. 약속 시험은 모두 약속대로 나왔습니다. 짧은 글, 문장수 9, 본문 없음은 422 였습니다. 빈칸만 다른 같은 글은 캐시가 0초에 같은 요약을 돌려주었습니다. 첫 요청은 모델이 6문장을 쓰느라 13.4초가 걸렸습니다. 실제 서버에 서로 다른 글 넷을 한꺼번에 보내자 둘은 200, 둘은 503 과 Retry-After: 5 를 받았습니다. 5초 뒤 다시 보낸 요청은 200 이었습니다. 시간 제한을 1초로 줄인 서버는 504 로 답했습니다. 이번 실행에서 Ollama 의 500 오류나 깨진 답 때문에 다시 물은 것은 기준선과 API 에서 한 번씩입니다.

이 프로젝트가 뜻하는 것

이 프로젝트는 회사 안에 "문서 요약 창구"를 차리는 일과 같습니다. 먼저 창구 없이 직원에게 그냥 부탁했을 때의 결과를 적어 기준으로 삼습니다(1단계). 창구의 응대 규칙서를 씁니다(2단계). 모의 손님을 보내 창구가 규칙대로 응대하는지 시험하고(3단계), 같은 문서 12건을 창구로 요약해 기준과 견줍니다(4단계). 끝으로 실제로 문을 열어 손님 넷이 한꺼번에 올 때와 일이 너무 오래 걸릴 때의 응대를 시험합니다(5단계). 여기서 창구가 요약 API(Application Programming Interface, 프로그램끼리 부르는 약속), 규칙서가 out_summary_api.py, 직원 둘이 동시 처리 수 2, 같은 부탁의 답을 적어 둔 메모가 캐시, "5초 뒤에 다시 오세요"라는 안내가 503 과 Retry-After, "기다리다 포기했습니다"라는 안내가 504 입니다. 사람 창구와 다른 점은, 이 API 는 붐빌 때 손님을 줄 세우지 않고 바로 돌려보낸다는 것입니다.

단계출력뜻
1. 문서12 건 · 회의록 4, 공지 3, 리뷰 묶음 3, 장애 보고 2 · 본문 166~247자지어낸 사내 문서 12건을 읽었습니다. 문서마다 요약에 들어가야 할 핵심 사실이 셋이라 모두 36개입니다
1. 기준선핵심 31/36 · 3문장을 지킴 5/12 · 평균 3.88초 · 다시 물음 1그냥 "3문장으로 요약해 주세요"라고 하면 핵심은 대부분 담지만, 3문장을 지킨 것은 12건 가운데 5건뿐입니다
1. 문장 수[1, 2, 3, 3, 3, 3, 3, 4, 4, 4, 5, 6]같은 부탁에도 1문장부터 6문장까지 제각각이었습니다
1. 1번 요약첫 마디 뒤로 인도네시아어가 섞임작은 모델에 지시를 짧게 주면 엉뚱한 언어로 쓰는 일도 생깁니다
2. API 파일out_summary_api.py · 113 줄요약 API 를 파일로 썼습니다. 3단계부터 이 파일을 불러 씁니다
3. health'준비': True, '데우기초': 0.78, … '동시처리': 2, '시간제한': 60.0서버가 켜질 때 모델을 데워 준비를 마쳤고, 환경 변수로 받은 설정을 알려 줍니다
3. 첫 요청문장수 2 · 모델문장수 6 · 걸린초 13.446모델은 6문장을 썼고, 서버가 약속한 2문장으로 잘라 돌려주었습니다. 6문장을 쓰느라 13초쯤 걸렸습니다
3. 같은 글캐시 True · 걸린초 0.0 · 요약 같음 True빈칸만 다른 같은 글은 모델에 묻지 않고 메모(캐시)에서 바로 같은 요약을 꺼냈습니다
3. 잘못된 입력짧은 글 422 · 문장수 9 422 · 본문 없음 422422 는 "요청이 약속한 꼴과 다르다"는 상태 코드입니다. 약속 밖의 요청은 모델까지 가지 않고 막혔습니다
4. 견주기기준선 핵심 31/36 · 3문장 5/12 · 3.88초 → API 핵심 30/36 · 3문장 11/12 · 3.21초API 는 3문장을 거의 다 지켰고 조금 빨랐습니다. 대신 핵심 사실은 하나 줄었습니다
4. 모델이 쓴 문장 수[6, 4, 5, 4, 5, 5, 4, 4, 4, 2, 5, 3]지시를 분명히 해도 모델이 정확히 3문장을 쓴 것은 한 건뿐입니다. 2문장만 쓴 10번은 서버가 더 채울 수 없었습니다
4. 핵심을 놓친 요약1번 1/3 · 3번 1/3 · 4번 2/3 · 11번 2/3앞에 날짜와 배경을 먼저 쓰다 보니, 뒤쪽 문장에 있던 결정이 잘려 나간 경우입니다
5. 넷을 한꺼번에[200, 200, 503, 503]직원이 둘이라 둘은 답을 받고(200), 둘은 "붐빈다"는 503 을 받았습니다
5. Retry-After5 → 5초 뒤 다시: 200503 에 붙은 안내대로 5초 기다렸다 다시 보내자 답을 받았습니다
5. 시간 제한 1초504 모델이 1초 안에 답하지 못했습니다.기다릴 시간을 1초로 줄인 서버는 모델을 끝까지 기다리지 않고 504 로 알려 주었습니다

한 줄로 줄이면, 노트북에서 한 번 물어 보던 요약을 입력 범위, 문장 수, 캐시, 붐빔과 시간 초과까지 약속대로 답하는 서비스로 바꾼 프로젝트입니다. 그 대가로 문장 수를 자르다 핵심 사실 하나를 잃었으므로, 결정을 앞에 쓰라고 프롬프트를 고치는 것이 다음 할 일입니다. 핵심 사실은 낱말이 들어 있는지로만 센 것이고 문서도 12건뿐이라, 31과 30의 차이를 요약 품질의 큰 차이로 읽지는 않습니다. 이 API 는 프로젝트 3에서 분류 API 와 함께 배포 묶음으로 들어갑니다.

← 이전 절17장 · 평가표와 발표다음 절 →17장 · 프로젝트 2 — 문의 분류 API 서비스