← 블로그 목록
ClaudeCodeLMStudioQwen프롬프트엔지니어링로컬AIAI1인개발

Claude Code로 로컬 LLM 정확도 올리기 — Qwen 분류 53% → 87% 튜닝 기록

2026년 4월 19일

투자킹(twojaking.com)은 LLM을 여러 지점에서 활용합니다.

  • 뉴스 기사 관련도 점수 산정
  • 기사 태그·요약 추출
  • 이벤트 클러스터링 (초회 + 증분)
  • 이벤트 중요도 스코어링
  • RAG 맥락 생성

이 작업들은 전부 로컬 LM Studio에서 실행 중인 Qwen으로 처리합니다. 로컬 모델을 그대로 붙이면 API 수준의 품질을 기대하기 어렵습니다. 최초 뉴스 분류 벤치마크에서 정확도는 **53.8%**였으며, include로 분류되어야 할 기사 12개를 모두 "none"으로 분류했습니다.

이 글은 정확도를 **87.5%**까지 끌어올린 과정에서 적용한 기법들을 정리합니다. 핵심은 더 큰 모델이 아니라 더 정밀한 프롬프트 설계와 출력 정규화입니다.


로컬 LLM 튜닝의 기본 사이클

로컬 LLM 튜닝의 핵심은 그라운드 트루스 구축 → 벤치마크 실행 → 오분류 분석 → 프롬프트 수정 사이클을 빠르게 반복하는 것입니다. Claude Code는 이 사이클에서 스크립트 작성, 오분류 패턴 분석, 프롬프트 diff 작성을 담당하고, 판단과 방향 결정은 사람이 합니다.

한 번 돌리는 데 약 3분입니다. 방법은 간단합니다.

  1. 오분류 로그를 붙여넣고 "어느 케이스에서 틀리고 있는지 패턴을 분류해줘"라고 요청합니다.
  2. 표와 패턴 요약이 나오면 거기서 방향을 정합니다.
  3. "이 규칙을 프롬프트에 추가해줘"라고 하면 diff를 즉시 반영합니다.
  4. 벤치 스크립트를 다시 돌립니다.

문제 1: JSON 파싱 실패

최초 프롬프트는 단순했습니다.

"이 기사가 암호화폐 시장을 파악하는 데 얼마나 유용한지 1~100으로 점수를 매겨라."

첫 번째 문제는 JSON 응답 자체가 깨지는 것이었습니다. LM Studio에 response_format을 같이 넘기면, 실제 JSON이 message.content가 아니라 message.reasoning_content에 출력되는 경우가 있습니다. 이 상태에서 include 12개 샘플의 점수는 전부 -1이었습니다. -1은 파이프라인에서 "모델이 해당 기사 ID에 점수를 반환하지 않았을 때" 쓰는 값입니다.

설정정확도include precision/recallnone precision/recall오분류
local (full thinking)53.8%0.0% / 0.0%53.8% / 100%12건

해결책은 다음 한 줄입니다.

message_data = data["choices"][0]["message"]
raw_content = message_data.get("content") or message_data.get("reasoning_content", "")
content = self._strip_thinking(raw_content)

content가 비어 있으면 reasoning_content 값을 대신 씁니다. 추론 모델이 흘리는 <think>...</think> 블록은 별도 후처리로 제거합니다.


그라운드 트루스 구축

수치를 개선하기 전에 정답 기준부터 정해야 합니다. 사람이 직접 기사마다 점수를 매기면 일관성이 떨어지므로, Claude Sonnet으로 정답지(Ground Truth)를 구성합니다.

  • DB에서 최근 기사를 뽑고
  • Sonnet에 점수와 판단 근거를 요청해
  • 결과를 수동으로 검토해 이상값만 보정
{
  "threshold_default": 72,
  "samples": [
    {
      "id": "i01",
      "label": "include",
      "source_label": "Decrypt",
      "title": "모건스탠리 비트코인 ETF, 첫 거래일 3천100만 달러 유입",
      "content": "비트코인 ETF가 이틀 연속 순유출 중 모건스탠리 신규 펀드가 0.14% 수수료로 3천100만 달러 유치.",
      "score_hint": [85, 100],
      "reasoning": "코인 ETF 실시간 자금 유입 수치 → 최고 관련도"
    }
  ]
}

