Skip to content
Published on

LLM Ops가 실제로 하는 일 — 재현, 오염, 체크포인트, 승격, 그리고 롤백

공유하기
Authors

들어가며 — "그 모델 어떻게 만들었죠"에 답하지 못하는 순간

석 달 전에 배포한 모델에서 문제가 발견됩니다. 어떤 데이터로 학습했는지, 어떤 설정이었는지, 그때의 평가 점수가 무엇이었는지 물어봅니다. 팀에서 나오는 답이 "아마 그 노트북에 있을 텐데요"라면, 그 조직에는 LLM Ops가 없는 것입니다.

LLM Ops를 도구 목록으로 배우면 이 상황을 못 막습니다. 실험 추적 도구를 붙였어도 데이터 스냅숏을 기록하지 않았다면 재현이 안 되고, 평가를 자동화했어도 평가 세트가 오염되어 있었다면 그 점수 전체가 무효입니다.

그래서 이 글은 도구가 아니라 책임으로 정리합니다. 각 절은 "무엇이 깨지는가"와 "그것을 막으려면 무엇을 기록해야 하는가"로 구성했습니다. 도구는 마지막에 카테고리별로만 붙입니다. 벤더 가격은 넣지 않았습니다. 금방 낡습니다.

재현성의 최소 단위 — 실행 명세

먼저 솔직하게 짚을 것이 있습니다. 비트 단위 재현은 대부분의 경우 불가능합니다. GPU 수가 바뀌면 리덕션 순서가 바뀌어 부동소수점 결과가 달라지고, 커널 자동 튜닝은 실행마다 다른 알고리즘을 고를 수 있으며, 드라이버 업데이트 하나로 결과가 흔들립니다. 결정론 플래그를 전부 켜면 재현되지만 속도가 크게 떨어져 사전학습에는 쓸 수 없습니다.

그래서 현실적인 목표는 "같은 비트"가 아니라 "같은 좌표에서 다시 출발할 수 있음" 입니다. 그 좌표를 실행 명세에 남깁니다.

# run_manifest.py — 학습 시작 시 랭크 0에서 한 번 호출합니다
import hashlib
import importlib.metadata as md
import json
import os
import platform
import subprocess
import time


def _sh(cmd: str) -> str:
    try:
        return subprocess.check_output(cmd, shell=True, text=True,
                                       stderr=subprocess.DEVNULL).strip()
    except Exception:
        return "unknown"


def build_manifest(config: dict, data_snapshot_id: str) -> dict:
    cfg_bytes = json.dumps(config, sort_keys=True).encode()
    return {
        "created_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
        "code": {
            "commit": _sh("git rev-parse HEAD"),
            "dirty": _sh("git status --porcelain") != "",
            "remote": _sh("git config --get remote.origin.url"),
        },
        "config": {
            "sha256": hashlib.sha256(cfg_bytes).hexdigest()[:16],
            "values": config,
        },
        "data": {
            "snapshot_id": data_snapshot_id,      # 불변 스냅숏 식별자
            "manifest_sha256": _sh(f"sha256sum data/manifests/{data_snapshot_id}.jsonl | cut -d' ' -f1"),
        },
        "packages": {
            p: md.version(p)
            for p in ("torch", "transformers", "trl", "peft", "accelerate",
                      "deepspeed", "datasets")
            if _safe_version(p)
        },
        "hardware": {
            "gpu": _sh("nvidia-smi --query-gpu=name --format=csv,noheader | head -1"),
            "gpu_count": int(os.environ.get("WORLD_SIZE", "1")),
            "driver": _sh("nvidia-smi --query-gpu=driver_version --format=csv,noheader | head -1"),
            "python": platform.python_version(),
        },
        "scheduler": {
            "slurm_job_id": os.environ.get("SLURM_JOB_ID"),
            "nodelist": os.environ.get("SLURM_JOB_NODELIST"),
        },
        "seeds": {"global": config.get("seed"), "data_order": config.get("data_seed")},
    }


def _safe_version(p: str) -> bool:
    try:
        md.version(p)
        return True
    except md.PackageNotFoundError:
        return False

시드에 대해 한 가지 덧붙입니다. 전역 시드 하나로는 부족합니다. 데이터 순서용 시드를 따로 두어야, 모델 초기화를 고정한 채 데이터 순서만 바꾸는 실험이 가능해집니다. 그리고 데이터 로더 워커는 각자 시드를 파생시키므로, 워커 수를 바꾸면 데이터 순서가 바뀝니다. 워커 수는 성능 손잡이가 아니라 실험 변수입니다.

