DevTech
2026년 8월 11일 · 11 min read

벤치마크가 27점 높은 모델로 갈아탔더니 5.3배 느려지고 결과는 같았다

코드 생성을 로컬 LLM에 위임하는 하네스를 몇 달째 돌리고 있습니다. 어느 날 설정 파일을 열어보니 모델이 1년 전 것이었습니다. 그 사이 벤치마크 점수가 27점 높은 모델이 나와 있었고, 마침 디스크에 받아만 두고 안 쓴 상태로 있었습니다.

바꾸면 좋아질 거라고 생각했습니다. 재보니 아니었습니다.

하네스가 무엇을 하는가

Node/Express/SQLite로 만든 가계부 앱이 있고, 테스트 코드 상당량을 로컬 모델에 위임해 씁니다. 도구는 aider, 모델은 Ollama로 띄운 30B급입니다.

중요한 건 이 위임이 에이전트형이 아니라는 점입니다. 모델이 저장소를 돌아다니며 파일을 찾고 고치는 방식이 아니라, 사람이 케이스 목록과 기대값을 완성 형태로 적어 주면 모델이 본문만 채웁니다. 스펙이 이런 식입니다.

describe('normalizeTheme', ...) 아래 2개

1. it('아는 값은 그대로', ...)
   normalizeTheme('dark') 가 'dark', normalizeTheme('light') 가 'light'
2. it('모르는 값·빈 값은 기본값으로 떨어진다', ...)
   normalizeTheme('보라색'), normalizeTheme(null) 이 전부 DEFAULT_THEME

이 형태에 도달하기까지 로그 70여 개를 쌓았습니다. 프롬프트는 10k 토큰 이하로, repo-map은 끄고, 참고 파일은 읽기 전용으로만 주는 식의 보정값이 그 위에 얹혀 있습니다.

설정을 열어보니 1년 묵어 있었다

model: ollama/qwen3-coder:30b
editor-model: ollama/qwen3:30b
map-tokens: 2048

qwen3-coder:30b은 2025년 7월 모델입니다. SWE-bench Verified 기준 50점대입니다. 그 사이 Qwen3.6이 나왔고 27B 밀집 모델이 77.2점을 받았습니다. 35B MoE 쪽은 73.4점입니다.

설정 파일을 보다가 별개 문제도 하나 찾았습니다. map-tokens: 2048인데 실제 실행 스크립트는 매번 --map-tokens 0으로 덮고 있었습니다. 하네스를 거치지 않고 aider를 직접 부르면 repo-map이 켜진 채 도는 상태였습니다. 예전 감사에서 repo-map을 켜고 돌린 41회 중 5회가 지정하지 않은 파일을 편집했고, 끈 32회는 0회였습니다. 이 설정은 그냥 맞춰두는 게 낫습니다.

예전에 같은 이유로 도구를 한 번 버렸다

Qwen3.6에는 걸리는 점이 하나 있었습니다. 추론(thinking)이 기본으로 켜져 있습니다.

이 하네스는 예전에 opencode 기반 위임을 쓰다가 aider로 갈아탄 적이 있습니다. 이유가 정확히 추론 모델이었습니다. 당시 측정한 표입니다.

호출 content reasoning
qwen3:30b 0자 705자
qwen3:30b + /no_think 0자 705자
qwen3-coder:30b 10자 0자

추론 모델이 OpenAI 호환 응답에서 content를 비우고 reasoning에 넣어버려서, 도구가 빈 응답을 받고 아무 편집도 못 했습니다. 그래서 추론 안 하는 코더 모델로 도망친 겁니다.

Qwen3.6으로 가면 그 조건이 되돌아옵니다. 그래서 교체 판단보다 먼저 이걸 확인했습니다.

thinking 억제 6경로를 다 재봤다

같은 프롬프트로 6가지 경로를 돌렸습니다.

경로 시간 content thinking
네이티브 기본값 345.8초 260자 8215자
네이티브 "think": false 64.8초 289자 0
/v1 기본값 178.8초 209자 3027자
/v1 + reasoning_effort: "none" 62.9초 217자 0
/v1 + extra_body 중첩 133.2초 271자 3137자
Modelfile SYSTEM /no_think 287.3초 259자 6309자

읽을 게 세 개 나왔습니다.

옛 결함은 재현되지 않는다

qwen3.6:27b은 추론을 켠 채로도 content를 정상으로 돌려줍니다. 전 경로에서 200자 이상 나옵니다. 도구가 빈 응답을 받는 상황은 안 생깁니다. 도망칠 이유였던 게 사라졌습니다.

