수백 건의 뉴스를 LLM 코멘터리 한 편으로 – 출력 강제와 실패 기록

외부 뉴스 소스 수십 개를 수집하는 파이프라인을 만들었다.

수집하고, 중복을 거르고, 죽은 소스를 끊어내고 나면 매일 깨끗한 뉴스 데이터가 출력되고 쌓인다.

그런데 거기서 한 가지 문제가 남았다.

데이터만 쌓아두면 아무도 안 읽는다.

VSI 경보 몇 건, 과열 섹터 몇 개, 헤드라인 수백 줄.

이걸 표로 던져주면 그건 리포트가 아니라 로그 덤프다.

매일 아침 “그래서 오늘 시장이 어떤데?”를 한국어 세 문단으로 읽고 싶었다.

그 요약을 사람이 매일 쓸 순 없으니, LLM(Claude Opus)에게 맡겼다.

이 글은 수백 건의 정형 데이터를 한 편의 코멘터리로 빚으면서 배운 것들의 기록이다.

핵심은 모델을 고르는 게 아니었다.

“시키는 대로만, 안 죽고, 싸게” 시키는 게 전부였다.


1. 데이터를 그대로 던지지 않는다 — 프롬프트로 빚는다

처음엔 수집한 이벤트를 통째로 프롬프트에 밀어넣을 생각이었다.

“오늘 데이터 다 줄 테니 알아서 요약해” 식으로.

이건 두 가지 이유로 안 됐다.

하나, 수백 건을 다 넣으면 입력 토큰이 폭발한다.

둘, 다 넣는다고 좋은 요약이 나오지도 않는다. 중요한 게 덜 중요한 것에 묻힌다.

그래서 모델에 넘기기 전에 데이터를 사람이 읽을 브리핑처럼 깎아서 빚었다.

parts.append("### 주요 이벤트 헤드라인 (Tier 우선)")
for ev in top_events[:10]:
    parts.append(
        f"- [{ev.get('source', '?')}/T{ev.get('tier', '?')}] "
        f"{ev.get('title', '?')} ({ev.get('collected_at', '')})"
    )

핵심은 top_events[:10]이다.

수백 건이 들어와도 모델에 넘기는 건 신뢰도(Tier) 높은 상위 10건뿐이다.

VSI 경보도 10건, 과열 섹터도 과열된 것만.

이건 토큰을 아끼려는 선택이기도 하지만 그보다 모델의 주의를 중요한 것에 몰아주는 선택이었다.

요약의 품질은 “얼마나 많이 넣느냐”가 아니라 “얼마나 잘 골라 넣느냐”에서 갈렸다.

프롬프트 엔지니어링이라고 하면 보통 지시문을 다듬는 걸 떠올리는데

내 경우 절반은 입력 데이터를 추리는 일이었다.

모델은 준 것만 본다.

그러니 무엇을 줄지가 절반이다.


2. 시키지도 않은 마크다운을 뱉는다 — 출력 형식을 강제하는 법

가장 오래 씨름한 건 출력 형식이었다.

이 코멘터리는 워드프레스 리포트 본문에 그대로 들어간다.

그래서 순수 텍스트 문단이어야 했다.

그런데 모델은 시키지도 않았는데 마크다운을 뱉는다.

제목에 #을 붙이고, 강조한답시고 **를 두르고, 가끔 이모지까지 박는다.

그게 그대로 본문에 들어가면 ## 오늘의 시장이 날것으로 노출된다.

결국 시스템 프롬프트에 출력 규칙을 못 박았다.

SYSTEM_PROMPT = """당신은 ... 수석 애널리스트입니다.
...
2. 3~5개 문단의 순수 텍스트로 구성합니다. 마크다운(#, **, |, ---, 이모지 등)을
   절대 사용하지 마세요. 제목 없이 바로 본문부터 시작하세요.
...
9. 문단 사이에 빈 줄 하나만 넣어 구분합니다."""

여기서 배운 게 두 개 있다.

하나, 금지는 구체적으로 해야 먹힌다.

“마크다운 쓰지 마”라고만 하면 모델은 자기 나름의 기준으로 해석한다.

#, **, |, ---, 이모지라고 실제 기호를 하나하나 적어주자 비로소 새는 게 줄었다.

둘, 하지 마라보다 이렇게 하라가 강하다.

“마크다운 쓰지 마”와 “제목 없이 바로 본문부터, 문단 사이 빈 줄 하나”를 같이 줬다.

빈 자리를 비워두면 모델이 자기 습관으로 채운다.

그 자리에 내가 원하는 형식을 미리 박아두는 게 금지보다 효과적이었다.

그래도 100%는 아니다.

이게 다음 한계로 이어진다.