데이터 파이프라인과 오염 관리

학습 데이터는 "폴더"가 아니라 불변 스냅숏이어야 합니다. 경로만 기록하고 그 경로의 내용이 나중에 바뀌면 실행 명세는 거짓말이 됩니다. 실무에서 쓰는 형태는 대체로 이렇습니다.

  • 원본은 객체 스토리지에 쓰기 한 번으로 올리고 절대 덮어쓰지 않습니다.
  • 학습이 참조하는 것은 파일 목록과 각 파일의 해시, 그리고 샘플 수를 담은 매니페스트입니다.
  • 매니페스트 자체에 버전을 붙이고, 실행 명세에는 매니페스트의 해시를 남깁니다.

오염은 이 위에서 관리합니다. 평가 세트의 문제나 정답이 학습 코퍼스에 들어가면 점수는 오르고 능력은 그대로입니다. 최소한의 방어는 두 가지입니다.

# 평가 세트와 학습 코퍼스의 n-그램 겹침을 러프하게 스캔합니다.
# 정밀한 도구가 아니라 "명백한 사고"를 잡는 1차 필터입니다.
from datasketch import MinHash, MinHashLSH   # pip install datasketch


def shingles(text: str, n: int = 13) -> set:
    toks = text.split()
    return {" ".join(toks[i:i + n]) for i in range(max(1, len(toks) - n + 1))}


def build_index(eval_docs, threshold: float = 0.8):
    lsh = MinHashLSH(threshold=threshold, num_perm=128)
    for i, doc in enumerate(eval_docs):
        m = MinHash(num_perm=128)
        for s in shingles(doc):
            m.update(s.encode())
        lsh.insert(f"eval-{i}", m)
    return lsh


def scan(train_docs, lsh) -> list:
    hits = []
    for j, doc in enumerate(train_docs):
        m = MinHash(num_perm=128)
        for s in shingles(doc):
            m.update(s.encode())
        found = lsh.query(m)
        if found:
            hits.append((j, found))
    return hits

두 번째 방어는 더 단순하고 더 강력합니다. 한 번도 학습 파이프라인에 넣지 않은 보류 세트를 따로 만들어 두는 것입니다. 이 세트는 별도 저장소에 두고, 학습 클러스터에서 접근할 수 없게 합니다. 오염 스캔은 놓칠 수 있지만, 애초에 파이프라인이 볼 수 없었던 데이터는 오염될 수 없습니다.

공개 모델 쪽에서 이 문제를 정면으로 다룬 사례가 참고가 됩니다. Allen AI의 Olmo 3은 체크포인트와 데이터셋, 의존성을 전부 공개해 제3자가 오염 여부를 직접 감사할 수 있게 했습니다. 내부 프로젝트에서 같은 수준을 할 수는 없어도, "감사 가능한가"를 설계 기준으로 삼는 것은 할 수 있습니다.

체크포인트 관리 — 주기와 보관은 서로 다른 문제입니다

두 질문을 섞으면 안 됩니다. "얼마나 자주 저장하는가"는 장애 대비이고, "얼마나 오래 보관하는가"는 감사와 비용입니다.

주기는 장애 간격에서 역산합니다. Young과 Daly의 1차 근사는 최적 주기가 저장 시간과 평균 장애 간격의 곱의 두 배에 제곱근을 씌운 값이라고 말합니다.

import math


def optimal_interval_min(save_minutes: float, mtbf_hours: float) -> float:
    """Young/Daly 1차 근사: T = sqrt(2 * delta * M)"""
    return math.sqrt(2 * save_minutes * mtbf_hours * 60)


def wasted_fraction(interval_min: float, save_minutes: float, mtbf_hours: float) -> float:
    """저장에 쓰는 시간 + 장애로 버리는 평균 재계산 시간의 비율"""
    mtbf_min = mtbf_hours * 60
    return save_minutes / interval_min + (interval_min / 2) / mtbf_min


for gpus, mtbf in [(64, 120.0), (512, 15.0), (16384, 3.1)]:
    t = optimal_interval_min(save_minutes=5, mtbf_hours=mtbf)
    print(f"GPU {gpus:6}  MTBF {mtbf:6.1f}h  최적 주기 {t:6.1f}분  "
          f"손실률 {wasted_fraction(t, 5, mtbf):.1%}")
