목표와 현재 결과

L4 한 장에서 3B 모델을 서빙하는 중에, 그 Pod를 내리지 않고 LoRA adapter를 학습해 실시간으로 반영했다. 재기동은 준비 patch 한 번뿐이었고 그 뒤 학습 3회와 adapter 교체는 전부 서버가 살아 있는 채로 이루어졌다.

증거는 Pod의 재시작 횟수다. adapter를 붙이기 전과 비교가 끝난 뒤가 같다.

RESTARTS 0    AGE 2026-08-05T01:10:58Z

model 목록은 그 사이에 하나에서 둘로 늘었다가 다시 하나로 줄었다. 재시작 없이 목록이 바뀌는 것은 런타임 반영으로만 설명된다.

학습은 서빙과 같은 GPU에서 돌렸다. 시작 전 계산은 9,300 MiB였고 실측은 9,452 MiB1.6% 차이였다. 그동안 서빙 쪽 점유는 5,118 MiB에서 한 번도 움직이지 않았다. adapter를 붙이고 호출한 뒤에도 같은 값이다 — LoRA 슬롯은 기동 시점에 선점되므로 adapter를 갈아끼우는 데 추가 memory가 들지 않는다.

시점usedfree
학습 전5,118 MiB17,448 MiB
최대14,570 MiB7,996 MiB
학습 후5,118 MiB17,448 MiB

학습 자체는 세 번 걸렸다. 1회차와 2회차는 형식을 전혀 배우지 못했고, 세 번째에야 학습 데이터의 원인: / 조치: 두 줄이 서빙 응답에 나타났다.

1회차   0/4 문항
2회차   0/4 문항
3회차   3/4 문항

두 번의 실패 원인이 서로 달랐고, 둘 다 loss 곡선만 봐서는 보이지 않았다. 1회차의 loss는 0.59까지 떨어졌지만 그중 대부분이 padding을 맞히는 데 쓰인 값이었다. 이 장에서 가장 값이 나간 교훈이 그것이다.

범위는 좁다. 응답 품질은 이 기록이 답하지 않는다. 3회차 adapter는 형식을 지켰지만 내용이 틀렸고, 한글·영문·한자가 섞여 깨진 자리가 있다. 12건짜리 데이터로 50 epoch을 돌린 결과이지 모델이 좋아진 것이 아니다. 평가 문항 자체에도 결함이 있어 base부터 쿠버네티스 맥락으로 읽지 못했다.

기준 모델은 Qwen/Qwen2.5-3B-Instruct-AWQ이고 학습 base는 같은 계열의 BF16 checkpoint다.

1. LoRA 지원 확인

quantized base에 adapter를 얹는 조합은 버전과 quantization 방식에 따라 지원 범위가 다르다. 그래서 옵션이 존재하는지부터 봤다.

1-1. 옵션 존재

기본 --help는 전체 옵션을 싣지 않는다. --help=all을 쓴다.

kubectl -n llm exec deployment/vllm-qwen -- \
  vllm serve --help=all 2>&1 | grep -E "^[[:space:]]+--(enable-lora|lora|max-lora|max-cpu-lora)"
key해석
--enable-lora존재LoRA 경로 자체가 있다
--max-lora-rank{1,8,16,...}열거형이다. 쓰려는 r이 목록에 있어야 한다
--lora-modules존재기동 시점에 adapter를 고정 지정하는 방식

여기서 확인한 것은 옵션의 존재뿐이다. AWQ base와 실제로 조합되는지는 올려봐야 안다.

1-2. 런타임 갱신 경로

Pod를 유지한 채 adapter를 바꾸려면 서버가 런타임 갱신 API를 열고 있어야 한다.

kubectl -n llm exec deployment/vllm-qwen -- \
  python3 -c "import vllm, os; print(vllm.__version__); print([k for k in os.environ if 'LORA' in k])"

환경 변수가 비어 있다. vLLM은 VLLM_ALLOW_RUNTIME_LORA_UPDATING이 켜져 있을 때만 load_lora_adapter 계열 라우트를 등록하므로, 지금은 꺼져 있는 상태다. 넣으려면 한 번은 재기동해야 한다.

