지난 글에서 1.5B의 평가점수를 85%까지 올렸다. 더 올려보려 했는데 인용을 올리면 오탐이 늘고, 오탐을 잡으면 인용이 떨어지는 일이 다섯 번 반복됐다.
그래서 이게 모델의 성능한계인지, 평가하는 방식의 문제인지, 또는 봇에게 주는 입력의 문제인지를 확인해봤다.
모델의 성능한계인가
삽질
| 시도 | 좋아진 것 | 나빠진 것 |
|---|---|---|
| 1차 학습 (79:21) | 인용 3/15 → 11/15 | 오탐 3/18 → 8/18 |
| 비율 재학습 (70:30) | 오탐 8/18 → 3/18 | 인용 11/15 → 10/15 |
| RFT | 오탐 1/18 → 0/18 | 인용 9/15 → 7/15 |
| DPO | 인용 13/15 → 15/15 | 오탐 1/18 → 5/18 |
| 시간 대조 쌍 | 긴 로그 5/7 → 6/7 | 짧은 로그 20/21 → 17/21 |
프롬프트에는 "짧은 시간에 몰려 있으면 로그가 몇 줄 안 돼도 근거로 본다"는 규칙이 있는데, 모델은 시간이 아니라 줄 수만 보고 있었다. 같은 알림인데 10줄이면 근거로 인정하고 8줄이면 표본이 부족하다고 버렸다.
그래서 로그 내용은 그대로 두고 시각만 바꾼 쌍을 28건 만들어 학습시켰다. 8줄이 3분 안에 몰려 있으면 근거로 보고, 같은 8줄이라도 6시간에 흩어져 있으면 근거가 아닌 걸로 본다. 로그 줄 수만 보는게 아니라 시각까지 보고 판단을 할 수 있게 만들었다. 그런데도 긴 로그 시나리오를 1개 더 맞힌 대신 짧은 로그 시나리오를 3개 더 틀렸고, 오탐도 1/18에서 5/18로 늘었다.
다섯 번이 전부 이런 모양이라 모델이 나아진 게 아니라 근거가 있다고 답하는 빈도만 오르내린 게 아닐까 의심이 들었다.
빈도만 바뀐 걸까
인용과 오탐 숫자만으로는 모델이 나아진 건지 답하는 빈도만 바뀐 건지 구분이 안 될수도 있다고 생각이 문득 들었다.
빈도와 상관없이 보려면 지난 글에서 후보 모델을 비교할 때 쓴 AUC를 쓰면 되는데, 근거가 있는 시나리오에 더 높은 확률을 줬는지만 보는 값이라 모델이 근거가 있다고 답하는 빈도가 바뀌어도 그대로다.
| 버전 | AUC |
|---|---|
| 학습 전 | 0.670 |
| 비율 학습 (3차) | 0.902 |
| 대조 쌍 학습 | 0.933 |
빈도만 바뀌었다면 세 값이 같아야 하는데 다르다. 적어도 이 사이에서는 모델이 실제로 나아졌다.
다만 33종으로 잰 값이라 우연히 이만큼 차이 났을 수도 있다. 그래서 시나리오를 무작위로 다시 뽑아 같은 계산을 만 번 반복하고, 차이가 어디까지 흔들리는지 봤다. 같은 알림에서 나온 시나리오끼리는 서로 유사해서 시나리오가 아니라 알림 단위로 뽑았다.
| 비교 | AUC 차이 | 흔들리는 범위 (95%) |
|---|---|---|
| 학습 전 → 대조 쌍 학습 | +0.263 | +0.074 ~ +0.364 |
| 학습 전 → 비율 학습 | +0.227 | -0.167 ~ +0.459 |
| 비율 학습 → 대조 쌍 학습 | +0.027 | -0.227 ~ +0.272 |
범위가 음수부터 양수까지 걸쳐 있으면, 좋아졌는지 나빠졌는지 이 데이터로는 모른다는 뜻이다. 학습 전에서 대조 쌍 학습까지는 범위가 전부 양수라 확실히 나아졌지만, 나머지 두 비교는 그렇지 않았다.
각 시도마다 무엇을 했나
모델은 근거가 있을 확률을 내고, 그 확률이 0.5를 넘으면 "근거 있음"으로 답한다. 이 0.5를 0.3이나 0.8로 바꾸면 근거가 있다고 답하는 빈도가 바뀐다. 그래서 0.5일 때의 정확도와, 가장 잘 맞는 값으로 바꿨을 때의 정확도를 같이 보면 시도마다 실력을 바꿨는지 빈도만 바꿨는지 알 수 있다.
가장 잘 맞는 값은 32종으로 고르고 남은 1종으로 채점하는 걸 33번 반복해서 정했다. 고른 데이터로 채점까지 하면 점수가 부풀기 때문이다.
| 버전 | AUC | 0.5일 때 정확도 | 가장 잘 맞는 값일 때 정확도 |
|---|---|---|---|
| 학습 전 | 0.670 | 52% | 70% |
| 비율 학습 (3차) | 0.902 | 72% | 91% |
| 대조 쌍 학습 | 0.933 | 91% | 88% |
| 시간 대조 쌍 | 0.900 | 82% | 85% |
| DPO | 0.874 | 85% | 82% |
- 비율 학습은 AUC가 0.670에서 0.902로 올랐다. 실력이 늘었다
- 비율 학습도 기준값만 바꾸면 91%가 되는데, 대조 쌍 학습은 0.5에서 이미 91%다. 대조 쌍 학습은 실력을 올렸다기보다 빈도를 맞춘 쪽에 가깝다
- 시간 대조 쌍과 DPO는 AUC가 오히려 조금 내려갔다
다만 이 셋 모두 범위를 구해보면 음수부터 양수까지 걸쳐 있어서 확정할 수는 없다. 그래서 학습한 네 버전 중 어느 게 더 나은지는 시나리오 33개로 가릴 수 없다.
그래도 하나 확실한 건, 대조 쌍 학습은 기준값을 옮겨도 91%보다 나아지지 않는다. 확률을 거의 0 아니면 1로만 내서, 기준값을 웬만큼 옮겨서는 판정이 바뀌지 않는다. 틀린 3종도 아슬아슬하게 틀린 게 아니라 확신을 갖고 틀렸다.
모델은 자기가 틀린 걸 알까
지난 글에서 로컬 모델이 대부분 처리하고 헷갈릴 때만 Gemini로 넘겨도 되겠다고 생각을 했는데, 그러려면 모델이 틀릴 때는 덜 확신하고 있어야 한다.
먼저 모델이 내뱉는 확률을 믿을 수 있는지 봤다. 0.9라고 답한 것 중 실제로 열에 아홉이 맞아야 한다. 학습 전 모델은 거의 그랬는데, 학습한 버전들은 실제보다 4배에서 8배 더 확신했다. 확률을 한꺼번에 낮춰주면 이 차이는 고칠 수 있다. 하지만 모든 확률을 같은 방식으로 낮추는 거라 높고 낮은 순서는 그대로고, 그래서 판정은 하나도 안 바뀐다.
그래서 확신의 정도만 보고 틀린 답을 골라낼 수 있는지 쟀다. 앞에서 쓴 AUC를 이번에는 정답과 오답 사이에 적용한 것이다.
| 버전 | 틀린 답 골라내기 (AUC) |
|---|---|
| 비율 학습 | 0.850 |
| 대조 쌍 학습 | 0.678 |
대조 쌍 학습은 0.678로 찍는 것에 가깝다. 틀릴 때도 맞을 때만큼 확신하기 때문이다. 앞에서 본 대로 틀린 3종도 확신을 갖고 틀렸으니 당연했다. 그래서 확신이 낮은 것만 Gemini로 넘기는 방식은 이 모델에서는 쓸 수 없다. 반대로 비율 학습 버전은 0.850이다. 틀릴 때는 맞을 때보다 확실히 덜 확신해서, 확신이 낮은 것만 넘기면 틀린 답을 꽤 잘 걸러낸다. 정확도는 대조 쌍 학습보다 낮은데, 자기가 틀릴 때를 아는 건 오히려 이쪽이다.
합치면 나아질까
확신이 낮은 답만 골라 Gemini에게 맡기는 게 안 된다면, 여러 버전이 낸 확률을 평균 내서 그 평균으로 판정하면 된다. 이미 확률을 다 뽑아놔서 학습을 새로 할 필요도 없고, 가능한 조합 열두 개를 계산했다.
| 조합 | AUC | 가장 잘 맞는 값일 때 정확도 |
|---|---|---|
| 대조 쌍 학습 단독 | 0.933 | 29/33 |
| + 시간 대조 쌍 | 0.967 | 30/33 |
| + DPO | 0.974 | 31/33 |
| + 시간 대조 쌍 + DPO | 0.974 | 31/33 |
어떻게 합쳐도 대조 쌍 학습 하나만 쓸 때보다 낫다. 혼자서는 AUC가 더 낮았던 시간 대조 쌍과 DPO도 합치면 도움이 됐다. 버전마다 틀리는 시나리오가 조금씩 달라서 한 버전이 틀린 걸 다른 버전이 맞혀주기 때문인 것 같다.
다만 이번에도 범위가 음수까지 내려가서 합친 쪽이 정말 낫다고 말할 수 없어 쓰진 않았다.
여기까지 해보니 처음 학습은 모델 실력을 실제로 올렸지만, 그 뒤에 한 시도들은 대부분 근거가 있다고 답하는 빈도만 바꾼 것 같다. 그리고 뭘 재든 좋아지는 것 같긴 한데 시나리오 33개로는 확실하다고 말할 수 없는 결과만 계속 나왔다. 그래서 모델보다 평가하는 방식에 문제가 있는 건 아닌지 보기로 했다.
평가하는 방식 탓인가
33개로 알 수 있는 차이일까
다음으로 모델 크기와 학습 비율을 같이 바꿔보려고 했다. 0.5B, 1.5B, 3B에 비율 세 가지씩이면 학습을 아홉 번 해야 하고 하룻밤이 걸린다.
이번에는 돌리기 전에 계산부터 했다. 시나리오 33개로 두 버전을 비교하면 실제로는 차이가 없어도 우연히 생기는 차이가 있다. 그보다 작은 차이는 재봐도 진짜인지 알 수 없다. 앞에서 범위를 구한 방식으로 보상 점수를 계산해보니까 그 기준이 0.115 정도였다.
| 비교 | 실제 보상 차이 | 33개로 알아낼 수 있는 최소 차이 |
|---|---|---|
| 학습 전과 후 | +0.218 | 0.125 |
| 70:30에서 59:41로 | +0.079 | 0.136 |
| 70:30에서 79:21로 | +0.012 | 0.126 |
비율을 어떻게 바꿔도 보상은 최대 0.079밖에 안 움직여서 0.115에 못 미쳤다. 그래서 학습을 돌리지 않았다.
모델이 커져도 효과가 같을까
대신 실험을 줄였다. 비율 세 가지를 크기마다 비교하는 건 포기하고 크기마다 학습 전과 후만 쟀다. 1.5B의 학습 전후 차이가 0.218로 최소 차이보다 커서 이 비교는 결과를 믿을 수 있다. 학습도 아홉 번에서 여섯 번으로 줄었다.
| 크기 | 학습 전 보상 | 학습 후 보상 | 차이 |
|---|---|---|---|
| 0.5B | 0.021 | 0.894 | +0.873 |
| 1.5B | 0.570 | 0.906 | +0.336 |
| 3B | 0.667 | 0.985 | +0.318 |
1.5B와 3B는 학습으로 오른 폭이 거의 같다. 모델 크기가 달라도 학습이 비슷하게 먹힌다는 걸 알 수 있었다.
0.5B는 학습 전에 답을 JSON으로 내지도 못해서, +0.873은 실력이 늘었다기보다 답하는 법을 처음 가르친 것에 가까워 비교에서 뺐다.
3B로 바꾸면 될까
3B는 학습하면 보상이 0.985로 Gemini와 같아진다. 그럼 3B로 바꾸면 되나 싶었는데, 1.5B 최종이 0.955라 차이가 0.030밖에 안 된다. 앞에서 33개로 알아낼 수 있는 최소 차이가 0.115였으니 이대로는 가릴 수 없다.
시나리오를 늘리면 최소 차이는 줄어든다. 다만 시나리오 수를 4배로 늘려야 최소 차이가 절반이 되는 식이라 줄이고 싶은 배수의 제곱만큼 시나리오가 필요하다. 0.115를 0.030까지 줄이려면 약 3.8분의 1로 줄여야 하니 시나리오는 3.8의 제곱인 약 14.5배가 필요하다.
| 정해야 하는 것 | 실제 보상 차이 | 필요한 시나리오 수 |
|---|---|---|
| 상위 두 버전 중 뭐가 나은가 | +0.030 | 478개 |
| 최종 버전이 Gemini와 다른가 | +0.030 | 477개 |
| 비율 중 뭐가 나은가 | +0.079 | 99개 |
478개는 만들 수 없는 수다. 지금 33개를 만드는 데도 생성기를 짜고 검증기를 다섯 개 붙이고 일일이 눈으로 다 검수했다. 그리고 차이를 확인할 수도 없는데 무료 티어 4코어 서버에 두 배 큰 모델을 올릴 이유도 없었다. 지난 글 끝에 어려운 일을 시키려면 모델을 키워야 할 것 같다고 적었는데 적어도 이 평가셋으로는 키워서 얻는 게 있는지 확인할 수 없었다.
보상을 믿어도 될까
앞에서 한 계산은 전부 보상으로 했다. 그런데 지난 글에서 Gemini와 비교한 건 채점자 총점 28 대 30이었다. 그래서 둘이 같은 걸 재는지 확인해봤다.
최종 버전 33개에서 시나리오마다 보상과 채점자 통과 여부를 나란히 놓았다. 채점자가 통과시킨 27개는 보상이 평균 1.000, 떨어뜨린 6개는 0.750이었다. 둘이 어느 정도 같이 움직이긴 한다. 그런데 보상이 깎인 3개는 채점자도 다 떨어뜨렸지만 보상이 만점인 30개 중 3개도 채점자가 떨어뜨렸다. 이유는 아래와 같다.
- 원인 가설이 기준이 요구한 'laddergame 스트림'을 구체적으로 짚지 못했다
- 증상 하나를 근본 원인이라고 답했다
- 조치에 로그에 없는 'Redis 스트림 오류'를 지어냈다
앞의 두 개는 원인을 제대로 짚었는지의 문제인데 보상은 그걸 안 본다.
마지막 하나는 채점자가 틀렸다. 봇이 말한 Redis 스트림 오류는 로그 둘째 줄에 실제로 있었다. 같은 답을 열 번 채점시키니 세 번은 지어냈다고 판정했다. 지난 글 총점이 28인데 여기서 27로 센 것도 이 한 개 때문이었다.
정리하면 보상이 깎이면 채점자도 떨어뜨리지만 보상이 만점이라고 채점자가 통과시키는 건 아니다.
더 큰 문제는, 최종 버전의 보상 33개 중 30개가 만점이고 3개만 0.5다. 보상으로는 사실상 3개만 구분되고 나머지 30개는 전부 같은 점수다. 478개가 필요하다고 나온 것도 차이가 작아서이기도 하지만 보상으로 구분되는 시나리오가 3개뿐이어서이기도 했다. 그리고 배점을 바꾸면 결론이 달라지는지도 안 재봤고 채점자 총점으로는 이 계산을 하지도 않았다.
지표를 더 예민하게는 못 하나
시나리오를 늘릴 수 없다면 남은 방법은 같은 33개로 더 잘 구분하는 점수를 만드는 것이다. 지금 보상은 있지도 않은 클래스 이름을 지어내도 만점을 줘서 그런 답을 감점하는 규칙을 붙여봤다.
맞는 답을 깎으면 안 되니까 멀쩡한 답 78개에 먼저 걸어보고 하나라도 걸리면 쓰지 않기로 했다. 걸린 건 0개였다. 문제는 실제 모델 답 166개에 걸어봐도 새로 잡힌 게 하나도 없었다는 것이다. 지금 모델은 이름을 지어내서 틀리는 게 아니라 있는 이름으로 원인을 잘못 짚어서 틀리기 때문이다. 그걸 잡으려면 내용을 이해해야 하고 결국 채점자를 불러야 한다.
결국 시나리오 33개와 지금 보상으로는 상위 버전끼리 비교할 수 없었고 시나리오도 점수도 쉽게 늘릴 방법이 없었다. 그래서 시나리오를 늘리는 목적을 비교를 더 정확하게 하는 게 아니라 아직 시나리오가 없는 알림을 채우는 쪽으로 바꿨다.
봇에게 주는 입력 탓인가
빠진 알림을 채우면 될까
운영에 정의된 알림 룰 29종 중 평가셋에 시나리오가 있는 건 16종이고 13종은 하나도 없다. 지금 비율대로 70개까지 늘리면 AppErrorLogSpike 시나리오만 같이 늘어나서 개수만 많아진다.
시나리오가 없는 13종 중 앱에서 나는 알림 4종으로 20개를 만들어봤는데 검증을 통과한 건 4개뿐이었다. 떨어진 이유는 시나리오를 잘못 만들어서가 아니었다.
봇은 로그를 다 보고 있을까
봇은 알림을 받으면 그 시각 근처의 로그를 가져와 모델에게 주는데 그 사이에 로그를 두 번 거른다.
Loki에 쌓인 로그 전부
│
├─ environment 라벨이 붙은 애플리케이션 로그만 ← 인프라 컨테이너 로그 제외
│
├─ 그중 ERROR가 들어간 줄만 ← WARN 이하 제외
│
▼
모델이 보는 것두 필터 모두 이유가 있다. 인프라 로그까지 넣으면 양이 너무 많고 WARN까지 넣으면 평소에도 수백 줄이 들어온다. 문제는 알림에 따라서는 이 필터 때문에 근거가 아예 모델에게 안 간다는 것이다. 예를 들어 디스크가 찼다는 알림의 근거는 앱 로그가 아니라 서버 상태를 모으는 node exporter 쪽에 있다.
봇이 볼 수 없는 알림은 얼마나 될까
알림 29종마다 봇이 원인을 보여주는 로그를 볼 수 있는지 하나씩 셌다. 검증을 통과한 양성 시나리오가 있으면 볼 수 있다고 봤고 없으면 그 알림이 보는 지표를 따라가서 관련 로그를 팀 레포에서 찾고 로그 레벨을 확인했다.
| 결과 | 개수 | 이유 |
|---|---|---|
| 봇이 원인을 볼 수 있음 | 8종 (28%) | 양성 시나리오가 검증을 통과했다 |
| 볼 수 없음: 원인이 인프라 로그에 있음 | 9종 (31%) | node, exporter, 에이전트 쪽 로그 |
| 볼 수 없음: 원인이 WARN 로그에만 있음 | 2종 (7%) | IP 차단 로그가 전부 log.warn |
| 볼 수 없음: 원인을 남기는 로그가 없음 | 1종 (3%) | 지표만 있고 로그를 안 남긴다 |
| 모름 | 9종 (31%) | 경우를 나눠 확인하지 않았다 |
최소 12종, 전체의 41%는 봇이 근거를 찾을 수가 없다. 모델을 아무리 잘 만들어도 이건 안 바뀐다. 모름 9종을 확인하려면 운영 서버에서 알림을 일부러 터뜨려봐야 해서 하지 않았다. 그래서 실제 비율은 41%보다 높을 수 있고 최대 72%까지 갈 수 있다.
세다가 헷갈린 건, 알림 자체가 로그에 남는지가 아니라 알림을 일으킨 실패가 로그에 남는지를 봐야 한다는 점이다. CircuitBreakerOpen은 서킷 브레이커가 열렸다는 로그는 안 남지만 열리게 만든 실패는 ERROR로 남는다. 알림 자체가 로그에 남는지로 셌으면 볼 수 없다고 잘못 셌을 것이다.
IP 차단은 반대다.
log.warn("악성 경로 접근 감지 → IP 즉시 차단: ip={} uri={}", ip, uri);
log.warn("차단된 IP 접근 시도: ip={} uri={}", ip, uri);log.error가 하나도 없고 차단은 외부 스캐너가 일으키는 거라 원인이 된 앱 에러도 없다. 지난 글에서 IP 차단 알림에 카드게임 에러 로그가 붙어온 예시를 들었는데 바로 이 경우다. 봇이 볼 수 있는 게 그 상관없는 로그뿐이었다.
모델 대신 입력을 고치기로
이 작업은 봇이 근거 없이 원인을 지어낸다는 문제에서 시작해서 모델을 고쳤다.
그런데 29종을 세보니 그 문제 정의가 다 맞지는 않았다. 어떤 알림에서는 봇이 지어낸 게 아니라 볼 수 있는 게 그것밖에 없었다.
그래서 모델은 여기까지만 고치기로 했고, 봇에게 어떤 로그를 보여줄지 살펴봤다. ERROR 말고 다른 로그도 가져오고, 인프라 컨테이너 로그에도 라벨을 붙이고, 지표만 있고 로그가 없는 곳에 로그를 추가했다.
다만 로그를 더 많이 보여주면 상관없는 로그도 같이 늘어나서 오탐이 다시 늘 수 있다. 그래서 바로 바꾸자고 하기 전에 로그를 넓힌 상태로 같은 33개를 다시 돌려서 오탐이 얼마나 느는지, 봇이 원인을 볼 수 없던 12종에서 실제로 볼 수 있게 되는지부터 재보려고 한다.
마무리하며
우선 시나리오 33개가 부족하다는 건 알았는데 역시나 부족했다. 하나하나 직접 만들고 검수한 시나리오라 만들다보면 꽤 많다고 생각했는데, 막상 버전끼리 비교해보니 좋아진 건지 아닌지 확실하게 말할 수 있는 게 거의 없었다. 점수를 올리는 것만큼 그 점수를 믿을 수 있는지 확인하는 게 중요하다는 걸 다시 느꼈다.
또 지난 글에서는 더 어려운 일을 시키려면 모델을 키워야 할 것 같다고 적었는데, 이번에는 생각이 조금 바뀌었다. 3B가 Gemini와 같은 점수를 냈지만 그 차이를 확인할 방법이 없었고, 애초에 봇이 원인을 볼 수 없는 알림도 많았다. 모델을 키우기 전에 모델이 무엇을 보고 있는지부터 확인하는 게 먼저인 것 같다.
그리고 실험을 돌리기 전에 계산부터 해보길 잘했다. 하룻밤 걸릴 학습을 돌렸으면 그럴듯한 결과는 나왔겠지만 믿을 수는 없었을 것 같다.
마지막으로 ML 문제라고 생각하고 시작했는데 결국 남은 일은 의미 있는 로그들만 남기는 일이라는 걸 보면서, ZZOL을 처음 손으로 일일이 코딩하던 때가 문득 떠올랐다. 그 당시만 해도 작은 설계 하나, 로그 하나 왜 넣어야되는지 몇시간을 토론하며 개발했었는데 지금은 그때만큼 코드 한 줄에 큰 의미를 두면서 하고 있지는 않은 것 같다.
다음 글에서는 이 모델을 실제로 서버에 올려보겠다~