# GPU     64  MTBF  120.0h  최적 주기  268.3분  손실률   3.7%
# GPU    512  MTBF   15.0h  최적 주기   94.9분  손실률  10.5%
# GPU  16384  MTBF    3.1h  최적 주기   43.1분  손실률  23.2%

마지막 줄의 MTBF 3.1시간은 임의의 숫자가 아닙니다. Llama 3 405B 학습에서 54일 동안 419건의 예기치 않은 중단이 있었다는 보고를 시간으로 환산한 값입니다. 이 규모에서는 최적 주기를 지켜도 전체 시간의 20퍼센트 이상이 저장과 재계산으로 사라진다는 뜻입니다. 규모를 키우면 체크포인트는 부수 작업이 아니라 주요 비용 항목이 됩니다.

보관은 별개입니다. 70B 모델의 전체 학습 상태를 저장하면 대략 이 정도입니다.

항목정밀도70B 기준 크기
파라미터bf16140 GB
마스터 가중치fp32280 GB
Adam 1차 모멘트fp32280 GB
Adam 2차 모멘트fp32280 GB
합계 (재개용 전체)980 GB
추론용 가중치만bf16140 GB

43분마다 980GB를 남기면 하루에 33TB입니다. 20일이면 660TB입니다. 그래서 보관 정책이 필요합니다. 실무에서 통용되는 형태는 계층을 나누는 것입니다.

  1. 재개용: 최신 2~3개만 유지하고 나머지는 즉시 삭제합니다. 전체 상태를 담습니다.
  2. 이정표: 정해진 토큰 수마다(예: 매 100B 토큰) 추론용 가중치만 남깁니다. 스케일링 곡선 분석과 사후 조사에 씁니다.
  3. 릴리스: 실제로 배포한 것만 영구 보관합니다. 실행 명세와 평가 리포트를 반드시 함께 묶습니다.

평가를 CI에 넣기

평가를 사람이 손으로 돌리면 반드시 빼먹습니다. 그런데 전체 평가 스위트를 매 커밋마다 돌리는 것도 불가능합니다. 계층을 나눕니다.

계층언제무엇을게이트
스모크매 커밋형식 준수, 토큰화 왕복, 대화 템플릿 렌더링, 100개 샘플 생성실패 시 머지 차단
회귀매 야간고정 골든 세트, 결정론적 디코딩, 지난 3개 버전과 비교임계치 이탈 시 알림
전체승격 전공개 벤치마크 + 도메인 세트 + 안전성리포트 첨부 없이는 승격 불가

스모크 계층이 잡아 주는 것은 품질이 아니라 사고입니다. 대화 템플릿이 깨졌거나, 토크나이저에 특수 토큰이 추가되었는데 반영이 안 됐거나, 종료 토큰이 바뀐 경우입니다. 이런 사고는 평가 점수가 아니라 예외로 드러나므로 빠르고 확실하게 잡힙니다.

# 회귀 계층 예시. 평가 도구와 태스크 버전을 함께 고정합니다.
pip install "lm-eval==0.4.12"

lm_eval --model hf \
  --model_args pretrained=./out/sft-8b,dtype=bfloat16 \
  --tasks arc_challenge,hellaswag,gsm8k \
  --batch_size 16 \
  --seed 1234 \
  --output_path reports/$(date +%F)-sft-8b.json \
  --log_samples

--log_samples를 켜서 생성 결과 자체를 남기는 것이 중요합니다. 점수만 남기면 나중에 "왜 떨어졌는가"에 답할 수 없습니다. 평가 도구 버전과 태스크 정의도 같이 기록하십시오. 같은 벤치마크라도 하네스 버전이 바뀌면 점수가 바뀝니다. 확인한 lm-evaluation-harness 버전은 0.4.12이고 배포일은 2026년 5월 11일입니다.

평가 설계 자체는 감으로 하지 않는 LLM 평가에 따로 정리해 두었습니다.

레지스트리와 승격 — 학습에서 서빙으로 넘기는 지점

모델 레지스트리는 파일 저장소가 아니라 증거 첨부 절차입니다. 각 단계로 넘어가려면 무엇이 붙어 있어야 하는지를 정해 두는 것이 전부입니다.