그 재기동이 이 작업에서 유일한 재기동이다. 뒤로는 adapter를 갈아끼워도 서버가 내려가지 않는다.

2. Deployment에 LoRA 경로 열기

기존 Deployment에 옵션을 더하는 방식을 택했다. adapter 전용 서버를 따로 띄우면 3B weight를 한 벌 더 올려야 해서 이 GPU의 예산에 들어가지 않는다.

2-1. 준비 patch

세 가지를 한 번에 넣는다.

넣는 것이유
--enable-lora 계열 argsLoRA 경로를 켠다
VLLM_ALLOW_RUNTIME_LORA_UPDATING=True런타임 라우트를 등록시킨다
adapter 디렉터리 mount학습 결과물을 컨테이너에서 읽게 한다

JSON Patch의 add는 없는 배열에 -로 붙일 수 없다. 세 자리가 이미 배열인지 먼저 봤다.

kubectl -n llm get deployment vllm-qwen \
  -o jsonpath='{.spec.template.spec.containers[0].env}{"\n"}'
kubectl -n llm get deployment vllm-qwen -o jsonpath='{range .spec.template.spec.containers[0]}volumeMounts={.volumeMounts}{"\n"}{end}{range .spec.template.spec}volumes={.volumes}{"\n"}{end}'
key해석
env없음/env에 배열째로 넣어야 한다
volumeMounts2개/volumeMounts/-로 원소를 덧붙일 수 있다
volumes2개같은 방식

JSON Patch는 부분 적용이 없다. 한 연산이 실패하면 앞의 args도 들어가지 않으므로 형태를 먼저 맞추는 편이 빠르다.

kubectl -n llm patch deployment vllm-qwen --type=json -p='[
  {"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--enable-lora"},
  {"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--max-loras=1"},
  {"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--max-lora-rank=8"},
  {"op":"add","path":"/spec/template/spec/containers/0/env","value":[{"name":"VLLM_ALLOW_RUNTIME_LORA_UPDATING","value":"True"}]},
  {"op":"add","path":"/spec/template/spec/containers/0/volumeMounts/-","value":{"name":"lora","mountPath":"/home/<user>/k3s-gpu-cluster/lora"}},
  {"op":"add","path":"/spec/template/spec/volumes/-","value":{"name":"lora","hostPath":{"path":"/home/<user>/k3s-gpu-cluster/lora","type":"DirectoryOrCreate"}}}
]'

컨테이너 mount 지점을 host 경로와 같은 절대경로로 맞췄다. 뒤에서 API에 경로를 넘길 때 어느 쪽 경로인지 헷갈릴 여지가 없어진다.

--lora-modules는 여기서 넣지 않았다. 기동 시점에 adapter가 아직 없으므로 경로를 미리 지정하면 서버가 없는 파일을 읽으려다 기동에 실패한다.

2-2. 기동 로그로 AWQ 조합 확인

kubectl -n llm rollout status deployment/vllm-qwen --timeout=20m
kubectl -n llm logs deployment/vllm-qwen | grep -iE "lora|adapter|error"
key해석
enable_loraTrue반영됐다
quantizationauto_awqAWQ base 그대로
오류없음AWQ와 LoRA 조합이 성립한다
Using default LoRA kernel configs경고AWQ 전용 튜닝 커널이 없어 기본값을 쓴다
local development only경고런타임 갱신은 개발용 기능이다
라우트4개붙이고 떼는 경로가 열렸다

max_loras=1non-default args에 안 찍힌다. 기본값과 같아서 생략된 것이지 무시된 것이 아니다.

두 경고는 성질이 다르다. 커널 경고는 성능에 영향이 있을 수 있으니 뒤에서 지연을 볼 때 감안할 항목이고, 개발용 경고는 이 기능을 운영 경로로 삼지 말라는 뜻이다. 운영에서 adapter를 바꾼다면 --lora-modules로 고정해 기동하는 쪽이 맞다.

2-3. 라우트 확인