그라운드 트루스 품질에서 중요한 점은 함정 케이스를 일부러 많이 포함시키는 것입니다. 쉬운 샘플만 넣으면 프롬프트 수정이 쉬운 방향으로만 쏠립니다.

기사기대함정 이유
전 리플 임원 "XRP가 ETF보다 낫다"none개인 의견, 실시간 사건 아님
이란 대사 협상단 관련 게시물 삭제none외교 세부사항, 시장 충격 없음
전기차는 미국 산업 정책의 잘못을 대표none오피니언 기사

평가 파이프라인 구축

그라운드 트루스가 있으면 채점을 자동화할 수 있습니다. bench_classifier.py가 이 역할을 합니다.

@dataclass
class ClassifierConfig:
    name: str
    model: str = "local"       # LM Studio에 로드된 모델 ID
    threshold: int = 72
    temperature: float = 0.1
    fast: bool = True          # True → reasoning_effort=none, False → medium

CONFIGS: list[ClassifierConfig] = [
    ClassifierConfig(name="local-full",  model="local",  threshold=72, fast=False),
    ClassifierConfig(name="local2-full", model="local2", threshold=72, fast=False),
]

설정 목록만 바꾸면 모델별·임계값별·thinking on/off별 비교가 자동으로 됩니다. 결과는 JSON으로 저장하며 오분류 기사 목록도 함께 포함합니다.

정확도 하나만 보는 것으로는 충분하지 않습니다. 어떤 방향으로 실수하는지를 봐야 프롬프트의 어느 부분을 고칠지 파악됩니다.

metrics = {
    "accuracy":          round(correct / total, 3),
    "include_precision": round(tp / (tp + fp), 3) if (tp + fp) else 0,
    "include_recall":    round(tp / (tp + fn), 3) if (tp + fn) else 0,
    "none_precision":    round(tn / (tn + fn), 3) if (tn + fn) else 0,
    "none_recall":       round(tn / (tn + fp), 3) if (tn + fp) else 0,
    "correct": correct, "total": total,
    "tp": tp, "fp": fp, "tn": tn, "fn": fn,
}

문제 2: 점수 중간값 수렴 → 2축 승수 구조

단순 1100 점수 요청의 핵심 문제는 모델이 5070 구간으로 수렴하는 현상입니다. 임계값 72를 걸어도 필터링이 작동하지 않습니다.

점수를 두 축으로 분해하고 둘을 곱하는 구조로 전환합니다.

최종점수 = [경제관련성 점수] × [실시간성 점수] / 100

실제 build_classification_messages 함수의 프롬프트에서 발췌하면:

━━ 경제관련성 점수 (0~100) ━━
  90~100: 암호화폐 가격 급변·대규모 청산·ETF 자금흐름, 중앙은행 금리 결정, GDP·CPI·PPI·PCE 발표
  70~89:  암호화폐 규제 조치(SEC·CFTC·DOJ), 주가지수 1%이상 등락
  50~69:  거시경제 지표(고용·무역수지), 환율 급변
  20~49:  암호화폐 해킹·보안, 온체인 지표, 금융 기업 실적
  1~19:   비금융 기업 실적·인사·M&A

━━ 실시간성 점수 (0~100) ━━
  90~100: 지금 막 발생·확인된 사건, 오늘 발표된 수치
  50~89:  진행 중인 협상·정책 움직임
  20~49:  분석·해설 기사
  1~19:   예측·전망, 오피니언, 기술적 분석