3. 투자 추천은 못 하게 막는다: 형식이 아니라 책임의 문제

출력에서 막아야 할 건 형식만이 아니었다.

이 시스템은 금융 데이터를 다룬다.

모델이 “지금 A주를 사라”고 써버리면 그건 형식 문제가 아니라 책임 문제다.

그래서 역할 자체에 선을 그었다.

SYSTEM_PROMPT = """...
- 투자자가 실행 가능한 인사이트를 제공하되, 직접적인 매수/매도 추천은 하지 않습니다.
...
8. 불확실한 예측은 피하고, 데이터에 기반한 관찰과 시사점에 집중합니다."""

“매수/매도 추천 금지”와 “불확실한 예측 금지”를 명시했다.

대신 “관찰과 시사점”이라는 안전한 출구를 같이 줬다.

여기서도 같은 원리다. 금지만 하면 모델이 어디로 갈지 모른다.

막은 자리 옆에 갈 수 있는 길을 같이 터줘야 한다.

LLM을 제품에 넣을 때 출력은 결국 두 층으로 통제된다.

겉모습(마크다운 금지)과 내용의 책임(추천 금지).

둘 다 system 프롬프트의 역할 정의에서 시작됐다.


4. 실패하면 코멘터리를 포기한다: None을 돌려주는 철학

외부 API는 반드시 실패한다.

LLM 공급자 API도 예외가 아니다.

타임아웃이 나고 rate limit이 걸리고 가끔 5xx를 뱉는다.

여기서 정해야 할 게 있었다.

코멘터리 생성이 실패하면 리포트 전체를 실패시킬 것인가?

답은 아니오였다.

try:
    message = self.client.messages.create(
        model=MODEL_ID,
        max_tokens=MAX_TOKENS,
        system=SYSTEM_PROMPT,
        messages=[{"role": "user", "content": user_prompt}],
        timeout=TIMEOUT_SECONDS,
    )
    return message.content[0].text.strip()

except anthropic.APITimeoutError:
    logger.warning("[Claude] API request timed out")
    return None
except anthropic.RateLimitError:
    logger.warning("[Claude] Rate limit exceeded")
    return None
except anthropic.APIError as e:
    logger.error(f"[Claude] API error: {e}")
    return None

실패하면 예외를 위로 던지지 않고 None을 돌려준다.

리포트의 본체는 어디까지나 데이터다. VSI, SCI, 헤드라인.

코멘터리는 그 위에 얹는 해설이지 리포트의 생사가 아니다.

그래서 코멘터리가 비면 리포트는 코멘터리 없이 나간다.

“오늘 AI 해설은 생성하지 못했습니다” 한 줄과 함께.

이건 앞서 만든 서킷브레이커와 정확히 같은 우선순위다.

dedup이 서비스의 생사를 쥐면 안 되듯 LLM 호출도 리포트의 생사를 쥐면 안 된다.

부가 가치를 주는 장치는 자기가 실패했다고 본체를 끌고 죽으면 안 된다.

이 우선순위를 코드에 명시적으로 박아두는 것. 그게 return None의 의미였다.

예외 종류를 나눠 잡은 것도 의도였다.

타임아웃과 rate limit은 warning, 진짜 API 에러는 error.

매일 한 번 도는 배치라 즉시 재시도는 안 붙였지만 로그 레벨만 봐도

“잠깐 막힌 건지, 뭔가 깨진 건지”가 갈리게 했다.


5. 토큰을 로그로 남긴다: 비용을 보이게

LLM 호출에는 매번 돈이 붙는다.

그런데 비용은 안 보면 안 보인다.

호출 한 번에 입력 몇 토큰, 출력 몇 토큰 썼는지 모르면 어느 날 청구서를 보고 놀라게 된다.

그래서 호출이 끝날 때마다 토큰 사용량을 로그로 남겼다.

text = message.content[0].text.strip()
logger.info(
    f"[Claude] Generated commentary: {len(text)} chars, "
    f"{message.usage.input_tokens} in / {message.usage.output_tokens} out tokens"
)

message.usage에 입력·출력 토큰이 들어있다.

이걸 매 호출 로그에 박아두면 나중에 “평소 입력 토큰이 얼마였더라”를 추적할 수 있다.

이게 중요한 이유가 있다.

1단계에서 top_events[:10]으로 입력을 추렸다고 했는데

그 절삭이 실제로 토큰을 얼마나 아끼는지는 로그가 없으면 그냥 감이다.

입력 토큰을 매일 찍어두면, “데이터가 늘어도 입력 토큰이 평평한가”를 눈으로 확인할 수 있다.

절삭이 새는 순간이 로그에 드러난다.

비용 통제는 결국 관측에서 시작된다.

