홈강좌LAYA 결정 모델 24편 중 15편째
LAYA 서비스 운영 — 그림자 비교, 판 교체, 되돌리기, 재학습 주기
지속 학습한 판을 운영에 넣는 마지막 단계다. 새 판을 지금 판 옆에 그림자로 띄워 실제 문의에 함께 묻고, 이미지 태그를 바꿔 교체하고, 문제가 생기면 이전 태그로 되돌린다. 끝으로 1~15편의 고리를 재학습 주기 하나로 묶는다.
01 1. 그림자 비교 — 바꾸기 전에 실제 문의로
11편의 판정은 고정된 평가 데이터로 한 것이다. 실제 문의는 다를 수 있으니, 바꾸기 전에 새 판을 그림자로 돌린다. 고객에게는 지금 판(v1)의 답만 쓰고, 새 판(v2)의 답은 기록만 한다. 새 판이 틀려도 손해가 없다.
12편의 서버는 모델을 여럿 올릴 수 있다. v1 을 tickets, v2 를 tickets-next 로 함께 올린다.
#!/bin/sh
# 지금 판(v1)과 새 판(v2)을 한 서버에 같이 올리고, 다음 주 문의 150건을 둘 다에 물어 비교한다(그림자 비교).
: "${LAYA_API_KEY:=$(python -c 'import secrets; print(secrets.token_hex(16))')}" # 밖에서 주지 않으면 이번 실행용 키를 만든다
export LAYA_API_KEY
export LAYA_HOST=127.0.0.1 LAYA_PORT=8000
export LAYA_EXTRA_MODELS='{"tickets": "checkpoints/v1", "tickets-next": "checkpoints/v2"}'
laya-serve > server.log 2>&1 &
SERVER=$!
for i in $(seq 1 90); do curl -s -o /dev/null localhost:8000/health && break; sleep 1; done
python shadow.py
kill $SERVER
wait $SERVER 2>/dev/null || true"""같은 문의를 서비스 중인 판(tickets)과 새 판(tickets-next)에 보내 답을 비교한다.
고객에게는 tickets 의 답만 쓰고, tickets-next 의 답은 기록만 한다 — 그래서 새 판이 틀려도 손해가 없다.
"""
import json
import os
import urllib.request
URL = "http://127.0.0.1:8000/v1/systemone"
HEADERS = {"content-type": "application/json", "authorization": f"Bearer {os.environ['LAYA_API_KEY']}"}
def ask(model, case):
body = json.dumps({"model": model, "state": case["state"], "questions": case["questions"]}).encode()
req = urllib.request.Request(URL, data=body, headers=HEADERS)
with urllib.request.urlopen(req) as res:
return json.load(res)["answers"]["team"]["choice"]
cases = [json.loads(line) for line in open("next-week-150.jsonl", encoding="utf-8")]
served_ok = next_ok = differ = 0
examples = []
for case in cases:
served, nxt = ask("tickets", case), ask("tickets-next", case)
gold = case["expected"]["team"]
served_ok += served == gold
next_ok += nxt == gold
if served != nxt:
differ += 1
if len(examples) < 4:
examples.append(f" {case['state']} served={served} next={nxt} gold={gold}")
n = len(cases)
print(f"cases: {n}, answers differ: {differ} ({differ / n:.0%})")
print(f"served (v1) correct: {served_ok / n:.3f}")
print(f"next (v2) correct: {next_ok / n:.3f}")
print("where they differ:")
print("\n".join(examples))그다음 주에 들어온 문의 150건(6편에서 만든 next-week-150.jsonl)에 둘 다 물었다.
sh shadow.shcases: 150, answers differ: 4 (3%)
served (v1) correct: 0.973
next (v2) correct: 1.000
where they differ:
패딩 결제 취소했는데 카드 승인이 그대로예요 확인 좀 해 주세요. served=account next=billing gold=billing
등급 혜택 쿠폰이 이번 달엔 안 나왔어요 빨리 확인 부탁드려요. served=billing next=account gold=account
같은 주문이 결제 완료로 두 개 떠요 served=technical next=billing gold=billing
청바지 결제 취소했는데 카드 승인이 그대로예요 빨리 확인 부탁드려요. served=account next=billing gold=billing- 150건 가운데 4건(3%)만 답이 달랐다. 나머지 146건은 둘이 같은 답을 냈다. 판을 바꿔도 대부분의 고객에게는 아무 차이가 없다는 뜻이다.
- 다른 4건은 모두 v2 가 맞혔다. "결제 취소했는데 카드 승인이 그대로예요" 는 결제팀인데 v1 은 계정팀으로 보냈다. 9편에서 사람이 고친 바로 그 유형이다.
- 실제 운영에서는 정답이 바로 없다. 답이 다른 건만 사람이 보면 된다. 150건 가운데 4건이면 검토할 양도 적다.
이 다음 주 문의는 9편의 운영 문의와 같은 표현을 많이 공유해서, v2 가 학습한 표현이 다시 나왔다. 그래서 v2 가 1.000 이다. 실제 서비스에서도 운영 문의는 비슷한 표현이 되풀이되니, 운영에서 배운 판이 운영에서 강한 것은 자연스럽다. 다만 평가 데이터(11편)의 0.815 가 처음 보는 표현에 대한 더 보수적인 숫자다.
02 2. 교체와 되돌리기 — 이미지 태그로
14편과 같은 방법으로 v1 도 이미지로 만들어 두었다. 판마다 이미지가 하나씩 있다.
image laya-tickets:v1 size 1.87GB교체는 지금 컨테이너를 멈추고 새 태그로 띄우는 것이다. 되돌리기는 같은 일을 이전 태그로 하는 것이다.
#!/bin/sh
: "${LAYA_API_KEY:=$(python -c 'import secrets; print(secrets.token_hex(16))')}" # 밖에서 주지 않으면 이번 실행용 키를 만든다
export LAYA_API_KEY
# 판 교체와 되돌리기 — 같은 포트에 v2 이미지를 띄웠다가, 문제가 생긴 셈 치고 v1 이미지로 되돌린다.
answer() {
curl -s localhost:8001/v1/systemone -H 'content-type: application/json' \
-H "authorization: Bearer $LAYA_API_KEY" -d @request.json |
python -c 'import json,sys; t=json.load(sys.stdin)["answers"]["team"]; print(" team", t["choice"], t["probabilities"])'
}
start() {
docker run -d --rm --name laya-tickets -p 127.0.0.1:8001:8000 -e LAYA_API_KEY laya-tickets:$1 > /dev/null
for i in $(seq 1 120); do curl -s -o /dev/null localhost:8001/health && break; sleep 1; done
echo "serving laya-tickets:$1"
}
stop() { docker stop laya-tickets > /dev/null; }
docker image ls laya-tickets --format '{{.Repository}}:{{.Tag}} {{.Size}}' | sort
start v2; answer; stop
echo "rollback"
start v1; answer; stop요청은 그림자 비교에서 둘이 다르게 답한 문의 "패딩 결제 취소했는데 카드 승인이 그대로예요" 다.
sh swap.shlaya-tickets:v1 1.87GB
laya-tickets:v2 1.87GB
serving laya-tickets:v2
team billing {'billing': 1.0, 'delivery': 0.0, 'account': 0.0, 'technical': 0.0}
rollback
serving laya-tickets:v1
team account {'billing': 0.004, 'delivery': 0.0, 'account': 0.996, 'technical': 0.0}v2 로 바꾸자 결제팀(billing 1.0)으로 맞게 갔고, v1 로 되돌리자 다시 계정팀(account 0.996)으로 갔다. 어느 판이 돌고 있는지가 태그 하나로 정해지고, 되돌리기가 명령 한 줄이다. 실제 서버에서는 멈췄다 띄우는 사이 요청이 끊기지 않게, 새 컨테이너를 먼저 띄워 /health 를 확인한 뒤 프록시(또는 로드 밸런서)의 대상을 바꾸고 옛 컨테이너를 멈춘다.
03 3. 재학습 주기 — 1~15편을 한 장으로
# 1) 운영: 서비스 판이 판단하고, 확신이 낮은 것과 표본을 사람이 본다 (9편)
python collect.py 0.99 0.30 data/feedback.jsonl
# 2) 데이터 쌓기: 이전 학습 데이터 + 새 정답 (10편)
cat data/train-v2.jsonl data/feedback.jsonl > data/train-v3.jsonl
# 3) 학습: 바탕 모델에서 누적 데이터로 처음부터 (10편)
laya-train --data data/train-v3.jsonl --base multilingual --out checkpoints/v3
# 4) 판정: 고정 평가 데이터로 지금 판과 비교, 기준을 넘을 때만 (11편)
python promote.py checkpoints/v2 checkpoints/v3
# 5) 이미지: 통과한 판만 (14편)
sh build.sh v3
# 6) 그림자 비교 → 교체 → 문제 있으면 v2 로 되돌리기 (이 편)주기는 데이터가 쌓이는 속도로 정한다. 새 정답이 수십 건 모였을 때마다, 처음에는 1~2주에 한 번이 알맞다. 학습은 맥북에서 몇 분이니 사람이 결과를 보며 손으로 돌려도 된다. 판이 안정되면 1)~4) 를 정기 작업으로 묶고, 5)~6) 은 사람이 승인한다.
04 실습 — 운영 확인 목록
| 확인 | 왜 |
|---|---|
응답의 routing.model 이 기대한 이름인가 | 이름이 틀리면 오류 없이 기본 모델로 간다(12편) |
| 표본 검사의 실제 오류율이 판마다 줄고 있나 | 지속 학습이 되고 있다는 첫 지표(9편) |
판정 기록(registry.jsonl)과 이미지 태그가 맞나 | 지금 도는 판이 승인된 판인지(11편) |
| API 키를 이미지가 아니라 실행 환경에서 넘기나 | 이미지가 새도 키는 안전하다(14편) |
| 모델 파일을 확인하고 올리나 | 체크포인트 해시를 기록해 두고 expected_sha256 으로 확인할 수 있다(공식 문서의 Checkpoint integrity) |
| 평가 데이터를 가끔 새 운영 문의로 보태나 | 평가가 오래되면 새 표현을 못 잰다(10편) |
여기까지가 첫 사례의 한 바퀴다. 설치·테스트(1~5편), 내 모델 만들기(6~8편), 지속 학습(9~11편), 배포(12~15편)였다. 16편에서 이 사례의 결과를 한자리에 모아 지속 학습이 정답률을 올렸는지 정리하고, 17~23편에서 다른 사례(쇼핑 검색어를 필터로)로 같은 과정을 한 번 더 하고, 24편에서 LAYA 를 LAYA 답게 쓰는 법을 정리한다. 이 강좌의 숫자는 합성 데이터로 잰 것이라 실제 서비스에서는 달라진다. 방법은 그대로 쓰되, 숫자는 늘 자기 데이터로 다시 잰다.