덧셈 구조였으면 둘 중 하나만 높아도 평균이 높게 나옵니다. 곱셈 구조에서는 두 축이 모두 높은 기사만 최종 점수가 높아집니다.


점수 상한(cap) 규칙

2축 승수만으로는 특정 카테고리에서 로컬 모델이 체계적으로 과대평가하는 패턴을 잡을 수 없습니다. 카테고리별 점수 상한을 별도로 부여합니다.

━━ 점수 상한 (반드시 준수) ━━
- 암호화폐 기술적 분석('돌파할까'·'저항선'·'데드크로스'): 최대 45점
- 예측·전망·시나리오만 담긴 기사: 최대 45점
- 오피니언·칼럼·특정인 의견만: 최대 35점
- 비금융 기업 실적·인사·M&A: 최대 20점
- 암호화폐 보안(스캠 동결·해킹 수사): 최대 30점
- 문화·스포츠·사회·환경·과학: 최대 10점
- 부동산·임대·모기지: 최대 25점
- 군사·외교 (에너지·금리 수치 충격 없는 경우): 최대 45점

이 상한들은 그라운드 트루스와 비교해 오분류 패턴을 찾아낸 결과입니다. "전기차는 미국 산업 정책의 잘못을 대표한다" 같은 오피니언 기사에 로컬 모델이 60점을 붙이던 것이, 상한 규칙 적용 후 35점 이하로 내려갑니다.


인덱스 치환 패턴: UUID 대신 순번 전달

LLM에 UUID를 직접 전달하면 두 가지 문제가 발생합니다. UUID를 변형해 반환하거나(hallucination), 응답 포맷이 오염됩니다.

이를 방지하기 위해 인덱스 치환 패턴을 사용합니다.

def build_classification_messages(items: list[dict]) -> tuple[list[AIMessage], dict[str, str]]:
    idx_to_real: dict[str, str] = {}
    articles_text = ""
    for i, item in enumerate(items, 1):
        real_id = item.get("link_hash", item.get("id", ""))
        idx_to_real[str(i)] = real_id          # "1" → 실제 UUID
        title = item.get("title", item.get("raw_title", ""))
        content = (item.get("summary") or item.get("raw_content") or "")[:150]
        source = item.get("source_label", "")
        prefix = f"[{source}] " if source else ""
        articles_text += f"[{i}] {prefix}제목: {title} / 내용: {content}\n"
    ...
    return messages, idx_to_real

프롬프트 빌더 함수는 idx_to_real 매핑을 함께 반환합니다. LLM은 순번(1, 2, 3, ...)만 보고 순번으로만 응답하며, 서비스 레이어에서 역치환해 실제 UUID를 복원합니다.

messages, idx_to_real = news_prompt.build_classification_messages(items)

structured = await ai_client.complete_structured(
    messages,
    response_model=NewsClassificationResult,
    ...
)
score_by_real = {
    idx_to_real[p.id]: p.score          # 순번 → 실제 UUID로 역치환
    for p in structured.data.items
    if p.id in idx_to_real
}

build_tag_extraction_messagesbuild_rag_overall_analysis_messages에도 동일한 패턴이 적용되어 있습니다.


2단계 정규화 호출

로컬 모델은 JSON 포맷을 지시해도 완벽히 지키지 못합니다. 설명문이나 마크다운이 앞뒤에 붙거나 스키마에서 조금씩 벗어납니다. 구조화 응답은 2단계로 호출합니다.

# 1단계: 스키마 지시 + 자유 출력
schema_messages = self._inject_json_schema_instruction(messages, response_model, schema_name)
first_response = await self._call_lm_studio(schema_messages, max_tokens, temperature, fast)

# 2단계: 1단계 결과를 response_format으로 정규화
cleanup_messages = [original_system, AIMessage(
    role="user",
    content=(
        "아래 텍스트를 JSON Schema에 맞는 올바른 JSON으로 변환하세요. "
        "설명이나 마크다운 없이 JSON만 반환하세요.\n\n"
        f"{first_response.content}"
    ),
)]
json_format = self._build_response_format(response_model, schema_name)
response = await self._call_lm_studio(
    cleanup_messages, max_tokens, temperature, fast,
    response_format=json_format,
)

