LLM을 활용하여 실제 서비스를 개발하다 보면 모델이 생성한 답변을 프로그램 내부에서 파싱하여 다음 로직으로 넘겨야 하는 상황이 자주 발생하더라고요. 그런데 모델이 가끔은 "네, 알겠습니다"와 같은 불필요한 서두를 붙이거나 추가적인 설명 문구를 포함해서 응답하면 정해진 형식이 깨지면서 파싱 오류가 발생하기 쉽네요. 이런 문제를 해결하기 위해 단순히 프롬프트에 JSON으로 출력해달라고 요청하는 것을 넘어 API 차원에서 제공하는 기능을 활용하는 것이 훨씬 안정적이고 효율적인 방법이 될 수 있어요.

{
  "model": "gpt-4o",
  "messages": [
    {
      "role": "system",
      "content": "You are a helpful assistant. Always respond in JSON format."
    },
    {
      "role": "user",
      "content": "Tell me about the capital of France in 3 words."
    }
  ],
  "response_format": {
    "type": "json_object"
  }
}

OpenAI의 모델들을 사용할 때 response_format 파라미터를 json_object로 설정하면 모델이 출력하는 결과물을 유효한 JSON 구조로 강제할 수 있어요. 이 기능을 사용하면 후처리 과정에서 정규표현식을 쓰거나 문자열을 자르는 등의 복잡한 처리 과정을 생략할 수 있어서 개발 생산성이 높아지더라고요. 다만 이 기능이 제대로 작동하려면 시스템 프롬프트 안에 반드시 결과물이 포함해야 하는 구체적인 키와 데이터 구조에 대한 설명이 포함되어야 하네요. 단순히 JSON으로 출력하라는 명령보다 어떤 데이터를 어떤 형식으로 담아야 하는지 명확하게 정의할수록 응답의 정확도가 높아지는 것을 알 수 있어요.

응답의 일관성을 더욱 높이기 위해서는 모델에게 전달하는 시스템 프롬프트에서 데이터 타입을 구체적으로 지정해 주는 것이 중요해요. 예를 들어 날짜나 숫자의 경우 특정 포맷을 따르도록 명시하면 이후에 데이터를 데이터베이스에 저장하거나 다른 API로 전달할 때 발생할 수 있는 예외 상황을 미리 방지할 수 있더라고요. 결과적으로 JSON 모드를 적절히 활용하고 프롬프트 구조를 체계적으로 설계하면 LLM의 유연한 답변 능력과 소프트웨어의 엄격한 데이터 규칙 사이에서 균형을 맞추기가 훨씬 수월해지는 것 같아요.

2주 동안 로컬 LLM 20종을 같은 조건으로 돌려 봤습니다. 목적은 두 가지였습니다. 하나는 터미널 코딩 에이전트에 올릴 모델을 고르는 것, 다른 하나는 블로그 자동 발행 파이프라인의 각 단계(초안·코드검토·사실성 판정)에 앉힐 모델을 고르는 것이었습니다.

결과만 말하면 20종 중에 기존 세팅을 갈아치운 모델은 거의 없었습니다. 대신 남은 게 따로 있는데, "이걸 어떻게 재야 하는가"에 대한 실패 목록입니다. 개인적으로는 그게 순위표보다 훨씬 값졌습니다.

측정 환경

항목
CPU Ryzen 9 5950X
GPU RTX 3080 12GB
RAM DDR4 64GB
런타임 Ollama native /api/chat
컨텍스트 num_ctx=32768
온도 temperature=0.2
tok/s 콜드런 버리고 웜런
  • 측정 중 GPU 경쟁 여부는 5초 간격으로 VRAM·사용률·그 시점 로드된 모델 목록까지 샘플링해서 확인했습니다.
  • 태스크는 6종 고정입니다. 도구 호출 / 코드 생성+실행 / 버그 진단(소·대) / 설계 문서(10점 체크리스트) / 언어 준수 / 롱컨텍스트 니들 검색.

첫 번째 벽: 12GB에 들어가는지 여부

가장 크고 단순한 분기점이었습니다. 모델 가중치가 12GB에 들어가면 100% GPU에 올라가고, 넘치면 CPU로 오프로드되면서 속도가 한 자리 수로 떨어집니다.

모델  VRAM  gen tok/s GPU 비율
llama3.2:3b 6.0GB 221.8 100%
ornith:9b 7.1GB 95.3 100%
qwen2.5-coder:7b 7.5GB 133.1 100%
llama3.1:8b 9.1GB 124.5 100%
gemma4:12b 9.98GB 63.8 100%
gemma4:26b 11.7GB 39.0 51%
qwen3.6:35b 11.5GB 38.8 59%
deepseek-coder-v2:16b 11.1GB 32.3 52%
mistral-nemo:12b 11.0GB 27.8 81%
deepseek-r1:14b 11.2GB 13.0 66%
gemma2:27b 11.3GB 5.4 54%
mistral-small3.2:24b 11.3GB 4.97
devstral-small-2:24b 11.2GB 4.88
qwen3.6:27b 11.7GB 3.92 51%
qwen2.5-coder:32b 11.2GB 3.1 36%
gemma4:31b 11.4GB 3.0 38%
deepseek-r1:32b 11.2GB 2.9 36%
llama3.3:70b 10.7GB 1.16 19%

여기서 읽어야 할 것들입니다.

  • 오프로드는 "조금 느려짐"이 아닙니다. 완전 적재 구간은 63~221 tok/s인데, 오프로드는 39 tok/s에서 시작해 1 tok/s대까지 떨어집니다. 체감상 다른 등급의 도구입니다.
  • 70b의 VRAM 10.7GB는 "많이 올라갔다"가 아닙니다. 12GB를 꽉 채우고도 81%가 CPU에 남았다는 뜻입니다. 42GB를 디스크에 두고 1.16 tok/s를 쓸 이유는 없었습니다.
  • 파라미터 수와 속도가 단조롭게 대응하지도 않습니다. 27b(3.92)가 35b(38.8)보다 10배 느렸습니다. 35b는 MoE라 활성 파라미터가 적고, 27b는 dense라 오프로드 비용을 그대로 물었습니다.
  • 32B급 dense는 이 하드웨어에서 사실상 전멸입니다(3 tok/s대).