보이지 않는 비용은 통제할 수 없다.


6. 같은 데이터, 다른 코멘터리: 비결정성이라는 정직한 한계

형식을 강제하고 실패를 삼키고 비용을 봐도 안 풀리는 게 하나 있다.

같은 입력을 줘도 매번 다른 글이 나온다.

어제와 거의 같은 데이터인데 오늘 코멘터리는 어제와 강조점이 다르다.

이건 버그가 아니라 LLM의 본질이다.

같은 프롬프트라도 샘플링 과정에서 매번 다른 문장이 나온다.

이걸 받아들이는 게 중요했다.

코멘터리는 “정답 하나를 계산하는” 작업이 아니라 “그럴듯한 해설 하나를 생성하는” 작업이다.

그러니 결정적 출력을 기대하는 것 자체가 잘못된 기대였다.

대신 내가 통제할 수 있는 건 편차의 폭이었다.

형식 규칙(3~5문단, 순수 텍스트)이 출력의 모양을 좁히고,

역할 정의(애널리스트, 추천 금지)가 내용의 방향을 좁힌다.

매번 다른 글이 나오되 다른 폭이 “허용 범위 안이게” 만드는 것.

비결정성을 없애는 게 아니라 가두는 것이 목표였다.

이건 정직하게 인정하고 가는 한계다.

이 코멘터리를 자동 매매 같은 결정적 의사결정에 직접 연결하면 안 된다.

사람이 읽고 판단하는 해설로는 충분하지만, 그 경계를 넘기면 비결정성이 그대로 리스크가 된다.

어디까지 믿고 어디부터 사람이 받아야 하는지, 그 선을 아는 게 LLM을 제품에 넣는 일의 절반이다.


마무리하며: LLM은 결국 똑똑한, 못 미더운 외부 의존성이다

정리하면 수백 건의 뉴스를 한 편의 코멘터리로 만드는 일은 이렇게 굴러갔다.

  1. 입력을 추린다. 다 넣지 않고 신뢰도 높은 상위 몇 건만 빚어서 넣는다.
  2. 출력을 강제한다. 금지는 구체적으로, “이렇게 하라”를 같이 준다.
  3. 책임에 선을 긋는다. 형식만이 아니라 역할에도 가드레일을 박는다.
  4. 실패를 삼킨다. 코멘터리는 부가물이지 본체가 아니다. 실패하면 None.
  5. 비용을 찍는다. 토큰을 매번 로그로 남겨 보이게 한다.
  6. 비결정성을 가둔다. 없애지 못하니 편차의 폭을 형식과 역할로 좁힌다.

이걸 뉴스 코멘터리에서 배웠지만, 적용 대상은 훨씬 넓다.

앞서 나는 RSS 피드와 LLM 공급자 API가 본질이 같은 “못 미더운 외부 의존성”이라고 썼다.

이번 글로 한 겹이 더해진다.

LLM은 거기에 “똑똑하지만 시킨 대로만 하지는 않는” 성격이 붙은 외부 의존성이다.

그래서 Agent나 LLM 기능을 제품에 넣을 때 나는 이제 이 질문을 던진다.

  1. 모델에 데이터를 다 던지고 있지는 않은가: 무엇을 줄지 추리는 단계가 있는가?
  2. 출력 형식을 “하지 마라”로만 막고 있지는 않은가: “이렇게 하라”를 같이 줬는가?
  3. 이 LLM 호출이 실패하면 그게 전체 기능을 끌고 죽는가: 부가물이 본체의 생사를 쥐고 있지 않은가?
  4. 한 호출에 토큰을 얼마나 쓰는지 로그에 남고 있는가: 비용이 보이는가?

LLM을 제품에 넣는 건 마법을 부르는 일이 아니다.

못 미더운 외부 의존성을, 이번엔 조금 더 똑똑한 놈으로, 똑같이 길들이는 일이다.

형식을 강제하고 실패를 삼키고 비용을 보이게 하고 비결정성을 가둔다.

그러고 나면 비로소 수백 건의 로그가 매일 아침 읽을 만한 세 문단이 된다.



함께 읽으면 좋은 글


글쓴이 · Plutojoshua

핀테크에서 LLM과 AI 에이전트를 실제 서비스에 적용하는 개발자다. 카카오 AI 앰배서더로 Kanana 모델을 앱에 직접 연동해 검증하고, 비전공자로 시작해 실제 코드와 측정값을 근거로 “왜 그렇게 설계했는지”를 1인칭으로 기록한다. 운영자 소개는 About, 코드는 GitHub에서 볼 수 있다.

“수백 건의 뉴스를 LLM 코멘터리 한 편으로 – 출력 강제와 실패 기록”에 대한 1개의 생각

댓글 남기기