단계필수 첨부물승인
후보실행 명세, 학습 손실 곡선, 데이터 매니페스트 해시자동
스테이징회귀 평가 리포트, 토크나이저와 대화 템플릿 지문, 라이선스 출처담당 엔지니어
운영전체 평가 리포트, 안전성 평가, 부하 테스트 결과, 롤백 계획리뷰 두 명
폐기대체 모델 지정, 보관 기간담당자

여기서 인수인계가 실제로 깨지는 지점은 대부분 다음 여섯 가지입니다. 순서는 제가 본 빈도순입니다.

  1. 대화 템플릿 불일치. 학습 때 쓴 템플릿과 서빙 엔진이 적용하는 템플릿이 다릅니다. 공백 하나, 줄바꿈 하나가 다르면 모델은 학습한 적 없는 분포를 봅니다. 체크포인트의 tokenizer_config.json에 들어 있는 템플릿 문자열의 해시를 학습 쪽과 서빙 쪽에서 각각 찍어 비교하십시오.
  2. 특수 토큰 추가 후 임베딩 크기 불일치. SFT 중 토큰을 추가하면 임베딩 행렬이 커지는데, 서빙 쪽 설정이 원래 어휘 크기를 들고 있으면 로드에 실패하거나 조용히 잘못된 토큰을 씁니다.
  3. 종료 토큰 설정 누락. 생성이 안 멈추고 최대 길이까지 갑니다. 지연 시간과 비용이 몇 배가 되고, 사용자에게는 잡담이 붙어 나갑니다.
  4. LoRA 어댑터 병합 여부. 병합하지 않은 어댑터를 서빙에 넘기면 기저 모델만 로드되어 파인튜닝 효과가 통째로 사라집니다. 평가는 어댑터를 붙여 돌리고 서빙은 안 붙이는 조합이 특히 잘 발생합니다.
  5. 정밀도 드리프트. 학습은 bf16, 평가는 fp32, 서빙은 fp8로 돌아가는 상황이 흔합니다. 서빙에 쓸 정밀도와 양자화로 평가를 한 번은 돌려야 합니다.
  6. 패딩 방향과 최대 위치 길이. 학습 시 오른쪽 패딩, 생성 시 왼쪽 패딩이 정석인데 뒤바뀌면 배치 생성에서만 품질이 무너집니다. 단건 테스트로는 안 잡힙니다.
# 승격 전 지문 대조 — 학습 산출물과 서빙 설정이 같은 것을 보고 있는지 확인합니다
import hashlib
import json
from transformers import AutoTokenizer, AutoConfig


def fingerprint(path: str) -> dict:
    tok = AutoTokenizer.from_pretrained(path)
    cfg = AutoConfig.from_pretrained(path)
    tmpl = tok.chat_template or ""
    return {
        "chat_template_sha": hashlib.sha256(tmpl.encode()).hexdigest()[:16],
        "vocab_size_tokenizer": len(tok),
        "vocab_size_config": cfg.vocab_size,
        "eos_token_id": tok.eos_token_id,
        "pad_token_id": tok.pad_token_id,
        "max_position_embeddings": getattr(cfg, "max_position_embeddings", None),
        "torch_dtype": str(getattr(cfg, "torch_dtype", None)),
    }


a, b = fingerprint("./out/sft-8b"), fingerprint("./serving/model")
diff = {k: (a[k], b[k]) for k in a if a[k] != b[k]}
print(json.dumps(diff, indent=2, ensure_ascii=False) if diff else "일치")
assert a["vocab_size_tokenizer"] == a["vocab_size_config"], "어휘 크기 불일치"

배포 후 — 회귀 감지와 롤백

배포한 뒤에 바뀌는 것은 모델만이 아닙니다. 서빙 엔진 버전, 프롬프트, 검색 인덱스, 상위 애플리케이션 코드가 각자 바뀝니다. 그래서 품질 저하가 발생했을 때 무엇이 바뀌었는지 특정하는 능력이 회귀 감지의 본질입니다.

