DevTech
2026년 8월 12일 · 9 min read

추론 강도를 낮췄더니 모델이 멀쩡한 데이터를 지우라고 했다

지난번에 로컬 위임 모델을 바꿀지 재봤다가 그냥 두기로 한 이야기를 썼습니다. 그때는 코드 생성이 대상이었습니다. 이번에는 대상이 달랐습니다. 코드를 짜 달라는 게 아니라 판단을 물어야 했습니다.

가계부 앱의 스키마 결정 하나가 걸려 있었습니다. 혼자 정하기엔 되돌리기 어려운 쪽이라, 로컬 모델에 근거를 물어보고 그 답을 검증해서 쓰기로 했습니다.

결론부터 적으면 이렇습니다. 새로 받은 모델은 도입하지 않았습니다. 그런데 지우지도 않았습니다. 그리고 그 과정에서 설정 하나가 제 짐작과 정반대로 동작한다는 걸 알게 됐습니다.

무엇을 물어야 했나

앱에 카드 혜택을 저장하는 테이블이 있습니다. 원래는 요율 하나만 다뤘습니다.

CREATE TABLE card_benefits (
  id      INTEGER PRIMARY KEY,
  rate    REAL NOT NULL DEFAULT 0,
  ...
);

그런데 혜택 구조가 요율만 있는 게 아니었습니다. "전월에 30만원 넘게 쓰면 이번 달에 3,000원"처럼 거래 금액과 무관한 형태가 나왔습니다. 요율 컬럼 하나로는 못 담습니다.

그래서 규칙을 선언으로 저장하는 컬럼을 하나 더 붙였습니다.

ALTER TABLE card_benefits ADD COLUMN rule_json TEXT;

읽는 쪽은 이렇게 갈립니다.

function ruleOf(benefit) {
  if (benefit.rule_json) {
    const parsed = JSON.parse(benefit.rule_json);
    if (parsed && typeof parsed.kind === 'string') return parsed;
  }
  return { kind: 'rate', rate: Number(benefit.rate || 0) };
}

규칙이 있으면 규칙을 쓰고, 없으면 옛 rate 컬럼으로 떨어집니다.

죽어 있는 값이 되살아나는 자리

여기서 애매한 상태가 생깁니다. 정액형 규칙이 붙은 행에도 rate 컬럼은 그대로 남아 있습니다. 지금은 아무 영향이 없습니다. 규칙이 이기니까요. 그런데 나중에 그 규칙을 지우면 ruleOfrate 로 되돌아가면서 묻혀 있던 값이 되살아납니다. 사용자는 규칙 하나를 지웠을 뿐인데 없던 혜택이 붙습니다.

이걸 어떻게 할 것인가. 이게 질문이었습니다.

후보를 고르는 데 규칙이 있었다

예전에 44일 동안 한 번도 안 쓴 대형 추론 모델을 지운 적이 있습니다. 지우면서 남긴 기록에 재도입 조건과 함께 이런 줄이 있었습니다.

재도입한다면 그 시점의 후보를 다시 재서 고른다. 그 모델을 그대로 다시 받지 않는다.

그래서 같은 모델은 후보에서 뺐고, 같은 계열의 작은 버전도 뺐습니다. 세대가 묵었고 그 계열이 갖고 있던 문제가 그대로였기 때문입니다.

크기를 우선 조건으로 뒀습니다. 그 모델이 44일간 안 돌았던 진짜 이유는 품질이 아니라 기기에서 안 떴기 때문이었습니다. 여유 메모리 44GB를 요구하는데 물리 메모리가 48GB인 기계였습니다.

그래서 gpt-oss:20b를 받았습니다. 13GB고 추론 강도를 조절할 수 있습니다. 기준선은 이미 갖고 있던 qwen3:30b 으로 잡았습니다.

하네스부터 정확히 잡기

이전 사이클에서 얻은 교훈이 있습니다. 위임 결과가 나쁘면 모델부터 의심하는 게 대체로 틀린 순서라는 것입니다. 그래서 모델을 재기 전에 하네스를 먼저 확인했습니다.

