LLM 엔지니어링19장 · MLOps 와 LLMOps, 워크플로 자동화20 / 23← 이전목차다음 →MetaContents

19장. MLOps 와 LLMOps, 워크플로 자동화

실습 6 — 학습과 배포 파이프라인

새 문의 묶음이 오면 "데이터 검사 → 학습 → 평가 → 게이트 → 배포"를 실습 1의 실행기로 저절로 돌립니다. 11월 묶음(inquiry_nov.csv, 지어낸 연습용 데이터)을 넣어 게이트가 어떻게 판단하는지 보고, 결과를 읽어 원인을 찾습니다. 배포는 레지스트리의 별칭을 옮기고, 서비스가 읽을 모델 파일을 out_serving/ 에 씁니다. 실습 1, 4, 5를 먼저 실행해야 합니다.

1단계. 준비(실습 5와 같은 준비)

Python
import json
import shutil
import sys
from pathlib import Path

import joblib
import mlflow
import mlflow.sklearn
import pandas as pd
from mlflow import MlflowClient
from sentence_transformers import SentenceTransformer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import f1_score, recall_score

sys.path.insert(0, ".")                        # 실습 1의 out_flow.py
from out_flow import Stop, run_flow

mlflow.set_tracking_uri("sqlite:///out_mlflow.db")
mlflow.set_experiment("문의분류")
client = MlflowClient()
emb = SentenceTransformer("intfloat/multilingual-e5-small", device="cpu")
shutil.rmtree("out_serving", ignore_errors=True)   # 다시 돌려도 같게: 지난 배포 파일을 지운다


def embed(texts):
    return emb.encode(["query: " + t for t in texts], normalize_embeddings=True)


test = pd.read_csv("inquiries.csv")
E_test = embed(test["문의"])
LABELS = sorted(test["분류"].unique())
mv = client.get_model_version_by_alias("inquiry-clf", "champion")
print("시작할 때 챔피언 버전", mv.version, "· 학습에 넣은 묶음", client.get_run(mv.run_id).data.params["batches"])
출력
시작할 때 챔피언 버전 2 · 학습에 넣은 묶음 inquiry_oct.csv

2단계. 파이프라인 만들기

Python
def pipeline(batch_file):
    """새 묶음 하나로 파이프라인을 돌린다. 학습 데이터 = 기본 + 챔피언이 받아들인 묶음 + 새 묶음"""
    def check(ctx):
        df = pd.read_csv(batch_file)
        problems = []
        if list(df.columns) != ["문의", "분류"]:
            problems.append(f"열 이름 {list(df.columns)}")
        if df["문의"].isna().any() or (df["문의"].str.strip() == "").any():
            problems.append("빈 문의")
        if not set(df["분류"]).issubset(LABELS):
            problems.append(f"모르는 분류 {sorted(set(df['분류']) - set(LABELS))}")
        if df["분류"].value_counts().min() < 5:
            problems.append("분류 하나가 5개 미만")
        if problems:
            raise Stop("; ".join(problems))
        return df

    def train(ctx):
        champ = client.get_model_version_by_alias("inquiry-clf", "champion")
        params = client.get_run(champ.run_id).data.params
        batches = params["batches"].split(",") + [batch_file]      # 챔피언이 받아들인 묶음을 이어 받는다
        data = pd.concat([pd.read_csv("inquiry_train.csv")] + [pd.read_csv(b) for b in batches], ignore_index=True)
        model = LogisticRegression(C=float(params["C"]), max_iter=1000).fit(embed(data["문의"]), data["분류"])
        return {"모델": model, "C": float(params["C"]), "묶음": ",".join(batches), "행": len(data), "챔피언": champ.version}

    def evaluate(ctx):
        t = ctx["학습"]
        pred = t["모델"].predict(E_test)
        champ_pred = mlflow.sklearn.load_model("models:/inquiry-clf@champion").predict(E_test)
        result = {"accuracy": (pred == test["분류"]).mean(), "macro_f1": f1_score(test["분류"], pred, average="macro"),
                  "champion_accuracy": (champ_pred == test["분류"]).mean(),
                  "min_recall": recall_score(test["분류"], pred, labels=LABELS, average=None).min()}
        # 후보도 실행으로 기록하고 버전으로 등록한다. 게이트에서 떨어져도 기록은 남긴다
        with mlflow.start_run(run_name=f"pipeline-{Path(batch_file).stem}"):
            mlflow.log_params({"features": "e5", "C": t["C"], "train_rows": t["행"], "batches": t["묶음"]})
            mlflow.log_metrics(result)
            info = mlflow.sklearn.log_model(t["모델"], name="model", registered_model_name="inquiry-clf",
                                            pip_requirements=["scikit-learn==1.9.1"])
        return dict(result, version=info.registered_model_version)

    def gate(ctx):
        r = ctx["평가"]
        reasons = []
        if r["accuracy"] < r["champion_accuracy"]:
            reasons.append(f"정확도 {r['accuracy']:.3f} < 챔피언 {r['champion_accuracy']:.3f}")
        if r["min_recall"] < 0.25:
            reasons.append(f"가장 낮은 재현율 {r['min_recall']:.3f} < 0.25")
        client.set_model_version_tag("inquiry-clf", str(r["version"]), "gate", "탈락" if reasons else "통과")
        if reasons:
            raise Stop("; ".join(reasons))
        return "통과"

    def deploy(ctx):
        r, t = ctx["평가"], ctx["학습"]
        client.set_registered_model_alias("inquiry-clf", "previous", t["챔피언"])
        client.set_registered_model_alias("inquiry-clf", "champion", r["version"])
        # 서비스가 읽을 파일: 모델과 판 정보. 새 파일을 다 쓴 뒤 이름을 바꾼다(실습 3과 같은 방법)
        Path("out_serving").mkdir(exist_ok=True)
        joblib.dump(t["모델"], "out_serving/model.tmp")
        Path("out_serving/model.tmp").replace("out_serving/inquiry_model.joblib")
        release = {"version": r["version"], "accuracy": round(r["accuracy"], 3), "batches": t["묶음"],
                   "embedding": "intfloat/multilingual-e5-small"}
        Path("out_serving/release.json").write_text(json.dumps(release, ensure_ascii=False), encoding="utf-8")
        return release

    tasks = {"검사": (check, []), "학습": (train, ["검사"]), "평가": (evaluate, ["학습"]),
             "게이트": (gate, ["평가"]), "배포": (deploy, ["게이트"])}
    return run_flow(tasks, log=lambda m: print("  " + m))