세 가지를 붙여 두면 대부분 잡힙니다.

  • 골든 세트 재생. 배포 직후와 매일 정해진 시각에 같은 입력 수백 건을 결정론적 설정으로 돌리고 결과를 저장합니다. 이전 결과와의 차이를 텍스트 수준에서 비교합니다. 점수보다 차이 목록이 더 유용합니다.
  • 출력 분포 모니터링. 응답 길이 분포, 거절 응답 비율, 구조화 출력의 스키마 검증 실패율, 종료 토큰 없이 최대 길이로 끝난 비율. 이 넷은 품질 지표가 아니지만 사고 지표로는 거의 완벽합니다.
  • 요청 단위 추적. 프롬프트, 모델 버전, 프롬프트 버전, 검색 문서 식별자, 지연 시간, 토큰 수를 한 트레이스에 묶습니다. 표준화 쪽은 OpenTelemetry의 GenAI 시맨틱 컨벤션이 담당하는데, 2026년 7월 기준으로 이 규약은 아직 개발 단계이며 안정화되지 않았다는 서술을 확인했습니다. 저는 공식 명세 문서에서 상태 표기를 직접 확인하지는 못했으니, 도입 전에 규약 저장소에서 안정성 등급을 직접 보시기 바랍니다. 실무적 함의는 단순합니다. 속성 이름이 바뀔 수 있으니 계측 코드를 얇은 어댑터 뒤에 두십시오.

롤백은 재빌드가 아니라 설정 변경이어야 합니다. 이전 버전의 가중치와 토크나이저, 프롬프트, 서빙 엔진 버전을 하나의 불변 묶음으로 저장해 두고, 배포는 그 묶음을 가리키는 포인터만 바꾸는 형태로 만듭니다. 어댑터 기반 배포라면 롤백이 특히 싸집니다. 기저 모델은 그대로 두고 어댑터만 교체하면 되기 때문입니다.

롤백에서 자주 빠뜨리는 것 하나. 프롬프트와 모델은 함께 롤백해야 합니다. 새 모델에 맞춰 프롬프트를 고쳤는데 모델만 되돌리면 그 조합은 한 번도 평가된 적이 없습니다.

도구 지도 — 카테고리별로

특정 제품을 권하지 않겠습니다. 카테고리와 선택 기준만 적습니다. 버전은 2026년 8월 2일에 확인한 값입니다.

카테고리후보선택 기준
실험 추적MLflow(3.15.0, 2026-07-31), Weights and Biases, ClearML, Aim오프라인/폐쇄망에서 자체 호스팅이 되는가. 아티팩트 저장소가 객체 스토리지를 직접 쓰는가
데이터 버전 관리DVC, LakeFS, 객체 스토리지 + 매니페스트 자체 구현수십 TB 규모에서 복사가 아니라 참조로 동작하는가
평가lm-evaluation-harness(0.4.12, 2026-05-11), LightEval, 자체 도메인 스위트태스크 정의에 버전이 붙는가. 샘플 단위 로그를 남기는가
추적과 관측Langfuse(4.14.2, 2026-07-30), 자체 OpenTelemetry 파이프라인이미 쓰는 관측 스택에 스팬을 흘려보낼 수 있는가
모델 레지스트리MLflow Model Registry, Hugging Face Hub(사설 포함), 객체 스토리지 + 메타데이터 DB승격 단계에 승인 절차를 강제할 수 있는가
서빙vLLM(0.26.0, 2026-07-25), SGLang(0.5.16, 2026-07-25), KServe(v0.19.0, 2026-06-14)학습 산출 포맷을 그대로 읽는가. 버전 고정이 가능한가

선택 기준을 한 줄로 요약하면 이렇습니다. 도구가 사라져도 기록이 남는가. 실험 추적 서버가 죽어도 실행 명세 JSON이 객체 스토리지에 남아 있다면 복구할 수 있습니다. 반대로 모든 메타데이터가 SaaS 안에만 있다면, 그 계약이 끝나는 날 조직의 학습 이력도 끝납니다.

마치며 — 재현할 수 없는 결과는 결과가 아닙니다

LLM Ops의 도구 목록은 매년 바뀌지만 책임은 바뀌지 않습니다. 이 실행을 다시 만들 수 있는가, 이 점수를 믿어도 되는가, 이 모델이 어디서 왔는지 말할 수 있는가, 문제가 생겼을 때 몇 분 안에 되돌릴 수 있는가.

네 질문에 전부 "예"라고 답할 수 있으면 도구는 무엇을 쓰든 상관없습니다. 하나라도 "아니오"라면 도구를 하나 더 붙이는 것으로는 해결되지 않습니다. 마지막 글에서는 이 원칙들이 실제 대규모 학습에서 어떻게 무너지고 어떻게 복구되었는지를 공개된 학습 사례에서 확인합니다.