대신 비용이 5배다

억제하면 64.8초, 안 하면 345.8초입니다. 15줄짜리 테스트 하나 쓰는 데 추론을 8215자 씁니다.

Qwen3 시절 요령이 폐기됐다

/no_think를 시스템 프롬프트에 굽는 방식은 Qwen3에서 통하던 관례입니다. Qwen3.6에서는 안 먹습니다. Modelfile로 구워도 6309자가 그대로 나옵니다. 남은 건 API 파라미터 두 개뿐입니다.

도구가 주는 플래그가 서버까지 닿지 않았다

가장 성가신 발견은 이겁니다. aider에는 --reasoning-effort가 있습니다. 그런데 안 듣습니다.

설치된 패키지를 열어보니 이렇게 넣고 있었습니다.

def set_reasoning_effort(self, effort):
    if effort is not None:
        ...
        self.extra_params["extra_body"]["reasoning_effort"] = effort

extra_body 안에 중첩해서 넣습니다. Ollama는 최상위 reasoning_effort만 읽고 중첩된 건 버립니다. 위 표의 5번째 줄이 그 결과입니다.

aider는 마지막 태그 릴리스가 2025년 8월입니다. Ollama가 thinking API를 정리하기 전 시대의 도구라, 이런 표류가 생깁니다.

우회는 있었습니다. aider는 모델별 extra_params를 요청 본문에 그대로 병합합니다. 여기에 최상위로 넣으면 됩니다.

- name: ollama_chat/qwen3.6:27b
  edit_format: diff
  use_repo_map: false
  extra_params:
    num_ctx: 32768
    think: false

옛 라운드를 모델만 바꿔 다시 돌렸다

여기서부터가 본론입니다. 벤치마크로 고르면 틀린다는 걸 알고 있었으니, 실제로 돌려서 비교해야 했습니다.

방법은 이렇게 잡았습니다.

  • 프롬프트·로그·최종 산출물이 전부 남아 있는 과거 라운드 2개를 고른다
  • 둘 다 "빈 파일에서 새로 생성" 형태라 사전상태 복원이 정확하다
  • 편집 형식·컨텍스트·프롬프트를 그대로 두고 모델만 바꾼다
  • 남의 작업 트리는 건드리지 않는다. 스크래치패드에 별도 저장소를 만든다
aider --model ollama_chat/<MODEL> \
  --map-tokens 0 --edit-format diff --timeout 900 \
  --yes --no-auto-commits --no-check-update --no-detect-urls \
  --read client/src/lib/theme.js \
  --file client/src/lib/theme.test.jsx \
  --message-file prompt-r15.md

기준선이 재현되는지부터 확인했다

비교를 시작하기 전에 기준선이 옛 로그와 같은 값을 내는지 봤습니다. 이게 안 맞으면 그 뒤 비교는 전부 무효입니다.

원본 로그   Tokens: 4.6k sent, 823 received
재실행      Tokens: 4.6k sent, 823 received   (44초)
한 번 더    Tokens: 4.6k sent, 823 received   (43초)

토큰 단위로 일치했습니다. 환경이 정상이라는 뜻입니다.

결과: 5.3배 느리고 결과는 같다

실행 모델 시간 줄수 it()
기준선 r15 qwen3-coder:30b 44초 92 12
기준선 r15 재실행 qwen3-coder:30b 43초 92 12
기준선 r17 qwen3-coder:30b 41초 85 12
후보 r15 qwen3.6:27b 226초 97 12
후보 r17 qwen3.6:27b 226초 103 12

느려진 이유는 세대가 아니라 구조입니다. 기존 모델은 30B MoE라 토큰당 활성 파라미터가 3B입니다. 후보는 27B 밀집이라 토큰당 17GB를 읽습니다. M4 Pro의 메모리 대역폭이 273GB/s이니 이론상한이 16 tok/s 언저리이고, 실측은 그 3분의 1쯤 나왔습니다.

억제 설정이 실제로 듣고 있었는지도 대조군으로 확인했습니다. think 지정만 뺀 실행은 943초가 지나도 편집을 못 끝내 중단했습니다. 같은 프롬프트를 후보는 226초에 끝냈습니다.

억제하지 않으면 추론이 응답 본문으로 샌다

중단한 대조군 로그를 열어보니 코드 앞에 이런 게 들어 있었습니다.

maybe they just want tests for functions, or I'll include them if needed.
The prompt lists specific tests to write.

Let's follow the exact structure requested:
 1 Start with exactly two lines of imports.
 ...
Let's draft the code carefully.

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';

