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

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

실습 1 — 작은 워크플로 실행기 만들기

Airflow 를 이 PC 에서 돌릴 수 없으니, DAG 의 핵심(순서, 다시 시도, 건너뛰기, 기록)만 담은 실행기를 파이썬으로 만듭니다. 실행기는 out_flow.py 파일로 저장해 실습 3, 6과 프로젝트가 불러 씁니다. 하늘숲 도서관 안내 문서(library/day0/)는 지어낸 연습용 데이터입니다.

1단계. 작업 순서 정하기

Python
from graphlib import CycleError, TopologicalSorter

# 밤마다 도서관 안내 문서로 색인을 만드는 작업들: {작업: [먼저 끝나야 하는 작업]}
graph = {"문서 읽기": [], "검사": ["문서 읽기"], "조각 나누기": ["검사"],
         "임베딩": ["조각 나누기"], "교체": ["임베딩"], "보고": ["조각 나누기"]}
# static_order(): 앞 작업이 늘 먼저 오도록 줄 세운다
print("차례:", list(TopologicalSorter(graph).static_order()))

# 실수로 문서 읽기가 교체 뒤에 오게 적으면 되돌아오는 길(순환)이 생긴다
bad = dict(graph, **{"문서 읽기": ["교체"]})
try:
    list(TopologicalSorter(bad).static_order())
except CycleError as e:
    # 결과: 순환을 이루는 작업들
    print("순환:", e.args[1])
출력
차례: ['문서 읽기', '검사', '조각 나누기', '임베딩', '보고', '교체']
순환: ['문서 읽기', '검사', '조각 나누기', '임베딩', '교체', '문서 읽기']

2단계. 실행기를 파일로 만들기

Python
from pathlib import Path

# 실습 3, 6과 프로젝트가 불러 쓸 실행기를 파일로 만든다
Path("out_flow.py").write_text('''"""작은 워크플로 실행기(19장). Airflow 의 DAG 실행을 흉내 낸다."""
import time
from graphlib import TopologicalSorter


class Stop(Exception):
    """멈춤: 고장이 아니라 '더 갈 필요가 없다'는 신호. 다시 시도하지 않고 뒤 작업을 건너뛴다."""


def run_flow(tasks, retries=1, wait=0.0, log=print):
    """tasks: {이름: (함수, [앞 작업 이름, ...])}. 함수는 ctx(앞 작업들이 돌려준 값을 이름으로 담은 사전)를 받는다.
    돌려주는 값: (작업마다의 기록 목록, ctx)"""
    order = list(TopologicalSorter({n: deps for n, (f, deps) in tasks.items()}).static_order())
    ctx, state, records = {}, {}, []
    for name in order:
        func, deps = tasks[name]
        if any(state[d] != "성공" for d in deps):            # 앞 작업이 하나라도 성공하지 않았으면 건너뛴다
            state[name] = "건너뜀"
            records.append({"작업": name, "상태": "건너뜀", "시도": 0, "초": 0.0, "메모": ""})
            log(f"[건너뜀] {name}")
            continue
        for attempt in range(1, retries + 2):                # 처음 한 번 + 다시 시도 retries 번
            start = time.perf_counter()
            try:
                ctx[name] = func(ctx)
                state[name], memo = "성공", ""
            except Stop as e:
                state[name], memo = "멈춤", str(e)
            except Exception as e:
                state[name], memo = "실패", f"{type(e).__name__}: {e}"
            sec = round(time.perf_counter() - start, 2)
            log(f"[{state[name]}] {name} ({attempt}번째 시도)" + (f" · {memo}" if memo else ""))
            if state[name] != "실패" or attempt == retries + 1:
                break
            time.sleep(wait)
        records.append({"작업": name, "상태": state[name], "시도": attempt, "초": sec, "메모": memo})
    return records, ctx
''', encoding="utf-8")

import sys
sys.path.insert(0, ".")                        # 작업 폴더(data)에 만든 out_flow.py 를 찾게 한다
from out_flow import Stop, run_flow
# 결과: 불러온 함수 이름
print(run_flow.__name__, Stop.__name__)
출력
run_flow Stop