파일 편집 도구를 거치지 않고 Ollama의 채팅 엔드포인트를 직접 부르기로 했습니다. 필요한 게 파일 편집이 아니라 답 하나였고, 중간 도구가 파라미터를 한 겹 중첩해 보내서 서버가 그냥 버리는 자리를 이미 겪은 적이 있었습니다.

curl -s http://127.0.0.1:11434/api/chat -d '{
  "model": "qwen3:30b",
  "stream": false,
  "think": true,
  "options": {"temperature": 0, "seed": 7},
  "messages": [{"role": "user", "content": "2+2 는 얼마인가? 숫자만 답하라."}]
}'

think false 가 추론을 끄지 않는다

여기서 예상 밖의 게 나왔습니다. think 를 켜고 끄면서 응답을 봤습니다.

qwen3:30b  think=true   content='4'                              thinking=529자
qwen3:30b  think=false  content='Okay, the user is asking "2+2…'  thinking=0자

think: false 로 두면 content 가 추론 산문으로 시작합니다.

처음엔 이게 왜 이러나 싶었습니다. 추론을 껐으면 짧고 깨끗해야 하는 거 아닌가. 그런데 그게 아니었습니다.

think: false 는 추론을 멈추는 게 아니라, 서버가 추론을 별도 필드로 갈라 주는 일을 멈추는 것이었습니다. 모델은 그대로 생각하고, 그 생각이 갈 곳이 없어지니까 응답 본문에 섞여 나옵니다.

같은 걸 새 후보로 재보니 달랐습니다.

gpt-oss:20b  think=true   content='4'  thinking=105자
gpt-oss:20b  think=false  content='4'  thinking=105자

두 모드 다 본문이 깨끗합니다.

이게 왜 중요하냐면, 예전에 헤드리스 에이전트가 깨진 적이 있는데 원인이 정확히 이거였기 때문입니다. 응답 본문을 통째로 파싱하는 구조였는데 앞에 산문이 붙으면서 깨졌습니다. 그때는 원인을 모델 탓으로 적어뒀는데, 실제로는 설정 이름을 잘못 읽은 쪽이 컸습니다.

설정의 이름이 그 설정의 효과를 말해주지 않습니다. 억제 계열 설정은 무엇을 멈추는지 재보고 나서 믿어야 합니다.

설정이 서버까지 닿았는지 재는 법

추론 강도가 실제로 먹히는지도 확인해야 했습니다. 도구가 파라미터를 삼키는 사고를 이미 겪었으니까요.

가장 싼 방법은 최저값과 최고값으로 같은 질문을 던져 추론 길이를 비교하는 것이었습니다.

effort=low   content='1161'  thinking=98자
effort=high  content='1161'  thinking=260자

27 곱하기 43은 1161이고 양쪽 다 맞혔습니다. 그런데 추론 길이가 갈렸습니다. 갈렸다는 사실 자체가 파라미터가 중간에서 안 죽고 서버까지 닿았다는 증거입니다.

실제 과제에서는 차이가 훨씬 컸습니다. 낮은 강도 514자, 높은 강도 16,065자.

기준선 재현이 안 됐다

절차 문서에 이런 게 있습니다. 비교 전에 기존 모델이 예전 로그와 같은 값을 내는지 확인하고, 어긋나면 그 뒤 비교는 전부 무효로 본다.

그래서 같은 질문을 두 번 던졌습니다. 달랐습니다.

1회차  본문 1694자  추론 4320자  eval 1448 tok
2회차  본문 1195자  추론 6526자  eval 1864 tok

온도 0에 시드까지 고정했는데 왜.

여기서 바로 "시드가 안 듣는다"고 결론 내리면 틀립니다. 짧은 질문으로 다시 재봤습니다.

짧은 입력, qwen3:30b   2회 완전 동일
짧은 입력, gpt-oss:20b 2회 완전 동일

시드는 듣고 있었습니다. 짧은 생성은 완벽히 재현됩니다. 갈리는 건 추론이 수천 토큰으로 길어질 때뿐이고, 이건 Apple Silicon의 Metal 백엔드에서 MoE 라우팅이 부동소수점 누적오차로 갈라지는 종류입니다. 설정으로 없앨 수 있는 게 아닙니다.

