투자킹(twojaking.com)은 LLM을 여러 지점에서 활용합니다.
- 뉴스 기사 관련도 점수 산정
- 기사 태그·요약 추출
- 이벤트 클러스터링 (초회 + 증분)
- 이벤트 중요도 스코어링
- RAG 맥락 생성
이 작업들은 전부 로컬 LM Studio에서 실행 중인 Qwen으로 처리합니다. 로컬 모델을 그대로 붙이면 API 수준의 품질을 기대하기 어렵습니다. 최초 뉴스 분류 벤치마크에서 정확도는 **53.8%**였으며, include로 분류되어야 할 기사 12개를 모두 "none"으로 분류했습니다.
이 글은 정확도를 **87.5%**까지 끌어올린 과정에서 적용한 기법들을 정리합니다. 핵심은 더 큰 모델이 아니라 더 정밀한 프롬프트 설계와 출력 정규화입니다.
로컬 LLM 튜닝의 기본 사이클
로컬 LLM 튜닝의 핵심은 그라운드 트루스 구축 → 벤치마크 실행 → 오분류 분석 → 프롬프트 수정 사이클을 빠르게 반복하는 것입니다. Claude Code는 이 사이클에서 스크립트 작성, 오분류 패턴 분석, 프롬프트 diff 작성을 담당하고, 판단과 방향 결정은 사람이 합니다.
한 번 돌리는 데 약 3분입니다. 방법은 간단합니다.
- 오분류 로그를 붙여넣고 "어느 케이스에서 틀리고 있는지 패턴을 분류해줘"라고 요청합니다.
- 표와 패턴 요약이 나오면 거기서 방향을 정합니다.
- "이 규칙을 프롬프트에 추가해줘"라고 하면 diff를 즉시 반영합니다.
- 벤치 스크립트를 다시 돌립니다.
문제 1: JSON 파싱 실패
최초 프롬프트는 단순했습니다.
"이 기사가 암호화폐 시장을 파악하는 데 얼마나 유용한지 1~100으로 점수를 매겨라."
첫 번째 문제는 JSON 응답 자체가 깨지는 것이었습니다. LM Studio에 response_format을 같이 넘기면, 실제 JSON이 message.content가 아니라 message.reasoning_content에 출력되는 경우가 있습니다. 이 상태에서 include 12개 샘플의 점수는 전부 -1이었습니다. -1은 파이프라인에서 "모델이 해당 기사 ID에 점수를 반환하지 않았을 때" 쓰는 값입니다.
| 설정 | 정확도 | include precision/recall | none 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_messages와 build_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/rec | none 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 튜닝의 시작점입니다.