AI가 없는 뉴스를 지어냈다. 로컬 LLM 할루시네이션 잡기 솔직 후기
매일 아침 맥미니가 주식 리포트를 자동으로 만든다. Ollama(로컬 LLM)가 뉴스를 분석하고, 종목 추천까지 뽑아준다. 그걸 사이트에 자동 배포한다.
오늘 리포트를 확인했는데 뭔가 이상했다. 삼성전자 주가가 170,500원이었다. 실제 주가는 222,750원이다.
그리고 이런 뉴스가 있었다.
"삼성전자 1분기 매출 76조원 달성, HBM 출하 확대"
"AT&T CEO John Smith, 구독 서비스 확대 발표"
두 뉴스 모두 그날 없었던 뉴스다. AI가 만들어낸 거다.

왜 이런 일이 생기나
구조가 잘못됐다. 내가 만든 파이프라인은 이렇다.
1. economy_news.py — RSS 피드에서 뉴스 긁어오기
2. stock_report.py — 뉴스 텍스트를 Ollama에 던지기
3. Ollama 응답 — 종목 추천 + 헤드라인 + 시장 분석 JSON
4. git push → Vercel 자동 배포
여기서 3번이 문제다.
Ollama(qwen2.5:14b)는 한국 주식 뉴스를 훈련 데이터로 거의 못 봤다. 그러니까 오늘 뉴스에서 중요한 헤드라인을 뽑아줘라고 하면, 뉴스 내용을 이해하는 게 아니라 한국 경제 뉴스에 있을 법한 것을 생성한다.
삼성전자 매출 기사, AT&T CEO 발표 — 다 훈련 데이터 기억에서 나온 거다. 그럴듯해 보이지만 사실이 아니다. 이걸 할루시네이션(hallucination)이라고 한다. LLM이 없는 사실을 만들어내는 현상.
처음엔 프롬프트 문제라고 생각했다
그래서 프롬프트를 강화했다.
- 반드시 수집된 뉴스에서만 인용할 것
- 없는 뉴스를 만들지 말 것
- 헤드라인은 정확한 제목 그대로 쓸 것
결과는 이랬다.
"삼성전자, 2분기 반도체 수요 회복 전망" — 연합뉴스 경제
출처까지 "연합뉴스 경제"라고 붙여서 지어냈다. 프롬프트를 강화해도 소용없었다. AI는 모르는 걸 물어보면 아는 척한다. 그게 LLM의 작동 방식이다.
근본 해결 AI에게 뉴스 생성을 맡기지 마라
생각을 바꿨다. AI가 헤드라인을 "생성"하게 하지 말고, RSS에서 직접 뽑으면 된다.
IMPACT_KEYWORDS = {
"high": ["금리", "인플레이션", "CPI", "FOMC", "반도체", "HBM", "earnings"],
"mid": ["주가", "상승", "하락", "실적", "전망", "acquisition"],
}
def classify_headlines(parsed):
def score(item):
text = item["title"] + " " + item["desc"]
if any(k in text for k in IMPACT_KEYWORDS["high"]):
return 2
if any(k in text for k in IMPACT_KEYWORDS["mid"]):
return 1
return 0
kr_sorted = sorted(parsed["kr"], key=score, reverse=True)
us_sorted = sorted(parsed["us"], key=score, reverse=True)
return kr_sorted[:5], us_sorted[:5]
RSS에서 받은 기사들을 키워드 기준으로 점수 매겨서 상위 5개씩 뽑는다.
그리고 Ollama가 뱉은 헤드라인은 버리고, 진짜 헤드라인을 주입한다.
news_data = call_ollama(news_text)
# AI가 만든 헤드라인 → 버림
news_data["kr_headlines"] = real_headlines_from_rss
결과:
📰 오늘의 헤드라인
- 두산테스나, 반도체 장비 수주 확대 — 한국경제 증권
- 삼성전자 가전 OEM 확대 계획 발표 — 매일경제 증권
- GM beats Q1 earnings estimates — CNBC
실제 뉴스다.
동시에 주가도 틀렸다