kubectl -n llm port-forward svc/vllm-qwen 8000:8000

붙잡고 있는 명령이라 별도 터미널에서 띄운다.

curl -s http://127.0.0.1:8000/openapi.json | grep -o '"/v1/[a-z_]*lora[a-z_]*"'

2-3-1. 빈 출력을 기능 부재로 읽었다

처음 돌렸을 때 이 명령이 아무것도 출력하지 않았다. 그것을 보고 vLLM이 이 라우트를 OpenAPI 스키마에 싣지 않는다고 결론 내렸고, 라우트 유무는 기동 로그로만 판정해야 한다고까지 정리했다.

실제로는 patch에 따른 rollout으로 Pod가 교체되면서 port-forward가 끊긴 것이었다. 빈 출력은 라우트가 없을 때와 연결이 끊겼을 때가 똑같이 생겼다.

관측이 비었을 때 관측 경로가 살아 있는지를 먼저 확인하지 않으면, 대상의 성질에 대한 결론으로 건너뛰게 된다.

port-forward를 다시 띄우고 같은 명령을 돌리면 나온다.

빈 출력과 연결 실패를 가르려면 상태 코드를 함께 본다.

curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/openapi.json

200이면 연결은 된 것이고, 그때의 빈 결과만 라우트 부재로 읽을 수 있다. 000은 HTTP 응답이 아니라 curl이 연결에 실패했다는 표시다.

학습을 시작하기 전 GPU 기준선을 잡는다. LoRA를 켠 상태의 값이다.

nvidia-smi --query-gpu=memory.total,memory.used,memory.free --format=csv
key해석
memory.total23,034 MiBL4 한 장
memory.used5,118 MiB서빙 인스턴스 하나
memory.free17,448 MiB학습이 쓸 수 있는 상한

BF16 3B의 LoRA 학습은 weight 6,330 MiB에 activation과 optimizer state를 더해 9,300 MiB 안팎으로 잡았다. 17,448 MiB 안에 들어간다는 계산이고, 계산은 근거이지 확인이 아니므로 학습 중에 실제로 재기로 한다.

3. 1회차 — 파이프라인은 돌았고 형식은 안 배웠다

원인:조치: 두 줄로 답하게 만드는 작은 형식 학습이다. 목적은 품질이 아니라 학습·배포·선택이 실제로 작동하는지 보는 것이다.

3-1. 데이터와 스크립트

mkdir -p ~/k3s-gpu-cluster/lora && cat > ~/k3s-gpu-cluster/lora/train.jsonl <<'EOF'
{"messages":[{"role":"user","content":"Pod가 CrashLoopBackOff 상태야"},{"role":"assistant","content":"원인: 컨테이너가 기동 직후 종료되어 재시작이 반복되고 있습니다.\n조치: kubectl logs --previous로 직전 로그를 확인하고 실패 지점을 찾으세요."}]}
EOF

평가용 4건은 학습에 넣지 않는다.

cat > ~/k3s-gpu-cluster/lora/eval.jsonl <<'EOF'
{"question":"노드가 NotReady야"}
{"question":"PVC가 Bound가 안 돼"}
{"question":"이미지를 못 받아오고 있어"}
{"question":"OOMKilled가 났어"}
EOF

학습 스크립트의 핵심은 두 곳이다.

def encode(r):
    text = tok.apply_chat_template(r["messages"], tokenize=False)
    enc = tok(text, truncation=True, max_length=512, padding="max_length")
    enc["labels"] = enc["input_ids"].copy()
    return enc
output_dir=OUT, max_steps=30, per_device_train_batch_size=1,
gradient_accumulation_steps=4, learning_rate=2e-4,
logging_steps=5, save_strategy="no", seed=SEED, bf16=True,

LoRA는 r=8, lora_alpha=16, target_modulesq_proj, k_proj, v_proj, o_proj다.

3-2. 학습

서빙 Pod를 그대로 둔 채 node에서 직접 돌린다.