출력 정규화는 프롬프트 튜닝만큼 정확도에 기여합니다. 프롬프트가 아무리 정밀해도 JSON을 못 읽으면 점수가 -1로 돌아오는 것은 동일합니다.


reasoning_effort: 태스크별 설정

LM Studio는 reasoning_effort 파라미터로 사고 깊이를 조절할 수 있습니다. none이면 내부 추론 없이 바로 답하고, medium이면 내부 추론 단계를 거칩니다. 품질은 medium이 높지만 응답 시간이 3~5배 늘어납니다.

payload = {
    "reasoning_effort": "none" if fast else "medium",
}

태스크별 설정 판단 기준:

태스크fast이유
뉴스 관련도 분류 (배치)True속도 우선, 배치 재시도로 커버
태그 추출True기계적 추출
증분 클러스터링True후보가 임베딩으로 이미 필터링됨
초회 클러스터링False한 번의 stateless 병합 결정, 재검증 불가
이벤트 스코어링False질적 판단 필요
RAG 맥락 생성True실시간 응답 필요

초회 클러스터링에만 fast=False를 적용하는 이유는 이 단계가 stateless한 한 번의 병합 결정이기 때문입니다. "이란 전쟁 유가 급등", "호르무즈 봉쇄", "중동 휴전 협상" 같은 기사들을 하나의 이벤트로 묶을지 세 개로 나눌지를 한 번에 결정하며, 잘못 묶이면 이후 단계에서 복구할 방법이 없습니다.

thinking은 한 번의 돌이킬 수 없는 결정에만 씁니다. 재검증 가능하거나 후속 단계에서 보정 가능한 판단에는 과합니다.


결과: 53.8% → 87.5%

튜닝 단계별 정확도 변화:

단계정확도주요 변경
초기 (단순 프롬프트)53.8%
JSON 파서 수정65.4%reasoning_content 폴백 추가
2축 승수 구조 적용76.9%경제관련성 × 실시간성 / 100
점수 상한 규칙 추가84.6%카테고리별 cap 설정
2단계 정규화 호출87.5%JSON 파싱 안정화

최종 벤치 결과 (26개 샘플):

설정정확도include prec/recnone prec/rec오분류
local-full (fast=False)87.5%91.7% / 91.7%84.6% / 84.6%3건

정리

로컬 Qwen이 Sonnet을 대체하는 것이 목표가 아닙니다. 이 서비스가 요구하는 품질 기준에 도달하는 것이 목표입니다. 핵심 원칙을 정리하면 다음과 같습니다.

  • 점수를 요구하면 반드시 상한을 건다. 단순 점수 요청은 중간값 수렴을 유발합니다. 축을 분해하고 상한을 주면 분포가 분리됩니다.
  • 그라운드 트루스에 함정 케이스를 포함시킨다. 쉬운 샘플만 있으면 프롬프트 수정이 엉뚱한 방향으로 흘러갑니다.
  • LLM에 UUID를 넘기지 않는다. 인덱스 치환 패턴으로 hallucination과 포맷 오염을 방지합니다.
  • JSON 파서가 본체다. 프롬프트 품질 못지않게 출력 정규화에 시간을 써야 합니다.
  • thinking은 돌이킬 수 없는 결정에만 쓴다. 재검증 가능한 단계는 빠른 모드로 충분합니다.

그라운드 트루스 → 벤치 루프 → 프롬프트 수정이라는 사이클은 다른 태스크에도 재사용 가능합니다. 이 사이클을 빠르게 돌릴 수 있는 환경을 먼저 구축하는 것이 로컬 LLM 튜닝의 시작점입니다.

0

댓글 0

Ctrl+Enter