라운드 1 — 주력 후보 4종

기존 메인이던 qwen3.6:35b를 포함해 네 종을 붙였습니다. 이 표가 이후 모든 라운드의 기준선이 됐습니다.

항목  gemma4:12b ornith:9b qwen3.6:35b gemma4:26b
적재 완전 완전 오프로드 오프로드
콜드로드 8.2s 3.3s 29.6s 23.5s
gen tok/s 63.8 95.3 38.8 39.0
prompt tok/s 814.7 1091.8 188.6 250.5
VRAM 9.98GB 7.1GB 11.5GB 11.7GB
툴콜 O O O think시 X
설계문서 10/10 8/10 8/10 10/10
한국어 O O 한자 누수 O
코딩 4.5 3.5 3.5 3.5
종합 1위 2위 3위 4위
  • 롱컨텍스트 니들 검색(≈12~14k 토큰)은 4종 전부 적중했습니다. 정확도가 아니라 속도만 갈렸습니다.
  • 버그 진단에서는 gemma4:12b만 깔끔했습니다. qwen3.6:35b는 결론은 맞지만 초반에 데드락이라고 오도한 뒤 번복했고, gemma4:26b는 "다행히 5를 넘지 않는다"며 자기모순을 냈고(실제 값은 7), ornith:9b는 아예 "버그 없음"으로 오답을 냈습니다.
  • ornith:9b의 think on은 위험했습니다. 사고 66.5k자·생성 32.5k 토큰을 쏟아붓고 답변은 공란으로 6분을 태웠습니다. 이 모델은 반드시 think off로 써야 합니다.

그런데 순위가 뒤집혔습니다

코딩 위주 벤치를 보완하려고 글쓰기·웹검색·상식을 추가로 재자 결과가 흔들렸습니다.

항목 gemma4:12b ornith:9b qwen3.6:35b gemma4:26b
글쓰기 4.5 4.5 5 4.5
분량통제 초과 약간짧음 정확 초과
웹검색(툴6개) X O O
상식·추론 O O O O
  • 툴을 6개 물리자 코딩 1위였던 gemma4:12b가 무너졌습니다. web_search를 호출조차 하지 않고 "검색 권한이 없다"며 거부했습니다. gemma4:26b는 엉뚱하게 get_datetime만 불렀습니다.
  • 툴 1개짜리 벤치에서는 네 종 다 통과했는데, 툴 6개에서는 gemma 계열이 취약했습니다. 이 관찰이 이후 모든 툴콜 판정의 기준을 바꿨습니다.
  • 결론은 이원화였습니다. 순수 코딩은 gemma4:12b, 웹검색 포함 범용은 ornith:9b.

라운드 2 — llama 3.x 계열 3종

llama3.2:3b / llama3.1:8b / llama3.3:70b를 붙였습니다. 여기서는 반복 횟수를 늘렸습니다(3b·8b는 n=3, 70b는 회당 35분이라 n=2).

모델 gen tok/s 툴콜 설계 한국어
llama3.2:3b 221.8 3/3 2/10 X
llama3.1:8b 124.5 1/3 4/10 O
llama3.3:70b 1.16 0/2 2.5/10
  • 세 종 모두 think를 지원하지 않습니다. {"think":true}에 400 does not support thinking이 떨어집니다. reasoning 축은 측정 자체가 불가였습니다.
  • 실제 코드 버그 진단은 8런 전부 실패했습니다(8/8). 프록시 코드에서 본문을 재직렬화한 뒤 Content-Length를 갱신하지 않는 버그를 심었는데, 셋 다 "그럴듯하지만 틀린" 답을 확신조로 냈습니다. 8b가 제시한 수정 코드는 할당 전 변수를 참조해 그 자체로 NameError였습니다.
  • llama3.1:8b는 한국어 문자열의 닫는 따옴표를 빠뜨려 SyntaxError를 냈고(2/3 재현), 에러를 툴 결과로 돌려받고도 2턴째 응답이 완전히 비었습니다. 에이전트 루프에서 이건 느린 것보다 나쁩니다. 복구도, 재시도도 없이 멈춥니다.
  • llama3.2:3b는 툴콜 인자가 3/3 무결한 유일한 모델이었지만, 한국어 지시를 어기고 베트남어(2/3)·일본어(1/3)를 흘렸습니다.
  • 설계문서 점수는 8b와 70b가 런 사이에 4.0↔2.5로 자리를 바꿨습니다. 평균이 완전 동률이라 단독 근거로 쓸 수 없는 지표였습니다.

라운드 3 — 코딩 특화 7종

deepseek-r1 2종, qwen2.5-coder 2종, mistral-nemo:12b, deepseek-coder-v2:16b, gemma2:27b입니다.

먼저 /api/show로 capabilities를 확인했더니 시나리오의 전제가 두 개 틀렸습니다.

모델 tools thinking
deepseek-coder-v2:16b X X
gemma2:27b X X
qwen2.5-coder:7b O X
qwen2.5-coder:32b O X
mistral-nemo:12b O X
deepseek-r1:14b O O
deepseek-r1:32b O O
  • 툴 미지원 2종은 에이전트 용도로 그 자리에서 실격입니다.
  • deepseek-r1의 think:false는 동작하지 않습니다. 400도 없이 조용히 무시되고 사고 토큰이 계속 생성됩니다(14b는 think_off에서도 사고 12,512자). 끌 수 없는 사고는 32k 컨텍스트에서 무시할 비용이 아닙니다.

결과는 이렇게 갈렸습니다.