date && python3 ~/k3s-gpu-cluster/lora/train.py && date
key해석
trainable%0.11933.68M만 학습한다
Map3/3학습 데이터가 3건이다
최종 loss0.5857잘 떨어진 것처럼 보인다
train_runtime25.94초전체 85초 중 41초가 다운로드

산출물을 확인한다.

ls -lh ~/k3s-gpu-cluster/lora/adapter
cat ~/k3s-gpu-cluster/lora/adapter/adapter_config.json
key해석
base_model_name_or_pathQwen/Qwen2.5-3B-InstructBF16 checkpoint로 학습됐다
r / lora_alpha8 / 16scaling은 2.0
target_modulesq,k,v,o_projMLP는 건드리지 않는다
adapter_model.safetensors15M3B base 대비 극히 작다
파생
전체 소요85초
학습 외 시간59초
adapter / base 크기비1/400

Map: 3/3이 여기서 이미 문제를 알린다. heredoc에 3건만 넣고 돌린 것인데 학습은 정상 종료되고 오류도 없다. 흔적이 이 한 줄뿐이다.

3-3. 재기동 없이 반영

붙이기 전 Pod 상태를 적어둔다. 이 값이 비교가 끝날 때까지 그대로여야 주장이 성립한다.

kubectl -n llm get pod -o custom-columns='NAME:.metadata.name,RESTARTS:.status.containerStatuses[0].restartCount,AGE:.metadata.creationTimestamp'
curl -s http://127.0.0.1:8000/v1/load_lora_adapter \
  -H "Content-Type: application/json" \
  -d '{"lora_name":"qwen2.5-3b-k8s-style","lora_path":"/home/<user>/k3s-gpu-cluster/lora/adapter"}'

요청 body의 ~는 셸이 풀어주지 않으므로 절대경로를 쓴다.

재기동 없이 model 목록이 하나에서 둘로 늘었다. 2-1의 patch 이후 Deployment를 건드리지 않았고 Pod도 그대로다.

adapter가 memory를 얼마나 더 쓰는지도 같이 쟀다.

curl -s http://127.0.0.1:8000/v1/load_lora_adapter \
  -H "Content-Type: application/json" \
  -d '{"lora_name":"qwen2.5-3b-k8s-style", ... }' \
  && nvidia-smi --query-gpu=memory.used,memory.free --format=csv,noheader
key로딩 전로딩 후해석
memory.used5,118 MiB5,118 MiB변화 없음
adapter 파라미터3,686,400BF16 기준 약 7 MiB

새로 할당했다면 7 MiB는 이 해상도에서 보였을 크기인데 한 자리도 움직이지 않았다. --enable-lora로 기동할 때 --max-loras × --max-lora-rank 만큼의 슬롯을 미리 확보하고, 로딩은 그 안을 채우는 것으로 보인다.

비용이 기동 시점에 이미 지불됐다는 뜻이다. adapter를 갈아끼울 때 memory 예산을 다시 잡을 필요가 없다.

다만 위 값은 올려두기만 하고 호출하지 않은 상태에서 잰 것이라, 첫 요청에서 GPU로 복사하는 구현일 가능성이 남는다. 학습 데이터에 있던 질문을 adapter로 한 번 보내 슬롯을 실제로 쓰게 한 뒤 다시 쟀다.

curl -s http://127.0.0.1:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model":"qwen2.5-3b-k8s-style","messages":[{"role":"user","content":"Pod가 Pending이야"}],"max_tokens":50,"temperature":0}' \
  > /dev/null \
  && nvidia-smi --query-gpu=memory.used,memory.free --format=csv,noheader

호출 뒤에도 그대로다. 첫 요청에서 복사하는 구조가 아니라 기동 시 선점하는 구조라는 것이 이것으로 확정된다.

3-4. base와 adapter 비교

while read -r line; do
  Q=$(echo "$line" | jq -r '.question')
  for M in qwen2.5-3b qwen2.5-3b-k8s-style; do
    echo "== $M / $Q"
    curl -s http://127.0.0.1:8000/v1/chat/completions \
      -H "Content-Type: application/json" \
      -d "{\"model\":\"$M\",\"messages\":[{\"role\":\"user\",\"content\":\"$Q\"}],\"max_tokens\":200,\"temperature\":0}" \
      | jq -r '.choices[0].message.content'
  done