3단계. 정상 흐름 돌리기

Python
import pandas as pd

# 작업 함수는 모두 ctx 하나를 받는다. 앞 작업의 결과는 ctx["앞 작업 이름"] 으로 꺼낸다
def read_docs(ctx):
    files = sorted(Path("library/day0").glob("*.txt"))
    return {f.stem: f.read_text(encoding="utf-8") for f in files}


def check(ctx):
    docs = ctx["문서 읽기"]
    empty = [n for n, t in docs.items() if not t.strip()]
    if empty:                                  # 빈 문서가 있으면 고장이 아니라 '멈춤'
        raise Stop(f"빈 문서 {empty}")
    return len(docs)


def split(ctx):
    # 문서를 빈 줄로 나누고, 첫 문단(제목)을 뺀 문단 하나를 조각 하나로 본다
    return [p for t in ctx["문서 읽기"].values() for p in t.split("\n\n")[1:] if p.strip()]


def report(ctx):
    return f"문서 {ctx['검사']}개, 조각 {len(ctx['조각 나누기'])}개"


tasks = {"문서 읽기": (read_docs, []), "검사": (check, ["문서 읽기"]),
         "조각 나누기": (split, ["검사"]), "보고": (report, ["조각 나누기"])}
records, ctx = run_flow(tasks)
# 결과: 작업마다의 상태와 시도 횟수, 보고 작업이 돌려준 글
print(pd.DataFrame(records)[["작업", "상태", "시도"]].to_string(index=False))
print(ctx["보고"])
출력
[성공] 문서 읽기 (1번째 시도)
[성공] 검사 (1번째 시도)
[성공] 조각 나누기 (1번째 시도)
[성공] 보고 (1번째 시도)
    작업 상태  시도
 문서 읽기 성공   1
    검사 성공   1
조각 나누기 성공   1
    보고 성공   1
문서 10개, 조각 29개

4단계. 실패하면 다시 시도하고, 멈추면 건너뛰기

Python
# 공유 폴더가 첫 번째에만 끊기는 상황을 흉내 낸다(씨앗 대신 횟수로 정해 늘 같게)
calls = {"수": 0}


def flaky_read(ctx):
    calls["수"] += 1
    if calls["수"] == 1:
        raise ConnectionError("공유 폴더 연결이 끊겼습니다")
    return read_docs(ctx)


# 다시 시도 1번(retries=1): 첫 시도가 실패해도 두 번째에 성공한다
records, _ = run_flow(dict(tasks, **{"문서 읽기": (flaky_read, [])}), retries=1)
print(pd.DataFrame(records)[["작업", "상태", "시도"]].to_string(index=False))


# 빈 파일이 섞인 밤(day3)의 문서: 검사가 '멈춤'을 내고 뒤 작업은 건너뛴다
def read_day3(ctx):
    return {f.stem: f.read_text(encoding="utf-8") for f in sorted(Path("library/day3").glob("*.txt"))}


records, _ = run_flow(dict(tasks, **{"문서 읽기": (read_day3, [])}))
# 결과: 검사는 멈춤(다시 시도하지 않는다), 뒤 두 작업은 건너뜀
print(pd.DataFrame(records)[["작업", "상태", "시도", "메모"]].to_string(index=False))
출력
[실패] 문서 읽기 (1번째 시도) · ConnectionError: 공유 폴더 연결이 끊겼습니다
[성공] 문서 읽기 (2번째 시도)
[성공] 검사 (1번째 시도)
[성공] 조각 나누기 (1번째 시도)
[성공] 보고 (1번째 시도)
    작업 상태  시도
 문서 읽기 성공   2
    검사 성공   1
조각 나누기 성공   1
    보고 성공   1
[성공] 문서 읽기 (1번째 시도)
[멈춤] 검사 (1번째 시도) · 빈 문서 ['분실물']
[건너뜀] 조각 나누기
[건너뜀] 보고
    작업  상태  시도           메모
 문서 읽기  성공   1
    검사  멈춤   1 빈 문서 ['분실물']
조각 나누기 건너뜀   0
    보고 건너뜀   0

이 실습이 뜻하는 것