모델 gen tok/s 툴콜 버그진단 설계
mistral-nemo:12b 27.8 O X 6/10
qwen2.5-coder:7b 133.1 텍스트 누출 X 8/10
qwen2.5-coder:32b 3.1 텍스트 누출 O 3/10
deepseek-r1:14b 13.0 X O 6/10
deepseek-r1:32b 2.9 X O 4/10
deepseek-coder-v2:16b 32.3 미지원 X 3/10
gemma2:27b 5.4 미지원 X 5/10
  • 속도·툴콜·코드이해가 한 모델에서 만나지 않았습니다. 구조적 툴콜에 성공한 건 mistral-nemo:12b 하나인데 그 하나가 버그 진단에 실패했고, 버그를 잡은 r1 계열은 툴을 아예 시도하지 않았습니다.
  • qwen2.5-coder 2종은 올바른 툴콜 JSON을 본문 텍스트로 출력했습니다. tool_calls 배열은 비어 있습니다. 모델은 맞게 생성했는데 구조화되지 않아 클라이언트가 쓸 수 없습니다.
  • r1 계열은 더 나빴습니다. 툴이 주어졌는데도 무시하고, 실행하지도 않은 명령의 출력을 지어냈습니다. 14b는 존재하지 않는 Take-First cmdlet까지 발명했습니다.
  • r1 계열은 한국어 강제가 통하지 않습니다. 영어·포르투갈어·중국어에 일본어 가나까지 섞였습니다. 한자 몇 자 누수가 아니라 답변 언어 자체가 바뀌는 수준입니다.
  • gemma2:27b는 num_ctx=32768을 요청해도 8192로 클램프됩니다. deepseek-coder-v2:16b는 ollama ps상 컨텍스트가 32768인데도 23k 토큰 프롬프트에서 400을 냈습니다.

여기서 결론이 한 번 뒤집혔습니다

같은 날 후속 검증에서 mistral-nemo:12b의 "툴콜 O"를 무효 처리했습니다. 위 T1은 툴 1개 + 가짜 툴 결과 1왕복이었기 때문입니다. 실제 배치 조건으로 다시 쟀습니다.

조건 초기 벤치 풀 툴셋 재검증

조건 초기 벤치 풀 툴셋 재검증
툴 개수 1개 4개
과제 1왕복 다중 파일 편집
채점 사람 확인 pytest 직접 실행
반복 1회 15회

 

모델 overall 테스트 수정한 파일
ornith:9b (대조군) 15/15 15/15
ornith:35b 15/15 15/15
mistral-nemo:12b 0/15 0/15 0건

실패 양상이 핵심입니다. 타임아웃도 크래시도 아니고, 툴을 한 번도 호출하지 않았습니다. 대신 "먼저 실패하는 테스트를 확인해 봅시다" 하고 bash 명령을 코드펜스 텍스트로 적어놓고는 정상 종료했습니다.

이건 qwen2.5-coder가 툴콜 JSON을 본문으로 뱉은 것과 같은 실패 축입니다. 툴 개수와 과제 복잡도가 올라가자 mistral-nemo도 같은 자리로 무너졌습니다.

툴 1개 벤치의 툴콜 결과를 에이전트 적격으로 읽으면 안 됩니다. 이 라운드가 스스로 그걸 증명했습니다.

라운드 4 — 24B급 3종

devstral-small-2:24b / mistral-small3.2:24b / qwen3.6:27b입니다.

항목 devstral mistral3.2 qwen3.6:27b
웜 tok/s 4.88 4.97 3.92
툴콜 O O O
피보나치 O 날조 O
버그(소) X X O
버그(대) 스킵 스킵 O
설계문서 7/10 7/10 10/10
언어 clean clean 가타카나 1건
  • qwen3.6:27b가 이번 2주에서 가장 좋았습니다. 심은 버그를 소·대 둘 다 think_off에서 잡은 유일한 모델이고, 설계문서는 만점입니다. 로컬에서 프론티어에 인접한 코드 추론을 보여줬습니다.
  • 다만 think on이 손해였습니다. 5,153토큰을 더 쓰고도 대용량 버그를 오히려 놓쳤습니다. think off로 써야 합니다.
  • mistral-small3.2:24b는 배제했습니다. 코드 실행 툴의 출력이 빈 값이었는데도 "55"라고 단정했습니다. 우연히 정답이었지만, 관측 없이 확신하는 모델은 에이전트로 쓸 수 없습니다.
  • qwen3.6:27b도 속도 이득은 없었습니다(3.92 tok/s). 주력 교체는 실태스크 A/B 후에 결정하는 게 맞다고 봤습니다.

판정자 자리는 성격이 다릅니다

블로그 자동 발행 파이프라인에는 "초안의 사실성을 검사하는" 단계가 있습니다. 여기 모델을 고르는 기준은 코딩 에이전트와 완전히 달랐습니다.

모델 붕괴 환각 속도 판정
gemma4:31b 0/5 없음 177~483s 채택
qwen3.6:35b 0/4 1회 64~115s 차선
gemma4:26b 3/5 74s 기각
gemma4:12b 기각
mistral-nemo:12b 0/1 16s 기각
  • gemma4:12b는 놓쳐서 기각, mistral-nemo:12b는 전부 막아서 기각입니다. 12b는 전제 붕괴가 4건인 글을 통과시켰습니다. mistral-nemo는 50배 빨랐지만, standalone이라 썼고 stand-alone이라 안 썼다는 이유로, very little이라 썼고 minimal이라 안 했다는 이유로 근거 미지지 판정을 냈습니다. 근거 부합을 문자열 일치로 착각한 겁니다. 이 기준이면 어떤 초안도 영원히 통과하지 못합니다.
  • 판정자 자리에서는 후자가 더 나쁩니다. 파이프라인이 통째로 멈춥니다.
  • 판정자의 환각은 로그에 그치지 않습니다. qwen3.6:35b가 asyncio.gather에 없는 인자가 존재한다고 서술했는데, 이 지적문은 다음 단계에서 "이 사실을 반영해 고쳐라"는 수리 지시가 됩니다. 없는 API를 글에 심을 수 있습니다.
  • 초안을 qwen3.6으로 쓰고 판정을 gemma 계열로 둔 설계에 근거가 섰습니다. 같은 글에서 31b는 A 오류를, 35b는 B 오류를 잡고 서로 놓쳤습니다. 계열이 다르면 보는 오류가 다릅니다.

gemma4:26b는 왜 기각됐나 — 디제너레이션

판정은 정확히 하는데, 그 판정을 한국어로 쓰지 못했습니다.

done_reason=length  eval=4096  content=8480자  → JSON 깨짐