done < ~/k3s-gpu-cluster/lora/eval.jsonl

base부터 이 대화가 쿠버네티스라는 것을 모른다. 이미지를 못 받아오고 있어를 이미지 처리 기능으로 받았고, OOMKilled에는 Alibaba Cloud를 꺼낸다. 뒤엣것은 Qwen의 기본 system prompt가 chat template에 붙기 때문이다.

문항형식 준수비고
노드가 NotReady야아니오짧아졌을 뿐
PVC가 Bound가 안 돼아니오Python 개념으로 오인
이미지를 못 받아오고 있어아니오
OOMKilled가 났어아니오JVM 언급

형식이 하나도 나오지 않았고, 응답이 base보다 나빠졌다.

이 방향이 진단의 단서다. 아무것도 학습되지 않았다면 base와 비슷해야 한다. 나빠졌다는 것은 LoRA weight가 유의미하게 움직였는데 그 방향이 과제가 아니었다는 뜻이다.

3-4-1. loss가 떨어지는 것을 학습이 되는 것으로 읽었다

14.41 → 0.59라는 곡선을 보고 학습이 진행됐다고 판단했다. 그 판단 위에서 max_steps=30도 충분하다고 보았다.

실제로는 padding="max_length"로 512까지 채운 pad 토큰이 labels에 그대로 들어가 있었다. Trainer의 CrossEntropy는 -100만 무시하고 pad token id는 -100이 아니다. 예시가 100 토큰 남짓이므로 loss의 대부분이 padding을 맞히는 데 쓰였다.

padding은 예측하기 쉽다. 곡선이 잘 떨어진 것은 그 때문이지 형식을 배워서가 아니었다.

loss 하강 자체는 학습 성공의 근거가 아니다. 무엇에 대한 loss인지 보지 않으면 떨어지는 곡선을 그대로 믿게 되고, 그 잘못된 지표가 다른 설정까지 정당화한다.

4. 2회차 — padding을 빼자 부족한 step이 드러났다

두 가지를 고쳤다. 데이터를 12건으로 채우고, pad 위치를 loss에서 뺐다.

enc["labels"] = [
    t if m == 1 else -100
    for t, m in zip(enc["input_ids"], enc["attention_mask"])
]

4-1. 학습 중 GPU 점유

되돌릴 수 없는 단계이므로 관측 터미널을 먼저 띄운다. watch는 같은 화면을 덮어써서 지나간 값이 남지 않으므로 줄로 쌓는 방식을 쓴다.

nvidia-smi --query-gpu=timestamp,memory.used,memory.free --format=csv,noheader -l 5 \
  | tee ~/k3s-gpu-cluster/lora/gpu-watch.log
파생
학습이 쓴 memory14,570 − 5,118 = 9,452 MiB
사전 계산9,300 MiB
차이1.6%
최저 free7,996 MiB
점유 구간35초

5초 간격이라 중간값 11,314가 잡혔다. 이 한 점 덕분에 총량이 항목별로 갈린다.

구간증가분정체
5,118 → 11,3146,196 MiBBF16 3B weight 적재
11,314 → 14,5703,256 MiBactivation + optimizer state

사전 계산은 weight 6,330 + activation·optimizer 3,000이었다. 합계뿐 아니라 항목별로도 맞는다. 최대값만 봤다면 총량이 우연히 맞은 것인지 구성까지 맞은 것인지 구분할 수 없었다.

같은 관측을 나머지 두 회차에서도 했다.

관측학습 조건step점유 시간최대
02:08데이터 3건, padding 포함3030초14,570 MiB
03:12데이터 123035초14,570 MiB
06:02데이터 12건, prompt 제외150150초14,570 MiB

02:08은 1회차와 같은 조건으로 다시 돌린 것이다. 1회차는 관측 창을 띄우지 않고 학습해서 최대 점유를 놓쳤고, 한 번 지나간 구간은 같은 동작을 다시 실행하는 것 외에 되찾을 방법이 없었다.