분리된 thinking 필드가 아니라 content로 옵니다. 도구가 이 모델에 대해 추론 태그를 걷어내도록 설정돼 있지 않으니 그대로 통과합니다. 속도 문제로 끝나는 게 아니라 편집 응답 파싱을 오염시킬 수 있는 자리입니다.

구조만 세면 빈 껍데기를 못 잡는다

테스트 개수와 줄수만 비교하고 끝낼 뻔했습니다. 그 지표는 통과하는 빈 껍데기를 못 잡습니다.

의존성이 없는 스크래치 저장소라 테스트를 돌릴 수 없는 상태였는데, 기존 작업 트리의 node_modules를 심링크로 빌려 읽기 전용으로 붙였습니다.

ln -s <worktree>/client/node_modules <scratch>/node_modules
npx vitest run --root <scratch>

결과입니다.

산출물 결과
기준선 / theme 1 failed, 11 passed (12)
기준선 / onboarding 12 passed (12)
후보 / theme 1 failed, 11 passed (12)
후보 / onboarding 12 passed (12)

동률입니다.

실행해봐서 과대보고를 피했다

구조를 비교하다가 차이를 하나 찾았습니다. 스펙에 이렇게 적혀 있었습니다.

beforeEachwindow.localStorage.clear(); 를 넣어라.

기준선은 beforeEach를 import만 하고 호출하지 않았습니다. 후보는 넣었습니다. 신모델이 지시를 더 잘 지킨 사례로 보였습니다.

그런데 실제로 돌려보니 둘 다 12개 전부 통과입니다. 각 테스트가 필요한 값을 직접 세팅해서 격리 부족이 지금은 드러나지 않습니다. 잠복 위험이지 현재 결함이 아닙니다.

실행 안 했으면 이걸 "결함 1건, 신모델 승"으로 적었을 겁니다.

실패한 테스트의 원인은 모델이 아니었다

두 모델이 똑같이 실패한 그 테스트가 남았습니다.

FAIL  applyTheme > root 가 없으면 기본값만 돌려주고 죽지 않는다
AssertionError: expected 'dark' to be 'light'

스펙 43번째 줄을 다시 봤습니다.

8. `it('root 가 없으면 기본값만 돌려주고 죽지 않는다', ...)`
   `applyTheme('dark', null)` 이 예외 없이 `DEFAULT_THEME` 를 반환

구현은 이렇습니다.

export function applyTheme(theme, root) {
  const el = root || (typeof document !== 'undefined' ? document.documentElement : null);
  if (!el) return DEFAULT_THEME;      // document 자체가 없을 때만
  const next = normalizeTheme(theme);
  if (next === 'dark') el.setAttribute('data-theme', 'dark');
  else el.removeAttribute('data-theme');
  return next;                        // jsdom 에서는 여기로 온다
}

jsdom에는 document가 있으니 root=null이면 document.documentElement로 떨어지고, 반환값은 'dark'입니다. DEFAULT_THEME가 아닙니다.

스펙이 틀렸고 두 모델이 그 틀린 지시를 똑같이 정확히 따랐습니다. 당시 실제로 머지된 파일에는 이 테스트가 없습니다. 검수하다가 지우고 넘어갔더군요. 지웠다는 건 그때도 이상하다고 느꼈다는 뜻인데, 스펙을 고치지는 않았습니다. 그래서 오늘 재실행에서 그대로 다시 나왔습니다.

그래서 무엇이 병목인가

SWE-bench Verified는 모델이 실제 저장소 이슈를 여러 턴에 걸쳐 고치는 능력을 잽니다. 파일을 찾고, 읽고, 고치고, 테스트를 돌려보고, 다시 고치는 과정입니다.

이 하네스는 그 능력을 안 씁니다. 파일은 사람이 지정하고, 케이스 목록도 기대값도 사람이 완성해 넘깁니다. 모델에 남는 자유도는 본문을 받아쓰는 부분입니다. 그 구간에서는 50점 모델과 77점 모델이 같은 것을 만듭니다.

바꿔 말하면 위임 품질이 나쁠 때 모델부터 의심하는 건 대체로 틀린 순서입니다. 스펙과 하네스를 먼저 봐야 합니다. 이번에 나온 결함 하나도 결국 제가 쓴 문장 하나였습니다.

그럼 MoE는 어떨까

여기까지가 밀집 27B 이야기입니다. 느렸던 원인이 세대가 아니라 구조였으니, 같은 세대의 MoE 모델이라면 속도 손해 없이 올라갈 수 있습니다. 21GB를 더 받아서 재봤습니다.