앞부분은 멀쩡하게 진짜 오류를 짚습니다. 그러다 장이록(Persistence)의 특ing의 특징을의 특징의 특징의...가 수백 번 반복되고 중간에 l'en de l'en de, outputting outputting이 섞여 상한까지 갑니다.

  • factual=true면 안 깨집니다. {"factual": true, "issues": []} 31자만 쓰면 되니까요. factual=false로 지적문을 써야 할 때만 무너집니다.
  • 처방을 전부 시도했지만 실패했습니다. repeat_penalty 1.15, temperature 0.7, 모델 권장 샘플링(temp 1.0/top_k 64), 컨텍스트 8k 축소 — 전부 붕괴하거나 파싱되더라도 지적문이 쓰레기였습니다.
  • 파싱에 성공한 케이스가 더 위험했습니다. JSON은 통과하는데 지적문이 재부팅-재부팅 같은 상태로 다음 단계에 넘어가 멀쩡한 글을 망가뜨립니다. 판정 불가보다 나쁩니다.

num_ctx는 모델마다 다르게 작동합니다

num_ctx를 지정하지 않으면 서버 기본값 65536이 걸립니다. 판정 프롬프트는 624토큰뿐이라 순낭비입니다. 그래서 줄여봤는데, 효과가 모델마다 갈렸습니다.

모델  아키텍처 64k KV 축소 효과
gemma4:31b dense 0.8GB -20%
qwen3-coder:30b dense 3.7GB -17%
qwen3.6:35b MoE 0.6GB 없음
  • 핵심은 "KV 캐시가 비운 VRAM에 가중치가 더 올라가는가"입니다. gemma4:31b는 GPU 적재가 8.2 → 9.0GB로 늘어 20%를 회복했습니다. qwen3-coder:30b는 캐시가 3.7GB나 빠지는데 적재는 10.6 → 10.5GB로 제자리였습니다.
  • qwen3.6:35b는 MoE라 KV 캐시가 0.6GB뿐이라 회수할 게 없습니다. 시간은 컨텍스트가 아니라 생성 토큰 수를 따라갔습니다.
  • 그래서 초기 벤치의 "오프로드 모델은 컨텍스트가 지렛대가 아니다"라는 일반화는 깨졌습니다. 변수는 오프로드 여부가 아니라 KV 캐시 크기와 dense/MoE입니다.
  • 8k가 16k보다 더 빨랐지만 쓰지 않았습니다. 프롬프트 624 + 사고 최대 4000 + 출력 상한 4096 = 8720이라 8192를 넘습니다. 넘치면 컨텍스트 시프트가 나고 그게 바로 위에서 진단한 디제너레이션입니다. 아끼려다 병을 만드는 셈입니다.

완전 적재 모델은 사정이 반대로, 큰 컨텍스트가 거의 공짜였습니다.

모델 32k 64k 128k
ornith:9b 7.3GB 8.0GB 9.4GB
gemma4:12b 9.8GB 10.2GB

ornith:9b는 128k에서도 9.4GB로 GPU 100%·속도 무손실이었습니다. gemma4:12b는 sliding-window attention 덕에 16k→64k에서 VRAM이 0.3GB만 늘었습니다.

★ 진짜 산출물 — 측정 함정 8가지

같은 대상을 놓고 세운 가설이 연달아 틀렸고, 조건을 바꾸자 결론이 매번 뒤집혔습니다. 전부 측정 설계 결함이었습니다. 이게 이 2주에서 제일 값진 부분입니다.

  1. 실패를 재현할 수 없는 조건으로 실패를 조사했습니다. gemma4:26b 단독 테스트가 7/7 정상으로 나와 "모델은 멀쩡, 로드 꼬임"이라고 두 번 결론냈습니다. 그런데 그 7회는 전부 factual=true 케이스였고, 그때 출력은 31자라 절대 안 깨집니다. → 판정자를 시험하려면 "틀렸다고 말해야만 하는 입력"을 줘야 합니다.
  2. 개수만 세고 내용을 안 읽었습니다. 26b 채택 근거였던 "반려 1건"은 issues 배열 길이만 센 것이었습니다. 그 텍스트를 읽었으면 쓰레기인 걸 봤을 겁니다.
  3. n=1로는 아무것도 확정하지 못합니다. 전 항목 1회 측정으로 낸 판정의 절반이 3회로 늘리자 뒤집혔습니다. 더 나쁜 건 2회로도 부족했다는 점입니다. 8b의 실패가 2차 런에서 사라져 "1회성 사고"로 철회했다가 3차 런에서 그대로 되살아났습니다. 2회의 불일치는 노이즈가 아니라 표본 부족의 신호입니다.
  4. 속도는 n=1로 충분하고 품질은 아닙니다. tok/s는 3회 편차가 3% 이내인데 설계문서 점수는 4.0↔2.5로 요동칩니다. 측정 예산은 정성 지표 반복에 몰아야 합니다.
  5. 정성 채점을 정규식에 맡기면 안 됩니다. 한자 정규식([一-鿿])만 자동화해서 3b의 베트남어·영어 누출을 기계적으로 전부 놓쳤습니다. 사람이 원문을 읽고서야 잡혔습니다. 버그 진단 채점도 키워드 Content-Length로 하니 모델이 원본 소스를 되뿌리면 걸려서 3건이 오탐이었습니다. 자동 검사 통과는 "깨끗하다"가 아니라 "그 검사가 보는 것만 없다"는 뜻입니다.
  6. 대표성 없는 표본으로 상한을 정할 뻔했습니다. 125자짜리 코드로 "컨텍스트 1024면 충분"이라는 값이 나왔는데, 실제 발행 코드 1239자 한 덩이가 1026토큰이었습니다. 그 값을 넣었으면 매 발행마다 코드가 조용히 잘렸을 겁니다. 관측은 운이고 상한은 구조입니다.
  7. GPU 경쟁 의심은 사후에 증명할 수 없습니다. 시작 시점 스냅샷 하나로 "측정 내내 유휴"라고 썼던 건 근거가 안 됩니다. 이제 5초마다 로드된 모델 목록을 남깁니다.
  8. 실행 전에 채택 기준을 못박아야 합니다. 샘플링 권장값이 35% 빨랐지만 검출률은 1.0/3으로 동률이었습니다. 35% 빠른 이유가 덜 숙고하고 덜 쓴 것(지적문 2문장 → 1문장)이었습니다. 속도가 좋다고 기준을 바꾸면 결과 보고 골대 옮기기입니다.