최대 점유가 세 번 다 같다. 데이터가 3건이든 12건이든, step이 30이든 150이든 14,570 MiB이고 점유 시간만 30초에서 150초로 늘었다.

학습 memory는 데이터 양이나 step 수가 아니라 모델 크기·batch·시퀀스 길이가 정한다. per_device_train_batch_size=1max_length=512 고정이므로 한 번에 GPU에 올라가는 양이 데이터 건수와 무관하다. 데이터를 늘려도 memory 예산을 다시 잡을 필요가 없다는 뜻이다.

서빙 점유가 학습 전후로 5,118 MiB에서 움직이지 않았다. [[02-vllm-serving-and-memory|--gpu-memory-utilization이 메모리 예산이라는 것]]은 앞서 확인했는데, 그 성질이 같은 GPU에 학습이 얹힌 상황에서도 유지된다는 뜻이다. 이것이 replica를 내리지 않아도 되는 근거다. 학습이 memory를 더 못 잡으면 별도 프로세스인 학습 쪽만 끝난다.

4-2. 결과

key해석
Map12/12데이터가 채워졌다
최종 loss1.691회차보다 높다
최종 grad_norm1.168여전히 크다
최종 lr6.667e-06스케줄이 거의 끝났다

형식은 여전히 4문항 전부 나오지 않았고, OOMKilled 응답은 번호가 4, 5, 3, 4, 5, 6으로 깨졌다. 그런데 로그가 이유를 말한다. 마지막 step에서도 loss가 내려가는 중이었고 grad_norm이 1.168로 높다. 수렴한 것이 아니라 중간에 끊긴 것이다.

12건에 batch_size=1, gradient_accumulation_steps=4면 1 epoch이 3 step이다. 30 step은 10 epoch뿐이다.

회차 간 loss를 크기로 비교하면 반대 결론이 나온다

1회차 최종 0.5857, 2회차 최종 1.69다. 숫자만 보면 1회차가 더 잘 학습된 것처럼 보이지만 실제로는 반대다. 1회차는 padding을 포함한 loss고 2회차는 실제 토큰만이다. 분모가 바뀌었으므로 값의 크기를 비교하면 안 된다.

설정을 바꾼 회차 사이에서는 loss 값이 아니라 곡선의 모양과 grad_norm을 본다.

5. 3회차 — prompt를 빼고 lr 구간을 늘렸다

남은 문제가 둘이었다.

첫째, prompt까지 학습하고 있었다. attention_mask1인 자리를 전부 label로 쓰면 system·user 구간이 loss에 들어간다. gradient의 절반 가까이가 이미 주어진 질문을 재현하는 데 쓰이고, 형식은 assistant 구간에만 있으므로 신호가 희석된다.

def encode(r):
    prompt = tok.apply_chat_template(
        r["messages"][:-1], tokenize=False, add_generation_prompt=True)
    full = tok.apply_chat_template(r["messages"], tokenize=False)
    p_len = len(tok(prompt, add_special_tokens=False)["input_ids"])
    enc = tok(full, truncation=True, max_length=512, padding="max_length")
    labels = [
        t if m == 1 else -100
        for t, m in zip(enc["input_ids"], enc["attention_mask"])
    ]
    labels[:p_len] = [-100] * p_len
    enc["labels"] = labels
    return enc

둘째, step이 부족했다. max_steps150으로 올렸다.

5-1. 결과

adapter를 다시 붙이려는데 400이 돌아왔다.

5-1-1. -f가 400의 이유를 지웠다

다음 명령이 curl: (22) The requested URL returned error: 400만 남기고 끝났다.

curl -fsS http://127.0.0.1:8000/v1/load_lora_adapter \
  -H "Content-Type: application/json" \
  -d '{"lora_name":"qwen2.5-3b-k8s-style", ...}'

curl의 -f는 4xx에서 응답 본문을 버리고 종료 코드만 남긴다. 서버가 400에 실어 보낸 이유가 통째로 사라졌다. 상태 코드로 분기하는 자리에서는 쓸 수 없는 옵션이다.