AI가 헤드라인을 지어내는 동안, 주가도 틀렸다. 원인은 yfinance 배치 다운로드였다.
# 기존 코드 — 이게 문제
df = yf.download(["005930.KS", "000660.KS", ...], period="1d")
yf.download()는 여러 종목을 한 번에 받는 함수인데, 네트워크 오류 하나만 생겨도 전체가 빈 DataFrame으로 반환된다. 에러 메시지도 없다. 주가 없음 → AI가 훈련 데이터 기억에서 채움 → 2~3년 전 주가.
개별 조회로 바꿨다.
def fetch_prices(tickers):
prices = {}
for tk in tickers:
try:
hist = yf.Ticker(tk).history(period="5d")
if not hist.empty:
prices[tk] = float(hist["Close"].iloc[-1])
except Exception as e:
print(f"[{tk}] 실패: {e}")
return prices
하나가 실패해도 나머지는 정상으로 받아온다. 그리고 종목별 유효 범위도 현실화했다.
# 기존 → 수정
삼성전자: (30000, 180000) → (30000, 500000) # 실제 22만원, 범위 초과로 버려지던 것
SK하이닉스: (60000, 500000) → (60000, 2000000)
TSMC: (50, 300) → (50, 700)
PRICE_VALID 범위가 2022년 기준이었다. 그 사이 주가가 많이 올랐다.
RSS 소스도 절반이 죽어있었다
국내 RSS 피드 12개 중 3개만 정상이었다.
이데일리: XML 파싱 에러
머니투데이: 410 Gone
조선비즈: 404 Not Found
파이낸셜뉴스: 404 Not Found
죽은 피드 지우고 살아있는 거 추가했다.
- 한국경제 증권 피드 추가
- 매일경제 증권 피드 추가
- 동아닷컴 경제 추가
국내 기사 수: 45건 → 80건.
RSS 피드는 가끔 확인해야 한다. 조용히 죽어있어도 아무도 안 알려준다.
수치 계산도 AI에게 맡기지 않는다
매수구간/목표가/손절가도 원래 AI가 계산했다. 현재 주가를 모르는 AI가 계산하면 당연히 틀린다. 후처리로 직접 계산하도록 바꿨다.
price = prices[ticker] # yfinance 실제 주가
pick["buy_zone"] = f"{price * 0.97:,.0f}~{price * 1.01:,.0f}원" # -3%~+1%
pick["target"] = f"{price * 1.12:,.0f}원" # +12%
pick["stop_loss"] = f"{price * 0.93:,.0f}원" # -7%
비율이 고정이라 섬세하진 않다. 그래도 AI가 지어내는 것보단 낫다.
결론 AI에게 사실 생성을 맡기지 마라
이번 작업에서 배운 건 하나다.
LLM이 뭔가를 생성하게 시키면 안 되는 경우가 있다.
- 사실(Fact) — 오늘 뉴스, 실시간 주가, 특정 날짜의 이벤트 → 외부 데이터 직접 사용
- 판단(Judgment) — 뉴스 감성, 시장 분위기 요약, 테마 분류 → AI에게 맡겨도 됨
AI는 모르는 걸 물어보면 아는 척한다. 그게 버그가 아니라 설계다. 훈련 데이터에 없는 정보를 물어보면, 그럴듯한 걸 만들어낸다. 구조를 바꿔서, 사실은 외부에서 가져오고 판단만 AI에게 맡기면 할루시네이션을 막을 수 있다.
수정 전 수정 후
| 삼성전자 주가 | 170,500원 (틀림) | 222,750원 (실제) |
| 헤드라인 | AI 생성 (없는 뉴스) | RSS 원문 직접 |
| 국내 기사 | 45건 | 80건 |
| 매수구간 | AI 임의 계산 | 현재가 -3%~+1% |
내일 리포트가 어떻게 나오는지 지켜볼 예정이다. AI로 무언갈 만든다는 건 재밌다.







