19장. MLOps 와 LLMOps, 워크플로 자동화
LLMOps 프레임워크
식당에서 정해진 레시피대로 굽는 빵과, 손님 말을 듣고 셰프가 그날 냉장고 재료로 만드는 요리를 견주어 봅니다. 빵은 레시피만 관리하면 됩니다. 셰프 요리는 셰프(모델), 주문을 받는 말(프롬프트), 냉장고의 재료(검색 색인)가 모두 맛을 바꿉니다. LLM 서비스는 셰프 요리에 가깝습니다.
그림은 LLM 서비스를 고치는 한 바퀴입니다. 1번에서 바꾸는 것은 모델보다 프롬프트나 색인일 때가 많습니다. 2번에서 정해 둔 질문 묶음으로 재고, 3번 게이트를 넘으면 4번에서 바꿔 끼우고, 5번에서 운영 중의 답을 지켜봅니다. 지켜보며 모은 나쁜 답은 다음 바퀴의 평가 묶음에 들어갑니다.
MLOps 와 LLMOps 견주기
| MLOps | LLMOps | |
|---|---|---|
| 자주 바뀌는 것 | 학습 데이터, 모델 | 프롬프트, 검색 색인, 모델 |
| 평가 | 정답과 견준 정확도 | 질문 묶음, 검색 적중률, 사람이나 LLM 의 채점(20장) |
| 비용 | 학습할 때 크다 | 부를 때마다 든다(토큰, GPU 시간) |
| 버전을 붙일 것 | 데이터, 코드, 모델 | 거기에 프롬프트, 색인, 평가 묶음까지 |
| 위험 | 정확도 하락 | 틀린 사실, 새는 정보, 프롬프트 인젝션(21장) |
여러 부품을 한 판으로 묶기
LLM 서비스의 한 판(릴리스)은 부품 버전의 묶음입니다. 묶음의 지문을 판 이름으로 쓰면, 운영 중의 답이 어느 묶음에서 나왔는지 기록에 남길 수 있습니다.
import hashlib
import json
# 서비스 한 판을 이루는 부품과 버전
release = {
"모델": "qwen2.5:3b",
"프롬프트": "도서관-안내 v3",
"색인": "v002",
"평가 묶음": "library_qa 2026-11",
}
# 부품을 정해진 차례로 글로 만든 뒤 지문을 낸다. 부품 하나만 바뀌어도 판 이름이 달라진다
release_id = hashlib.sha256(json.dumps(release, sort_keys=True, ensure_ascii=False).encode()).hexdigest()[:8]
print("판 이름:", release_id)
print("색인만 v003 으로:", hashlib.sha256(json.dumps(dict(release, 색인="v003"), sort_keys=True, ensure_ascii=False).encode()).hexdigest()[:8])프롬프트에도 버전을 붙인다
프롬프트를 코드 안에 글로 박아 두면 언제 무엇이 바뀌었는지 알기 어렵습니다. 버전을 붙여 모아 두고, 별칭으로 부릅니다.
prompts = {
"도서관-안내": {
2: "도서관 안내 도우미입니다. 자료로만 답하세요.\n자료: {ctx}\n질문: {q}",
3: "도서관 안내 도우미입니다. 자료로만 짧게 답하고, 자료에 없으면 '안내 데스크에 문의하세요'라고 답하세요.\n자료: {ctx}\n질문: {q}",
}
}
aliases = {"도서관-안내": {"production": 3}}
# 서비스는 버전 번호 대신 별칭으로 프롬프트를 고른다
v = aliases["도서관-안내"]["production"]
print(f"v{v}:", prompts["도서관-안내"][v].format(ctx="대출 기간은 14일입니다.", q="며칠 빌려요?"))코드에 나오는 함수
| 코드 | 쉬운 뜻 |
|---|---|
json.dumps(release, …) | 사전을 JSON(JavaScript Object Notation) 글자로 바꾼다. 끝의 s 는 string(문자열)을 뜻한다 |
sort_keys=True | 열쇠를 정렬해 적는다. 사전을 적은 차례가 달라도 같은 글이 된다 |
ensure_ascii=False | 한글을 \uXXXX 꼴로 바꾸지 않고 그대로 적는다. ASCII(American Standard Code for Information Interchange)는 영문과 숫자 위주의 글자표다 |
.encode() | 글자를 바이트로 바꾼다. 아무것도 주지 않으면 UTF-8 이다 |
dict(release, 색인="v003") | release 를 복사하면서 색인 칸만 바꾼 새 사전을 만든다. 원래 release 는 그대로다 |
aliases["도서관-안내"]["production"] | production(운영) 별칭이 가리키는 프롬프트 버전 번호를 꺼낸다 |
.format(ctx=…, q=…) | 프롬프트 글 안의 {ctx}, {q} 자리에 자료와 질문을 끼워 넣는다. ctx 는 context(맥락, 자료), q 는 question(질문)의 줄임말이다 |
이 코드가 뜻하는 것
도시락 가게의 세트 메뉴를 떠올려 봅니다. 세트는 밥, 반찬, 국의 조합입니다. 반찬 하나만 바꿔도 다른 세트입니다. 소스 레시피 카드는 2번, 3번처럼 판이 있고, "오늘 쓰는 카드" 표시가 붙은 것만 주방에서 씁니다. 여기서 세트의 구성이 release, 세트 번호가 release_id, 레시피 카드 묶음이 prompts, "오늘 쓰는 카드" 표시가 production 별칭입니다.
| 줄 | 하는 일 | 쉽게 말하면 |
|---|---|---|
release = {…} | 부품 넷의 버전을 모은다 | 세트의 구성을 적는다 |
release_id = hashlib.sha256(json.dumps(…)…)[:8] | 구성을 정렬된 글로 바꾸고 8글자 지문을 낸다 | 구성으로 세트 번호를 정한다 |
둘째 print | 색인만 v003 으로 바꾼 구성의 지문을 낸다 | 반찬 하나를 바꾸면 세트 번호가 달라진다 |
v = aliases["도서관-안내"]["production"] | 운영 별칭이 가리키는 버전 3 을 꺼낸다 | 오늘 쓰는 카드는 3번이다 |
prompts["도서관-안내"][v].format(…) | v3 글에 자료와 질문을 끼운다 | 카드에 오늘 재료를 적어 넣는다 |
결과의 판 이름 414775d2 와 색인만 바꾼 66f26474 는 전혀 다릅니다. 운영 기록에 판 이름을 남기면 어느 답이 어느 묶음에서 나왔는지 가릴 수 있습니다. 비유와 다른 점은, 가게는 세트 번호를 사람이 붙이지만 여기서는 내용으로 계산하므로 구성이 같으면 언제 누가 만들어도 같은 번호가 나온다는 것입니다.
작게 줄여서 눈으로 보기
sort_keys=True 가 왜 필요한지 봅니다. 내용은 같고 적은 차례만 다른 사전 둘입니다.
import hashlib
import json
a = {"모델": "qwen2.5:3b", "색인": "v002"}
b = {"색인": "v002", "모델": "qwen2.5:3b"} # 같은 내용, 적은 차례만 다르다
print(json.dumps(b, ensure_ascii=False)) # 차례를 맞추지 않으면 적은 차례 그대로
for d in [a, b]:
s = json.dumps(d, sort_keys=True, ensure_ascii=False)
print(s, hashlib.sha256(s.encode()).hexdigest()[:8])
print(json.dumps(a)) # ensure_ascii 를 빼면 한글이 \u 꼴로 바뀐다{"색인": "v002", "모델": "qwen2.5:3b"}
{"모델": "qwen2.5:3b", "색인": "v002"} 29ab79fb
{"모델": "qwen2.5:3b", "색인": "v002"} 29ab79fb
{"\ubaa8\ub378": "qwen2.5:3b", "\uc0c9\uc778": "v002"}| 결과 | 읽는 법 |
|---|---|
| 첫 줄 | 정렬하지 않으면 b 는 "색인"이 앞에 온다. 이 글로 지문을 내면 a 와 다른 지문이 나온다 |
| 둘째, 셋째 줄 | 정렬하면 a 와 b 가 같은 글이 되어 지문도 29ab79fb 로 같다 |
| 넷째 줄 | ensure_ascii 를 빼면 한글이 \u 로 시작하는 꼴로 적힌다. 지문은 낼 수 있지만 눈으로 읽기 어렵다 |
주의할 점
- 프롬프트를 한 줄 고쳐도 답이 크게 바뀔 수 있습니다. 프롬프트 변경도 모델 변경처럼 평가와 게이트를 거칩니다.
- 색인을 바꾸면 같은 모델과 프롬프트라도 답이 바뀝니다. 이 장의 프로젝트가 색인 갱신에 게이트를 다는 까닭입니다.