여기에 환경 함정도 하나 있습니다. Windows에서 subprocess.run(text=True)는 cp949로 디코드합니다. 모델이 생성한 코드가 한글을 print하면 UnicodeDecodeError로 터지고, try/except에 걸려 벤치는 계속 돕니다. 예외가 잡혀도 그 태스크의 입력이 바뀐 것이므로 측정은 폐기해야 합니다.

최종 라우팅

슬롯 모델 근거
코딩 gemma4:12b 완전적재·균형
범용·툴 ornith:9b 툴콜 15/15
초안 qwen3.6:35b 글쓰기 최상
코드검토 qwen3-coder:30b 유지
사실성 판정 gemma4:31b 무붕괴·무환각
대치 후보 qwen3.6:27b A/B 대기

컨텍스트 권장값은 이렇게 정리했습니다.

모델 num_ctx 근거
ornith:9b 65536~131072 128k도 무손실
gemma4:12b 32768~65536 10.2GB 유지
오프로드 계열 16384~32768 키워도 손해
사실성 판정 16384 64k 대비 -20%

think는 대부분의 자리에서 껐습니다. ornith:9b는 폭주 리스크, gemma4:31b는 품질 개선 없이 시간 2배, qwen3.6:27b는 5천 토큰 더 쓰고 버그를 놓쳤습니다. 켜서 이득이 확인된 자리가 거의 없었습니다.

삭제 권고

llama3.1:8b(모든 축에서 열위), llama3.3:70b(1.14 tok/s·42GB), deepseek-coder-v2:16b(툴 미지원+롱컨텍스트 실패), gemma2:27b(툴 미지원+ctx 8k 상한), qwen2.5-coder:32b·deepseek-r1:32b(3 tok/s대), mistral-nemo:12b(풀 툴셋 0/15), ornith:35b(9b 대비 이점 0).

정리

20종을 돌려서 기존 세팅은 거의 그대로 남았습니다. 처음엔 실패한 프로젝트처럼 보였는데, 다시 보면 그게 결과입니다. "바꿀 이유가 없다"를 데이터로 확인한 것도 결론입니다.

한 줄로 남길 건 이쪽입니다. 툴 1개로 잰 툴콜은 에이전트 적격을 판정하지 못하고, 통과 케이스만 골라 재면 아무것도 보이지 않으며, 자동 채점이 통과했다는 건 그 검사가 보는 것만 없다는 뜻입니다. 12GB 카드에서 모델을 고르는 일은 결국 적재 여부라는 하드 제약 위에서 툴콜 안정성을 확인하는 작업이었습니다. 나머지 지표는 대체로 그다음이었습니다.

LLM이 도구를 호출할 때 생성하는 JSON 결과가 항상 완벽하지는 않아요. 가끔 필드가 누락되거나 타입이 어긋나는 경우가 종종 있거든요. 특히 복잡한 파라미터가 있을수록 LLM의 추론 오류로 인해 예상치 못한 형태가 출력되기도 해요. 이런 경우 백엔드에서 바로 파싱 에러가 발생할 수 있어서 꼼꼼한 검증 과정이 필요해요. Pydantic을 쓰면 스키마를 명확히 정의할 수 있어요. LLM의 raw output을 Pydantic 모델에 던져서 validate하면 자동으로 타입 변환과 유효성 검사를 해줘요. 예를 들어 숫자가 문자열로 넘어와도 int로 변환해주거나, 필수 필드가 빠졌다면 ValidationError를 던져주죠. 검증 로직을 일일이 작성하기 번거롭다면 model_validate_json 메서드를 쓰면 됩니다. 이 메서드는 JSON 문자열을 직접 받아서 모델 인스턴스로 만들어주는데, 실패 시 에러 내용을 상세히 알려줘요.

만약 검증에 실패하면 에러 메시지를 다시 LLM에게 피드백해서 수정하도록 할 수 있죠. 이렇게 하면 개발자가 일일이 if-else로 필드를 체크하는 번거로움을 줄일 수 있어요. 특히 도구가 여러 개일 때 각 도구마다 별도의 스키마를 두면 코드 구조도 깔끔해지고, 유지보수도 훨씬 수월해지네요. 검증에 실패했을 때 단순히 에러를 던지는 것보다, 재시도 로직을 함께 고려하는 게 좋아요. Pydantic의 ValidationError에서 추출한 에러 메시지를 프롬프트에 추가해 LLM에게 '이런 형태로 다시 출력해줘'라고 요청하면, LLM이 스스로를 교정할 가능성이 높아지죠. 이런 피드백 루프를 구현하면 처음부터 완벽한 출력을 기대하는 것보다 훨씬 안정적으로 도구를 호출할 수 있어요.

JSON 스키마가 복잡해질수록 Pydantic의 Field와 description을 꼼꼼히 채워주는 것도 중요해요. LLM은 description을 참고해 파라미터 값을 생성하거든요. 또한 model_dump_json 메서드를 활용하면 검증된 데이터를 다시 JSON으로 변환할 때 타입 오류를 사전에 방지할 수 있어요. 에러 처리 시 try-except 블록 안에서 ValidationError의 errors 메서드를 호출하면 어떤 필드에서 어떤 문제가 발생했는지 상세한 리스트를 얻을 수 있어요. 이 정보를 프롬프트에 포함시키면 LLM이 오류 원인을 더 정확히 이해하고 수정할 수 있답니다.

from pydantic import BaseModel, Field

class WeatherSearch(BaseModel):
    location: str = Field(..., description="검색할 도시 이름")
    unit: str = Field(default="celsius", description="온도 단위 (celsius 또는 fahrenheit)")

def call_weather_tool(llm_raw_output: str) -> dict:
    try:
        validated = WeatherSearch.model_validate_json(llm_raw_output)
        return {
            "location": validated.location,
            "unit": validated.unit
        }
    except Exception as e:
        # 에러 메시지를 LLM 피드백용으로 반환
        raise ValueError(f"Tool validation failed: {e}")

