1편에서 정리했듯 Jev의 학습 방법 RLCD는 이름과 목표만 공개돼 있다. 보상 함수도, 내부 구조도, 논문도 없다. 그래서 “어떻게 만들었나”를 밖에서 분석할 방법이 없다.
대신 할 수 있는 일이 있다. Jev가 하는 일을 오픈 모델로 직접 만들어 보고, 어디까지 되고 어디서 막히는지 재는 것이다. 막히는 자리가 곧 TypeSafe가 실제로 돈을 받고 파는 부분일 것이다. M1 맥북(16GB)에서 MLX로 엔진을 만들고, 3편에서 라이브 Jev API에 돌린 것과 같은 문항으로 비교했다.
미리 적어 두면, 이건 Jev의 재현이 아니다. 인터페이스의 재현이다. 같은 시도를 더 크게 한 독립 프로젝트로 SemIf와 NanoJev가 있고, 여기서는 직접 만든 것과 직접 잰 것만 다룬다.
생성하지 않고 logits만 읽는다
LLM으로 분류를 시키면 보통 답을 문장이나 JSON으로 생성하게 한 뒤 파싱한다. 토큰을 하나씩 뽑는 동안 시간이 가고, 형식이 깨질 수 있고, 확률은 버려진다.
생성을 아예 안 하면 세 문제가 같이 사라진다. 선택지마다 글자 라벨(A, B, C…)을 붙여 프롬프트에 넣고, 모델에 forward를 한 번만 돌린 다음, 답이 나올 자리에서 그 라벨 토큰들의 logits만 꺼내 softmax를 취한다.
def label_logits(self, state, instructions, options):
ids = self._prompt_ids(state, instructions, options) # 채팅 템플릿까지 적용한 토큰열
logits = self.model(mx.array(ids)[None])[0, -1, :] # forward 1회, 마지막 위치
return logits[mx.array(self.label_ids[:len(options)])] # 라벨 토큰의 logits만
def choice(self, state, instructions, criteria):
probs = softmax(self.label_logits(state, instructions, texts))
best = argmax(probs)
return {"choice": keys[best], "confidence": rescaled_confidence(probs),
"probabilities": dict(zip(keys, probs))}
이 구조에서는 목록에 없는 답이 나올 수 없다. 읽는 값이 선택지 개수만큼의 logits뿐이기 때문이다. JSON 검증도 재시도도 필요 없다. Jev가 내세우는 “타입 오류가 수학적으로 불가능하다”는 성질은 이 방식이면 누구나 공짜로 얻는다.
나머지 두 타입도 같은 읽기의 변형이다. Noul은 yes와 no 두 라벨의 확률을 정규화한 값이다. 라벨 순서에 따른 쏠림을 줄이려고 순서를 뒤집어 한 번 더 돌린 뒤 평균을 냈다. Score는 등급 라벨의 확률로 기댓값을 계산한다. Jev가 “high”를 2.0이 아니라 1.99로 돌려주는 것도 같은 계산이다. 세 타입을 다 합쳐 엔진 본체가 70줄 정도다.
confidence 공식을 응답에서 역산했다
Jev는 Choice와 Score에 confidence를 같이 돌려주는데, 문서는 분포의 모양을 0과 1 사이 숫자 하나로 접은 값이라고만 설명하고 공식은 적지 않았다. 같은 뜻의 값을 내려면 공식을 알아야 해서, 일부러 답이 갈릴 만한 질문을 Jev에 던져 확률 분포와 confidence를 받아 후보 공식들과 맞춰 봤다.
| Jev가 준 확률 분포 | Jev의 confidence | 최대확률 | 1위−2위 | 1−엔트로피 | (최대−1/n)/(1−1/n) |
|---|---|---|---|---|---|
| 0.74, 0.14, 0.11, 0.01 | 0.65 | 0.74 | 0.60 | 0.43 | 0.653 |
| 0.94, 0.06, 0, 0, 0 | 0.92 | 0.94 | 0.88 | 0.86 | 0.925 |
| 0.89, 0.11 | 0.78 | 0.89 | 0.78 | 0.50 | 0.780 |
| 0.99, 0.01, 0 | 0.99 | 0.99 | 0.98 | 0.95 | 0.985 |
여섯 응답에서 평균 오차가 0.003으로, 소수 둘째 자리 반올림 수준이다. Jev의 confidence는 최대확률을 선택지 수에 맞춰 늘린 값이다. 확률이 고르게 퍼지면 0, 하나에 몰리면 1이 된다.
def rescaled_confidence(probs):
n = len(probs)
return (max(probs) - 1 / n) / (1 - 1 / n)
실무에서 알아둘 점이 하나 따라 나온다. 선택지가 둘일 때 최대확률 0.89는 confidence 0.78이다. 같은 0.9라는 임계값을 확률에 걸 때와 confidence에 걸 때 자동 처리되는 건수가 달라진다. 어느 숫자에 기준을 거는지 정해 두지 않으면 같은 규칙이 다른 결과를 낸다.
같은 문항으로 Jev와 비교했다
문항은 3편에서 Jev에 돌린 것 그대로다. 고객 문의를 다섯 분류 중 하나로 고르는 문항 45개를 영어와 한국어로 각각 써서 90개, 이중부정 문항 6개, 숫자와 날짜 문항 17개다. 모델은 Qwen3의 4bit 양자화 버전을 크기별로 썼다.
| 모델 | 영어 분류 | 한국어 분류 | 숫자·날짜 | 판정 하나당 |
|---|---|---|---|---|
| Qwen3-0.6B | 51% | 29% | 29% | 165ms |
| Qwen3-1.7B | 78% | 44% | 59% | 452ms |
| Qwen3-4B | 98% | 84% | 53% | 998ms |
| Qwen3-8B | 98% | 89% | 29% | 1,969ms |
| Jev (라이브 API) | 100% | 100% | 75% | 246ms (태평양 왕복 포함) |
0.6B는 쓸 수 없다. 다섯 개 중 하나를 찍으면 20%인데 한국어에서 29%가 나왔다. 1.7B도 부족하고, 4B부터 영어가 Jev에 가까워진다. 같은 엔진, 같은 프롬프트에서 모델 크기만 바꾼 결과라 인터페이스는 아무 기여를 하지 않는다는 뜻이다. 정확도는 전적으로 밑에 깔린 모델에서 나온다.
눈에 띄는 건 세 가지다.
한국어 격차가 크다. 4B는 영어 98%에 한국어 84%, 8B도 98%에 89%다. Jev는 두 언어 모두 100%였다. 다만 내 엔진은 질문과 선택지 설명, 문의는 한국어로 넣었지만 시스템 문구와 프롬프트의 틀은 영어 그대로였다. 전부 한국어로 바꾸면 줄어드는지는 아직 확인하지 않았다.
속도는 내 노트북이 진다. 4B에서 판정 하나에 1초다. Jev는 미국 서부까지 왕복 160밀리초를 포함하고도 246밀리초였다. 서버급 GPU에서 돌리면 달라지겠지만, 0.1초 안쪽의 서버 처리 시간은 작은 모델을 그냥 돌려서 나오는 값이 아니다. 같은 state에 질문 열 개를 붙여도 지연이 안 느는 것도 Jev 쪽 특징인데, 내 엔진은 질문마다 forward를 새로 돌린다. state 부분의 계산을 재사용하면 줄일 수 있지만 아직 구현하지 않았다.
숫자 문항은 크기와 같이 오르지 않았다. 8B가 29%로 오히려 떨어졌다. 모델 문제인지 라벨 방식의 문제인지 가리지 못했다. 선택지가 “6, 8, 10, 12, 14”인데 라벨이 “A, B, C…“라서 숫자와 라벨이 섞였을 가능성이 있다. 미해결로 남겨 둔다.
보정은 따로 해야 한다
정확도보다 더 중요한 차이는 확률의 질이다. 1편에서 본 것처럼 Jev의 출발점이 보정이기 때문이다. 영어와 한국어 분류 90문항에서 모델이 고른 답의 확률과 실제 정답 여부를 다섯 구간으로 나눠 ECE를 쟀다.
| 모델 | 정확도 | 고른 답의 평균 확률 | ECE | temperature 보정 후 ECE |
|---|---|---|---|---|
| Qwen3-0.6B | 40% | 0.81 | 0.408 | 0.203 |
| Qwen3-1.7B | 61% | 0.84 | 0.229 | 0.092 |
| Qwen3-4B | 91% | 0.99 | 0.075 | 0.025 |
| Qwen3-8B | 93% | 0.98 | 0.067 | 0.044 |
0.6B는 열 번 중 네 번 맞히면서 확신은 0.81이다. 이 확률에 임계값을 걸면 틀린 답이 자신 있게 통과한다. 작은 모델일수록 과신이 심하고, 4B에서도 ECE 0.075는 1편에서 본 PPO 이후 GPT-4의 0.074와 같은 수준이다.
여기에 가장 단순한 후처리를 붙였다. logits를 temperature 하나로 나눠 분포를 눕히는 방법이다. 값은 라벨이 붙은 문항으로 정하되, 맞춘 문항으로 다시 채점하지 않도록 다섯 묶음으로 나눠 교차로 맞췄다. 파라미터 하나로 ECE가 절반에서 3분의 1까지 내려갔다. 찾아진 temperature는 3.4에서 4.8로, 원래 분포가 그만큼 지나치게 뾰족했다는 뜻이다.
한계도 분명하다. 이 보정은 이 문항 세트에 맞춘 값이라 업무가 바뀌면 다시 맞춰야 한다. Jev는 업무마다 다시 학습하지 않는다고 말한다. 업무를 가리지 않고 보정이 유지되는지가 RLCD의 실제 주장이고, 그건 후처리로 흉내 낼 수 없는 부분이다. 문항이 90개뿐이라 ECE 수치 자체의 오차도 작지 않다.
직접 만들어 보고 알게 된 것
인터페이스는 공짜다. 목록 밖 답이 안 나온다는 보장, 파싱이 필요 없는 출력, 선택지별 확률은 오픈 모델과 70줄이면 얻는다. 이 부분은 Jev만의 것이 아니고, 지금 LLM으로 분류를 생성시키고 있다면 오늘 바꿀 수 있다.
공짜가 아닌 것은 세 가지였다. 작은 모델로는 정확도가 안 나오고, 정확도가 나오는 크기에서는 0.1초대 지연이 안 나오고, 확률은 그대로 쓰면 과신한다. 셋 다 직접 하려면 데이터와 학습과 서빙이 필요한 일이다. Jev가 100만 토큰에 $0.042를 받고 파는 것은 이 세 가지를 합친 것이라고 보는 게 맞다. 특히 한국어에서 그 차이가 컸다.
어느 쪽을 쓸지는 조건에 달려 있다. 데이터를 밖으로 보낼 수 없거나, 호출이 워낙 많아 고정비가 낫거나, 업무가 하나로 고정돼 있어 보정을 한 번만 맞추면 되는 경우에는 직접 돌리는 쪽이 설 자리가 있다. 영어 분류에 4B 모델이면 Jev에 근접한다. 업무가 자주 바뀌고 한국어가 섞이고 응답 시간이 중요하면 지금은 API 쪽이 낫다.
이 실험은 M1 한 대, 4bit 양자화, 프롬프트 한 종류, 문항 119개로 한 것이다. 다음으로 해볼 일은 state 계산 재사용으로 질문을 늘려도 지연이 안 늘게 만드는 것, 한국어 지시문으로 격차가 줄어드는지 보는 것, 그리고 작은 모델에 판정 전용 미세조정을 했을 때 보정까지 같이 좋아지는지 확인하는 것이다.