들어가기 전에
왜 작은 로컬 모델을 사용하나요
사내에서 내가 하는 일도 그렇고, 여기 ZZOL에서도 마찬가지로 보안과 비용 측면에서 Gemini 같은 상용 모델보다 직접 서빙해서 쓰는 로컬 모델에 관심을 갖고 있는데, 과연 이 차이가 얼마나 나고, 그 간격을 어떻게 메꿀 수 있을지 요즘 고민을 하고 있다.
지난 평가 하네스에서는 프롬프트와 입력을 고정한 채 모델만 바꿔 평가할 수 있게 만들어뒀다. 그래서 하네스를 완성하고 제일 먼저 Gemini를 부르던 자리에 노트북에서 돌아가는 모델을 넣고 같은 평가셋을 돌려봤는데, Gemini는 91%가 나왔고 1.5B는 45%가 나왔다.
이 글은 그 점수를 85%까지 올리면서 무엇을 근거로 무엇을 골랐는지에 대한 기록이다. 사소한 해결과정이라도 사실대로 적어봤다. 측정 코드와 실행 원본은 zzolbot-evals에 있다.
어떤 모델을 쓰나요
맥북에서 돌고 무료 티어 서버에 올라가는 후보들을 학습 없이 한 번 돌려봤다.
| 모델 | 파라미터 | 파싱 성공 | 곡선 값 | 프롬프트 |
|---|---|---|---|---|
| Qwen2.5-0.5B | 0.49B | 1/33 | 계산 불가 | 그대로 |
| Llama-3.2-1B | 1.24B | 3/33 | 계산 불가 | 그대로 |
| Qwen2.5-1.5B | 1.54B | 33/33 | 0.670 | 그대로 |
| gemma-2-2b | 2.61B | 27/33 | 0.908 | 수정 필요 (따로 관리 필요) |
곡선 값은 근거가 있는 경우와 없는 경우를 얼마나 잘 가려내는지를 0.5에서 1 사이로 나타낸 값이고, 0.5면 찍는 것과 같다.
맨 위의 두 모델은 JSON이 깨져서 판정을 할 수조차 없었고, 반대로 gemma는 0.908로 훨씬 잘 가려낸다. 학습 전인데도 성능이 좋았다. 대신 gemma-2의 템플릿은 system 역할을 안 받아서 프롬프트를 따로 수정해야한다. 그리고 모델을 띄울 곳이 무료 티어 4코어 서버고 그 위에서 이미 다른 여러 컨테이너가 돌고 있었기 때문에, 여유공간을 생각했을 때 1.5B와 2.6B 차이는 무시할 수 없었다.
그래서 Qwen2.5-1.5B-Instruct-4bit를 선택했다. 프롬프트를 수정 없이 쓸 수 있고, 파싱을 오류 없이 해냈고, 서버에 남은 자원으로 올릴 수 있는 후보였다.
4비트는 가중치 하나를 16비트 대신 4비트로 줄여 저장한 버전이다. (이 모델로 학습을 돌리면 메모리를 48GB 중 18~19GB 쓴다.)
쟁점이 무엇인가요
우선 이 봇은 사람의 자연어 기반 질문을 바탕으로 로그, 지표, DB를 적절하게 찾아서 근거와 함께 다시 자연어 답변을 줘야한다. 그 중에서 질의 시각 근처에 쌓인 에러 로그가 그 알림과 아무 상관 없을 때가 있는데, 그럴 때는 모른다고 답해야 하는 걸 무관한 로그를 근거로 삼아 그럴듯한 원인을 만들어내는 경우가 다수였다.
무엇을 평가하나요
평가셋은 실제 로그 포맷과 알림 룰을 보고 직접 설계한 시나리오 33종이다. 로그에 진짜 원인이 있어서 찾아내야 정답인 게 15종, 로그가 알림과 무관해서 모른다고 해야 정답인 게 18종이다. 전자를 양성, 후자를 음성이라고 부른다.
점수는 두 가지로 낸다.
총점은 채점자가 통과시킨 시나리오 수로, 33종 중 몇 종인지로 쓴다. 원인이 진짜인지 판정까지 보는 대신 채점자를 매번 불러야 한다.
보상은 채점자 없이 코드만으로 매기는 점수다. 형식이 유효하면 0.1, 판정이 정답과 맞으면 0.5, 인용이 로그 한 줄과 정확히 일치하면 0.4를 주고, 근거가 없는데 원인을 말하면 0.3을 뺀다. 만점 1.0이다. 호출 비용이 없어서 학습을 여러 번 돌려가며 비교할 때 이쪽을 쓴다.
배점은 이렇게 잡았다. 근거가 있냐 없냐를 맞히는 게 이 일의 본질이라 판정에 0.5를 줬고, 판정을 맞혀도 근거를 못 대면 운영에서 쓸 수 없으니 인용을 그와 비슷한 무게로 뒀다. 형식은 JSON이 깨지면 나머지를 셀 수조차 없는 최소 조건이라 작게 줬다. 감점은 판정을 틀린 것보다 가볍고 형식 실수보다 무겁게 뒀다.
누가 평가하나요
평가도 LLM이 한다. 평가 결과를 믿을 수 있기 위해 일부러 정답에 여러 변형을 만들어서 채점용 LLM을 평가해봤다. 원인을 엉뚱한 로그로 바꿔보거나, 코드명을 바꿔보거나, 판정을 뒤집거나 근거도 지워본 답변을 채점자가 보고 평가하도록 만들었고, 65건 중 65건 일치를 확인했다.
깡통 로컬 모델을 평가해보자
판정이 어떻게 갈리는지 보려고 33종을 두 번씩 돌려 66번을 봤다. 분포는 이렇다.
| 답: 근거 있음 | 답: 근거 없음 | |
|---|---|---|
| 정답이 근거 있음 (30번) | 6 | 24 |
| 정답이 근거 없음 (36번) | 6 | 30 |
66번 중 54번을 "근거 없음"이라고 답했고, 그중 정답이 근거 없음인 30건은 통과했다. 판정만 놓고 세면 66번 중 36번을 맞혔다.
그런데 총점은 45%가 나왔다. 채점자는 판정만 보지 않고 근거와 원인까지 같이 보기 때문에, 판정을 맞혔어도 원인을 두루뭉술하게 말하면 탈락한다.
점수의 대부분이 실력이 아니라 답변 전략에서 나왔고, 모른다고만 답해도 음성 18종은 다 맞았다. 평가셋에 양성과 음성을 적절히 섞어놓길 잘했다.
정확히 뭘 못 하는 걸까
모델이 틀리는 경우를 전부 일일이 살펴봤다. 잘못된 케이스는 아래와 같다.
- 양성인데, 로그 인용을 제대로 못한 경우
- 음성인데, 원인이 있다면서 로그를 갖고 왔지만, 그 로그마저 강등되면서 결국 옳지 않은 경로로 정답이 된 경우
우선 1번 경우는 로그를 있는 그대로 긁어오지를 못했다. 다행히 없는 걸 지어내지는 않았는데, 대괄호를 빼먹는다거나, 트레이스 필드를 빼먹는다거나, 타임스탬프만 옮겨 적는 경우가 많았다.
2번의 경우는 복잡했다. 음성은 애초에 근거가 없다고 대답해야 정답인 케이스들이다. 하지만 모델은 근거가 있다고 대답을 했는데, 가져온 근거들이 제대로 되지 않아서 강등이됐고, 그래서 결국 최종 출력이 정답이 된 케이스들이다.
33종을 다 나눠보면 이렇다.
| 모델이 근거 있다고 주장 | 인용 검증 통과 | 강등 | |
|---|---|---|---|
| 양성 15종 | 13 | 3 | 10 |
| 음성 18종 | 15 | 3 | 12 |
프롬프트로는 안 될까
비용이 훨씬 싸니까 모델을 건드리기 전에 프롬프트로 될지 먼저 봤다.
안 됐는데 이유가 두 가지였다.
프롬프트는 이미 할만큼 다듬어진 상태였다. 로그를 근거로 삼을 때 지켜야 할 규칙 두 개를 넣어서 Gemini 기준 18/20을 20/20으로 올린 적이 있다. 소형 모델은 그렇게 다듬은 프롬프트를 그대로 쓰는데도 인용을 못 한다.
규칙을 더 넣어봤지만 효과가 노이즈와 구별되지 않았다. 겨냥한 7종 중 4종만 잡혔고, 나머지에서 나온 변화는 측정 오차와 구분할 수 없는 크기였다.
프롬프트로 얻을 수 있는 건 얻은 상태라, 남은 건 모델 쪽이라고 판단했다.
학습시키기
사실 파인튜닝은 학습이 아니란 걸 안다. 새로운 개념을 알려주는게 아니라 그저 가중치 조정이지만, 이 튜닝으로라도 이 문제를 해결할 수 있을지 궁금했다.
다만 가중치 15억 개를 전부 다시 조정하는 건 맥북으로 안 된다. 그래서 원본 가중치는 그대로 고정해두고 각 층에 작은 보정용 가중치를 덧붙여 그것만 학습하는 방식인 LoRA(Low-Rank Adaptation)를 썼다. 학습할 양이 훨씬 적어서 맥북에서 30분이면 끝나고, 결과물도 새 모델이 아니라 20MB짜리 보정값 파일 하나로 나온다. 베이스가 4비트라 보정값도 그 위에서 조정된다.
설정은 이렇다.
| 값 | |
|---|---|
| 방식 | LoRA, rank 8, scale 20.0 |
| 적용 레이어 | 전체 28층 중 16층 |
| 학습률 | 1e-4, adam |
| batch / max_seq_length | 1 / 3200 |
| 데이터 | 100건 (train 85 / valid 15) |
| 한 번 돌리는 비용 | 학습 30분, 평가 5분, API 호출 0 |
rank는 덧붙이는 보정값의 크기다. 작을수록 학습할 게 적고 원본을 덜 흔든다. scale은 그 보정을 얼마나 강하게 반영할지다. 학습률은 틀렸을 때 한 번에 얼마나 고칠지에 대한 수치고, 크면 빨리 배우는 대신 튀고 작으면 안 튀는 대신 느리다. adam은 그 폭을 항목마다 자동으로 조절해주는 방식이다.
rank와 scale은 mlx-lm 기본값을 그대로 썼다. 보정을 어느 모듈에 붙일지도 기본값을 따랐는데, 흔히 알려진 것과 달리 q/v만이 아니라 레이어 안의 Linear 전부에 붙는다.
그리고 이번에는 저 숫자들을 한 번도 안 바꿨다. rank를 4나 16으로 해보지도, 붙이는 층을 줄여보지도 않았다. 앞으로 이 글에서 바꾸는 건 학습 데이터뿐이다.
그래서 뒤에 나오는 "데이터를 이렇게 바꿨더니 이렇게 됐다"는 결론들은 전부 어댑터 설정을 고정한 상태에서의 이야기다. 설정을 흔들면 결론이 달라질 수도 있는데, 그건 재보지 않았다.
학습 데이터
학습 데이터는 188개다. 데이터는 Gemini가 만들고 내가 전부 검수해서 틀린 내용이 없는지 확인했다. 알림과 로그를 주고 정답 JSON을 받는 식인데, 받은 걸 그대로 쓰지는 않고 다섯 가지를 코드로 검사해서 하나라도 걸리면 버렸다.
- 형식이 맞는가
- 근거 있음과 없음 판정이 기대와 맞는가
- 인용이 로그 원문과 일치하는가
- 근거가 없다고 해놓고 원인을 말하지는 않는가
- 답에 나온 식별자가 실제 로그에 있는가
학습데이터는 판정 분포가 양성 79%, 음성 21%였다. 데이터 경향이 양성에 좀 더 치우져져 있긴했지만, 실제 상황과 더 비슷하기도 하고, 대신 평가시에 오탐이 있는지 꼼꼼히 체크했다.
토큰 상한
학습을 돌리기 전에 정답이 토큰 상한 안에 온전히 들어가는지부터 봐야했다.
토큰은 모델이 글을 읽고 쓸 때 쓰는 최소 단위인데 한글이면 한두 글자쯤이 토큰 하나다. 학습 샘플 하나는 입력(알림과 로그)과 정답(JSON)을 이어붙인 것인데, 모델마다 한 번에 처리할 수 있는 토큰 수가 정해져 있어서 그 둘을 합친 길이가 상한을 넘으면 넘친 만큼 맨 뒤가 잘린다.
그래서 학습시킬 데이터들의 토큰 길이를 재봤다.
| 중앙값 | 최대 | |
|---|---|---|
| 학습 샘플 전체 | 2235 | 3086 |
기본 상한이 2048인데 입력과 정답을 합친 길이의 중앙값 2235다. 100건 중 70건이 상한을 넘었다. 넘친 만큼 맨 뒤가 잘리는데, 응답 스키마 구조상 근거부분(evidenceLine)이 맨 뒤에 있어서 문제가 됐다.
{"summary": ..., "rootCauseHypothesis": ..., "suggestedActions": [...],
"evidenceFound": ..., "evidenceLine": "..."}그래서 상한을 3200으로 올렸다. 데이터를 짧게 깎는 방법도 있었지만, 실제 시나리오를 기반으로한 평가셋의 입력이 최대 2573이기도 했고, 메모리도 여유가 있었다. 대신 배치를 2에서 1로 낮춰놨다.
인용을 잡아보자
가장 먼저 우선시 했던 건 인용이었는데, 어느정도 의도대로 되긴했다.
| 근거 주장 (양성 중에서) | 인용 통과 (양성 중에서) | 오탐 (음성 중에서) | |
|---|---|---|---|
| 학습 전 | 13/15 | 3/15 | 3/18 (17%) |
| 학습 후 | 14/15 | 11/15 | 8/18 (44%) |
같은 시나리오끼리 학습 전후를 짝지어 세어보면 개선 8, 악화 0이었다. 원래 통과하던 3종을 유지하면서 8종을 더 통과시켰다. 혹시 몰라서 여러 시드를 주입해서 평가도 해봤는데 매번 통과 개수, 개선과 악화 구성, 근거 주장 횟수까지 일치했다.
하지만 오탐이 17%에서 44%가 됐다.
왜 오탐이 늘었을까
위에 적어놓긴 했지만 학습데이터의 비율이 양성 79%, 음성 21%다. 아마 학습 데이터의 판정 비율을 모델이 따라하지 않았을까... 생각했다.
그래서 이게 진짜 원인인지도 확인해봤다. 모델도 학습 설정도 평가셋도 그대로 두고 데이터 비율만 옮겼다. 음성 샘플을 늘려 59 대 41로 맞추고 평가도 해보고 다시 양성도 조금 늘려서 70대 30으로 맞추고 평가해봤다.
| 학습 비율 (양성 / 음성) | 인용 통과 | 오탐(최종) | |
|---|---|---|---|
| 학습 전 | - | 3/15 | 3/18 (17%) |
| 1차 | 79 / 21 | 11/15 | 8/18 (44%) |
| 2차 | 59 / 41 | 8/15 | 2/18 (11%) |
| 3차 | 70 / 30 | 10/15 | 3/18 (17%) |
음성 비율을 올리면 오탐이 따라 내려가고 인용도 같이 내려간다. 세 번 다 같은 방향이었다. 2차에서 인용이 11에서 8로 떨어졌을 때 되돌릴 수도 있었는데, 떨어진 게 능력인지 성향인지부터 확인했다.
| 근거를 주장한 횟수 | 주장했을 때의 정확도 | |
|---|---|---|
| 1차 | 14/15 | 79% |
| 2차 | 9/15 | 89% |
주장할 때의 정확도는 오히려 올랐다. 로그를 옮겨 적는 능력은 유지됐고 주장하는 빈도만 내려갔다. 학습이 망가진 게 아니라 판정 성향이 옮겨간 것 갔다.
우선 말할 수 있었던 건, 비율과 성향이 같이 움직인다는 것이다. 세 번이 같은 방향을 가리킨다. 바꾼 게 데이터의 판정 비율뿐인데 모델의 판정 비율이 따라왔다. 학습 비율에 따라 얼마나 자주 근거가 있다고 말하는지에 관한 가중치가 조정된 것 같다.
세 비율 중 70대30이 제일 낫다는 건 말할 수 없다. 3번의 케이스 차이가 평가셋 33종으로는 잡을 수 없다고 생각한다. 그래서 3차를 고른 건 최적을 찾은 게 아니라 두 조건을 다 만족하는 지점 하나를 잡은 것에 가깝다.
모델의 파라미터 문제인가
그리고 이 비율이 1.5B에서만 그런건지 확인해봤다. 크기를 바꿔가며 같은 학습을 다시 돌려보니 3B에서도 비슷한 크기의 효과가 나왔다. 학습 비율이 중요하겠다고 생각이 들었다.
디코더로 막기
남은 실패를 보니 유형이 달랐다.
모델 인용: [2026-08-26 14:10:05.122] [ERROR] [,] --- [pool-1-thread-4] c.g.h.RedisStream...
실제 원문: [2026-08-26 14:10:05.130] [ERROR] [,] --- [pool-1-thread-4] c.g.h.RedisStream...사실상 크게 틀린 건 아니지만 로그 시각 밀리초가 달라서 실패로 판정됐다. 이런 건 데이터를 늘려도 확률적으로 생기는 경우고, 이건 가중치로 할 수 없는 일이었다.
모델은 출력을 한 번에 쓰지 않는다. 다음에 올 토큰 하나를 어휘 사전 전체에서 고르고, 그걸 뒤에 붙인 다음 또 그다음 토큰을 사전에서 고른다. 이렇게 한 글자씩 늘려가면서 출력을 뽑아내고, 토큰을 고르는 과정을 디코딩이라고 한다.
그래서 학습이 아니라 디코딩 단계에서 막으면 되지 않을까 생각했다. 고르는 순간에 사전에 다 펼쳐주지 않으면 되니까 위와 같은 케이스를 해결할 수 있을 것 같았다. "evidenceLine": " 를 만난 다음부터는 실제 로그 줄들로 만들어둔 후보 목록 안에서만 다음 토큰을 고르게 했다. 어휘 전체를 훑는 게 아니라 로그 줄 몇 개를 한 번 토큰화해두는 것이라 비용은 사실상 없다.
| 인용 통과 | |
|---|---|
| 3차 | 10/15 |
| 3차 + 디코딩 | 13/15 |
학습 없이 3종이 더 통과했다.
하지만 인용이 항상 정확해지면서 오탐이 3/18에서 7/18로 늘었다. 인용 검증은 날조된 인용을 잡으려고 만든 건데, 무관한 로그를 근거라고 주장하는 모델은 인용도 어설프게 하니 검증에 걸려 실제로는 오탐까지 막고 있었던 것 같다.
그래서 이후 실험에는 항상 켜두지 않고, 켠 것과 안 켠 것 둘 다 재기로 했다. 인용은 올리고 오탐은 늘리는 개입이라, 그 위에 학습을 쌓으면 나중에 무엇 덕인지 말할 수 없게 된다.
같은 로그, 다른 알림
오탐은 여전히 7/18이었고, 판정 성향을 비율로 조절하는 방식은 여기서 한계에 왔다.
오탐 유형을 다시 보니 모델이 알림과 로그의 관계를 보는 게 아니라 표면 패턴을 보고 있을 가능성이 있었다. 로그에 ERROR가 여러 줄 있으면 알림이 뭐든 원인이 있다고 답하는 식인 것 같았다.
그래서 알림과 로그 관계를 이해해야만 맞힐 수 있도록 로그를 고정하고 알림만 바꾼 쌍을 학습 데이터에 19쌍 넣었다. 같은 로그인데 어떤 알림에서는 근거가 되고 어떤 알림에서는 근거가 아니다.
두 로그가 정말 같은지는 코드로 강제해서, 바이트 단위로 같지 않으면 쌍이 만들어지지 않는다. 이 검사가 없으면 생성기가 로그를 미묘하게 바꿔도 통과해서 대조 학습의 전제가 조용히 무너진다.
같은 배치에 오탐 함정 15건도 같이 넣었다. 학습 커버리지가 0이던 축 두 개를 겨냥한 것으로, 근거가 희소한 경우 8건과 인프라 계층 7건이다.
| 인용 통과 | 오탐(주장) | |
|---|---|---|
| 3차 + 제약 디코딩 | 13/15 | 7/18 |
| 대조 쌍 학습 | 9/15 | 1/18 |
| 대조 쌍 학습 + 제약 디코딩 | 13/15 | 1/18 |
오탐이 1/18로 내려갔다. 대조 쌍만 쓰면 인용이 9/15로 떨어지는데, 제약 디코딩을 같이 걸면 13/15로 다시 올라온다.
진짜 대조 쌍 덕분인가
이 결과가 대조 쌍 구조 덕인지 확인하려고 통제군을 뒀다. 비율만 음성 쪽으로 옮기고 대조 쌍은 안 넣은 경우(2차, 59:41)랑 비교해봤다.
| 비율 | 대조 쌍 | 인용 | 오탐(주장) | |
|---|---|---|---|---|
| 2차 (통제) | 59 / 41 | 없음 | 8/15 | 2/18 |
| 대조 쌍 | 52 / 48 | 19쌍 | 9/15 | 1/18 |
차이가 각각 1건이다. 방향은 가설과 맞지만 이 크기는 노이즈와 구별되지 않는다. 부호검정 p = 0.75이고 대조 쌍이 효과가 있었다고 말할 수 없다.
SFT는 샘플을 하나씩 독립적으로 학습하니 손실에 "이 둘은 알림만 다르다"는 내용이 없고, 배치가 1이라 쌍의 두 항이 같은 갱신에 들어가지도 않는다.
쌍을 만들어 넣으면 모델이 둘을 비교할 줄 알았는데, 손실 함수는 쌍을 본 적이 없다. 데이터에 그런 통계가 있으니 가중치가 그 통계를 따라갔을 뿐인 것 같다.
더 강하게 걸려면 여러 샘플의 수정량을 모아뒀다가 한 번에 반영해서 쌍의 두 항을 같은 갱신에 넣으면 되지만, 우선 정해진 일정이 있어서 이 실험군을 최종으로 확정했다.
| 건수 | 비율 | 오탐(최종) | |
|---|---|---|---|
| 1차 | 100 | 79 / 21 | 8/18 |
| 2차 | 135 | 59 / 41 | 2/18 |
| 3차 | 113 | 70 / 30 | 3/18 |
| 대조 쌍 + 함정 | 188 | 52 / 48 | 1/18 |
SFT가 유일한 선택지였나
학습에도 여러 종류가 있는데, SFT가 맞나 의문이 들었고, 가볍게 여러 테스트들도 해봤다.
먼저 RFT(Rejection sampling Fine-Tuning)다. 모델에게 답을 여러 번 내게 하고 그중 점수가 높은 것만 골라 다시 학습시키는 방식이다.
| 보상 | 인용 통과 | 오탐(주장) | |
|---|---|---|---|
| 대조 쌍 학습 | 0.906 | 9/15 | 1/18 |
| + RFT | 0.876 | 7/15 | 0/18 |
인용이 9/15에서 7/15로 떨어졌다. 잘한 답만 남겨 다시 배우니 이미 잘하는 것만 또 배운 것 같다.
다음은 DPO(Direct Preference Optimization)다. 좋은 답과 나쁜 답을 짝지어 주고 좋은 쪽을 선호하게 유도하는 방식이다. 원본에서 얼마나 벗어나도 되는지를 정하는 beta를 0.1과 0.5로 바꿔가며 돌렸고, 인용 쪽을 최대로 올린 상태에서 보려고 제약 디코딩을 같이 걸었다.
| 보상 | 인용 통과 | 오탐(주장) | |
|---|---|---|---|
| 대조 쌍 학습 + 제약 디코딩 | 0.955 | 13/15 | 1/18 |
| + DPO (beta 0.1) | 0.833 | 15/15 | 11/18 |
| + DPO (beta 0.5) | 0.924 | 15/15 | 5/18 |
인용이 15/15다. 로컬 모델이 만점을 찍은 건 처음이고 Gemini와 같은 값이다. 그런데 오탐이 1/18에서 11/18과 5/18이 됐다. 로그를 옮겨 적는 건 완벽해졌는데 아무 로그나 근거라고 주장한다.
원인이 둘이었다.
하나는 선호 쌍이 치우친 것이다. 쌍은 Gemini가 만든 정답을 좋은 답으로, 모델에게 여덟 번 물어 나온 최저 점수 답을 나쁜 답으로 짝지어 만든다. 그런데 이 모델은 이미 음성에서 오탐이 1/18이라 여덟 번을 다 맞힌다. 나쁜 답이 없으니 쌍도 없다. 그래서 쌍 17개 중 양성이 15개, 음성이 2개였다. "근거를 주장하라"를 열다섯 번 배우고 "모른다고 하라"는 두 번 배운 것이다.
다른 하나는 모데이 답을 길게 쓰는 쪽을 선호하도록 조정된 것이다. 근거가 있다고 답하면 원인을 설명하고 로그를 인용하니 답이 길어지고, 없다고 답하면 그 두 칸이 비어 짧아진다. 학습 데이터에서 두 배 차이가 난다(810자 대 409자). DPO는 답에 들어간 토큰 점수를 전부 더해서 두 답을 비교하는데 길이로 나누지 않는다. 그래서 토큰이 많을수록 차이를 벌리기 쉽고, 점수를 올리는 가장 쉬운 길이 길게 쓰는 것이 된다. 쌍의 82%가 그 방향이었고, 긴 답은 곧 근거가 있다고 답한 답이다. 그래서 최적화는 내가 원하는 방향이 아니라 점수를 올리는 가장 싼 방향으로 간다.
어쨌든 기준선에 못 미쳤으니 채택하지 않았다.
학습 결과는?
정리하면 다음과 같다.
| 인용 통과 | 오탐(주장) | 보상 | |
|---|---|---|---|
| 학습 전 | 3/15 | 15/18 (83%) | 0.570 |
| 학습 후, 제약 없이 | 9/15 | 1/18 (6%) | 0.906 |
| 학습 후 + 제약 디코딩 | 13/15 | 1/18 (6%) | 0.955 |
가중치 조정만으로 오탐 83%에서 6%로 줄였다. 인용은 디코딩 제약도 적용해서 개선했다.
ASIS, TOBE를 실제 IP 차단 요청이 급증했다는 알림 예시로 정리하면 아래와 같다. 봇에게 주어진 로그 네 줄은 전부 이것이다.
[2026-07-15 10:15:29.485] [ERROR] ... EventDispatcher : 이벤트 처리 실패:
consumer=SelectCardCommandEventConsumer, joinCode=PXMH, cardIndex=3
[2026-07-15 10:15:37.299] [ERROR] ... (같은 형태, PLAYER_2)
[2026-07-15 10:15:37.434] [ERROR] ... (같은 형태, PLAYER_2)
[2026-07-15 10:15:38.474] [ERROR] ... (같은 형태, PLAYER_1)카드게임 이벤트가 실패한 로그다. IP 차단과는 아무 상관이 없다. 정답은 모른다고 답하는 것이다.
학습 전
요약: IP 차단 요청 급증
근거 발견: 예
원인 가설: X-Forwarded-For 소실로 내부 IP가 BAN되는 패턴
제안 조치:
- 새로운 서비스에 대한 X-Forwarded-For 설정을 확인하고 이를 수정로그를 보고 낸 답이 아니다. 모델이 꾸며낸 내용을 적고 근거를 찾았다고 했다. 받는 사람은 이걸 봇의 진단으로 읽고 X-Forwarded-For 설정을 보러 가게 된다.
최종
요약: 제공된 로그에서 이 알림을 뒷받침할 근거를 찾지 못했습니다.
근거 발견: 아니오
원인 가설: (없음)
제안 조치:
- IP 차단 시스템의 메트릭을 확인하여 실제 차단 요청 급증이 발생했는지 검증
- IP 차단 로직 또는 관련 서비스의 최근 변경 사항 확인원인을 안 만든다. 그리고 로그에 없는 걸 확인하려면 어디를 봐야 하는지로 넘긴다. 진단이 아니라 다음 행동을 주는 쪽으로 바뀌었다.
숫자 83%에서 6%는 음성 18종에서 이 장면이 열다섯 번 나오다가 한 번으로 줄었다는 뜻이다.
Gemini와 비교해보기
같은 평가셋, 같은 프롬프트, 같은 채점 기준으로 나란히 쟀다.
| 총점 | 인용 통과 | 오탐(주장) | |
|---|---|---|---|
| Gemini 2.5 Flash | 30/33 (91%) | 15/15 | 1/18 (6%) |
| 1.5B 학습 전 | 15/33 (45%) | 3/15 | 15/18 (83%) |
| 1.5B 최종 | 28/33 (85%) | 13/15 | 1/18 (6%) |
없는 근거를 있다고 말하는 비율이 83%에서 6%가 됐다. 이 83%와 6%는 둘 다 주장 오탐이다. 모델이 입으로 뭐라고 했는지를 센 값이다.
물론 오탐이 둘 다 1/18이라고 해서 두 시스템이 같다고 말할 수는 없다. 18건에서 1건은 표본이 너무 작아서 이 값만으로는 실제 비율이 어디쯤인지 단정하지 못한다.
총점 2점 차이도 마찬가지다. 33종에서 2종이면 채점자가 한두 번 다르게 판정해도 뒤집힌다. "격차를 15점에서 2점으로 줄였다"는 말은 쓸 수 있지만 "거의 따라잡았다"는 아직 못 쓴다.
그래도 가용할 수 있는 서버 스펙 한도 내에서 빠르게 학습해서 띄울 모델을 만들었다는거에 의의를 둔다.
다른 걸 틀린다
틀리는 시나리오를 맞대어 보면 겹치는 게 하나뿐이다.
| 개수 | |
|---|---|
| 둘 다 틀림 | 1 |
| Gemini만 틀림 | 2 |
| 1.5B만 틀림 | 4 |
같은 점수대라기보다 서로 다른 걸 틀리고 있는 것 같다.
이건 라우팅 신호로 읽을 수 있다. 로컬이 대부분 처리하고 불확실할 때만 큰 모델로 넘기면 비용과 품질의 경계가 어디에 생기는지도 볼 수 있을 것 같다.
안 잰 것
품질만 쟀고 속도는 안 쟀다. 여기 나온 수치는 전부 맥북에서 나온 것이라 실제로 운영에 쓰려면 배포할 서버에서 다시 재야 한다. 알림 한 건에 몇 초가 걸리는지, 운영 서비스와 자원을 나눠 써도 되는지는 그때 정해지는데, 다음 글에서 다룬다.
마무리하며
모델에게 일을 가르쳤다고 생각했는데 실제로 옮긴 건 판정 빈도였다. 학습 데이터의 양성 비율을 79에서 59로, 다시 70으로 옮기면 모델이 근거가 있다고 말하는 빈도가 따라왔다. 세 번 다 같은 방향이었고 3B에서도 같았다. 무엇이 근거인지를 가르친 게 아니라 얼마나 자주 그렇게 말할지를 조절한 것 뿐이었다.
가중치로는 아예 안 되는 것도 있었다. 밀리초 두 자리를 틀리는 실패는 예시를 아무리 늘려도 확률적으로 남는데, 그건 학습이 아니라 디코더로 막았다.
그리고 이 숫자들에는 한계가 있다. 총점85%와 오탐6%는 33종에서 나온 값인데, 비율 세 가지 중 70대30을 고른 것도, 대조 쌍과 오탐 함정을 채택한 것도, 강화학습을 버린 것도 전부 같은 33종을 보고 결정했다. 고르는 데 쓴 문제로 시험을 본거라 처음 보는 알림에서는 더 낮게 나올 수도 있다.
여기까지가 모델을 네 번 고쳐 85%까지 올린 기록이다. 거기서 멈추지 않고 더 올려보려 했는데 인용성능을 올리면 오탐도 올라가고, 오탐을 내리면 인용성능도 내려가는 경우가 다섯 번 더 반복됐다. 그게 모델 탓인지, 재는 방식 탓인지, 아니면 애초에 봇에게 주는 입력 탓인지를 가려본 이야기는 다음 글에 적는다.