OpenAI API를 쓰다 보면 프롬프트가 모델 컨텍스트 한도를 넘어서 잘리거나, 생각보다 요금이 많이 나와서 당황할 때가 있더라고요. 저도 긴 문서를 그대로 넣었다가 한도 초과 에러를 만난 적이 있는데, 결국은 보내기 전에 토큰 수를 미리 세어보는 게 답이었어요. 이걸 도와주는 게 OpenAI가 공식으로 제공하는 tiktoken이라는 라이브러리예요.

사용법은 생각보다 단순해요. pip install tiktoken으로 설치한 다음, encoding_for_model에 모델 이름을 넘겨서 그 모델에 맞는 인코딩을 가져오고, encode로 텍스트를 토큰 리스트로 바꾼 뒤 그 길이를 세면 그게 토큰 수거든요. gpt-4o 계열은 내부적으로 o200k_base라는 인코딩을 쓰는데, encoding_for_model을 쓰면 이걸 알아서 골라주기 때문에 우리가 직접 신경 쓸 필요는 없어요.

import tiktoken

def count_tokens(text: str, model: str = "gpt-4o") -> int:
    enc = tiktoken.encoding_for_model(model)  # gpt-4o는 o200k_base 사용
    return len(enc.encode(text))

text = "안녕하세요, 토큰 수를 세어봅니다."
print(count_tokens(text))  # 이 문자열의 토큰 개수 출력

여기서 한 가지 헷갈리기 쉬운 게 있는데, 채팅 형식으로 보낼 때는 실제 토큰 수가 순수 텍스트보다 조금 더 많아요. role이나 메시지 구분자 같은 메타데이터가 붙기 때문인데, 보통 메시지 하나당 몇 토큰 정도가 추가로 든다고 보면 돼요. 그래서 저는 계산된 값에 약간 여유를 두고 한도를 잡는 편이에요. 그리고 tiktoken은 텍스트만 세기 때문에 이미지 입력이나 함수 호출 같은 건 이 수치에 안 잡힌다는 점도 기억해두면 좋아요. 미리 세어보는 습관만 들여도 한도 초과 에러나 예상 밖 요금으로 놀랄 일이 확 줄더라고요.

출처: OpenAI Cookbook — How to count tokens with tiktoken (https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb)

지난 주에 로컬 LLM 기반의 OpenCode 에이전트를 세팅하며, RAG(검색 강화 생성)를 통한 개인비서 시스템을 완성했다. 이번 포스팅에서는 RAG 아키텍처와 로컬 모델 벤치 테스트 결과를 정리한다.

1. RAG 아키텍처
RAG는 로컬 LLM이 Obsidian 볼트를 '기억'할 수 있게 한다. 핵심 구성 요소:
- 임베딩: bge-m3로 문서를 벡터화
- 벡터DB: Chroma 임베디드 DB에 인덱스 저장
- CLI 도구: vault_rag.py로 index·search·ask 기능 제공
메모: RAG는 로컬 모델의 text streaming과 통합 가능하지만 12GB 환경에서는 bge-m3만 상주하고 메인 LLM은 필요시 로드하므로 동시 상주가 불필요하다.

2. 4종 로컬 모델 벤치
같은 조건에서 아래 4개 모델을 비교했다:
- 코딩 특화 → QWEN3-CODER (30B)
- 범용 + 웹검색 → ORNITH
- 컨텍스트 최적화 → GEMMA4 (12b)
핵심 발견: 6개 도구 모두 사용할 때 gemma 계열이 web_search를 무시/거부하는 것을 확인했다. 반면 qwen과 ornith는 웹검색 도구에 견고한 성능을 보였다. 이로 인해 코딩(gemma)과 웹 검색(ornith/qwen) 역할 분리가 합리적임을 결론냈다.

3. 실사용 체감
실제로 작업해보니:
- OpenCode 에이전트의 think 제어는 파라미터 드롭으로 직접 지원치 않으므로 RAG에서는 native로 off가 가능
- AGENTS.md를 cwd와 상위 경로, .config/opencode에서 자동 탐색
- 별도의 위치 파일은 instructions로 명시 참조해야 로드됨
- 로컬 모델의 도구호출을 텍스트로 노출 → 단일 실행으로 한 번성 단정 금지

결론
RAG + 벤치 결과를 바탕으로, 특정 역할별로 LLM를 라우팅하는 것이 효율적이다. 코딩 작업은 ORNITH가 강하고, 웹 검색은 QWEN 시리즈가 적합하다는 실사용 증거가 나왔다. 향후 RAG를 OpenCode에 MCP 서버로 통합하여 더 원활한 에이전트 경험을 제공할 계획이다.


bench 결과 (20종 결과는 이쪽)

더보기

로컬 LLM 코딩 모델 비교 벤치 — 2026-07-14

  • 용도: opencode 터미널 에이전트(리포 파악·다중 파일 편집·도구 호출) + 간단 설계 문서
  • 환경: Windows · PowerShell · Ollama(localhost:11434, /api/chat) · Ryzen 9 5950X · RTX 3080 12GB · DDR4 64GB
  • 측정 조건(전 모델 동일): num_ctx=32768, temperature=0.2, 콜드런 1회 후 웜런 측정, 모델 전환 시 이전 모델 언로드
  • 측정 중 외부 GPU 부하 없음(데스크톱 유휴, nvidia-smi idle 4~5%)
  • 대상: qwen3.6:35b(현 메인) · ornith:9b · gemma4:12b · gemma4:26b

종합 비교표

항목 gemma4:12b ornith:9b qwen3.6:35b gemma4:26b
적재 완전(GPU 100%) 완전(GPU 100%) 오프로드 오프로드
콜드로드(s) 8.2 3.3 29.6 23.5(total 36)
gen tok/s 63.8 95.3 38.8 39.0
prompt eval tok/s 814.7 1091.8 188.6 250.5
GPU/CPU 100% GPU 100% GPU 59%/41% 51%/49%
VRAM 9.98GB 7.1GB 11.5GB 11.7GB
툴콜 O O O O(think 시 X)
설계문서(n/10→점수) 10/10 → 5 8/10 → 4 8/10 → 4 10/10 → 5
한국어 O O △(한자 2자 누수) O
think 오버헤드 중(off 대비 gen ~4.5배) 낮음(단, 폭주 리스크) 높음
코딩(1-5) 4.5 3.5 3.5 3.5
         