print("파이프라인 준비 끝")
출력
파이프라인 준비 끝

3단계. 11월 묶음 넣기

Python
records, ctx = pipeline("inquiry_nov.csv")
r = ctx.get("평가", {})
# 결과: 후보와 챔피언의 정확도, 그리고 지금 챔피언
print(f"후보 버전 {r.get('version')} · 정확도 {r.get('accuracy', 0):.3f} · 챔피언 {r.get('champion_accuracy', 0):.3f}")
print("지금 챔피언 버전", client.get_model_version_by_alias("inquiry-clf", "champion").version)
출력
  [성공] 검사 (1번째 시도)
  [성공] 학습 (1번째 시도)
  [성공] 평가 (1번째 시도)
  [멈춤] 게이트 (1번째 시도) · 정확도 0.700 < 챔피언 0.775
  [건너뜀] 배포
후보 버전 3 · 정확도 0.700 · 챔피언 0.775
지금 챔피언 버전 2

4단계. 원인 찾고 고쳐서 다시 넣기

Python
champion = mlflow.sklearn.load_model("models:/inquiry-clf@champion")
nov = pd.read_csv("inquiry_nov.csv")
# 챔피언의 예측과 11월 묶음의 라벨을 엇갈려 센다. 대각선에서 벗어난 칸이 몰려 있으면 라벨이 수상하다
cross = pd.crosstab(nov["분류"].rename("11월 라벨"), pd.Series(champion.predict(embed(nov["문의"])), name="챔피언 예측"))
print(cross)
print(f"챔피언과 라벨이 같은 비율: {(champion.predict(embed(nov['문의'])) == nov['분류']).mean():.3f}")
출력
챔피언 예측  결제  교환반품  기타  배송  회원
11월 라벨
결제       0     6   0   2   0
교환반품     0     0   0   7   1
기타       0     1   0   0   7
배송       1     0   5   0   2
회원       7     0   0   0   1
챔피언과 라벨이 같은 비율: 0.025
Python
# 위 표를 보고 라벨 도구가 분류를 한 칸씩 밀어 적었다고 판단했다. 거꾸로 돌려 바로잡은 파일을 따로 쓴다
fix = {"교환반품": "배송", "결제": "교환반품", "회원": "결제", "기타": "회원", "배송": "기타"}
nov.assign(분류=nov["분류"].map(fix)).to_csv("out_inquiry_nov_fixed.csv", index=False, encoding="utf-8-sig")
records, ctx = pipeline("out_inquiry_nov_fixed.csv")
# 결과: 배포한 판 정보와 별칭
print(json.loads(Path("out_serving/release.json").read_text(encoding="utf-8")) if Path("out_serving/release.json").exists() else "배포 안 됨")
print("별칭:", client.get_registered_model("inquiry-clf").aliases)
versions = client.search_model_versions("name='inquiry-clf'")
print("게이트 꼬리표:", {v.version: v.tags.get("gate", "-") for v in sorted(versions, key=lambda v: int(v.version))})
출력
  [성공] 검사 (1번째 시도)
  [성공] 학습 (1번째 시도)
  [성공] 평가 (1번째 시도)
  [성공] 게이트 (1번째 시도)
  [성공] 배포 (1번째 시도)
{'version': 4, 'accuracy': 0.825, 'batches': 'inquiry_oct.csv,out_inquiry_nov_fixed.csv', 'embedding': 'intfloat/multilingual-e5-small'}
별칭: {'champion': 4, 'previous': 2}
게이트 꼬리표: {1: '-', 2: '-', 3: '탈락', 4: '통과'}