원인은 앞 회차의 adapter가 같은 이름으로 아직 올라가 있는 것이었다.

-f를 빼면 본문이 보인다.

curl -s -w '\n[%{http_code}]\n' http://127.0.0.1:8000/v1/load_lora_adapter \
  -H "Content-Type: application/json" \
  -d '{"lora_name":"qwen2.5-3b-k8s-style","lora_path":"/home/<user>/k3s-gpu-cluster/lora/adapter"}'

같은 이름으로 다시 load해도 이미 올라간 adapter가 새 파일로 교체되지 않는다. 재학습했으면 반드시 먼저 떼야 한다. 이번에는 400이 나서 걸렸지만, 나지 않았다면 새 weight를 반영한 줄 알고 옛 adapter로 평가했을 것이고 응답만 봐서는 그것을 알 수 없다.

curl -s http://127.0.0.1:8000/v1/unload_lora_adapter \
  -H "Content-Type: application/json" \
  -d '{"lora_name":"qwen2.5-3b-k8s-style"}'
문항형식 준수비고
노드가 NotReady야내용은 틀림
PVC가 Bound가 안 돼컨트roller 혼입
이미지를 못 받아오고 있어아니오어仪원 혼입
OOMKilled가 났어조치: 중복, [原因]·기界 혼입

학습 데이터의 형식이 서빙 응답에 나타났다. 이 작업이 확인하려던 것은 여기까지다.

5-2. 어디까지 돌렸어야 했는가

loss와 grad_norm을 함께 읽으면 학습이 끝난 지점이 보인다.

stepepochlossgrad_norm
30101.0051.736
5016.70.13340.6343
60200.028160.1593
7023.30.0094130.07803
90300.004260.03652
150500.0026220.02293

step 60~70에서 grad_norm이 0.63 → 0.16 → 0.078로 무너지고, 이후 80 step 동안 0.023에서 움직이지 않는다. gradient가 사라졌다는 것은 더 배울 것이 없다는 뜻이다.

파생
실질 학습 구간step 1~70
과적합 구간step 70~150 (53%)
step당 소요0.96초

그 뒤 loss가 0.028 → 0.0026으로 한 자릿수 더 떨어진 것은 학습이 아니라 12건을 글자 단위로 외우는 과정이다. 대가가 응답에 그대로 나타났다. Qwen은 중국어 겸용 모델이라 토큰 분포가 흔들리면 한자로 새는 것이 전형적이다.

max_steps는 길이만 바꾸지 않는다

기본 스케줄러가 max_steps에 걸쳐 learning rate를 선형으로 감쇠시킨다. 그래서 같은 step 번호라도 회차마다 lr이 다르다.

step 30 시점 lrlossgrad_norm
2회차 max_steps=306.667e-061.691.168
3회차 max_steps=1501.613e-041.0051.736

2회차는 step 30에 이미 lr이 0 근처로 내려앉아 후반부가 거의 아무 일도 하지 않았다. 30 → 150이 들은 것은 step이 늘어서만이 아니라 lr이 높게 유지된 구간이 함께 늘었기 때문이다.

그래서 3회차 곡선의 70번째 지점을 잘라내는 것과 max_steps=70으로 다시 돌리는 것은 같지 않다.

6. 무중단 반영의 증거

비교가 끝난 뒤 Pod 상태를 다시 본다.

kubectl -n llm get pod -o custom-columns='NAME:.metadata.name,RESTARTS:.status.containerStatuses[0].restartCount,AGE:.metadata.creationTimestamp'
key로딩 전비교 후
NAMEvllm-qwen-f64c65855-mqgvs동일
RESTARTS00
AGE2026-08-05T01:10:58Z동일

Pod가 교체되지도 재시작되지도 않았다. 그 사이에 학습이 세 번 돌았고 adapter가 붙었다 떨어졌다.

adapter를 떼면 목록이 원래대로 돌아간다.