종합 ★ 1위(균형) 2위(속도) 3위 4위

콜드로드: 언로드 직후 첫 호출 load_duration. gen/prompt tok/s: 웜런 eval/prompt_eval 기반. 적재율: ollama ps. VRAM: nvidia-smi memory.used.

정량 상세

  • 긴 컨텍스트(≈12k~14k 토큰 니들 검색): 4종 전부 니들(ORCHID-7749) 정확히 검색 성공(HIT), 멈춤·누락 없음. prompt eval 처리량은 완전적재 모델이 압도(ornith 3393 · gemma12 2317 tok/s vs 오프로드 gemma26 1401 · qwen35 912 tok/s).
  • 적재/속도 계층이 뚜렷하게 2분화:
    • 완전 GPU 적재(ornith 7.1GB, gemma12 9.98GB) → 콜드로드 빠름, gen 64~95 tok/s
    • 12GB 초과 오프로드(qwen35, gemma26) → 콜드로드 24~30s, gen ~39 tok/s로 반토막
  • VRAM 여유 = 컨텍스트 여유: ornith 7.1GB로 32k에서도 여유 최대, gemma12 9.98GB로 32k 완전적재 유지. 오프로드 2종은 32k에서 이미 11.5~11.7GB로 포화.

정성 상세

1) 툴콜(가장 큰 파일 3개, run_shell 정의)

  • think off: 4종 모두 정상 — 단일 run_shell tool_call 발생, 설명 텍스트 누출 0 (content_len=0). 이전 qwen3-coder류 XML 누출 현상 재현 안 됨(툴 1개 조건).
  • think on: gemma4:26b만 회귀 — tool_calls 0, 명령을 텍스트로 설명만 함(누출성 실패). 나머지 3종은 think on에서도 정상 tool_call.
  • (부수 관찰: 전 모델 bash 문법 명령 생성 — 프롬프트에 OS 미명시 탓, 툴콜 구조/누출 판정과 무관)

2) 코드 생성+실행(피보나치 n=10)

  • 4종 전부 동일 반복문 구현·정답 55. gemma12가 docstring·설명 가장 충실, ornith가 가장 간결(print(fibonacci(10))).

3) 긴 문맥 버그 지적(deadlock_demo.py, 실제 출력=항상 7)

  • 정답: 데드락 아님·경쟁조건 아님(락 정상), 최종 count가 5를 초과해 7로 과다증가하는 논리버그.
  • gemma4:12b ✓ 정확·간결 — 5 초과(6/7) 정확 지적
  • qwen3.6:35b ✓ 결론은 정확하나 매우 장황·초반 오도(데드락 언급 후 번복)
  • gemma4:26b △ 대체로 정확하나 "다행히 5를 넘지 않는다"고 자기모순(실제는 7)
  • ornith:9b오답 — "버그 없음, 최대 15"(스레드별 독립 카운트로 오해; count는 공유 변수)

4) 인증 설계 문서(체크리스트 10항목)

항목 qwen35 ornith9 gemma12 gemma26

1 비밀번호 해싱(bcrypt/argon2)
2 이메일 유니크+중복처리(4xx) △(unique만) △(unique만) ✓(400 중복) ✓(400 중복)
3 입력검증(형식·강도)
4 인증방식(JWT/세션)
5 토큰/세션 만료
6 로그인 실패 401
7 엔드포인트+스키마
8 적절한 상태코드(201/400/401) △(201/200만) △(201/200만)
9 users 데이터모델
10 레이트리밋/브루트포스
충족/환산 ~8/10 → 4 ~8/10 → 4 10/10 → 5 10/10 → 5
  • gemma 2종은 에러 상태코드(400/401)·중복처리까지 명시해 만점. qwen/ornith는 성공 경로 위주로 401·에러코드 누락. qwen은 CSRF·HSTS·2FA 등 부가 항목 풍부하나 한자 2자(兼顾) 누수.

5) 언어 준수(중국어 누수)

  • ornith9 · gemma12 · gemma26: 전 응답 한자 0 → O
  • qwen3.6:35b: 설계문서에 兼顾 1회(2자) 누수 →

think 오버헤드

  • qwen3.6:35b: 높음 — 버그찾기 thinking ~6.9k자, gen 1083→2514 토큰(2배+). 답질 향상 폭 대비 컨텍스트 소모 큼.
  • gemma4:12b: 중 — thinking ~7.9k자, gen 579→2657. off에서도 이미 정답이라 think 실익 낮음.
  • gemma4:26b: 중 — thinking ~4.2k자, gen 707→1648. + think 시 툴콜 회귀.
  • ornith:9b: 평소 낮음, 그러나 치명적 폭주 리스크 — 버그찾기 think on에서 thinking 66.5k자·gen 32.5k 토큰 폭주 후 답변 공란(약 6분 소요). 에이전트에 think on은 위험.

최종 추천

1순위: gemma4:12b — opencode 에이전트 기본 모델 권장.

  • 12GB에 완전 적재(GPU 100%) 로 콜드로드 8s·gen 63.8 tok/s 확보, 32k 컨텍스트 유지. 오프로드인 현 메인 qwen3.6:35b(39 tok/s) 대비 속도 1.6배.
  • 툴콜 안정(think on/off 모두 정상), 버그찾기 정확, 설계문서 만점, 한국어·한자누수 clean — 정확성·안정성·속도 균형이 가장 좋음.

속도 최우선이면 ornith:9b — gen 95 tok/s·VRAM 7.1GB로 컨텍스트 여유 최대, 툴콜(think off) 안정. 단 추론 정확도가 약하고(버그 오답) think on 폭주 리스크가 있어 반드시 think off로 툴 중심 루프에 사용.

현 메인 qwen3.6:35b·gemma4:26b(오프로드 2종)은 에이전트 용도에서 열위 — 12GB 초과로 CPU 오프로드되어 gen ~39 tok/s·콜드로드 24~30s. gemma4:26b는 설계 품질(5)이 좋으나 think 시 툴콜 회귀가 있고, 완전적재 gemma4:12b가 속도·안정성에서 앞서 대체 가능. qwen3.6:35b는 복잡 추론 시 품질은 나오지만 속도·한자누수 면에서 에이전트엔 부담.