이 실습이 뜻하는 것

이 실습은 빵집이 새 재료로 빵을 바꿔 내놓는 절차와 같습니다. 지금 매장에 나가는 빵을 확인하고(1단계), 새 재료가 오면 "재료 검사 → 시험 굽기 → 맛 평가 → 출시 심사 → 매장 진열"을 차례로 하는 절차표를 만듭니다(2단계). 11월 재료를 넣었더니 출시 심사에서 떨어지고(3단계), 재료를 살펴보니 상자 이름표가 한 칸씩 밀려 붙어 있어서 바로잡아 다시 넣자 통과해 진열됩니다(4단계). 여기서 절차표가 파이프라인(run_flow() 에 넘긴 다섯 작업), 출시 심사가 게이트, 매장 진열이 별칭 이동과 out_serving/ 파일, 밀린 이름표가 11월 묶음의 밀린 라벨입니다. 비유와 다른 점은, 빵집의 재료 검사라면 이름표가 틀린 것도 눈에 띄겠지만, 여기의 데이터 검사는 열, 빈칸, 분류 이름, 분류별 개수만 보므로 그럴듯한 다른 분류로 바뀐 라벨은 통과한다는 것입니다.

단계출력뜻
1. 시작챔피언 버전 2 · 학습에 넣은 묶음 inquiry_oct.csv실습 5가 남긴 상태에서 시작합니다. 다음 학습은 10월 묶음 위에 새 묶음을 더합니다
2. 준비파이프라인 준비 끝검사, 학습, 평가, 게이트, 배포 다섯 작업을 묶었습니다
3. 11월 묶음검사·학습·평가 성공, 게이트 멈춤 정확도 0.700 < 챔피언 0.775데이터 검사는 통과했지만 후보가 챔피언보다 못해 게이트가 막았고, 배포는 건너뛰었습니다
3. 결과후보 버전 3 · 지금 챔피언 버전 2떨어진 후보도 버전 3으로 기록이 남았고, 서비스는 버전 2를 그대로 씁니다
4. 엇갈려 세기회원 라벨 7개를 챔피언은 결제로, 결제 라벨 6개를 교환반품으로라벨과 예측이 규칙적으로 한 칸씩 어긋나 있습니다. 문의가 이상한 것이 아니라 라벨이 밀린 것입니다
4. 같은 비율0.02511월 문의 40개 가운데 챔피언의 예측과 라벨이 같은 것은 하나뿐입니다
4. 고쳐서 다시다섯 작업 모두 성공 · version 4 · accuracy 0.825바로잡은 묶음으로 학습한 후보는 시험 문의 40개 가운데 33개를 맞혀 게이트를 넘고 배포되었습니다
4. 꼬리표{'champion': 4, 'previous': 2} · {3: '탈락', 4: '통과'}버전 4가 챔피언, 버전 2가 직전 판이 되었고, 후보마다 게이트를 통과했는지가 꼬리표로 남았습니다

한 줄로 줄이면, 새 데이터가 와도 사람이 손대지 않고 학습부터 배포까지 돌되, 나빠지는 모델은 게이트가 막는다는 것을 보인 실습입니다. 이번에 막힌 원인은 모델이 아니라 데이터의 라벨이었고, 그 원인은 사람이 표를 읽고 찾았습니다. 파이프라인은 "나빠졌다"는 것은 알려 주지만 "왜"까지 알려 주지는 않습니다. 0.825 도 시험 문의 40개에서의 점수입니다. 같은 짜임(검사, 평가, 게이트, 교체)을 RAG 색인에 쓴 것이 프로젝트입니다.

코드 설명

  • 파이프라인은 실습 1의 run_flow() 에 다섯 작업을 넘긴 것입니다. 검사나 게이트가 Stop 을 내면 뒤의 배포는 건너뜁니다.
  • 학습 작업은 챔피언을 만든 실행의 params 에서 C 와 받아들인 묶음(batches)을 읽습니다. 그래서 다음 판은 늘 지금 챔피언의 데이터 위에 쌓입니다.
  • 평가 작업은 후보를 등록하고, 게이트는 그 버전에 gate 꼬리표(통과·탈락)를 답니다. 떨어진 후보도 기록이 남아 나중에 원인을 볼 수 있습니다.
  • 데이터 검사는 열, 빈칸, 모르는 분류, 분류별 개수만 봅니다. 라벨이 그럴듯한 다른 분류로 바뀐 것은 지나가고, 평가와 게이트에서 드러납니다.
  • pd.crosstab 으로 챔피언의 예측과 새 라벨을 엇갈려 세면, 라벨이 규칙적으로 밀린 것이 한눈에 보입니다.
← 이전 절19장 · 실습 5 — 모델 레지스트리로 배포하고 되돌리기다음 절 →19장 · 프로젝트 1 — 매일 밤 도는 RAG 갱신