실행 기존 30B MoE 신 27B 밀집 신 35B MoE
r15 왕복 44초 226초 45초
r17 왕복 41초 226초 35초
r17 산출 토큰 915 1.2k 1.2k
vitest 11/12, 12/12 11/12, 12/12 11/12, 12/12

속도는 이겼습니다. r17에서 6초 빠르면서 산출 토큰은 30% 많습니다. 유효 생성속도로 환산하면 22 tok/s에서 34 tok/s입니다. 실행 중인 프로세스를 보니 이유가 하나 더 있었습니다.

ollama runner --mlx-engine --model qwen3.6:35b-a3b-coding-nvfp4
fmt=safetensors  quant=nvfp4  vram=21.2GB

MLX 엔진으로 돕니다. 모델과 엔진이 동시에 바뀐 셈이라 속도 이득을 어느 쪽에 돌릴지는 이 자료로 못 가릅니다. 배치할 구성 그대로를 잰 거니 배치 판단에는 쓸 수 있고, 원인 규명에는 못 씁니다.

품질은 또 동률이었습니다.

쉬운 유형이라 그런 건 아닐까

여기서 의심이 하나 남습니다. 지금까지 잰 두 라운드는 순수 함수에 테스트를 붙이는 작업입니다. 원래 잘 되던 유형이라 차이가 안 났을 수도 있습니다.

그래서 예전 기록에서 결함률이 가장 높았던 유형을 찾았습니다. 새 UI 컴포넌트 생성이고 라운드당 결함 2.2건입니다. 그중 결함 3건이 문서로 남아 있는 라운드를 하나 골라 같은 방식으로 재현했습니다.

실행 왕복 줄수 빈 상태 번쩍임 삭제 에러 미처리
기존 모델 40초 146 재현됨 재현됨
신 MoE 44초 143 재현됨 재현됨

둘 다 이렇게 썼습니다.

const [loading, setLoading] = useState(false);
...
// 카드가 없으면 빈 상태 표시
if (cards.length === 0) {
  return <EmptyState title="먼저 카드를 등록해 주세요" ... />;
}

최초 적재 중에는 목록이 비어 있고 loadingfalse입니다. 그래서 "먼저 등록해 주세요"가 떴다 사라집니다. 사용자는 등록이 안 된 줄 알고 나갑니다. 1년 전 모델이 밟았던 자리를 신모델이 똑같이 밟습니다.

결론은 현행 유지

후보 속도 품질 판정
27B 밀집 5.3배 손해 동률 탈락
35B MoE 동률에서 약간 이득 동률 (쉬운 유형, 어려운 유형 모두) 무해하나 근거 없음

MoE 쪽은 바꿔도 잃는 게 없습니다. 그런데 얻는 것도 측정되지 않았습니다. 벤치마크가 23점 높은데 두 가지 작업 유형 어디에서도 결함을 하나도 줄이지 못했습니다.

교체하면 치르는 비용은 디스크가 아닙니다. 프롬프트 크기 상한이나 repo-map 설정 같은 보정값은 전부 현행 모델로 로그 70여 개를 쌓아 얻은 값입니다. 측정된 이득이 0인데 그 기반을 갈아엎을 이유가 없습니다.

받아둔 모델은 지우지 않았습니다. 지금 위임을 못 맡기는 작업 유형이 생기면 그때 꺼내면 됩니다. 21GB의 값어치는 거기에 있습니다.

남은 것

도구는 그대로 둡니다. opencodegoose 같은 에이전트형은 편집을 전부 도구 호출로 라우팅하는데, 30B급 로컬 모델이 정확히 거기서 깨집니다. opencode 쪽은 헤드리스 실행이 초기화 단계에서 멈추는 이슈가 아직 열려 있고, 이 환경에서도 20회 중 10회가 세션조차 못 만들었습니다. aider의 평문 편집 형식은 도구 호출을 요구하지 않아서, 약한 모델과 붙일 때 이 차이가 큽니다.

한 가지는 인정해야 합니다. aider는 1년째 릴리스가 없고, 그 표류가 이번에 --reasoning-effort로 드러났습니다. 지금은 설정으로 우회되지만 다음에는 안 될 수도 있습니다. 그때는 도구를 바꾸는 게 아니라 하네스가 API를 직접 부르는 쪽이 답일 겁니다. 어차피 이 워크로드에서 도구가 해주는 일은 파일 하나 쓰는 것뿐입니다.

참고한 자료는 Ollama의 MLX 전환 공지Ollama thinking 문서입니다.

Sponsored

댓글