그러면 게이트를 어떻게 할 것인가. "재현이 안 되니 비교 무효"로 끝내면 아무것도 못 합니다. 제가 실제로 알고 싶은 건 문장이 같은지가 아니라 결론이 같은지였습니다.

그래서 게이트를 본문 일치에서 결론 안정성으로 바꾸고, 모델마다 세 번씩 돌렸습니다. 절차 문서를 벗어난 판단이라 그 사실을 기록에 적었습니다.

사실을 질문에 심어두기

산출물이 코드면 테스트를 돌려서 판정합니다. 판단은 그게 안 됩니다.

그래서 제가 이미 실측해서 아는 사실을 질문에 심어뒀습니다. 실제 DB를 열어 확인한 값들입니다.

card_benefits 행수        34
rule_json 이 있는 행      0     (컬럼 자체가 아직 없음)
rate 가 0 인 행           0
rate 분포                 0.5×1, 1.0×1, 1.2×2, 5.0×24, 7.0×1, 10.0×2, 20.0×1, 30.0×1, 50.0×1

핵심은 규칙이 달린 행이 0건이라는 겁니다. 마이그레이션이 아직 안 돌아서 컬럼조차 없었습니다. 그러니까 "규칙이 달린 행의 rate를 정리하자"는 제안은 지금 대상이 하나도 없습니다.

그리고 34행은 전부 규칙이 없어서 rate살아 있는 유효값입니다. 이걸 건드리면 안 됩니다.

선택지는 이렇게 줬습니다. 아무것도 안 함, 데이터 마이그레이션, 쓰기 시점 정규화, 컬럼 자체를 버리기, 정렬 문제 손보기. 그리고 "이 중에 답이 없으면 다른 걸 제안하라"고 덧붙였습니다.

결과가 갈렸다

실행 권고
기준선 3회 쓰기 정규화만. 마이그레이션은 필요 없다고 명시적으로 거부
새 후보 높은 강도 2회 쓰기 정규화 후 마이그레이션. "현재 0개"는 짚음
새 후보 높은 강도 1회 34행을 전부 새 형식으로 변환
새 후보 낮은 강도 2회 마이그레이션 먼저. "기존 34개 행에 남아 있는 불필요한 rate 값을 정리"

낮은 강도가 사실을 반대로 읽었다

마지막 줄이 문제였습니다.

34행에는 "불필요한 rate 값"이 없습니다. 전부 유효값입니다. 질문에 그렇게 적혀 있었습니다. 그런데 낮은 강도에서 두 번 다 그걸 반대로 읽었습니다.

이 권고를 그대로 실행하면 실제 가계부 설정 34줄이 0으로 밀립니다. 이 프로젝트에는 과거에 실거래 2,212건을 날린 사고가 있었고, 그 뒤로 데이터를 건드리는 작업은 사본에서만 검증하는 규칙이 생겼습니다. 같은 범주의 사고입니다.

무서운 건 답이 이상해 보이지 않았다는 겁니다. 표로 정리돼 있고, 문장은 자신 있고, 순서도 그럴듯했습니다. 사실을 모르는 사람이 읽으면 그냥 통과시킬 만했습니다.

자문형 위임에서 위험한 실패는 답을 못 내는 게 아니라 그럴듯하게 틀린 답입니다. 검증 가능한 사실을 질문에 심어두지 않으면 잡을 방법이 없습니다.

속도도 기준선이 빨랐습니다. 30초대 대 80초대.

그래서 백필은 안 했다

기준선의 판단이 맞았습니다. 마이그레이션은 두 가지 이유로 안 하기로 했습니다.

첫째, 대상이 0건입니다. 지금 실행하면 아무 행도 안 바뀝니다.

둘째, 조건을 조금만 느슨하게 쓰면 멀쩡한 34행을 밀어버립니다. 이득이 0인데 사고 가능성만 있는 변경입니다.

대신 두 가지를 했습니다. 하나는 쓰기 시점 정규화입니다.