이 실습은 김장하는 날의 순서표와 반장을 만드는 것과 같습니다. 할 일과 "이것이 끝나야 저것을 한다"를 적어 줄을 세우고, 적다가 처음으로 돌아오는 고리가 생기면 알아챕니다(1단계). 순서표대로 일을 시키는 반장의 규칙을 공책에 적어 두어 다른 날에도 부릅니다(2단계). 평소처럼 네 가지 일을 차례로 시켜 보고(3단계), 수도가 한 번 끊기면 다시 틀어 보고, 배추가 상했으면 그 자리에서 멈추고 뒤 일은 하지 않습니다(4단계). 여기서 순서표가 DAG(Directed Acyclic Graph, 방향이 있고 고리가 없는 그래프), 반장이 run_flow(), 공책이 out_flow.py, 다시 틀어 보기가 다시 시도(retries), 그 자리에서 멈추기가 Stop 입니다.

단계출력뜻
1. 차례['문서 읽기', '검사', '조각 나누기', '임베딩', '보고', '교체']앞뒤 관계를 지키는 차례입니다. 보고와 교체는 서로 기다리지 않으므로 어느 쪽이 먼저 와도 됩니다
1. 순환[..., '교체', '문서 읽기']문서 읽기가 교체를 기다리게 잘못 적으면 처음으로 돌아오는 고리가 생겨, 차례를 정할 수 없다고 알려 줍니다
2. 파일로run_flow Stop실행기를 out_flow.py 로 저장하고 다시 불러왔습니다. 실습 3, 6과 프로젝트가 이 파일을 씁니다
3. 정상 흐름네 작업 모두 성공, 시도 1문제가 없는 밤에는 모든 작업이 한 번에 끝납니다
3. 보고문서 10개, 조각 29개day0 의 문서 10개가 문단 조각 29개로 나뉘었습니다
4. 다시 시도문서 읽기 성공, 시도 2첫 시도의 연결 끊김(ConnectionError)을 두 번째 시도가 넘겼습니다
4. 멈춤검사 멈춤 · 빈 문서 ['분실물']day3 에는 빈 파일이 있어 검사가 멈췄고, 다시 해도 같으므로 다시 시도하지 않았습니다(시도 1)
4. 건너뜀조각 나누기, 보고 건너뜀, 시도 0앞 작업이 성공하지 않았으니 뒤 작업은 아예 돌지 않았습니다

한 줄로 줄이면, Airflow 가 해 주는 일(차례, 다시 시도, 멈추면 건너뛰기, 기록)을 파이썬 몇십 줄로 흉내 낸 실습입니다. 일시적인 고장은 다시 시도로, 나쁜 데이터는 멈춤으로 나눠 다룬다는 것이 핵심입니다. 이 실행기는 실습 3의 색인 갱신, 실습 6의 학습과 배포 파이프라인, 프로젝트의 밤 작업이 그대로 불러 씁니다. 정해진 시각에 저절로 도는 일정 기능은 없으므로, 그것은 프로젝트 6단계의 Airflow 안내 코드에서 봅니다.

코드 설명

  • TopologicalSorter(graph).static_order() 는 앞뒤 관계를 지키는 차례를 돌려주고, 순환이 있으면 CycleError 를 냅니다.
  • run_flow() 는 차례대로 작업을 돌리며, 앞 작업이 "성공"이 아니면 뒤 작업을 "건너뜀"으로 둡니다. Airflow 의 기본 규칙(앞이 모두 성공해야 돈다)과 같습니다.
  • 작업이 예외를 내면 retries 만큼 다시 시도합니다. 일시적인 끊김은 다시 시도로 넘어갑니다.
  • Stop 은 고장이 아닌 "멈춤"입니다. 데이터가 나쁘다는 판단은 다시 시도해도 같으므로 바로 멈추고 뒤를 건너뜁니다. Airflow 의 단락 작업이 같은 일을 합니다.
  • 작업은 결과를 돌려주고, 실행기가 ctx 에 이름으로 담아 뒤 작업에 넘깁니다.
← 이전 절19장 · 실습 지도다음 절 →19장 · 실습 2 — 바뀐 문서만 찾기