운용 팁: opencode는 /v1에서 think 파라미터를 드롭하므로(메모리 opencode-thinking-param-drop) 에이전트 경로는 사실상 think off로 동작 → 위 think off 지표가 실사용 기준. RAG 답변 경로는 별도.


보완 벤치 — 범용성(글쓰기·웹검색·지식)

코딩 위주 본 벤치를 보완해 비코딩 범용성을 추가 측정(think off, num_ctx=16384).

모델 글쓰기(칼럼) 분량통제 웹검색(툴 6개) 일반지식·추론 한자

qwen3.6:35b ★ 5 (밀도·표현 최상) 517자(정확) O(web_search 정확 호출) 정답(근거 '외래어' 오기 경미) 0
ornith:9b 4.5 (간결·자연) 433자(약간 짧음) O(web_search 정확 호출) ★ 정답·근거 clean 0
gemma4:12b 4.5 ('연결되지 않을 권리' 인용) 625자(초과) X(검색 거부, 툴 미호출) 정답·근거 정확 0
gemma4:26b 4.5 (관용구 자연) 579자(초과) △(get_datetime만 호출, 검색 미도달) 정답·근거 정확 0

핵심(코딩 순위와 뒤집힘)

  • 글쓰기: 4종 전부 우수. 한국어 서론-본론-결론 구성·자연스러움 모두 실사용 가능 수준. qwen3.6:35b가 표현 밀도·분량 통제에서 가장 앞섬(비코딩 글쓰기는 35B급 우위). gemma 2종은 내용은 좋으나 500자 지시를 넘김(분량 통제 약).
  • 웹검색/멀티툴이 결정적 분기점: 툴 6개(web_search·fetch·read_file·list_dir·run_shell·get_datetime)를 물리자, 본 벤치의 코딩 1위 gemma4:12b가 web_search를 호출조차 안 하고 "검색 권한 없음"으로 거부. gemma4:26b는 엉뚱하게 get_datetime만 호출. 반면 qwen3.6:35b·ornith:9b는 web_search를 정확히 선택. → 툴 1개(코딩 벤치)에선 4종 다 통과했지만, 웹검색 같은 멀티툴 환경에선 qwen/ornith가 견고하고 gemma 계열이 취약.
  • 일반지식·추론: 4종 전부 정답(빛 8분·'경제' 맞춤법). ornith가 가장 깔끔, qwen은 근거에 '외래어' 오기(경미).

범용성 결론

  • 글쓰기·상식 Q&A 같은 일반 어시스턴트 용도: 4종 다 무난, 품질 우선이면 qwen3.6:35b, 속도 우선이면 ornith:9b.
  • 웹검색·멀티툴 에이전트 용도: qwen3.6:35b·ornith:9b 권장. gemma4:12b는 코딩엔 강하나 멀티툴 웹검색에서 툴 미호출로 부적합 → 웹검색은 별도 모델 분리 필요.
  • 종합: 순수 코딩 에이전트 = gemma4:12b, 웹검색 포함 범용 에이전트 = ornith:9b(속도·툴선택 견고, 단 심층추론 약) 또는 qwen3.6:35b(품질·오프로드 감수). 용도별 이원화가 현실적.

컨텍스트 윈도우 운용(12GB 실측)

num_ctx를 바꿔가며 VRAM·적재율(ollama ps)·생성속도 측정. 12GB에선 num_ctx가 KV 캐시로 VRAM을 직접 점유해 오프로드 임계를 좌우함.

모델 32k 64k 128k 적재 판정

ornith:9b 7.3GB · 92tok/s 8.0GB · 91tok/s 9.4GB · 95tok/s 128k까지 100% GPU, 속도 무손실
gemma4:12b 9.8GB · 55tok/s 10.2GB · 52tok/s (미측정) 64k까지 100% GPU(16k→64k VRAM +0.3GB)
qwen3.6:35b 11.7GB · 36tok/s 8k에서도 60/40 오프로드(가중치 23GB)
gemma4:26b 11.5GB · 37tok/s 8k에서도 49/51 오프로드(가중치 17GB)

핵심

  1. 완전 적재 모델은 큰 컨텍스트가 사실상 공짜. ornith:9b는 128k에서도 9.4GB로 GPU 100%·속도 무손실. gemma4:12b는 sliding-window attention으로 KV가 거의 안 커져 64k까지 여유(≤10.2GB).
  2. 오프로드 2종은 컨텍스트가 지렛대가 아님. qwen3.6:35b·gemma4:26b는 가중치만으로 12GB 초과 → num_ctx를 8k로 낮춰도 오프로드 그대로(속도 회복 X).
  3. num_ctx는 "예약"이지 "사용"이 아님. 크게 잡아도 빈 컨텍스트는 생성속도에 영향 없음(ornith 92tok/s 동일). 실비용은 ①채웠을 때 prompt-eval 시간 ②lost-in-the-middle 품질 저하 ③선점 VRAM.

권장 설정

  • ornith:9b(범용·웹검색·에이전트): num_ctx=65536 기본, 대형 리포/장문 131072까지. 이 박스 최고의 롱컨텍스트 모델 → opencode "컨텍스트 꽉 참·멈춤" 해소.
  • gemma4:12b(코딩): num_ctx=32768 기본, 필요 시 49152~65536(≤10.2GB 유지).
  • qwen3.6:35b / gemma4:26b(오프로드): 16384~32768 고정. 더 키우면 느린 prompt-eval(188/250 tok/s)+장문 겹쳐 손해, 낮춰도 이득 없음.
  • RAG 경로: 검색 청크 유한 → 16~32k면 충분, 128k 불필요.
  • KV 캐시 양자화(q8_0): 불필요(여유 크고, MoE엔 속도효과 0 → ollama-flash-kv-cache-null).

한 줄 요약: 컨텍스트는 "GPU에 100% 남는 최대치"까지만 — ornith 64~128k, gemma12 32~64k, 오프로드 2종 16~32k.

+ Recent posts