const rate = ruleOf({ rule_json, rate: body.rate }).kind === RATE_KIND
  ? Number(body.rate)
  : 0;

애초에 그런 행이 저장되지 못하게 막습니다. 유형 판정을 따로 하지 않고 ruleOf 에 맡긴 건, 읽는 쪽과 쓰는 쪽이 각자 판단하면 두 답이 갈라지기 때문입니다.

조사하다 발견한 진짜 버그

또 하나는 목록 정렬이었습니다. 조사하다 발견한 건데, 목록이 ORDER BY rate DESC 로 정렬되고 있었습니다. 정액형은 rate 가 0이라 값과 상관없이 맨 아래로 갑니다. 매달 7,000원 붙는 혜택이 0.5%짜리 밑에 놓입니다.

이건 가정이 아니라 지금 일어나는 일이었습니다.

CASE WHEN rule_json IS NULL OR json_extract(rule_json,'$.kind')='rate'
     THEN 0 ELSE 1 END,
rate DESC,
id

유형을 가로질러 크기를 비교하진 않습니다. 정액 3,000원과 요율 0.7% 중 뭐가 큰지는 거래 금액을 알아야 정해지는데 목록은 그걸 모릅니다. 대신 유형끼리 묶어서, 맨 아래 있는 게 "0%라서"가 아니라 "다른 유형이라서"가 되게 했습니다.

뮤테이션으로 확인하기

테스트는 로컬 모델에 위임해서 9개를 만들었고, 실제로 회귀를 잡는지는 뮤테이션 테스트로 확인했습니다. 코드를 일부러 망가뜨리고 테스트가 실패하는지 보는 방식입니다.

6개 중 5개가 잡혔습니다. 안 잡힌 하나는 정렬의 id 타이브레이커였는데, SQLite의 자연 순서가 우연히 id 순이라 구분이 안 됐습니다. 이건 못 잡는다고 PR에 그대로 적었습니다.

안 잡히던 뮤턴트를 가르기

처음엔 "유형 묶기"도 안 잡혔습니다. 정액형은 rate 가 0이라 어떤 정렬이든 맨 아래로 가서 구분이 안 됐거든요. 요율이 0인 요율형을 하나 두는 케이스를 추가하니까 갈렸습니다. 요율 0은 "이 카테고리엔 혜택 없음"을 명시적으로 적어두는 실제 쓰임이 있는 값입니다.

채택과 보관은 다른 결정이다

새 모델은 도입하지 않았습니다. 그런데 지우지도 않았습니다.

품질로는 졌지만 성질이 다르기 때문입니다. 억제 설정과 무관하게 본문이 깨끗하다는 성질이요. 응답을 통째로 파싱하는 자리, 중간 도구가 파라미터를 삼켜서 억제를 못 거는 자리에서는 그 성질이 품질보다 앞섭니다.

그래서 용도를 문서에 못박았습니다.

본문이 깨끗해야 하면 새 후보, 답이 맞아야 하면 기준선에 억제를 켜서.

용도를 안 적고 남기면 다음 사람이 기본값으로 착각합니다. 그게 제일 위험합니다. 실제로 이번에 그 모델이 낮은 강도에서 어떤 답을 냈는지 보면, 기본값이 되는 순간이 사고 나는 순간입니다.

정리

세 가지가 남았습니다.

설정 이름을 믿지 말 것. think: false 는 추론을 끄는 게 아니라 분리를 멈추는 것이었고, 모델마다 다르게 동작했습니다. 새 모델을 붙일 때마다 두 모드로 찔러보고 본문을 눈으로 봐야 합니다.

게이트가 워크로드에 안 맞으면 게이트를 바꿀 것. 다만 바꿨다는 사실을 적어야 합니다. 토큰 단위 재현은 짧고 결정적인 산출에서만 성립합니다.

그리고 판단을 물을 때는 답을 채점할 수 있는 사실을 질문에 심어둘 것. 이게 없으면 그럴듯하게 틀린 답을 걸러낼 방법이 없습니다. 이번엔 "0건"이라는 사실 하나가 유효 데이터 34줄을 지켰습니다.

Sponsored

댓글