"6주"를 0.06으로 읽은 로컬 LLM
자동매매 봇의 데일리 LLM 역할을 자가호스팅 모델로 옮기다 만난 오독 버그를 DB 원문 재현과 A/B 실측으로 잡은 기록
하려던 것
자동매매 봇에는 매일 headless claude -p로 도는 역할들이 있다. 시황 분석, 전략가(포트폴리오 목표 비중 제안), 반대 심문, 자가 진단. 비용을 줄이려고 이 중 일부를 DGX Spark에 올린 vLLM 기반 gemma 서버로 옮기는 중이고, 오늘은 전략가 프롬프트를 로컬 모델에 그대로 쏴서 품질을 확인하는 날이었다.
어떻게 시켰나
Claude Code에 로컬 LLM 서버 통합 가이드를 붙여넣고, 운영에서 쓰는 프롬프트로 테스트를 시켰다. 그런데 첫 실측에서 사고가 나왔다. 보유 표기 - 069500 (KODEX 200): 6주, 평단 …의 6주를 gemma가 목표 비중 0.06으로, 22주를 0.22로 그대로 옮겨 적는다. 15회 중 8회.
무서운 건 실패하는 쪽이 아니다. 베낀 비중의 합이 1.0을 넘으면 검증(RANGE_FAIL)에 걸려 폐기되지만, 우연히 합이 1.0 이하면 그대로 통과한다. 실제로 8/14 프롬프트는 두 종목만 베껴서 합이 정확히 1.00 — 방어선을 그냥 지나갔다.
어디서 막혔나
재현이 안 됐다. 서버 쪽에서 리포트에 적힌 프롬프트를 손으로 재구성해 24회를 돌렸는데 0회. 재구성판이 실물과 다르다는 뜻이라, 원본으로 다시 시키면서 조건을 못박았다.
반드시 DB 값을 그대로 덤프할 것. 손으로 다시 타이핑하거나 정리하지 말 것 — 공백·줄바꿈·섹션 순서 중 무엇이 원인인지 아직 모른다.
Claude Code가 운영 DB에서 프롬프트를 바이트 그대로 꺼내 md5 해시까지 파일과 대조한 뒤 돌리니, 원문 30/30 확정 재현. 재구성판 0/24와 원본 30/30 — 같은 내용처럼 보여도 재현은 원본 그대로만 의미가 있었다. 중간에 JSON 파싱 실패가 섞여 나와 잠깐 헷갈렸는데, 이건 하네스의 max_tokens 900이 부족해 응답이 잘린 별개 문제였다. Claude가 finish_reason을 확인해 버그와 분리하고, 1400으로 보조 실행을 따로 올렸다.
어떻게 풀었나
표기 4종(원문 / 콜론 떼기 / 수량 제거 / 경고 라벨) × 15회 A/B로 원인을 좁혔다. 수량을 제거하면 0/30으로 깨끗하게 사라진다. 콜론만 떼도 큰 수량 베낌은 0인데 소수량 잔존이 애매했고, 수량을 56→41주로 바꾸니 비중이 0.43으로 따라오는 것까지 확인 — 수량 표기가 원인이다.
수정은 두 가지. 보유 표기에서 수량 제거 + 종목코드 뒤 콜론 제거, 그리고 출력 스키마의 theses를 중첩 맵에서 배열로 변경(중첩 맵은 구조화 출력에서 공백 폭주로 절반 넘게 파싱이 깨졌고, 배열은 13/13). 재검증은 DB 원문이 아니라 수정된 생성부가 새로 렌더링한 프롬프트로, 2개 프롬프트 × 2조건 × 15회 = 60/60 통과. 이 입출력 계약은 결정 문서에 박고, 프롬프트 예시와 스키마 상수를 테스트가 함께 잠그게 했다. 이관 방식도 하나 못박았다.
기존 Claude 트랙을 갈아끼우지 말고 새 트랙으로 — 갈아끼우면 A/B가 거짓말이 된다.
배운 점
- LLM 교체는 “같은 프롬프트면 되겠지”가 안 통한다. 표기 하나(
6주)가 출력을 뒤집었다. - 재현은 원본 바이트 그대로 해야 한다. 손으로 재구성한 프롬프트는 0/24, DB 덤프 원문은 30/30이었다.
- 제일 위험한 실패는 검증에 걸리는 실패가 아니라 우연히 통과하는 실패다. 합 1.00은 15/15 나오는 안정 상태였다.
- 응답 잘림 같은 부수 문제는 본 버그와 분리해 따로 세야 A/B가 안 흐려진다.
- 비교 실험의 대조군은 건드리지 않는다. 이관은 새 트랙으로.