curl -s http://127.0.0.1:8000/v1/unload_lora_adapter \
  -H "Content-Type: application/json" \
  -d '{"lora_name":"qwen2.5-3b-k8s-style"}'

adapter가 등록되기만 한 것은 2-1의 재기동으로도 설명된다. 재시작 횟수가 그대로인 채 목록이 늘었다 줄어든 것은 런타임 반영으로만 설명된다.

마지막으로 2-1의 patch를 되돌린다. 여기서는 Pod가 교체되므로 재기동이 일어난다.

kubectl -n llm rollout undo deployment/vllm-qwen
kubectl -n llm rollout status deployment/vllm-qwen --timeout=20m
kubectl -n llm get deployment vllm-qwen -o jsonpath='{.spec.template.spec.containers[0].args}{"\n\n"}{.spec.template.spec.containers[0].env}{"\n\n"}{.spec.template.spec.volumes}{"\n"}'
key해석
args원래 6개--enable-lora 계열 셋이 빠졌다
env없음런타임 갱신 라우트가 닫혔다
volumescache, shmadapter mount가 빠졌다
curl -s http://127.0.0.1:8000/v1/models | jq -r '.data[].id'

Pod가 교체되므로 올려둔 adapter도 함께 내려간다. adapter 파일 자체는 남아 있으므로, 다시 쓸 때는 2-1의 patch만 넣으면 학습 없이 바로 붙일 수 있다.

7. 확인된 범위

확인된 것부터 적는다.

- 서빙 Pod를 내리지 않고 LoRA adapter를 학습해 반영했다
- 재기동은 준비 patch 1회뿐이었고 그 뒤 학습 3회와 adapter 교체는 무중단이었다
- AWQ base에 LoRA adapter가 붙는다
- 학습이 서빙과 같은 GPU의 남는 memory에 들어갔다 (9,452 MiB, 계산 대비 1.6% 차이)
- 학습 중에도 서빙 점유가 5,118 MiB에서 변하지 않았다
- 학습 데이터의 형식이 서빙 응답에 나타났다 (4문항 중 3개)

확인하지 못한 것이 더 많다.

응답 품질은 이 기록의 판단 대상이 아니다. 3회차 adapter는 형식을 지켰지만 내용이 틀렸고 토큰이 깨진 자리가 있다. 12건에 50 epoch을 돌린 결과이지 모델이 좋아진 것이 아니다. 품질을 올리려면 step을 줄이는 것이 아니라 데이터를 늘려야 한다. 12건에서 epoch만 낮추면 형식도 함께 흐려진다.

평가 문항 자체에 결함이 있다. 이미지를 못 받아오고 있어를 base가 이미지 처리 기능으로 해석했다. 문항이 짧고 system prompt도 없어 쿠버네티스 맥락으로 읽을 근거가 없었다. base가 Alibaba Cloud를 꺼내는 것도 Qwen의 기본 system prompt가 템플릿에 붙기 때문이다. 품질을 보려면 평가셋부터 다시 설계해야 한다.

base 응답도 회차마다 달라졌다

temperature: 0인데 1회차와 2회차의 base 응답이 중간부터 갈렸다. 앞부분은 같다가 목록 다섯 번째 항목에서 달라지는 식이다. --enable-lora로 커널이 바뀐 것(Using default LoRA kernel configs)에 따른 수치 비결정성으로 보이지만, 그것이 유일한 원인인지는 가르지 못했다.

문장 단위 차이를 base와 adapter 비교의 근거로 쓸 수 없다. 형식 준수 여부처럼 큰 차이만 판단에 쓴다.

adapter를 붙였을 때의 지연 변화도 재지 않았다. Using default LoRA kernel configs 경고가 AWQ 전용 튜닝 커널의 부재를 알렸으므로 영향이 있을 수 있는데, 이번에는 응답 내용만 보고 응답 시간을 기록하지 않았다.

런타임 갱신은 개발용 기능이다. vLLM이 local development only로 표시한다. 운영에서 adapter를 다루려면 --lora-modules로 고정해 기동하는 경로를 따로 확인해야 한다.